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:
Renato
2026-07-24 01:59:40 +02:00
parent 269762557e
commit 72e503d7bc
22 changed files with 824 additions and 300 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"fingerprint": "ee41cd16aee142978eedd00dcf1854a07a534d9e" "fingerprint": "c6be2155b5d458c41adc0623b75fe4fad880b5f6"
} }
+47 -1
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -5,3 +5,4 @@ next-env.d.ts
.env .env
.env.local .env.local
data/ data/
.atl/
+12 -2
View File
@@ -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:
+91
View File
@@ -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
View File
@@ -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
-114
View File
@@ -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": [
+297 -88
View File
@@ -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 <button
onClick={() => goTo(page - 1)} 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
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"
<button
key={i}
onClick={() => goTo(i)}
className={`h-1.5 rounded-full transition-all ${
i === page ? "w-6 bg-[var(--accent)]" : "w-1.5 bg-[var(--border)] hover:bg-[var(--muted)]"
}`} }`}
/> >
))} {/* Page Scrubber Slider */}
{totalPages > 100 && ( <div className="flex items-center gap-3 w-full max-w-xl mx-auto">
<span className="text-[10px] text-[var(--muted)] ml-1">+{totalPages - 100}</span> <button
)} onClick={() => goTo(page - 1)}
</div> disabled={page === 0}
)} className="p-1 text-white/70 hover:text-white disabled:opacity-30"
>
<ChevronLeft className="h-5 w-5" />
</button>
{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}
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> </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>
) )
+4 -2
View File
@@ -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>
) )
} }
+21 -8
View File
@@ -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 &middot; traducción automática de manga worst-scan &middot; traducción automática de manga
</footer> </footer>
</> </>
+71 -15
View File
@@ -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 />
))} ))}
+4 -4
View File
@@ -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} />
))} ))}
+90
View File
@@ -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 })
}
}
+30 -1
View File
@@ -21,13 +21,22 @@ export function Sidebar() {
router.push("/login") router.push("/login")
} }
// Do not render sidebar / bottom bar if inside reader page
if (pathname.includes("/read")) {
return null
}
return ( return (
<aside className="fixed left-0 top-0 z-30 flex h-screen w-56 flex-col border-r border-[var(--border)] bg-[var(--background)]"> <>
{/* 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"> <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)]"> <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>
</div> </div>
<nav className="flex flex-1 flex-col gap-0.5 p-3"> <nav className="flex flex-1 flex-col gap-0.5 p-3">
@@ -60,5 +69,25 @@ export function Sidebar() {
</button> </button>
</div> </div>
</aside> </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 }) => {
const isActive = pathname.startsWith(href)
return (
<Link
key={href}
href={href}
className={`flex flex-col items-center justify-center gap-1 py-1 px-3 text-[10px] font-medium transition-all ${
isActive ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
<Icon className="h-5 w-5" />
<span>{label}</span>
</Link>
)
})}
</nav>
</>
) )
} }
+14 -1
View File
@@ -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">
+3
View File
@@ -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}`),
+1 -1
View File
@@ -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
View File
@@ -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)
} }
+48 -14
View File
@@ -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
@@ -23,24 +23,44 @@ export function stopPoller() {
export async function pollOnce(): Promise<{ newPosts: number }> { export async function pollOnce(): Promise<{ newPosts: number }> {
let newPosts = 0 let newPosts = 0
try {
const allGids = new Set<string>()
try { try {
const res = await api.galleries.list("completed", 1, 100) const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data?.data || res.data || [] const galleries = res.data?.data || res.data || []
for (const g of galleries) { for (const g of galleries) {
if (g.gid) allGids.add(g.gid)
}
} catch {}
try { try {
const existing = getPostByGid(g.gid) const transRes = await api.galleries.translated()
if (existing) continue const translated = (transRes as any)?.data?.data || (transRes as any)?.data || []
for (const t of translated) {
if (t.gid) allGids.add(t.gid)
}
} catch {}
const sumRes = await api.galleries.summary(g.gid) for (const gid of allGids) {
try {
const sumRes = await api.galleries.summary(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))
const existing = getPostByGid(gid)
if (!existing) {
await createPost({ await createPost({
gid: g.gid, gid,
title: summary.title, title: cleanT,
title_jpn: summary.title_jpn, title_jpn: summary.title_jpn,
artist: summary.artist, artist: summary.artist,
parody: summary.parody, parody: summary.parody,
@@ -48,14 +68,28 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
num_pages: summary.num_pages, num_pages: summary.num_pages,
source: summary.source, source: summary.source,
cover_url: summary.cover?.external_url, cover_url: summary.cover?.external_url,
summary: generateSummary(summary), url: sourceUrl,
summary: finalSummary,
slug, slug,
published: 1, published: 1,
}) })
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
newPosts++ 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,
})
}
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
View File
@@ -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
+1
View File
@@ -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