feat(web): sync translated manga galleries, clean titles, synopsis integration, original source links, anthology badges, and mobile-first reader UI
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "ee41cd16aee142978eedd00dcf1854a07a534d9e"
|
"fingerprint": "c6be2155b5d458c41adc0623b75fe4fad880b5f6"
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-1
@@ -1,4 +1,4 @@
|
|||||||
# Skill Registry — worst_scan_web
|
# Skill Registry — worst_web
|
||||||
|
|
||||||
<!-- Auto-generated by gentle-ai skill-registry refresh. Run `gentle-ai skill-registry refresh --force` to regenerate. -->
|
<!-- Auto-generated by gentle-ai skill-registry refresh. Run `gentle-ai skill-registry refresh --force` to regenerate. -->
|
||||||
|
|
||||||
@@ -7,7 +7,9 @@ Last updated: 2026-07-23
|
|||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
- /home/ren/.config/opencode/skills
|
- /home/ren/.config/opencode/skills
|
||||||
|
- /home/ren/.claude/skills
|
||||||
- /home/ren/.codex/skills
|
- /home/ren/.codex/skills
|
||||||
|
- /home/ren/.qwen/skills
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
@@ -19,16 +21,60 @@ Last updated: 2026-07-23
|
|||||||
|
|
||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `api-design` | REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. | user | `/home/ren/.claude/skills/api-design/SKILL.md` |
|
||||||
|
| `backend-patterns` | Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. | user | `/home/ren/.claude/skills/backend-patterns/SKILL.md` |
|
||||||
| `branch-pr` | Create Gentle AI pull requests with issue-first checks. Trigger: creating, opening, or preparing PRs for review. | user | `/home/ren/.config/opencode/skills/branch-pr/SKILL.md` |
|
| `branch-pr` | Create Gentle AI pull requests with issue-first checks. Trigger: creating, opening, or preparing PRs for review. | user | `/home/ren/.config/opencode/skills/branch-pr/SKILL.md` |
|
||||||
| `chained-pr` | Trigger: PRs over 400 lines, stacked PRs, review slices. Split oversized changes into chained PRs that protect review focus. | user | `/home/ren/.config/opencode/skills/chained-pr/SKILL.md` |
|
| `chained-pr` | Trigger: PRs over 400 lines, stacked PRs, review slices. Split oversized changes into chained PRs that protect review focus. | user | `/home/ren/.config/opencode/skills/chained-pr/SKILL.md` |
|
||||||
|
| `clickhouse-io` | ClickHouse database patterns, query optimization, analytics, and data engineering best practices for high-performance analytical workloads. | user | `/home/ren/.claude/skills/clickhouse-io/SKILL.md` |
|
||||||
|
| `coding-standards` | Universal coding standards, best practices, and patterns for TypeScript, JavaScript, React, and Node.js development. | user | `/home/ren/.claude/skills/coding-standards/SKILL.md` |
|
||||||
| `cognitive-doc-design` | Design docs that reduce cognitive load. Trigger: writing guides, READMEs, RFCs, onboarding, architecture, or review-facing docs. | user | `/home/ren/.config/opencode/skills/cognitive-doc-design/SKILL.md` |
|
| `cognitive-doc-design` | Design docs that reduce cognitive load. Trigger: writing guides, READMEs, RFCs, onboarding, architecture, or review-facing docs. | user | `/home/ren/.config/opencode/skills/cognitive-doc-design/SKILL.md` |
|
||||||
| `comment-writer` | Write warm, direct collaboration comments. Trigger: PR feedback, issue replies, reviews, Slack messages, or GitHub comments. | user | `/home/ren/.config/opencode/skills/comment-writer/SKILL.md` |
|
| `comment-writer` | Write warm, direct collaboration comments. Trigger: PR feedback, issue replies, reviews, Slack messages, or GitHub comments. | user | `/home/ren/.config/opencode/skills/comment-writer/SKILL.md` |
|
||||||
|
| `configure-ecc` | Interactive installer for Everything Claude Code — guides users through selecting and installing skills and rules to user-level or project-level directories, verifies paths, and optionally optimizes installed files. | user | `/home/ren/.claude/skills/configure-ecc/SKILL.md` |
|
||||||
|
| `constructor` | Build, rebuild, flatten, clean, and verify structures in the Minecraft world managed by Crafty Controller on this VPS. Use when requests involve placing/removing blocks, terraforming areas, teleporting players, recreating murals/flags from images, validating coordinates or block states, and keeping ren.md updated after world changes. | user | `/home/ren/.codex/skills/constructor/SKILL.md` |
|
||||||
|
| `continuous-learning` | Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use. | user | `/home/ren/.claude/skills/continuous-learning/SKILL.md` |
|
||||||
|
| `continuous-learning-v2` | Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. | user | `/home/ren/.claude/skills/continuous-learning-v2/SKILL.md` |
|
||||||
|
| `cpp-testing` | Use only when writing/updating/fixing C++ tests, configuring GoogleTest/CTest, diagnosing failing or flaky tests, or adding coverage/sanitizers. | user | `/home/ren/.claude/skills/cpp-testing/SKILL.md` |
|
||||||
|
| `database-migrations` | Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma, Drizzle, Django, TypeORM, golang-migrate). | user | `/home/ren/.claude/skills/database-migrations/SKILL.md` |
|
||||||
|
| `deployment-patterns` | Deployment workflows, CI/CD pipeline patterns, Docker containerization, health checks, rollback strategies, and production readiness checklists for web applications. | user | `/home/ren/.claude/skills/deployment-patterns/SKILL.md` |
|
||||||
|
| `django-patterns` | Django architecture patterns, REST API design with DRF, ORM best practices, caching, signals, middleware, and production-grade Django apps. | user | `/home/ren/.claude/skills/django-patterns/SKILL.md` |
|
||||||
|
| `django-security` | Django security best practices, authentication, authorization, CSRF protection, SQL injection prevention, XSS prevention, and secure deployment configurations. | user | `/home/ren/.claude/skills/django-security/SKILL.md` |
|
||||||
|
| `django-tdd` | Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. | user | `/home/ren/.claude/skills/django-tdd/SKILL.md` |
|
||||||
|
| `django-verification` | Verification loop for Django projects: migrations, linting, tests with coverage, security scans, and deployment readiness checks before release or PR. | user | `/home/ren/.claude/skills/django-verification/SKILL.md` |
|
||||||
|
| `docker-patterns` | Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration. | user | `/home/ren/.claude/skills/docker-patterns/SKILL.md` |
|
||||||
|
| `e2e-testing` | Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies. | user | `/home/ren/.claude/skills/e2e-testing/SKILL.md` |
|
||||||
|
| `eval-harness` | Formal evaluation framework for Claude Code sessions implementing eval-driven development (EDD) principles | user | `/home/ren/.claude/skills/eval-harness/SKILL.md` |
|
||||||
|
| `frontend-patterns` | Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. | user | `/home/ren/.claude/skills/frontend-patterns/SKILL.md` |
|
||||||
|
| `gentle-ai-chained-pr` | Split large changes into chained or stacked pull requests that protect reviewer focus and stay within Gentle AI's 400-line cognitive review budget. Trigger: when a PR would exceed 400 changed lines, when planning chained PRs, stacked PRs, or reviewable slices. | user | `/home/ren/.claude/skills/chained-pr/SKILL.md` |
|
||||||
| `go-testing` | Trigger: Go tests, go test coverage, Bubbletea teatest, golden files. Apply focused Go testing patterns. | user | `/home/ren/.config/opencode/skills/go-testing/SKILL.md` |
|
| `go-testing` | Trigger: Go tests, go test coverage, Bubbletea teatest, golden files. Apply focused Go testing patterns. | user | `/home/ren/.config/opencode/skills/go-testing/SKILL.md` |
|
||||||
|
| `golang-patterns` | Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. | user | `/home/ren/.claude/skills/golang-patterns/SKILL.md` |
|
||||||
|
| `golang-testing` | Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. | user | `/home/ren/.claude/skills/golang-testing/SKILL.md` |
|
||||||
| `issue-creation` | Create Gentle AI issues with issue-first checks. Trigger: creating GitHub issues, bug reports, or feature requests. | user | `/home/ren/.config/opencode/skills/issue-creation/SKILL.md` |
|
| `issue-creation` | Create Gentle AI issues with issue-first checks. Trigger: creating GitHub issues, bug reports, or feature requests. | user | `/home/ren/.config/opencode/skills/issue-creation/SKILL.md` |
|
||||||
|
| `iterative-retrieval` | Pattern for progressively refining context retrieval to solve the subagent context problem | user | `/home/ren/.claude/skills/iterative-retrieval/SKILL.md` |
|
||||||
|
| `java-coding-standards` | Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout. | user | `/home/ren/.claude/skills/java-coding-standards/SKILL.md` |
|
||||||
|
| `jpa-patterns` | JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot. | user | `/home/ren/.claude/skills/jpa-patterns/SKILL.md` |
|
||||||
| `judgment-day` | Trigger: judgment day, dual review, adversarial review, juzgar. Run blind dual review, fix confirmed issues, then re-judge. | user | `/home/ren/.config/opencode/skills/judgment-day/SKILL.md` |
|
| `judgment-day` | Trigger: judgment day, dual review, adversarial review, juzgar. Run blind dual review, fix confirmed issues, then re-judge. | user | `/home/ren/.config/opencode/skills/judgment-day/SKILL.md` |
|
||||||
|
| `kilo-force-multiplier` | Use Kilo (OpenCode's AI agent) as a force multiplier for development work. Covers subagent architecture, custom commands, skills system, MCP integration, multi-model routing, context management, and the SDD workflow. Trigger: when setting up Kilo, creating custom commands, building skills, configuring MCP servers, or optimizing AI-assisted development workflows. | user | `/home/ren/.claude/skills/kilo-force-multiplier/SKILL.md` |
|
||||||
|
| `minebuilder` | — | user | `/home/ren/.claude/skills/minebuilder/SKILL.md` |
|
||||||
|
| `minecraft-construction` | Minecraft large-scale construction via Crafty API. Parallel section processing, proper terrain flattening, chunk management, and multi-agent coordination for efficient building. | user | `/home/ren/.claude/skills/minecraft-construction/SKILL.md` |
|
||||||
|
| `nutrient-document-processing` | Process, convert, OCR, extract, redact, sign, and fill documents using the Nutrient DWS API. Works with PDFs, DOCX, XLSX, PPTX, HTML, and images. | user | `/home/ren/.claude/skills/nutrient-document-processing/SKILL.md` |
|
||||||
|
| `postgres-patterns` | PostgreSQL database patterns for query optimization, schema design, indexing, and security. Based on Supabase best practices. | user | `/home/ren/.claude/skills/postgres-patterns/SKILL.md` |
|
||||||
|
| `project-guidelines-example` | Example project-specific skill template based on a real production application. | user | `/home/ren/.claude/skills/project-guidelines-example/SKILL.md` |
|
||||||
|
| `python-patterns` | Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications. | user | `/home/ren/.claude/skills/python-patterns/SKILL.md` |
|
||||||
|
| `python-testing` | Python testing strategies using pytest, TDD methodology, fixtures, mocking, parametrization, and coverage requirements. | user | `/home/ren/.claude/skills/python-testing/SKILL.md` |
|
||||||
|
| `security-review` | Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns. | user | `/home/ren/.claude/skills/security-review/SKILL.md` |
|
||||||
|
| `security-scan` | Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions. | user | `/home/ren/.claude/skills/security-scan/SKILL.md` |
|
||||||
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | user | `/home/ren/.config/opencode/skills/skill-creator/SKILL.md` |
|
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | user | `/home/ren/.config/opencode/skills/skill-creator/SKILL.md` |
|
||||||
| `skill-improver` | Trigger: improve skills, audit skills, refactor skills, skill quality. Audit and upgrade existing LLM-first skills. | user | `/home/ren/.config/opencode/skills/skill-improver/SKILL.md` |
|
| `skill-improver` | Trigger: improve skills, audit skills, refactor skills, skill quality. Audit and upgrade existing LLM-first skills. | user | `/home/ren/.config/opencode/skills/skill-improver/SKILL.md` |
|
||||||
|
| `skill-installer` | Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). | user | `/home/ren/.codex/skills/.system/skill-installer/SKILL.md` |
|
||||||
|
| `springboot-patterns` | Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. | user | `/home/ren/.claude/skills/springboot-patterns/SKILL.md` |
|
||||||
|
| `springboot-security` | Spring Security best practices for authn/authz, validation, CSRF, secrets, headers, rate limiting, and dependency security in Java Spring Boot services. | user | `/home/ren/.claude/skills/springboot-security/SKILL.md` |
|
||||||
|
| `springboot-tdd` | Test-driven development for Spring Boot using JUnit 5, Mockito, MockMvc, Testcontainers, and JaCoCo. Use when adding features, fixing bugs, or refactoring. | user | `/home/ren/.claude/skills/springboot-tdd/SKILL.md` |
|
||||||
|
| `springboot-verification` | Verification loop for Spring Boot projects: build, static analysis, tests with coverage, security scans, and diff review before release or PR. | user | `/home/ren/.claude/skills/springboot-verification/SKILL.md` |
|
||||||
|
| `strategic-compact` | Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction. | user | `/home/ren/.claude/skills/strategic-compact/SKILL.md` |
|
||||||
|
| `tdd-workflow` | Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. | user | `/home/ren/.claude/skills/tdd-workflow/SKILL.md` |
|
||||||
|
| `verification-loop` | A comprehensive verification system for Claude Code sessions. | user | `/home/ren/.claude/skills/verification-loop/SKILL.md` |
|
||||||
| `work-unit-commits` | Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code. | user | `/home/ren/.config/opencode/skills/work-unit-commits/SKILL.md` |
|
| `work-unit-commits` | Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code. | user | `/home/ren/.config/opencode/skills/work-unit-commits/SKILL.md` |
|
||||||
|
| `zai-vision` | Analyze images via Z.AI GLM-4.6V vision model. Use when the user shares an image file path, base64 image data, image URL, or asks to describe/analyze/OCR/read visual content. Gives vision capabilities to text-only models. | user | `/home/ren/.claude/skills/zai-vision/SKILL.md` |
|
||||||
|
|
||||||
## Loading protocol
|
## Loading protocol
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ API_KEY=
|
|||||||
# Auth de la web (vacío = sin auth en dev)
|
# Auth de la web (vacío = sin auth en dev)
|
||||||
WEB_PASSWORD=
|
WEB_PASSWORD=
|
||||||
|
|
||||||
|
# Webhook Secret para sincronización automática desde el pipeline
|
||||||
|
WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Puerto
|
# Puerto
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
data/
|
data/
|
||||||
|
.atl/
|
||||||
|
|||||||
+12
-2
@@ -2,15 +2,25 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: worst-scan-web
|
container_name: worst-scan-web
|
||||||
ports:
|
expose:
|
||||||
- "3000:3000"
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
- API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1}
|
- API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1}
|
||||||
- API_KEY=${API_KEY:-}
|
- API_KEY=${API_KEY:-}
|
||||||
- WEB_PASSWORD=${WEB_PASSWORD:-}
|
- WEB_PASSWORD=${WEB_PASSWORD:-}
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
- default
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Dominios Disponibles
|
||||||
|
|
||||||
|
> Generado: 2026-07-06 — Después de limpieza de contenedores
|
||||||
|
|
||||||
|
## Resumen de recuperación
|
||||||
|
|
||||||
|
| Recurso | Antes | Después | Liberado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Contenedores | 30 | **14** | 16 |
|
||||||
|
| Imágenes | 38 | **12** | 26 |
|
||||||
|
| Disco (imágenes) | 34.86 GB | **8.7 GB** | ~26 GB |
|
||||||
|
| Build cache | 15.34 GB | **4.1 GB** | ~11 GB |
|
||||||
|
| Redes Docker | 16 | **10** | 6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dominios liberados (sin uso actual)
|
||||||
|
|
||||||
|
| Dominio | Apuntaba a | Posible uso futuro |
|
||||||
|
|---|---|---|
|
||||||
|
| `mate.cbcren.online` | math-frontend + math-backend | App web, API REST, dashboard |
|
||||||
|
| `manga.cbcren.online` | Kavita (lector manga) | Static site, app web |
|
||||||
|
| `eco2.cbcren.online` | eco2-app (economía) | App Node.js / web |
|
||||||
|
| `simulacro.cbcren.online` | simulacro-app (flask) | Python app, web app |
|
||||||
|
| `am.cbcren.online` | Open WebUI (LLM) | Chat UI, interfaz AI |
|
||||||
|
| `demo2.cbcren.online` | pymesbot_backend | API demo, bot backend |
|
||||||
|
|
||||||
|
### Cómo reutilizar un dominio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Crear el nuevo contenedor en la red caddy
|
||||||
|
docker run -d --name mi-app --network caddy mi-imagen
|
||||||
|
|
||||||
|
# 2. Editar Caddyfile con el dominio y el nuevo servicio
|
||||||
|
# /home/ren/immich/Caddyfile
|
||||||
|
# mimarcacbcren.online {
|
||||||
|
# reverse_proxy mi-app:3000
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 3. Recargar Caddy sin downtime
|
||||||
|
docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Estos dominios ya están definidos en `/home/ren/immich/Caddyfile`. Solo hay que cambiar el `reverse_proxy` al nuevo contenedor. Ejemplo de bloque en Caddyfile:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
am.cbcren.online {
|
||||||
|
reverse_proxy nuevo-contenedor:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dominios activos (no tocar)
|
||||||
|
|
||||||
|
| Dominio | Servicio | Puerto |
|
||||||
|
|---|---|---|
|
||||||
|
| `eco.cbcren.online` | ASF (ArchiSteamFarm) — con basic auth | 1242 |
|
||||||
|
| `gitea.cbcren.online` | Gitea (repos git) | 3000 |
|
||||||
|
| `nextcloud.cbcren.online` | Nextcloud (files) | 80 |
|
||||||
|
| `bunismocraft.cbcren.online` | Crafty (Minecraft) | 8443 |
|
||||||
|
| `n8n.cbcren.online` | n8n (workflows) — apunta a host:5000 | 5000 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Puertos locales liberados
|
||||||
|
|
||||||
|
| Puerto | Antes | Ahora |
|
||||||
|
|---|---|---|
|
||||||
|
| `127.0.0.1:3010` → 3000 | math-frontend | **Libre** |
|
||||||
|
| `127.0.0.1:3011` → 3001 | math-backend | **Libre** |
|
||||||
|
| `127.0.0.1:5433` → 5432 | math-postgres | **Libre** |
|
||||||
|
| `127.0.0.1:6379` → 6379 | math-redis | **Libre** |
|
||||||
|
| `0.0.0.0:8089` → 80 | omni-tools | **Libre** |
|
||||||
|
| `0.0.0.0:8201` → 8000 | pymesbot_backend | **Libre** |
|
||||||
|
| `0.0.0.0:8202` → 8080 | pymesbot_picoclaw | **Libre** |
|
||||||
|
| TCP 3002 | math-pdf-worker | **Libre** |
|
||||||
|
| TCP 3003 | math-exercise-worker | **Libre** |
|
||||||
|
| TCP 3004 | math-notification-worker | **Libre** |
|
||||||
|
| `0.0.0.0:18080` → 8080 | finanzas_fireflyiii | **Libre** |
|
||||||
|
|
||||||
|
## Redes Docker eliminadas
|
||||||
|
|
||||||
|
| Red | Contenía |
|
||||||
|
|---|---|
|
||||||
|
| `economia-study-app_default` | eco2-app |
|
||||||
|
| `finanzas_default` | finanzas_fireflyiii |
|
||||||
|
| `pygmesbot_demo_net` | pymesbot_picoclaw + pymesbot_backend |
|
||||||
|
| `omni-tools_default` | omni-tools |
|
||||||
|
| `openclaw-config_pymesbot_net` | pymesbot_backend |
|
||||||
|
| `math_math-network` | Stack math completo |
|
||||||
@@ -3,6 +3,9 @@ import type { NextConfig } from "next"
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
|
turbopack: {
|
||||||
|
root: __dirname,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
Generated
-114
@@ -618,9 +618,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -637,9 +634,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -656,9 +650,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -675,9 +666,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -694,9 +682,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -713,9 +698,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -732,9 +714,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -751,9 +730,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -770,9 +746,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -795,9 +768,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -820,9 +790,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -845,9 +812,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -870,9 +834,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -895,9 +856,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -920,9 +878,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -945,9 +900,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1163,9 +1115,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1182,9 +1131,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1201,9 +1147,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1220,9 +1163,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1461,9 +1401,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1481,9 +1418,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1501,9 +1435,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1521,9 +1452,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2084,9 +2012,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2101,9 +2026,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2118,9 +2040,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2135,9 +2054,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2152,9 +2068,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2169,9 +2082,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2186,9 +2096,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2203,9 +2110,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2220,9 +2124,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2237,9 +2138,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4994,9 +4892,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5018,9 +4913,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5042,9 +4934,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5066,9 +4955,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,39 +1,68 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react"
|
import { useEffect, useRef, useState, useCallback } from "react"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react"
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Loader2,
|
||||||
|
Rows,
|
||||||
|
BookOpen,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
const { gid } = useParams<{ gid: string }>()
|
const { gid } = useParams<{ gid: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [pageUrls, setPageUrls] = useState<string[]>([])
|
const [pageUrls, setPageUrls] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const [mode, setMode] = useState<"paged" | "webtoon">("paged")
|
||||||
const [showControls, setShowControls] = useState(true)
|
const [showControls, setShowControls] = useState(true)
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
const touchStartX = useRef<number | null>(null)
|
||||||
|
const touchStartY = useRef<number | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Fetch page list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pagesRes = await fetch(`/api/proxy/galleries/${gid}/pages`)
|
||||||
|
if (pagesRes.ok) {
|
||||||
|
const pagesData = await pagesRes.json()
|
||||||
|
const total = pagesData?.data?.total_pages || 0
|
||||||
|
if (total > 0) {
|
||||||
|
const urls = Array.from({ length: total }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
|
||||||
|
setPageUrls(urls)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/proxy/galleries/${gid}/summary`)
|
const res = await fetch(`/api/proxy/galleries/${gid}/summary`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const summary = data?.data
|
const summary = data?.data
|
||||||
setTitle(summary?.title || `g/${gid}`)
|
if (summary?.title) setTitle(summary.title)
|
||||||
|
|
||||||
if (!summary) { setError("Gallery no encontrada"); setLoading(false); return }
|
if (summary?.num_pages && summary.num_pages > 0) {
|
||||||
|
const urls = Array.from({ length: summary.num_pages }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
|
||||||
if (summary.num_pages && summary.num_pages > 0) {
|
|
||||||
const urls: string[] = []
|
|
||||||
for (let i = 0; i < summary.num_pages; i++) {
|
|
||||||
urls.push(`/api/proxy/galleries/${gid}/cover`)
|
|
||||||
}
|
|
||||||
setPageUrls(urls)
|
setPageUrls(urls)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -45,7 +74,7 @@ export default function ReaderPage() {
|
|||||||
const artData = await artRes.json()
|
const artData = await artRes.json()
|
||||||
const rendered = artData?.data?.files?.rendered
|
const rendered = artData?.data?.files?.rendered
|
||||||
if (rendered?.length) {
|
if (rendered?.length) {
|
||||||
const urls = rendered.map(() => `/api/proxy/galleries/${gid}/cover`)
|
const urls = Array.from({ length: rendered.length }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
|
||||||
setPageUrls(urls)
|
setPageUrls(urls)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -61,7 +90,7 @@ export default function ReaderPage() {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
setError("No hay imágenes disponibles. La API del pipeline solo expone la portada.")
|
setError("No hay imágenes disponibles para esta galería.")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,47 +98,119 @@ export default function ReaderPage() {
|
|||||||
}, [gid])
|
}, [gid])
|
||||||
|
|
||||||
const totalPages = pageUrls.length
|
const totalPages = pageUrls.length
|
||||||
const goTo = useCallback((n: number) => setPage(Math.max(0, Math.min(n, totalPages - 1))), [totalPages])
|
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(n: number) => {
|
||||||
|
setPage((prev) => {
|
||||||
|
const next = Math.max(0, Math.min(n, totalPages - 1))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[totalPages]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preload next 2 images
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "paged" && pageUrls.length > 0) {
|
||||||
|
const next1 = pageUrls[page + 1]
|
||||||
|
const next2 = pageUrls[page + 2]
|
||||||
|
if (next1) { const img1 = new Image(); img1.src = next1 }
|
||||||
|
if (next2) { const img2 = new Image(); img2.src = next2 }
|
||||||
|
}
|
||||||
|
}, [page, pageUrls, mode])
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === "ArrowLeft") goTo(page - 1)
|
if (e.key === "ArrowLeft") goTo(page - 1)
|
||||||
if (e.key === "ArrowRight") goTo(page + 1)
|
if (e.key === "ArrowRight") goTo(page + 1)
|
||||||
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 3))
|
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 3))
|
||||||
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25))
|
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25))
|
||||||
|
if (e.key === "0") setZoom(1)
|
||||||
|
if (e.key === "f" || e.key === "F") toggleFullscreen()
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [page, goTo])
|
}, [page, goTo])
|
||||||
|
|
||||||
const handleMouseMove = useCallback(() => {
|
const handleUserActivity = useCallback(() => {
|
||||||
setShowControls(true)
|
setShowControls(true)
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current)
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
hideTimer.current = setTimeout(() => setShowControls(false), 2000)
|
hideTimer.current = setTimeout(() => setShowControls(false), 3500)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(() => {})
|
||||||
|
setIsFullscreen(true)
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().catch(() => {})
|
||||||
|
setIsFullscreen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch gesture handling
|
||||||
|
function handleTouchStart(e: React.TouchEvent) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
touchStartY.current = e.touches[0].clientY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: React.TouchEvent) {
|
||||||
|
if (touchStartX.current === null || touchStartY.current === null) return
|
||||||
|
|
||||||
|
const diffX = touchStartX.current - e.changedTouches[0].clientX
|
||||||
|
const diffY = touchStartY.current - e.changedTouches[0].clientY
|
||||||
|
|
||||||
|
// Swipe horizontal if X diff > Y diff and X diff > 40px
|
||||||
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 40) {
|
||||||
|
if (mode === "paged") {
|
||||||
|
if (diffX > 0) goTo(page + 1) // Swipe left -> Next page
|
||||||
|
else goTo(page - 1) // Swipe right -> Prev page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStartX.current = null
|
||||||
|
touchStartY.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScreenClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
const width = window.innerWidth
|
||||||
|
const clickX = e.clientX
|
||||||
|
|
||||||
|
// Center 40% tap -> toggle controls
|
||||||
|
if (clickX >= width * 0.3 && clickX <= width * 0.7) {
|
||||||
|
setShowControls((s) => !s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "paged") {
|
||||||
|
if (clickX < width * 0.3) {
|
||||||
|
goTo(page - 1)
|
||||||
|
} else if (clickX > width * 0.7) {
|
||||||
|
goTo(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-40 text-[var(--muted)]">
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-[#09090b] text-[var(--muted)]">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<Loader2 className="h-10 w-10 animate-spin text-[var(--accent)] mb-4" />
|
||||||
|
<p className="text-sm font-medium">Cargando manga...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-lg py-20 text-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#09090b] p-4">
|
||||||
<div className="card p-8">
|
<div className="w-full max-w-sm card p-6 text-center">
|
||||||
<p className="mb-4 text-sm text-[var(--error)]">{error}</p>
|
<p className="mb-4 text-sm text-[var(--error)]">{error}</p>
|
||||||
<p className="text-xs text-[var(--muted)] mb-4">
|
<button onClick={() => router.back()} className="btn-primary w-full">
|
||||||
La API del pipeline solo sirve la página 1 vía <code className="text-[var(--accent)]">/galleries/{"{gid}"}/cover</code>.
|
<ArrowLeft className="h-4 w-4" /> Volver
|
||||||
</p>
|
</button>
|
||||||
<a
|
|
||||||
href={`/gallery/${gid}`}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
Volver al detalle
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -117,88 +218,196 @@ export default function ReaderPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-[calc(100vh-3rem)] flex-col bg-black/40"
|
className="fixed inset-0 z-50 flex flex-col bg-[#09090b] text-white select-none overflow-hidden touch-none"
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleUserActivity}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
<div
|
{/* Floating Top Controls Header */}
|
||||||
className={`flex items-center justify-between border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 transition-opacity duration-300 ${
|
<header
|
||||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
className={`fixed top-0 left-0 right-0 z-50 flex items-center justify-between border-b border-white/10 bg-[#09090b]/90 backdrop-blur-md px-3 py-2 transition-transform duration-300 ${
|
||||||
|
showControls ? "translate-y-0" : "-translate-y-full pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate text-sm text-[var(--muted)]">{title}</span>
|
<div className="flex items-center gap-2 max-w-[60%]">
|
||||||
<div className="flex items-center gap-3">
|
<button
|
||||||
<button onClick={() => setZoom((z) => Math.max(z - 0.25, 0.25))} className="btn-ghost p-1">
|
onClick={() => router.back()}
|
||||||
<ZoomOut className="h-4 w-4" />
|
className="rounded-full p-2 hover:bg-white/10 text-white/80 transition-colors"
|
||||||
|
title="Volver"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="w-10 text-center text-xs text-[var(--muted)]">{Math.round(zoom * 100)}%</span>
|
<span className="truncate text-xs md:text-sm font-medium text-white/90">
|
||||||
<button onClick={() => setZoom((z) => Math.min(z + 0.25, 3))} className="btn-ghost p-1">
|
{title || `g/${gid}`}
|
||||||
<ZoomIn className="h-4 w-4" />
|
</span>
|
||||||
</button>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<span className="text-xs text-[var(--muted)]">{page + 1} / {totalPages}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-auto">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<div className="flex items-center gap-4 px-4">
|
{/* Mode Switcher */}
|
||||||
{totalPages > 1 && (
|
<button
|
||||||
|
onClick={() => setMode((m) => (m === "paged" ? "webtoon" : "paged"))}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-white/90 hover:bg-white/10 transition-colors"
|
||||||
|
title={mode === "paged" ? "Modo Cascada (Webtoon)" : "Modo Paginado"}
|
||||||
|
>
|
||||||
|
{mode === "paged" ? (
|
||||||
|
<>
|
||||||
|
<Rows className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||||
|
<span className="hidden sm:inline">Cascada</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BookOpen className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||||
|
<span className="hidden sm:inline">Paginado</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Zoom controls (paged mode) */}
|
||||||
|
{mode === "paged" && (
|
||||||
|
<div className="hidden md:flex items-center gap-1 border-l border-r border-white/10 px-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom((z) => Math.max(z - 0.25, 0.5))}
|
||||||
|
className="rounded p-1 hover:bg-white/10 text-white/70"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center text-xs text-white/60">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom((z) => Math.min(z + 0.25, 3))}
|
||||||
|
className="rounded p-1 hover:bg-white/10 text-white/70"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{zoom !== 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(1)}
|
||||||
|
className="rounded p-1 hover:bg-white/10 text-white/70"
|
||||||
|
title="Restablecer Zoom"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="hidden sm:flex rounded-full p-2 hover:bg-white/10 text-white/70"
|
||||||
|
title="Pantalla completa"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Image Viewer Container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onClick={handleScreenClick}
|
||||||
|
className="flex-1 w-full h-full overflow-auto flex items-center justify-center relative touch-pan-y"
|
||||||
|
>
|
||||||
|
{mode === "paged" ? (
|
||||||
|
/* Paged Mode Layout */
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-2 sm:p-4">
|
||||||
|
<img
|
||||||
|
key={page}
|
||||||
|
src={pageUrls[page]}
|
||||||
|
alt={`Página ${page + 1}`}
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
className="max-h-full max-w-full origin-center object-contain transition-transform duration-150"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Webtoon / Vertical Continuous Scroll Mode */
|
||||||
|
<div className="w-full max-w-3xl mx-auto flex flex-col gap-1 py-12 px-0 overflow-y-auto">
|
||||||
|
{pageUrls.map((url, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt={`Página ${i + 1}`}
|
||||||
|
className="w-full h-auto object-contain block bg-[#18181b]/30"
|
||||||
|
loading={i < 4 ? "eager" : "lazy"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop Side Arrow Buttons */}
|
||||||
|
{mode === "paged" && totalPages > 1 && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => goTo(page - 1)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
goTo(page - 1)
|
||||||
|
}}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
className={`hidden md:flex fixed left-4 top-1/2 -translate-y-1/2 rounded-full border border-white/15 bg-black/60 p-3 text-white backdrop-blur-md transition-all hover:scale-110 disabled:opacity-20 ${
|
||||||
showControls ? "opacity-100" : "opacity-0"
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-6 w-6" />
|
<ChevronLeft className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={pageUrls[page]}
|
|
||||||
alt={totalPages > 1 ? `Página ${page + 1}` : "Portada"}
|
|
||||||
style={{ transform: `scale(${zoom})` }}
|
|
||||||
className="max-h-[calc(100vh-8rem)] max-w-full origin-center object-contain transition-transform"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => goTo(page + 1)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
goTo(page + 1)
|
||||||
|
}}
|
||||||
disabled={page >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
className={`hidden md:flex fixed right-4 top-1/2 -translate-y-1/2 rounded-full border border-white/15 bg-black/60 p-3 text-white backdrop-blur-md transition-all hover:scale-110 disabled:opacity-20 ${
|
||||||
showControls ? "opacity-100" : "opacity-0"
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{/* Floating Bottom Navigation Bar */}
|
||||||
<div className={`flex justify-center gap-1.5 border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 transition-opacity duration-300 ${
|
{mode === "paged" && totalPages > 1 && (
|
||||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
<footer
|
||||||
}`}>
|
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col gap-2 border-t border-white/10 bg-[#09090b]/90 backdrop-blur-md px-4 py-2.5 transition-transform duration-300 ${
|
||||||
{Array.from({ length: Math.min(totalPages, 100) }).map((_, i) => (
|
showControls ? "translate-y-0" : "translate-y-full pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Page Scrubber Slider */}
|
||||||
|
<div className="flex items-center gap-3 w-full max-w-xl mx-auto">
|
||||||
<button
|
<button
|
||||||
key={i}
|
onClick={() => goTo(page - 1)}
|
||||||
onClick={() => goTo(i)}
|
disabled={page === 0}
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
className="p-1 text-white/70 hover:text-white disabled:opacity-30"
|
||||||
i === page ? "w-6 bg-[var(--accent)]" : "w-1.5 bg-[var(--border)] hover:bg-[var(--muted)]"
|
>
|
||||||
}`}
|
<ChevronLeft className="h-5 w-5" />
|
||||||
/>
|
</button>
|
||||||
))}
|
|
||||||
{totalPages > 100 && (
|
|
||||||
<span className="text-[10px] text-[var(--muted)] ml-1">+{totalPages - 100}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages === 1 && (
|
<input
|
||||||
<div className="border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 text-center text-xs text-[var(--muted)]">
|
type="range"
|
||||||
Solo portada disponible. El pipeline solo expone la página 1 vía /cover.
|
min={0}
|
||||||
</div>
|
max={totalPages - 1}
|
||||||
|
value={page}
|
||||||
|
onChange={(e) => goTo(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5 accent-[var(--accent)] bg-white/20 rounded-lg cursor-pointer appearance-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="p-1 text-white/70 hover:text-white disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Counter Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-white/70">
|
||||||
|
<span className="font-semibold text-white">{page + 1}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Sidebar } from "@/components/layout/sidebar"
|
|||||||
|
|
||||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="ml-56 flex-1 p-6 lg:p-8">{children}</main>
|
<main className="w-full min-h-screen md:pl-56 p-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Image } from "lucide-react"
|
import { Image, Search, Shield } from "lucide-react"
|
||||||
|
|
||||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-[var(--background)]/80 backdrop-blur-lg">
|
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-3 sm:px-4">
|
||||||
<Link href="/" className="flex items-center gap-2.5">
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||||
<Image className="h-4 w-4 text-[var(--accent)]" />
|
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-2 sm:gap-4">
|
||||||
<Link href="/feed" className="text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
<Link
|
||||||
Admin
|
href="/search"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Buscar</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/feed"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Admin</span>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
|
|
||||||
<footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)]">
|
<main className="mx-auto max-w-5xl px-3 py-4 sm:px-4 sm:py-8">{children}</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)] mb-12 sm:mb-0">
|
||||||
worst-scan · traducción automática de manga
|
worst-scan · traducción automática de manga
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -21,11 +21,24 @@ export default async function PostDetailPage({
|
|||||||
.filter((p) => p.id !== post.id)
|
.filter((p) => p.id !== post.id)
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
|
|
||||||
|
const isAnthology =
|
||||||
|
post.tags?.some((t) =>
|
||||||
|
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
|
||||||
|
) || post.num_pages >= 100
|
||||||
|
|
||||||
|
const originalUrl =
|
||||||
|
post.url ||
|
||||||
|
(post.source === "nhentai"
|
||||||
|
? `https://nhentai.net/g/${post.gid}/`
|
||||||
|
: post.source === "ehentai"
|
||||||
|
? `https://e-hentai.org/g/${post.gid}/`
|
||||||
|
: null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="pb-16 md:pb-0">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
className="mb-4 sm:mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
Volver
|
Volver
|
||||||
@@ -33,23 +46,33 @@ export default async function PostDetailPage({
|
|||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
<div className="card overflow-hidden p-0">
|
||||||
<div className="flex flex-col gap-0 md:flex-row">
|
<div className="flex flex-col gap-0 md:flex-row">
|
||||||
<div className="md:w-80 flex-shrink-0">
|
<div className="w-full md:w-80 flex-shrink-0 bg-[var(--surface)]">
|
||||||
<img
|
<img
|
||||||
src={`/api/cover/${post.gid}`}
|
src={`/api/cover/${post.gid}`}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
className="h-auto w-full object-cover md:h-full"
|
className="h-72 sm:h-96 w-full object-cover md:h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 p-6">
|
<div className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold leading-tight">{post.title}</h1>
|
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||||
|
{isAnthology && (
|
||||||
|
<span className="rounded bg-[var(--accent)]/10 px-2 py-0.5 text-[11px] font-semibold text-[var(--accent)] border border-[var(--accent)]/30">
|
||||||
|
Antología / Tomo Completo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{post.num_pages > 0 && (
|
||||||
|
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg sm:text-xl font-bold leading-tight">{post.title}</h1>
|
||||||
{post.title_jpn && (
|
{post.title_jpn && (
|
||||||
<p className="mt-1 text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
|
||||||
{post.artist && (
|
{post.artist && (
|
||||||
<TagBadge tag={`artist:${post.artist}`} href={`/tag/${encodeURIComponent(`artist:${post.artist}`)}`} />
|
<TagBadge tag={`artist:${post.artist}`} href={`/tag/${encodeURIComponent(`artist:${post.artist}`)}`} />
|
||||||
)}
|
)}
|
||||||
@@ -64,7 +87,8 @@ export default async function PostDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{post.summary && (
|
{post.summary && (
|
||||||
<div className="text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line">
|
<div className="text-xs sm:text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line bg-[var(--surface)]/50 p-3 sm:p-4 rounded-lg border border-[var(--border)]">
|
||||||
|
<h3 className="text-[10px] sm:text-xs font-semibold text-[var(--foreground)] uppercase tracking-wider mb-1.5">Sinopsis</h3>
|
||||||
{post.summary}
|
{post.summary}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -77,21 +101,31 @@ export default async function PostDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex items-center gap-2">
|
<div className="hidden md:flex flex-wrap items-center gap-2 pt-2 mt-auto">
|
||||||
<Link
|
<Link
|
||||||
href={`/gallery/${post.gid}/read`}
|
href={`/gallery/${post.gid}/read`}
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
<BookOpen className="h-4 w-4" />
|
<BookOpen className="h-4 w-4" />
|
||||||
Leer
|
Leer Manga
|
||||||
</Link>
|
</Link>
|
||||||
|
{originalUrl && (
|
||||||
|
<a
|
||||||
|
href={originalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-ghost border border-[var(--border)] hover:bg-[var(--surface)]"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Ver en {post.source === "ehentai" ? "e-hentai" : "nhentai"}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={`/api/proxy/galleries/${post.gid}/artifacts`}
|
href={`/api/proxy/galleries/${post.gid}/artifacts`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn-ghost"
|
className="btn-ghost"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
Archivos
|
Archivos
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,12 +133,34 @@ export default async function PostDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky Bottom Action Bar for Mobile */}
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-40 flex items-center gap-2 border-t border-[var(--border)] bg-[var(--background)]/95 backdrop-blur-lg p-3 shadow-2xl">
|
||||||
|
<Link
|
||||||
|
href={`/gallery/${post.gid}/read`}
|
||||||
|
className="btn-primary flex-1 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Leer Manga
|
||||||
|
</Link>
|
||||||
|
{originalUrl && (
|
||||||
|
<a
|
||||||
|
href={originalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-ghost border border-[var(--border)] p-2.5"
|
||||||
|
title={`Ver en ${post.source === "ehentai" ? "e-hentai" : "nhentai"}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
<section className="mt-12">
|
<section className="mt-8 sm:mt-12">
|
||||||
<h2 className="mb-4 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
<h2 className="mb-4 text-xs sm:text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||||
Más publicaciones
|
Más publicaciones
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-4">
|
||||||
{related.map((p) => (
|
{related.map((p) => (
|
||||||
<PostCard key={p.id} post={p} compact />
|
<PostCard key={p.id} post={p} compact />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export default function PublicHomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Publicaciones</h1>
|
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Publicaciones</h1>
|
||||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">
|
||||||
Traducciones automáticas generadas por worst-scan
|
Traducciones automáticas generadas por worst-scan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<PostCard key={post.id} post={post} />
|
<PostCard key={post.id} post={post} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import crypto from "crypto"
|
||||||
|
import { createPost, getPostByGid, updatePost } from "@/lib/db"
|
||||||
|
import { cleanTitle, slugify } from "@/lib/slug"
|
||||||
|
import { cacheCover } from "@/lib/cover-cache"
|
||||||
|
|
||||||
|
function verifySignature(bodyText: string, signature: string, secret: string): boolean {
|
||||||
|
try {
|
||||||
|
const hmac = crypto.createHmac("sha256", secret)
|
||||||
|
hmac.update(bodyText)
|
||||||
|
const expected = hmac.digest("hex")
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rawBody = await req.text()
|
||||||
|
const secret = process.env.WEBHOOK_SECRET || process.env.FANSUB_WEBHOOK_SECRET || ""
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
const signature = req.headers.get("x-signature-256") || req.headers.get("X-Signature-256") || ""
|
||||||
|
if (!signature || !verifySignature(rawBody, signature, secret)) {
|
||||||
|
return Response.json({ error: "Unauthorized: invalid signature" }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(rawBody)
|
||||||
|
|
||||||
|
const gid = payload.gid
|
||||||
|
const rawTitle = payload.title
|
||||||
|
if (!gid || !rawTitle) {
|
||||||
|
return Response.json({ error: "Invalid payload: gid and title required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = cleanTitle(rawTitle)
|
||||||
|
const tags = Array.isArray(payload.tags) ? payload.tags : []
|
||||||
|
const numPages = payload.num_pages || payload.pages || 0
|
||||||
|
const synopsis = payload.synopsis || ""
|
||||||
|
const sourceUrl = payload.url || (payload.source === "nhentai" ? `https://nhentai.net/g/${gid}/` : (payload.source === "ehentai" ? `https://e-hentai.org/g/${gid}/` : undefined))
|
||||||
|
|
||||||
|
const slug = slugify(title, gid)
|
||||||
|
|
||||||
|
let post = getPostByGid(gid)
|
||||||
|
if (!post) {
|
||||||
|
post = createPost({
|
||||||
|
gid,
|
||||||
|
title,
|
||||||
|
title_jpn: payload.title_jpn || undefined,
|
||||||
|
artist: payload.artist || undefined,
|
||||||
|
parody: payload.parody || undefined,
|
||||||
|
tags,
|
||||||
|
num_pages: numPages,
|
||||||
|
source: payload.source || undefined,
|
||||||
|
cover_url: payload.cover_url || undefined,
|
||||||
|
url: sourceUrl,
|
||||||
|
summary: synopsis || undefined,
|
||||||
|
slug,
|
||||||
|
published: 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
post = updatePost(post.id, {
|
||||||
|
title,
|
||||||
|
title_jpn: payload.title_jpn || post.title_jpn || undefined,
|
||||||
|
artist: payload.artist || post.artist || undefined,
|
||||||
|
parody: payload.parody || post.parody || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : post.tags,
|
||||||
|
num_pages: numPages || post.num_pages,
|
||||||
|
source: payload.source || post.source || undefined,
|
||||||
|
cover_url: payload.cover_url || post.cover_url || undefined,
|
||||||
|
url: sourceUrl || post.url || undefined,
|
||||||
|
summary: synopsis || post.summary || undefined,
|
||||||
|
})!
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.PORT || "3000"
|
||||||
|
const proxyUrl = `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`
|
||||||
|
await cacheCover(gid, proxyUrl)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
gid,
|
||||||
|
post,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: String(err) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,44 +21,73 @@ export function Sidebar() {
|
|||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Do not render sidebar / bottom bar if inside reader page
|
||||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-56 flex-col border-r border-[var(--border)] bg-[var(--background)]">
|
if (pathname.includes("/read")) {
|
||||||
<div className="flex items-center gap-2.5 border-b border-[var(--border)] px-5 py-4">
|
return null
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
}
|
||||||
<Image className="h-4 w-4 text-[var(--accent)]" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden md:flex fixed left-0 top-0 z-30 h-screen w-56 flex-col border-r border-[var(--border)] bg-[var(--background)]">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-[var(--border)] px-5 py-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||||
|
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
||||||
|
{links.map(({ href, label, icon: Icon }) => {
|
||||||
|
const isActive = pathname.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all ${
|
||||||
|
isActive
|
||||||
|
? "bg-[var(--surface)] font-medium text-[var(--foreground)]"
|
||||||
|
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border)] p-3">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-all hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Navigation Bar */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 flex h-14 items-center justify-around border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-lg px-2">
|
||||||
{links.map(({ href, label, icon: Icon }) => {
|
{links.map(({ href, label, icon: Icon }) => {
|
||||||
const isActive = pathname.startsWith(href)
|
const isActive = pathname.startsWith(href)
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all ${
|
className={`flex flex-col items-center justify-center gap-1 py-1 px-3 text-[10px] font-medium transition-all ${
|
||||||
isActive
|
isActive ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
? "bg-[var(--surface)] font-medium text-[var(--foreground)]"
|
|
||||||
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
|
<Icon className="h-5 w-5" />
|
||||||
{label}
|
<span>{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</>
|
||||||
<div className="border-t border-[var(--border)] p-3">
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-all hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Salir
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { formatDate } from "@/lib/utils"
|
|||||||
import { TagBadge } from "@/components/tag-badge"
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
|
||||||
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||||
|
const isAnthology =
|
||||||
|
post.tags?.some((t) =>
|
||||||
|
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
|
||||||
|
) || post.num_pages >= 100
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -24,6 +29,9 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
|||||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-1 items-end gap-2">
|
<div className="flex flex-1 items-end gap-2">
|
||||||
|
{isAnthology && (
|
||||||
|
<span className="text-[9px] font-semibold text-[var(--accent)] uppercase">Tomo</span>
|
||||||
|
)}
|
||||||
{post.source && (
|
{post.source && (
|
||||||
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
||||||
)}
|
)}
|
||||||
@@ -41,13 +49,18 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
|
|||||||
href={`/p/${post.slug}`}
|
href={`/p/${post.slug}`}
|
||||||
className="card card-hover group flex flex-col overflow-hidden p-0"
|
className="card card-hover group flex flex-col overflow-hidden p-0"
|
||||||
>
|
>
|
||||||
<div className="aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
<div className="relative aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||||
<img
|
<img
|
||||||
src={`/api/cover/${post.gid}`}
|
src={`/api/cover/${post.gid}`}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
{isAnthology && (
|
||||||
|
<span className="absolute top-2 left-2 rounded bg-black/80 backdrop-blur-sm px-1.5 py-0.5 text-[9px] font-bold text-[var(--accent)] border border-[var(--accent)]/40 shadow">
|
||||||
|
ANTOLOGÍA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export const api = {
|
|||||||
return fetchApi<ApiResponse<PaginatedResponse<GalleryItem>>>(`/galleries?${params}`)
|
return fetchApi<ApiResponse<PaginatedResponse<GalleryItem>>>(`/galleries?${params}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
translated: () =>
|
||||||
|
fetchApi<ApiResponse<{ data: { gid: string; title: string; raw_name: string; has_mobi: boolean; pages: number }[] }>>("/galleries/translated"),
|
||||||
|
|
||||||
get: (gid: string) =>
|
get: (gid: string) =>
|
||||||
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
|
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function cacheCover(gid: string, proxyUrl: string): Promise<string
|
|||||||
|
|
||||||
export function getCoverPath(gid: string): string | null {
|
export function getCoverPath(gid: string): string | null {
|
||||||
ensureDir()
|
ensureDir()
|
||||||
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
|
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(`${gid}.`))
|
||||||
if (files.length === 0) return null
|
if (files.length === 0) return null
|
||||||
return path.join(COVERS_DIR, files[0])
|
return path.join(COVERS_DIR, files[0])
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -30,6 +30,7 @@ function migrate(db: Database.Database) {
|
|||||||
num_pages INTEGER DEFAULT 0,
|
num_pages INTEGER DEFAULT 0,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
cover_url TEXT,
|
cover_url TEXT,
|
||||||
|
url TEXT,
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
slug TEXT UNIQUE NOT NULL,
|
slug TEXT UNIQUE NOT NULL,
|
||||||
published INTEGER DEFAULT 0,
|
published INTEGER DEFAULT 0,
|
||||||
@@ -43,6 +44,10 @@ function migrate(db: Database.Database) {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
@@ -56,6 +61,7 @@ export interface Post {
|
|||||||
num_pages: number
|
num_pages: number
|
||||||
source: string | null
|
source: string | null
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
|
url: string | null
|
||||||
summary: string | null
|
summary: string | null
|
||||||
slug: string
|
slug: string
|
||||||
published: number
|
published: number
|
||||||
@@ -74,6 +80,7 @@ export interface PostInput {
|
|||||||
num_pages?: number
|
num_pages?: number
|
||||||
source?: string
|
source?: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
|
url?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
slug: string
|
slug: string
|
||||||
published?: number
|
published?: number
|
||||||
@@ -117,8 +124,8 @@ export function createPost(input: PostInput): Post {
|
|||||||
const published = input.published ?? 1
|
const published = input.published ?? 1
|
||||||
const publishedAt = published ? "datetime('now')" : null
|
const publishedAt = published ? "datetime('now')" : null
|
||||||
const stmt = d.prepare(`
|
const stmt = d.prepare(`
|
||||||
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
|
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at)
|
||||||
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
|
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
|
||||||
`)
|
`)
|
||||||
const result = stmt.run({
|
const result = stmt.run({
|
||||||
gid: input.gid,
|
gid: input.gid,
|
||||||
@@ -130,6 +137,7 @@ export function createPost(input: PostInput): Post {
|
|||||||
num_pages: input.num_pages || 0,
|
num_pages: input.num_pages || 0,
|
||||||
source: input.source || null,
|
source: input.source || null,
|
||||||
cover_url: input.cover_url || null,
|
cover_url: input.cover_url || null,
|
||||||
|
url: input.url || null,
|
||||||
summary: input.summary || null,
|
summary: input.summary || null,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
published,
|
published,
|
||||||
@@ -157,7 +165,7 @@ export function updatePost(id: number, updates: Partial<PostInput & { published:
|
|||||||
if (fields.length === 0) return getPostById(id)
|
if (fields.length === 0) return getPostById(id)
|
||||||
|
|
||||||
fields.push("updated_at = datetime('now')")
|
fields.push("updated_at = datetime('now')")
|
||||||
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
|
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = @id`).run(values)
|
||||||
return getPostById(id)
|
return getPostById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-27
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
import { api } from "./api"
|
import { api } from "./api"
|
||||||
import { slugify } from "./slug"
|
import { cleanTitle, slugify } from "./slug"
|
||||||
import { createPost, getPostByGid } from "./db"
|
import { createPost, getPostByGid, updatePost } from "./db"
|
||||||
import { cacheCover } from "./cover-cache"
|
import { cacheCover } from "./cover-cache"
|
||||||
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -24,38 +24,72 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
|
|||||||
let newPosts = 0
|
let newPosts = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.galleries.list("completed", 1, 100)
|
const allGids = new Set<string>()
|
||||||
const galleries = res.data?.data || res.data || []
|
|
||||||
|
|
||||||
for (const g of galleries) {
|
try {
|
||||||
|
const res = await api.galleries.list("completed", 1, 100)
|
||||||
|
const galleries = res.data?.data || res.data || []
|
||||||
|
for (const g of galleries) {
|
||||||
|
if (g.gid) allGids.add(g.gid)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transRes = await api.galleries.translated()
|
||||||
|
const translated = (transRes as any)?.data?.data || (transRes as any)?.data || []
|
||||||
|
for (const t of translated) {
|
||||||
|
if (t.gid) allGids.add(t.gid)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
for (const gid of allGids) {
|
||||||
try {
|
try {
|
||||||
const existing = getPostByGid(g.gid)
|
const sumRes = await api.galleries.summary(gid)
|
||||||
if (existing) continue
|
|
||||||
|
|
||||||
const sumRes = await api.galleries.summary(g.gid)
|
|
||||||
const summary = sumRes?.data
|
const summary = sumRes?.data
|
||||||
if (!summary) continue
|
if (!summary || !summary.title) continue
|
||||||
|
|
||||||
const slug = slugify(summary.title, g.gid)
|
const cleanT = cleanTitle(summary.title)
|
||||||
|
const slug = slugify(cleanT, gid)
|
||||||
|
const finalSummary = summary.synopsis && summary.synopsis.trim() !== ""
|
||||||
|
? summary.synopsis
|
||||||
|
: generateSummary(summary)
|
||||||
|
const sourceUrl = summary.url || (summary.source === "nhentai" ? `https://nhentai.net/g/${gid}/` : (summary.source === "ehentai" ? `https://e-hentai.org/g/${gid}/` : undefined))
|
||||||
|
|
||||||
await createPost({
|
const existing = getPostByGid(gid)
|
||||||
gid: g.gid,
|
|
||||||
title: summary.title,
|
|
||||||
title_jpn: summary.title_jpn,
|
|
||||||
artist: summary.artist,
|
|
||||||
parody: summary.parody,
|
|
||||||
tags: summary.tags,
|
|
||||||
num_pages: summary.num_pages,
|
|
||||||
source: summary.source,
|
|
||||||
cover_url: summary.cover?.external_url,
|
|
||||||
summary: generateSummary(summary),
|
|
||||||
slug,
|
|
||||||
published: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
|
if (!existing) {
|
||||||
|
await createPost({
|
||||||
|
gid,
|
||||||
|
title: cleanT,
|
||||||
|
title_jpn: summary.title_jpn,
|
||||||
|
artist: summary.artist,
|
||||||
|
parody: summary.parody,
|
||||||
|
tags: summary.tags,
|
||||||
|
num_pages: summary.num_pages,
|
||||||
|
source: summary.source,
|
||||||
|
cover_url: summary.cover?.external_url,
|
||||||
|
url: sourceUrl,
|
||||||
|
summary: finalSummary,
|
||||||
|
slug,
|
||||||
|
published: 1,
|
||||||
|
})
|
||||||
|
newPosts++
|
||||||
|
} else {
|
||||||
|
updatePost(existing.id, {
|
||||||
|
title: cleanT,
|
||||||
|
title_jpn: summary.title_jpn || undefined,
|
||||||
|
artist: summary.artist || undefined,
|
||||||
|
parody: summary.parody || undefined,
|
||||||
|
tags: summary.tags,
|
||||||
|
num_pages: summary.num_pages,
|
||||||
|
source: summary.source || undefined,
|
||||||
|
url: sourceUrl || existing.url || undefined,
|
||||||
|
summary: finalSummary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
newPosts++
|
const port = process.env.PORT || "3000"
|
||||||
|
await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -1,8 +1,34 @@
|
|||||||
|
export function cleanTitle(raw: string): string {
|
||||||
|
if (!raw) return ""
|
||||||
|
|
||||||
|
let title = raw
|
||||||
|
|
||||||
|
// 1. Remove dual-language subtitle after '|'
|
||||||
|
if (title.includes("|")) {
|
||||||
|
title = title.split("|")[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove language / format tags in brackets or braces
|
||||||
|
title = title.replace(/\s*[\(\[\{](?:spanish|english|chinese|japanese|korean|digital|decensored|uncensored|censored|colorized|colored|full color|doujins\.com|hennojin|sample|raw)[\)\]\}]/gi, "")
|
||||||
|
|
||||||
|
// 3. Remove leading circle / artist in brackets
|
||||||
|
title = title.replace(/^\s*\[[^\]]+\]\s*/, "")
|
||||||
|
|
||||||
|
// 4. Remove trailing parody in parentheses if present
|
||||||
|
title = title.replace(/\s*\([^)]+\)\s*$/, "")
|
||||||
|
|
||||||
|
// 5. Remove trailing numbers/ID in parens or brackets
|
||||||
|
title = title.replace(/\s*[\(\[]\d+[\)\]]\s*$/, "")
|
||||||
|
|
||||||
|
title = title.trim()
|
||||||
|
|
||||||
|
return title || raw
|
||||||
|
}
|
||||||
|
|
||||||
export function slugify(text: string, gid?: string): string {
|
export function slugify(text: string, gid?: string): string {
|
||||||
let slug = text
|
const cleaned = cleanTitle(text)
|
||||||
|
let slug = cleaned
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\[.*?\]/g, "")
|
|
||||||
.replace(/\(.*?\)/g, "")
|
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
@@ -11,11 +37,11 @@ export function slugify(text: string, gid?: string): string {
|
|||||||
.slice(0, 80)
|
.slice(0, 80)
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
slug = `gallery`
|
slug = "gallery"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gid) {
|
if (gid) {
|
||||||
slug = `${slug}-${gid.slice(0, 8)}`
|
slug = `${slug}-${gid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return slug
|
return slug
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface GallerySummary {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
artist: string
|
artist: string
|
||||||
parody: string
|
parody: string
|
||||||
|
synopsis?: string
|
||||||
cover: {
|
cover: {
|
||||||
local_endpoint: string
|
local_endpoint: string
|
||||||
local_available: boolean
|
local_available: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user