Files
worst-scan-web/AGENTS.md
T

364 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# worst-scan-web — Portal Web del Fansub (Next.js 16)
Portal web público + panel admin del fansub de manga **worst-scan** (escaneado y traducción automática JP/EN/KR/CN → ES). Este repo es SOLO el frontend web (Next.js). El pipeline de traducción (descarga, OCR, LLM, inpainting, render, MOBI, bot de Telegram) vive en un repo aparte y expone una API REST FastAPI en `:8080` (`API_BASE_URL`), que esta web consume vía proxy.
**Dominio de producción:** `manga.cbcren.online` (vía Caddy con auto-HTTPS)
**Doc de arquitectura original:** ver `agy.md` (AGY = Arquitectura, Galerías, Yapa)
---
## Stack
| Capa | Tecnología |
|------|-----------|
| Framework | Next.js **16.2.11** (App Router, Turbopack, `output: standalone`) |
| UI | React **19.2.4**, Tailwind CSS **4**, iconos lucide-react |
| Datos | SQLite vía `better-sqlite3` ^13 (modo WAL), SWR ^2.4.2 en cliente |
| Auth | JWT HS256 vía `jose` ^6 (cookie `session`, expiración 7d) |
| Utilidades | JSZip ^3 (parseo CBZ en cliente), TypeScript 5 |
| Deploy | Docker (multi-stage Node 22-alpine) o systemd + Node directo; Caddy como reverse proxy |
---
## Arquitectura General
```
┌─────────────────────────────────────┐
│ Caddy (manga.cbcren.online) │
│ auto-HTTPS + TLS │
└──────────────┬──────────────────────┘
┌──────────────▼──────────────────────┐
│ worst-scan-web (Next.js) │
│ puerto 3000 (container) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Sitio PÚBLICO (server-side)│ │
│ │ / /p/[slug] /tag/[tag] │ │
│ │ → SQLite directo (posts) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Panel PRIVADO (client-side)│ │
│ │ /feed /queue /search │ │
│ │ /submit /admin/posts │ │
│ │ → SWR → /api/proxy/* │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ API propia: /api/auth, │ │
│ │ /api/posts, /api/cover, │ │
│ │ /api/cron, /api/setup, │ │
│ │ /api/webhooks/manga, │ │
│ │ /api/proxy/[...path] │ │
│ └─────────────────────────────┘ │
└──────────────┬──────────────────────┘
│ API_BASE_URL (proxy)
┌──────────────▼──────────────────────┐
│ Pipeline REST (FastAPI :8080) │
│ /api/v1/health|status|galleries │
│ /summary|artifacts|cover|pages │
└─────────────────────────────────────┘
```
---
## Estructura de Archivos
```
src/
├── lib/
│ ├── db.ts # SQLite: tabla posts + capa de datos completa
│ ├── auth.ts # JWT HS256: createSession / verifySession
│ ├── api.ts # Cliente del pipeline REST (API_BASE_URL)
│ ├── types.ts # Interfaces (GallerySummary, etc.)
│ ├── cbz.ts # loadPagesFromCbz (JSZip) — cliente
│ ├── cover-cache.ts # cacheCover / getCoverPath (disco, por gid)
│ ├── poller.ts # startPoller / pollOnce — sync pipeline→posts
│ ├── settings.ts # initSettings / get / saveSettings (JSON)
│ ├── slug.ts # slugify / cleanTitle
│ └── utils.ts # formatSize, extractTag, cn, etc.
├── middleware.ts # Auth por prefijo de path (ver §Auth)
├── instrumentation.ts # register() → initSettings + startPoller (1 vez)
├── app/
│ ├── layout.tsx # Root: <html lang="es">, metadata, Inter
│ ├── globals.css # Tema oscuro + @utility Tailwind v4
│ ├── (public)/ # GRUPO PÚBLICO — server-side, sin login
│ │ ├── layout.tsx # Header sticky + nav "Buscar"
│ │ ├── page.tsx # / grid de posts publicados
│ │ ├── p/[slug]/page.tsx # detalle de post (fansub)
│ │ └── tag/[tag]/page.tsx # posts por tag
│ ├── (app)/ # GRUPO PRIVADO — client-side, login requerido
│ │ ├── layout.tsx # Sidebar desktop + bottom-nav móvil
│ │ ├── feed/page.tsx # /feed galleries del pipeline
│ │ ├── gallery/[gid]/page.tsx # detalle gallery
│ │ ├── gallery/[gid]/read/page.tsx # lector (paged/webtoon)
│ │ ├── queue/page.tsx # /queue estado pipeline
│ │ ├── search/page.tsx # /search nhentai/e-hentai
│ │ ├── submit/page.tsx # /submit encolar URLs
│ │ └── admin/posts/page.tsx + [id]/page.tsx # CRUD posts
│ ├── login/page.tsx # /login (password)
│ ├── setup/page.tsx # /setup primer arranque (4 campos)
│ └── api/ # Route handlers (ver §API)
├── components/ # cover-image, gallery-card, gallery-grid,
│ │ # post-card, tag-badge, filter-bar,
│ │ # search-form, submit-form, empty-state,
│ │ # layout/sidebar
├── hooks/ # use-galleries, use-queue, use-status
data/ # worst-scan.db (SQLite), covers/, (runtime)
deploy/ # Caddyfile, setup.sh, worst-scan-web.service
scripts/ # start.js, setup.sh
```
---
## Base de Datos (`src/lib/db.ts`)
SQLite vía `better-sqlite3`. Ruta: `process.env.DB_PATH` o `data/worst-scan.db`. WAL + `foreign_keys = ON`.
**Tabla `posts`:**
| Columna | Tipo | Notas |
|---------|------|-------|
| `id` | INTEGER | PK AUTOINCREMENT |
| `gid` | TEXT | NOT NULL, UNIQUE — id de la gallery del pipeline |
| `title` | TEXT | NOT NULL |
| `title_jpn` | TEXT | nullable |
| `artist` | TEXT | nullable |
| `parody` | TEXT | nullable |
| `tags` | TEXT | JSON serializado (`string[]`) |
| `num_pages` | INTEGER | DEFAULT 0 |
| `source` | TEXT | `nhentai` / `ehentai` |
| `cover_url` | TEXT | nullable |
| `url` | TEXT | nullable |
| `summary` | TEXT | nullable |
| `slug` | TEXT | UNIQUE NOT NULL — URL amigable `/p/{slug}` |
| `published` | INTEGER | DEFAULT 0 |
| `created_at` | TEXT | `datetime('now')` |
| `updated_at` | TEXT | `datetime('now')` |
| `published_at` | TEXT | nullable |
**Índices:** `idx_posts_gid`, `idx_posts_slug` (redundantes con UNIQUE), `idx_posts_published`, `idx_posts_created(created_at DESC)`.
**Funciones de la capa de datos:** `getDb()`, `migrate()`, `rowToPost()`, `getAllPosts(publishedOnly)`, `getPostById()`, `getPostByGid()`, `getPostBySlug()`, `createPost()`, `updatePost()`, `publishPost()`, `deletePost()`, `getPostsByTag()` (usa `json_each`), `getPostCount()`, `getGidsNotInPosts()` (**stub — devuelve `[]`**).
> ⚠️ **`createPost` publica por defecto** (`published = input.published ?? 1`) — todo post nuevo sale publicado.
> ⚠️ **`updatePost` interpola los nombres de columna del body sin whitelist** → riesgo de SQL injection (ver §Bugs).
---
## Autenticación (`src/lib/auth.ts` + `src/middleware.ts`)
- **Cookie:** `session` (httpOnly, `secure` en production, sameSite lax, 7d).
- **Token:** JWT HS256, payload `{ authenticated: true, timestamp }`, expiración `7d`.
- **Secret:** `process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret"` ⚠️ fallback predecible.
- **Login:** `POST /api/auth/login` con `{ password }` → compara con `WEB_PASSWORD`. **Si no hay `WEB_PASSWORD` configurada, emite sesión sin verificar nada.**
- **Logout:** `POST /api/auth/logout` borra la cookie (el JWT no se revoca, solo expira).
- **Middleware** (`src/middleware.ts`): paths en `publicPaths` → allow; páginas `/`, `/p/`, `/tag/` → allow; **sin `WEB_PASSWORD` → TODO abierto**; si no, exige cookie válida → redirect `/login`.
**`publicPaths`:** `/login`, `/api/auth/login`, `/api/auth/logout`, `/api/health`, `/api/proxy/health`, `/api/posts`, `/api/cover`, `/api/cron`, `/api/setup`, `/setup`, `/_next`, `/favicon.ico`, `/fonts`.
> ⚠️ **BUG:** la coincidencia es por **prefijo** (`pathname.startsWith(p)`): `/api/posts` también destapa `/api/posts/[id]` y `/api/posts/[id]/publish`; `/api/cron` destapa `/api/cron/poll`. Consecuencia: crear/editar/borrar/publicar posts y disparar el cron **nunca requieren auth**.
---
## API Propia (route handlers)
| Ruta | Métodos | Auth | Función |
|------|---------|------|---------|
| `/api/auth/login` | POST | pública | Valida password → setea cookie `session` |
| `/api/auth/logout` | POST | pública | Borra cookie |
| `/api/health` | GET | pública | `{status:"ok", uptime}` — healthcheck |
| `/api/cover/[gid]` | GET | pública | Sirve portada cacheada (`Cache-Control: public, max-age=86400`); si falta, self-cachea desde `/api/proxy/galleries/{gid}/cover`; 404 si no hay |
| `/api/cron/poll` | GET | pública (bug) | Invoca `pollOnce()``{ok, newPosts}` |
| `/api/posts` | GET/POST | pública (bug) | GET: lista posts (`?published=1` filtra); POST: crea (409 si el gid existe) |
| `/api/posts/[id]` | GET/PATCH/DELETE | pública (bug) | GET detalle; PATCH `updatePost` (⚠️ SQLi); DELETE borra + cover |
| `/api/posts/[id]/publish` | POST | pública (bug) | `{publish: bool}``publishPost` |
| `/api/proxy/[...path]` | GET/POST/PUT/DELETE/PATCH | protegida* | Proxy inverso al pipeline (`API_BASE_URL` + `/` + subpath) |
| `/api/setup` | GET/POST | pública | GET: `{configured}`; POST: guarda allowlist `["API_BASE_URL","API_KEY","WEB_PASSWORD","WEBHOOK_SECRET"]` en `data/settings.json` ⚠️ sin auth |
| `/api/webhooks/manga` | POST | firma HMAC* | Crea/actualiza post desde el pipeline + cachea cover |
\* cuando `WEB_PASSWORD`/`WEBHOOK_SECRET` están configurados; si no, abiertos.
---
## Proxy (`src/app/api/proxy/[...path]/route.ts`)
- Target: `API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"`.
- Autentica upstream con `X-API-Key: process.env.API_KEY` si está set.
- Mapeo: `/api/proxy/galleries/123?x=1``{API_BASE}/galleries/123?x=1` (query string se pasa tal cual).
- Reenvía body (blob) solo en métodos no-GET/HEAD; copia `Content-Type` de vuelta; descarta `content-encoding/content-length/transfer-encoding`.
- Errores upstream → `502 {error:{code:"proxy_error", message}}`.
- `GET /api/proxy/health` público (allowlist); el resto protegido por sesión (si `WEB_PASSWORD` set).
---
## Poller (`src/lib/poller.ts` + `src/instrumentation.ts`)
- `POLL_INTERVAL_MS = 60_000` — corre cada 1 minuto (más una vez al arranque).
- Arranque: `instrumentation.ts``register()` (solo `NEXT_RUNTIME === "nodejs"`) llama `initSettings()` + `startPoller()`.
- **`pollOnce()`:**
1. Lista `api.galleries.list("completed", 1, 100)` + `api.galleries.translated()` del pipeline.
2. Por cada gid: `api.galleries.summary(gid)`.
3. `cleanTitle()` + `slugify(cleanT, gid)`; sintetiza summary (`synopsis` o `generateSummary()`).
4. **Auto-publica todo:** no existe → `createPost({..., published: 1})`; existe → `updatePost`.
5. `cacheCover(gid, "http://127.0.0.1:{PORT||3000}/api/proxy/galleries/{gid}/cover")`.
> ⚠️ **BLOQUEANTE CONOCIDO:** el endpoint `/galleries` del pipeline solo lista las galleries del UrlQueue activo (~6), no las traducidas en `output/artifacts/` (~38 invisibles). El poller no puede descubrirlas. Solución: que `/galleries` escanee `output/artifacts/` o use `/galleries/translated`.
> ⚠️ **BUG self-fetch:** `cacheCover` fetchea `/api/proxy/...` (protegido por middleware); sin cookie de servidor, con `WEB_PASSWORD` set el fetch recibe redirect 307 a `/login` y **guarda HTML de login como portada**.
---
## Webhook (`src/app/api/webhooks/manga/route.ts`)
- **Solo POST.** Firma HMAC-SHA256 del raw body; header `x-signature-256` o `X-Signature-256`. Secret: `WEBHOOK_SECRET || FANSUB_WEBHOOK_SECRET`. **Sin secret configurado → verificación omitida, endpoint abierto.**
- **Payload:** `gid` (req), `title` (req), `tags[]`, `num_pages|pages`, `synopsis`, `url`, `source`, `title_jpn`, `artist`, `parody`, `cover_url`.
- **Acción:** no existe → `createPost({..., published: 1})`; existe → `updatePost`. Luego `cacheCover(...)` (mismo bug de self-fetch).
- Respuesta `{success, gid, post}`; 401 firma mala; 400 sin gid/title.
---
## Caché de Portadas (`src/lib/cover-cache.ts`)
- Storage: `process.env.COVERS_DIR || data/covers/`. Archivo por gid: `{gid}.{ext}` (sin hash).
- `cacheCover(gid, proxyUrl)`: fetch con timeout 15s → buffer a disco; falla silenciosa (`null`).
- `getCoverPath(gid)`: `readdirSync().filter(f => f.startsWith(gid + "."))`. `getCoverContentType`, `deleteCover`.
---
## CBZ (`src/lib/cbz.ts`) — solo cliente
- `loadPagesFromCbz(url, signal?)``{pages, pageCount}`: fetch → JSZip → filtra imágenes (`.jpg/.jpeg/.png/.webp/.gif/.bmp`) → ordena por primer número en el nombre → `URL.createObjectURL` por página.
- `revokePageUrls(urls)` libera los object URLs.
- No hay lectura de CBZ en servidor ni endpoints de páginas reales (plan: `/api/pages/[gid]`).
---
## Frontend — Grupos y Páginas
### `(public)` — server-side, sin login (lee SQLite directo)
- `/` — grid de posts publicados (`getAllPosts(true)`), `PostCard`, `grid-cols-2 sm:3 md:4 xl:5`.
- `/p/[slug]` — detalle de post: portada `/api/cover/{gid}`, título + `title_jpn`, badge "Antología/Tomo" si `isAnthology` (tags anthology/compilation/tankoubon o `num_pages >= 100`), `TagBadge` (artist/parody → `/tag/...`), sinopsis solo si `isRealSynopsis` (no empieza con `**`/`Fuente:` y >50 chars), "Más publicaciones" (4 compact). Botones: Leer Manga (`/gallery/[gid]/read`**privado**), Ver en nhentai/e-hentai, Archivos (desktop).
- `/tag/[tag]` — posts por tag (`getPostsByTag`), `decodeURIComponent(tag)` ⚠️ sin try/catch.
### `(app)` — client-side, login requerido (SWR → `/api/proxy/*`)
- `/feed` — galleries del pipeline (`useGalleries(status, 1)` → SWR `/api/proxy/galleries?page=1&per_page=50&status=...`, refresh 30s/10s), `FilterBar` + `GalleryGrid` + skeletons.
- `/gallery/[gid]` — detalle: SWR `/summary` + `/artifacts`, `CoverImage`, tags, badge ESP, lista de archivos colapsable, botones Leer/Original/Eliminar.
- `/gallery/[gid]/read`**lector**: modos `paged` (zoom 0.53, flechas, slider, tap zones, swipe >40px, precarga 2 págs) y `webtoon` (scroll continuo, lazy desde 5ª); controles auto-ocultos 3.5s; fullscreen (`f`); keyboard `←→`, `+/-/0`; cascada de fallback para páginas: `/pages``/summary` (num_pages) → `/artifacts` → HEAD `/cover` → error. Root `fixed inset-0 z-50 bg-[#09090b] text-white touch-none`.
- `/queue` — estado pipeline: SWR `/api/proxy/queue?status=...&per_page=100` + `/api/proxy/status` (refresh 10s), `SlotGauge`, `StatBox`, botón Retry.
- `/search``SearchForm`: POST `/api/proxy/search` `{query, source}` → grid con estado `blocked`; "Encolar" / "Encolar todo (N)" → POST `/api/proxy/galleries`.
- `/submit``SubmitForm`: textarea URLs → POST `/api/proxy/galleries` por línea.
- `/admin/posts` — CRUD posts: toggle publicar (POST `/api/posts/{id}/publish`), eliminar (confirm), links a `/p/{slug}` y `/gallery/{gid}`; editor `[id]`: PATCH `{title, summary, slug}`.
- Layout: `Sidebar` desktop fija (`w-56`, 5 links + Salir) + bottom-nav móvil (`h-14`, sin Salir); logo apunta a `/` (público).
### Standalone
- `/login` — password → POST `/api/auth/login``/feed`.
- `/setup` — primer arranque: GET `/api/setup` → si `configured` va a `/feed`; si no, 4 campos (`API_BASE_URL`, `API_KEY`, `WEB_PASSWORD` min 6, `WEBHOOK_SECRET`) → POST `/api/setup`.
### Hooks
`useGalleries(status, page)` (usado), `useGallery` (muerto), `useQueue`/`useQueueStats` (muertos), `useStatus`/`useSystemStatus` (muerto). QueuePage y GalleryDetailPage reimplementan SWR inline con fetcher duplicado.
---
## Estilos (Tailwind CSS 4)
- `@import "tailwindcss" source("../../src")` en `globals.css`. Sin CSS modules.
- **Tema oscuro exclusivo** (`html { color-scheme: dark }`): `--background:#09090b`, `--surface:#18181b`, `--border:#27272a`, `--foreground:#fafafa`, `--muted:#a1a1aa`, `--accent:#6366f1` (indigo), `--success:#22c55e`, `--warning:#eab308`, `--error:#ef4444` (+ variantes `-subtle`), `--radius:.5rem`.
- `@utility` custom: `card`, `card-hover`, `tag-pill`, `btn`, `btn-primary`, `btn-ghost`, `btn-danger`, `input`, `skeleton` (+ shimmer).
- Fuente **Inter** (`@font-face` woff2 de fonts.gstatic.com, fallback system-ui). Iconos lucide-react. Scrollbars finos.
- Hardcodeos menores: lector `bg-[#09090b]` fijo; badge ESP emerald fijo.
---
## Variables de Entorno (`.env` / `.env.example`)
| Variable | Default | Propósito |
|----------|---------|-----------|
| `API_BASE_URL` | `http://host.docker.internal:8080/api/v1` | Base URL del pipeline REST (bare-metal: `127.0.0.1`) |
| `API_KEY` | *(vacío)* | API key del pipeline (vacío = sin auth upstream) |
| `WEB_PASSWORD` | *(vacío)* | Password del panel admin (vacío = todo abierto) |
| `WEBHOOK_SECRET` | *(vacío)* | Validación HMAC del webhook |
| `DB_PATH` | `data/worst-scan.db` | Ruta SQLite |
| `COVERS_DIR` | `data/covers` | Directorio de portadas |
| `JWT_SECRET` | — | (usado por auth.ts si existe; si no, cae a `WEB_PASSWORD`) |
| `PORT` | 3000 | Puerto del server (Dockerfile/systemd) |
| `HOSTNAME` | 0.0.0.0 | Bind (Docker/systemd) |
---
## Despliegue
**Dos estrategias documentadas, MUTUAMENTE EXCLUYENTES:**
### A) Docker (agy.md — la real en producción)
- Multi-stage Node 22-alpine: `deps` (python3/make/g++ para better-sqlite3 + `npm ci`) → `builder` (`npm run build`) → `runner` (usuario `nextjs` uid 1001, `EXPOSE 3000`, `ENV PORT=3000 HOSTNAME=0.0.0.0 DB_PATH=/app/data/worst-scan.db COVERS_DIR=/app/data/covers`, `VOLUME /app/data`, HEALTHCHECK `wget /api/health`).
- Compose: `expose: 3000` (sin mapeo), `env_file: .env`, `extra_hosts: host.docker.internal:host-gateway`, red `caddy` (external), volumen `data:/app/data`, `restart: unless-stopped`.
- Caddy en contenedor `caddy-ingress` con auto-HTTPS para `manga.cbcren.online`; recarga: `docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile`. Caddyfile canónico del host: `/home/ren/immich/Caddyfile`.
### B) systemd + Node directo (`install.sh` / `deploy/setup.sh`)
- Instala Node 22 + Caddy, `npm run build`, corre `.next/standalone/server.js` bajo systemd (`Type=exec`, `Restart=always`, `HOSTNAME=127.0.0.1`, `PORT=3000`).
- ⚠️ `install.sh` **desinstala Docker** a propósito — no conviven con la estrategia A.
- ⚠️ `deploy/setup.sh` escribe `WorkingDirectory=$APP_DIR/standalone` (falta `.next/`).
- ⚠️ **`deploy/setup.sh:22` contiene la contraseña de sudo hardcodeada en texto plano** — CRÍTICO, parametrizar.
### Scripts
- `npm start``scripts/start.js`: sincroniza `.next/static` → standalone, `NODE_ENV=production`, spawn `node server.js` con `PORT`/`HOSTNAME`.
- `install.sh`: swap 1GB si <512MB → git/curl → **Docker cleanup** → Node 22 (nodesource) → mirror npm si China → Caddy (cloudsmith) → clone `gitea.cbcren.online/renato97/worst-scan-web` (reset --hard) → `.env` (nunca sobreescribe) → build → systemd unit → bloque Caddy `:80` → health wait → resumen con `http://IP/setup`.
---
## Bugs Conocidos & Deuda Técnica
### 🔴 Seguridad
1. **SQL injection en `updatePost`** (`db.ts:148-170`) — nombres de columna del body interpolados sin whitelist; permite sobrescribir `published`/`published_at` y columnas arbitrarias.
2. **Middleware por prefijo** (`middleware.ts:23`) — `/api/posts*` y `/api/cron*` públicos en la práctica (PATCH/DELETE/publish/cron sin auth).
3. **Sin `WEB_PASSWORD` → app 100% abierta**, incluidas rutas admin.
4. **`/api/setup` sin auth** — cualquiera configura la app y fija secrets (guardados en texto plano en `data/settings.json`).
5. **Webhook sin `WEBHOOK_SECRET` → abierto** (crear/modificar posts arbitrarios).
6. **JWT fallback predecible** (`worst-scan-web-dev-secret`); si se usa `WEB_PASSWORD` como clave, quien la conozca forja tokens.
7. **Password de sudo hardcodeada** en `deploy/setup.sh:22`.
8. Logout no revoca el JWT (solo expira); sin refresh/rotación.
### 🐛 Funcionales
9. **~38 galerías invisibles** — `/galleries` del pipeline solo lista las del UrlQueue (~6); el poller no descubre las traducidas. Bloqueante para la web.
10. **Cover self-cache rota con auth** — fetch de servidor sin cookie recibe 307 → guarda HTML de login como portada.
11. **`createPost` publica todo por defecto** — no hay flujo de borrador real.
12. `getGidsNotInPosts()` es un **stub** (`return []`).
13. `ALTER TABLE posts ADD COLUMN url` en `migrate()` es **código muerto** (columna ya existe → siempre lanza y se traga).
14. **Enlaces rotos público→privado**: "Buscar" (`/search`) y "Leer Manga" (`/gallery/[gid]/read`) llevan a rutas con login.
15. **Bug de conteo en `SubmitForm`**`"X de Y ok"` inflado (cuenta todo menos `"?"`); criterio de fila inconsistente con el contador.
16. **Hooks muertos**: `useGallery`, `useQueue`, `useQueueStats`, `useStatus` — sin uso; fetcher SWR duplicado en 6 archivos.
17. **Overlap bottom-nav móvil** — el layout `(app)` no compensa la nav fija (`p-0` sin `pb-14`).
18. **Logo del sidebar → `/`** (saca al usuario del panel al sitio público); **sin logout en móvil**.
19. **`decodeURIComponent(tag)` sin try/catch** → 500 con `%` literal en el tag.
20. **Sin paginación**`per_page=50/100` fijos, sin UI de página pese a que la API devuelve `meta`.
21. Lector: zoom solo desktop; tap zones inertes en webtoon; `touch-none`+`touch-pan-y` pueden trabar scroll; título `g/{gid}` si falla summary.
22. `window.location.href = "/feed"` tras eliminar (recarga completa); errores silenciosos en editor de posts, retry y search.
### 🟡 Docs / Infra
23. `README.md` es boilerplate de create-next-app; `AGENTS.md` (viejo) documentaba el pipeline Python que no está en este repo — esta versión lo reemplaza.
24. Docker vs systemd contradictorios (`install.sh` desinstala Docker).
25. `.env.example` con paths Docker (`/app/data`, `host.docker.internal`) que en bare-metal hay que sobreescribir.
26. CORS `*` en la API del pipeline (lockear en producción).
27. `dominios-disponibles.md` desactualizado (`manga.cbcren.online` figura libre).
---
## Roadmap / Plan Pendiente (de `agy.md`, 7 pasos)
1. Columnas `pages_count`, `mobi_stored`, `cbz_path` en `posts`.
2. Nuevo `src/lib/gallery-store.ts` (downloadAndExtractCbz con JSZip, downloadMobi, getPagePath…).
3. Rewrite del poller (que descubra galleries traducidas).
4. Endpoints `/api/pages/*` y `/api/download/*/mobi`.
5. Mover lector a grupo `(reader)/` (fuera del layout admin).
6. 6 UI fixes (enlaces públicos, paginación, hooks, bottom-nav…).
7. Cover fallback a `pages/001.jpg`.
---
## What NOT to Add
- No CI/CD pipelines, no pre-commit hooks, no linter/formatter configs nuevos (seguir el espíritu del repo).
- No escribir raw SQL fuera de `db.ts`.
- No exponer secrets en texto plano (ni en scripts ni en `settings.json`).
- No nuevos entry points sin discusión previa.