feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"fingerprint": "ee41cd16aee142978eedd00dcf1854a07a534d9e"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Skill Registry — worst_scan_web
|
||||||
|
|
||||||
|
<!-- Auto-generated by gentle-ai skill-registry refresh. Run `gentle-ai skill-registry refresh --force` to regenerate. -->
|
||||||
|
|
||||||
|
Last updated: 2026-07-23
|
||||||
|
|
||||||
|
## Sources scanned
|
||||||
|
|
||||||
|
- /home/ren/.config/opencode/skills
|
||||||
|
- /home/ren/.codex/skills
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
|
||||||
|
|
||||||
|
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
| Skill | Trigger / description | Scope | Path |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
## Loading protocol
|
||||||
|
|
||||||
|
1. Match task context and target files against the `Trigger / description` column.
|
||||||
|
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
|
||||||
|
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
|
||||||
|
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
AGENTS.md
|
||||||
|
*.md
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# API del pipeline (VPS1)
|
||||||
|
API_BASE_URL=http://127.0.0.1:8080/api/v1
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
|
# Auth de la web (vacío = sin auth en dev)
|
||||||
|
WEB_PASSWORD=
|
||||||
|
|
||||||
|
# Puerto
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Persistencia (SQLite + covers en data/)
|
||||||
|
DB_PATH=data/worst-scan.db
|
||||||
|
COVERS_DIR=data/covers
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
data/
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
# worstscan — Manga Translation Pipeline
|
||||||
|
|
||||||
|
Pipeline de traducción automática de manga (JP/EN/KR/CN → ES) con bot de Telegram, API REST, y delivery a Kindle vía Nextcloud. Descarga de nhentai/e-hentai, traduce con LLM via proxy OpenAI-compatible, inpinta globos con modelos de visión, renderiza texto, empaqueta en MOBI con KCC y sube a Nextcloud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura General
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Entry Points │
|
||||||
|
│ bot_artifacts.py │ artifacts.py │ cli.py │
|
||||||
|
└────────┬──────────┴───────┬──────────┴───────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
┌────────▼──────────────────▼──────────────────▼────────┐
|
||||||
|
│ Queues & State │
|
||||||
|
│ UrlQueue (JSON) GalleryQueue (JSON) StateDB │
|
||||||
|
│ data/bot_queue.json data/modal_queue/ SQLite │
|
||||||
|
└────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────▼──────────────────────────────┐
|
||||||
|
│ Translation Pipeline │
|
||||||
|
│ ┌──────┐ ┌──────────┐ ┌──────┐ ┌────────┐ ┐ │
|
||||||
|
│ │DL │→ │Preprocess│→ │Refine│→ │Inpaint │→ │ │
|
||||||
|
│ │.cbz │ │detect/ │ │LLM │ │AOT/ │ │ │
|
||||||
|
│ │ │ │OCR/trans │ │polish│ │lama │ │ │
|
||||||
|
│ └──────┘ └──────────┘ └──────┘ └────────┘ │ │
|
||||||
|
│ ┌────────────────────────┐ ┌──────────┐ ┐ │ │
|
||||||
|
│ │Complete (V14 Render) │→ │KCC→MOBI │→ │ │ │
|
||||||
|
│ │PIL+textbbox typesetting│ │KPW34 │ │ │ │
|
||||||
|
│ └────────────────────────┘ └──────────┘ ┘ │ │
|
||||||
|
└────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────▼──────────────────────────────┐
|
||||||
|
│ Delivery │
|
||||||
|
│ Nextcloud (sudo cp + occ files:scan) │
|
||||||
|
│ Local (shutil.copy2 to output/) │
|
||||||
|
└────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────▼──────────────────────────────┐
|
||||||
|
│ API REST (FastAPI) │
|
||||||
|
│ GET /health | /status | POST /galleries | /search │
|
||||||
|
│ /summary | /cover | X-API-Key auth. Puerto 8080. │
|
||||||
|
│ Swagger en /docs. Puerto 8080. │
|
||||||
|
└───────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry Points (Completo)
|
||||||
|
|
||||||
|
| File | Rol | Cómo ejecutar | Producción |
|
||||||
|
|------|-----|---------------|------------|
|
||||||
|
| **`bot_artifacts.py`** | **Bot Telegram + API REST + Feed diario** | `uv run python3 bot_artifacts.py` (systemd `bot-artifacts.service`) | ✅ |
|
||||||
|
| **`artifacts.py`** | **Pipeline completa** (download → translate → inpaint → render → MOBI) | `python artifacts.py [--skip-mobi] [--no-es-search] <url>` | ✅ |
|
||||||
|
| **`cli.py`** | **CLI tool** (11 subcomandos) | `python cli.py <subcommand> [args]` | ✅ |
|
||||||
|
| `inject_gallery.py` | Inyección directa al pipeline | `python inject_gallery.py <URL1> <URL2> ...` | ❌ aux |
|
||||||
|
| `process.py` | Pipeline anterior (718 lines, coexiste) | — | ❌ legacy |
|
||||||
|
| `bot.py` | Bot Telegram legacy (26KB, 749 lines) | `python -m src.bot` | ❌ legacy |
|
||||||
|
| `visual_feedback.py` | Bucle de feedback visual LLM | — | ❌ standalone |
|
||||||
|
| `_render_all.py` | Render interno variante | — | ❌ exp |
|
||||||
|
| `_render_gallery.py` | Render interno | — | ❌ exp |
|
||||||
|
| `_render_no_stroke.py` | Render sin stroke | — | ❌ exp |
|
||||||
|
| `_render_variants.py` | Variantes de render | — | ❌ exp |
|
||||||
|
| `_smart_render.py` | Smart render v1 | — | ❌ exp |
|
||||||
|
| `_smart_render_v2.py` | Smart render v2 | — | ❌ exp |
|
||||||
|
| `_smart_render_v3.py` | Smart render v3 | — | ❌ exp |
|
||||||
|
| `_vision_feedback.py` | Vision feedback v1 | — | ❌ exp |
|
||||||
|
| `_vision_feedback_v2.py` | Vision feedback v2 | — | ❌ exp |
|
||||||
|
| `scripts/batch_convert_cbz.py` | Batch KCC de CBZs en Nextcloud | Standalone | ❌ script |
|
||||||
|
| `scripts/download_type90.py` | Descarga TYPE.90 español | Standalone | ❌ script |
|
||||||
|
| `scripts/test_pipeline_653011.py` | Test end-to-end nhentai | Standalone | ❌ script |
|
||||||
|
|
||||||
|
### systemd service
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/bot-artifacts.service
|
||||||
|
ExecStart=/home/ren/.local/bin/uv run python3 bot_artifacts.py
|
||||||
|
Restart=always
|
||||||
|
```
|
||||||
|
Restart: `sudo systemctl restart bot-artifacts.service`
|
||||||
|
Logs: `sudo journalctl -u bot-artifacts.service -f`
|
||||||
|
|
||||||
|
### Import Quirk
|
||||||
|
`src/` NO está instalado como paquete. Todos los comandos se ejecutan desde la raíz del proyecto, o `PYTHONPATH=.` cuando se corre desde otro lado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config (.env vía pydantic-settings)
|
||||||
|
|
||||||
|
Todas las settings via `src/config.py:Settings(BaseSettings)`. `.env` en la raíz del proyecto.
|
||||||
|
|
||||||
|
### Telegram y Delivery
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `telegram_bot_token` | `""` | Token del bot (obligatorio para bot_artifacts.py) |
|
||||||
|
| `telegram_chat_id` | `0` | Chat/User autorizado |
|
||||||
|
| `delivery_backend` | `"local"` | `"local"` o `"nextcloud"` |
|
||||||
|
| `output_dir` | `"output"` | Directorio de salida para delivery local |
|
||||||
|
| `nextcloud_url` | `""` | Nextcloud server URL |
|
||||||
|
| `nextcloud_user` | `""` | Nextcloud username |
|
||||||
|
| `nextcloud_pass` | `""` | Nextcloud password |
|
||||||
|
| `nextcloud_base_path` | `"/kindle"` | Nextcloud base path |
|
||||||
|
| `nextcloud_data_path` | `""` | Nextcloud data directory path |
|
||||||
|
| `state_db_path` | `"data/manga_state.db"` | SQLite DB path |
|
||||||
|
|
||||||
|
### Translation Pipeline
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `translation_enabled` | `True` | Flag para activar/desactivar traducción |
|
||||||
|
| `translation_api_key` | `""` | API key para proxy de traducción |
|
||||||
|
| `translation_api_base` | `"http://127.0.0.1:55990/v1"` | Endpoint del proxy (OpenAI-compatible) |
|
||||||
|
| `translation_model` | `"xiaomi/mimo-v2.5"` | Modelo LLM por defecto |
|
||||||
|
| `translation_target_lang` | `"ESP"` | Lengua destino |
|
||||||
|
| `translation_config_path` | `"translate_config.json"` | Config file de manga-translator |
|
||||||
|
| `minimax_api_key` | `""` | MiniMax API key (fallback de translation_api_key) |
|
||||||
|
| `minimax_api_host` | `"https://api.minimax.io"` | MiniMax host |
|
||||||
|
| `vlm_endpoint` | `"/v1/coding_plan/vlm"` | VLM endpoint |
|
||||||
|
| `minimax_chat_model` | `"MiniMax-Text-01"` | MiniMax chat model |
|
||||||
|
| `vlm_timeout` | `90` | VLM timeout en segundos |
|
||||||
|
|
||||||
|
### Scrapers
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `hentai_search_url` | `"https://e-hentai.org/..."` | URL por defecto de búsqueda e-hentai |
|
||||||
|
| `hentai_subfolder` | `"hentai"` | Subcarpeta para hentai en Nextcloud |
|
||||||
|
| `hentai_cleanup_days` | `7` | Días para cleanup |
|
||||||
|
| `hentai_run_hour` | `6` | Hora del scheduler |
|
||||||
|
| `hentai_rate_limit` | `1.5` | Rate limit en segundos |
|
||||||
|
| `priority_rate_limit` | `0.75` | Rate limit para priority |
|
||||||
|
| `ehentai_manga_run_hour` | `6` | Hora del EhentaiMangaScheduler |
|
||||||
|
| `nhentai_daily_count` | `10` | Límite diario nhentai |
|
||||||
|
| `fansub_enabled` | `True` | Activar fansub pipeline |
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `api_enabled` | `True` | Activar servidor API REST |
|
||||||
|
| `api_host` | `"0.0.0.0"` | Bind address |
|
||||||
|
| `api_port` | `8080` | Puerto |
|
||||||
|
| `api_key` | `""` | API key (vacío = sin auth en dev) |
|
||||||
|
| `api_rate_limit_per_min` | `60` | Rate limit general req/min |
|
||||||
|
| `api_submit_limit_per_min` | `10` | Rate limit para submits/min |
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `mangas_folder` | `"/mnt/mangas"` | Carpeta vigilada por MangaWatcher |
|
||||||
|
| `kindle_profile` | `"KPW34"` | Perfil Kindle para KCC |
|
||||||
|
| `watch_interval` | `30` | Intervalo MangaWatcher en segundos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline de Traducción — Flujo de Cascada
|
||||||
|
|
||||||
|
`bot_artifacts.py` y `artifacts.py` ejecutan el mismo pipeline de 5 fases:
|
||||||
|
|
||||||
|
```
|
||||||
|
URL (nhentai/e-hentai)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────┐
|
||||||
|
│ [1/5] Download (artifacts.download_only) │
|
||||||
|
│ nhentai CDN (i.nhentai.net, ~0.1s/page) │
|
||||||
|
│ o fallback: e-hentai scraper (~2s/page) │
|
||||||
|
│ → data/work_{gid}/download_meta.json (enriquecido) │
|
||||||
|
│ → data/work_{gid}/ CBZ extraído en images/ │
|
||||||
|
└───────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────┐
|
||||||
|
│ [2/5] Preprocess (preprocess.preprocess_gallery) │
|
||||||
|
│ Detección de texto (CTD para JP, dbconvnext para EN) │
|
||||||
|
│ OCR (48px) → LLM translate → máscaras de inpaint │
|
||||||
|
│ Dynamic detection con 4 fallback detectors │
|
||||||
|
│ Pages en chunks de 50 con gc.collect() entre chunks │
|
||||||
|
│ → data/work_{gid}/ regions/ y masks/ │
|
||||||
|
└───────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────┐
|
||||||
|
│ [3/5] Quality + Refine (refine.refine_gallery) │
|
||||||
|
│ LLM polish con contexto completo de la galería │
|
||||||
|
│ 2 pasadas de refinamiento por página │
|
||||||
|
│ Chunking: si >50 págs, chunks de 50 │
|
||||||
|
│ Timeout: n_chunks * 120s (mín 300s) │
|
||||||
|
│ max_tokens=14000 (límite 16K del modelo) │
|
||||||
|
└───────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────┐
|
||||||
|
│ [4/5] Inpaint (inpaint_local.inpaint_gallery_local) │
|
||||||
|
│ GPU auto-detection (CUDA > MPS > CPU) │
|
||||||
|
│ Modelo global único: AOT (JP) o lama_large (EN/KR/CN) │
|
||||||
|
│ Semaphore: min(max(cpu//2,3),8) páginas concurrentes │
|
||||||
|
│ Missing/empty masks → copia original │
|
||||||
|
│ → data/work_{gid}/ inpainted/ │
|
||||||
|
└───────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────┐
|
||||||
|
│ [5/5] Complete + MOBI (complete.complete_gallery_cbz) │
|
||||||
|
│ V14 PIL renderer con textbbox real │
|
||||||
|
│ Binary-search font size (24-64px, fallback 12-23px) │
|
||||||
|
│ Flood-fill bubble detection + overlap resolution │
|
||||||
|
│ → CBZ → KCC (--format MOBI --profile KPW34) → .mobi │
|
||||||
|
│ → output/artifacts/{gid}/ + upload a Nextcloud │
|
||||||
|
└───────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enriched download metadata (`download_meta.json`)
|
||||||
|
|
||||||
|
A partir del commit de /summary + /cover, `_download()` en `artifacts.py` escribe `download_meta.json` enriquecido con:
|
||||||
|
|
||||||
|
| Campo | Tipo | Descripción |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `gid` | str | Gallery ID |
|
||||||
|
| `title` | str | Título en inglés |
|
||||||
|
| `title_jpn` | str | Título en japonés (si existe) |
|
||||||
|
| `config` | str | Ruta del config de traducción |
|
||||||
|
| `num_pages` | int | Número de páginas |
|
||||||
|
| `pages` | int | Alias backward-compat de `num_pages` |
|
||||||
|
| `is_spanish` | bool | Si ya está en español (skip translate) |
|
||||||
|
| `url` | str | URL original |
|
||||||
|
| `source` | str | `"nhentai"` o `"ehentai"` |
|
||||||
|
| `media_id` | str | media_id de nhentai (para cover URL) |
|
||||||
|
| `tags` | list[str] | Tags completos (`artist:xxx`, `parody:yyy`, etc.) |
|
||||||
|
| `artist` | str | Artista extraído de tags |
|
||||||
|
| `parody` | str | Parodia/franquicia extraída de tags |
|
||||||
|
| `cover_url` | str | URL externa de portada (nhentai CDN o e-hentai thumb) |
|
||||||
|
|
||||||
|
Las galleries **viejas** (descargadas antes del cambio) no tendrán estos campos. El endpoint `/summary` hace fallback a `QueueItem` de `UrlQueue` para cubrir `media_id`/`title_jpn`/`artist`/`parody`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## bot_artifacts.py — Bot Telegram + API Server
|
||||||
|
|
||||||
|
Archivo de **1586 líneas**. Se ejecuta como servicio systemd. Es el entry point más importante.
|
||||||
|
|
||||||
|
### Arquitectura de Procesamiento (Two-Phase)
|
||||||
|
|
||||||
|
El procesamiento de galleries se divide en dos fases con **semáforos separados**:
|
||||||
|
|
||||||
|
```
|
||||||
|
asyncio.Queue (_processing_queue)
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ 6 Workers (_queue_worker) │
|
||||||
|
│ Consume (url, bot, chat_id)│
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
│ Phase 1 (I/O) │ Phase 2 (CPU) │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────────────┐ ┌────────────────────┐
|
||||||
|
│ _download_sem │ │ _translate_sem │
|
||||||
|
│ MAX_CONCURRENT=2 │ │ MAX_CONCURRENT=2 │
|
||||||
|
│ artifacts. │ │ _process_one() → │
|
||||||
|
│ download_only() │ │ artifacts.process()│
|
||||||
|
│ (descarga CBZ) │ │ (translate+inpaint │
|
||||||
|
│ │ │ +render+MOBI+up) │
|
||||||
|
└──────────────────┘ └────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inicialización (`main()` + `_post_init`)
|
||||||
|
|
||||||
|
1. `Application.builder().token(settings.telegram_bot_token).build()`
|
||||||
|
2. Registra CommandHandlers + MessageHandler
|
||||||
|
3. `_post_init` corre AL ARRANCAR el bot:
|
||||||
|
1. Notifica "Bot encendido" a `ALLOWED_CHAT_ID`
|
||||||
|
2. Lanza 6 workers (`_queue_worker`)
|
||||||
|
3. `_drain_pending_on_startup(app)` — recupera galleries pendientes/fallidas
|
||||||
|
4. Si `settings.api_enabled` → lanza `_start_api_server()`
|
||||||
|
5. `_periodic_cleanup_loop()` — cleanup cada 24h
|
||||||
|
6. `_daily_nhentai_feed_loop(app)` — feed diario a las 6AM ART (UTC-3)
|
||||||
|
|
||||||
|
### Comandos de Telegram
|
||||||
|
|
||||||
|
| Comando | Handler | Descripción |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `/start` | `start_command` | Texto de ayuda listando todos los comandos |
|
||||||
|
| `/status` | `status_command` | Galleries activas, slots, UrlQueue stats, CPU/RAM, fallidas |
|
||||||
|
| `/retry [gid]` | `retry_command` | Reintentar todas o una gallery fallida |
|
||||||
|
| `/clean` | `clean_command` | Limpiar archivos >72h (vía `src.cleaner.clean_old_files`) |
|
||||||
|
| `/b <url>` | `bypass_command` | Procesar gallery saltando filtros de tags |
|
||||||
|
| `/update` | `nhfeed_command` | Activar manualmente el feed de portada de nhentai.net |
|
||||||
|
| `/nhfeed` | `nhfeed_command` | Alias de /update |
|
||||||
|
| `/wipe` | `wipe_command` | Limpiar cola + work dirs (NO borra output/artifacts ni Nextcloud) |
|
||||||
|
|
||||||
|
### Message Handler: `handle_message`
|
||||||
|
|
||||||
|
Detecta URLs en el mensaje usando `MessageEntity` (offset+length exacto) con fallback a regex.
|
||||||
|
|
||||||
|
**Tipos de URL** y sus rutas:
|
||||||
|
|
||||||
|
| Tipo URL | Regex | Routing |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `gallery` | `e-hentai.org/g/(\d+)/([a-f0-9]+)` o `nhentai.net/g/(\d+)` | `_processing_queue.put()` → `_queue_worker` → `_process_gallery_url()` |
|
||||||
|
| `search` | `e-hentai.org/?` | `asyncio.create_task(_process_search_url())` |
|
||||||
|
| `nh_search` | `nhentai.net/search?` | `asyncio.create_task(_process_nhentai_search_url())` |
|
||||||
|
| `nh_artist` | `nhentai.net/artist/([a-z0-9_-]+)` | `asyncio.create_task(_process_nhentai_artist_url())` |
|
||||||
|
|
||||||
|
**Dedup en handle_message** para URLs de tipo `gallery`:
|
||||||
|
1. Si ya está `processing` o `pending` → skip
|
||||||
|
2. Si ya completada (artifacts dir o status completed) → skip
|
||||||
|
3. Si fallida antes → limpia work_dir, resetea a pending, reintenta
|
||||||
|
4. Si nueva → `UrlQueue.add()` + encola
|
||||||
|
|
||||||
|
### Funciones Internas Clave
|
||||||
|
|
||||||
|
| Función | Firma | Rol |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `_queue_worker` | `(worker_id: int) -> None` | Loop infinito: consume `_processing_queue`, llama `_process_gallery_url` |
|
||||||
|
| `_process_gallery_url` | `(url, bot, chat_id, bypass_filter=False)` | **Two-phase**: download_sem → `artifacts.download_only()`; translate_sem → `_process_one()` |
|
||||||
|
| `_process_one` | `(url, bot, chat_id) -> bool` | Fase 2: `artifacts.process()` → CBZ → `send_to_kindle()` → actualiza stats |
|
||||||
|
| `_process_search_url` | `(url, bot, chat_id)` | Búsqueda e-hentai: scrape → batch metadata → dedup 5 niveles → batch processing |
|
||||||
|
| `_process_nhentai_search_url` | `(url, bot, chat_id)` | Búsqueda nhentai: content filter → FC→B&W redirect → dedup → batch processing |
|
||||||
|
| `_process_nhentai_artist_url` | `(url, bot, chat_id)` | Artista nhentai: mismo flujo que search |
|
||||||
|
| `_find_bw_alternative` | `(detail) -> NhentariGallery \| None` | Busca versión B&W alternativa cuando `should_download` bloquea por full color |
|
||||||
|
| `_check_duplicate` | `(gid, url, metadata) -> str \| None` | 5 niveles: artifacts dir → work dir → work_key → parent key → SQLite fingerprint |
|
||||||
|
| `_gdata_metadata` | `(gid, token) -> dict \| None` | Fetch metadata de e-hentai gdata API |
|
||||||
|
| `_drain_pending_on_startup` | `(application)` | Recupera URLs pendientes + fallidas post-reinicio |
|
||||||
|
| `_periodic_cleanup_loop` | `()` | Cleanup cada 24h via `src.cleaner.clean_old_files()` |
|
||||||
|
| `_daily_nhentai_feed_loop` | `(app)` | Feed diario nhentai a las 6AM ART |
|
||||||
|
| `_start_api_server` | `()` | Inicia FastAPI/uvicorn en el mismo event loop |
|
||||||
|
| `_wipe_all` | `()` | Wipe standalone (sin bot) — llamado por API DELETE /queue |
|
||||||
|
| `_clean_idle_memory` | `()` | Limpia cachés globales de manga-translator + `gc.collect()` cuando idle |
|
||||||
|
| `_load_failed` / `_save_failed` | `() -> list` / `(list)` | Persistencia de fallidas en `data/failed_galleries.json` |
|
||||||
|
| `_add_failed` / `_remove_failed` | `(url, gid, title, error)` / `(gid)` | Manager de lista de fallidas |
|
||||||
|
| `_extract_urls` | `(text, entities) -> list[(url, type)]` | Extrae URLs + tipo del texto del mensaje |
|
||||||
|
| `_extract_nh_query` | `(url) -> str` | Extrae `?q=` de URL de búsqueda nhentai |
|
||||||
|
| `_extract_nh_artist_sort` | `(url) -> str` | Extrae `?sort=` de URL de artista nhentai |
|
||||||
|
| `_build_parent_work_key` | `(title, title_jpn, tags) -> str` | Work key que ignora chapter/volume markers |
|
||||||
|
| `_detect_batch_lang` | `(tags) -> str` | Detecta idioma de tags para dedup batch |
|
||||||
|
| `_send_batch_summary` | `(batch) -> None` | Envía resumen de batch processing |
|
||||||
|
| `_notify_batch_done` | `(batch_id) -> None` | Tracking interno de progreso de batch |
|
||||||
|
|
||||||
|
### Variables Globales
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAX_CONCURRENT = 2 # Slots de download y translate
|
||||||
|
_NUM_WORKERS = 6 # Workers de la cola
|
||||||
|
_translate_sem = Semaphore(2)
|
||||||
|
_download_sem = Semaphore(2)
|
||||||
|
_stats = {"completed": 0, "failed": 0, "active": 0}
|
||||||
|
_wiping = False
|
||||||
|
_processing_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
_batch_tracker: dict[str, dict] = {}
|
||||||
|
_LANG_PRIORITY = {"english": 0, "spanish": 1, "japanese": 2, ...}
|
||||||
|
FAILED_FILE = Path("data/failed_galleries.json")
|
||||||
|
ARTIFACTS_DIR = Path("output/artifacts")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Recovery
|
||||||
|
|
||||||
|
- `_drain_pending_on_startup()` corre en `post_init`
|
||||||
|
- UrlQueue se persiste en `data/bot_queue.json` (JSON)
|
||||||
|
- Items en estado "processing" al reiniciar se resetean a "pending" (crash recovery)
|
||||||
|
- `failed_galleries.json` también se drena al arranque
|
||||||
|
- `reload_from_disk()` es función **module-level** en `url_queue.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API (FastAPI) — `src/api/`
|
||||||
|
|
||||||
|
Servidor FastAPI en el mismo event loop que el bot de Telegram. Corre en `0.0.0.0:8080` por defecto.
|
||||||
|
|
||||||
|
### Inicio
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bot_artifacts.py:1497-1498 y 1550-1582
|
||||||
|
if settings.api_enabled:
|
||||||
|
asyncio.create_task(_start_api_server())
|
||||||
|
|
||||||
|
async def _start_api_server():
|
||||||
|
api_app = create_app(
|
||||||
|
download_sem=_download_sem,
|
||||||
|
translate_sem=_translate_sem,
|
||||||
|
process_gallery_url=_process_gallery_url,
|
||||||
|
wipe_fn=_wipe_all,
|
||||||
|
api_key=settings.api_key,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
config = uvicorn.Config(api_app, host=..., port=..., log_level="warning")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
await server.serve()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estructura de Archivos
|
||||||
|
|
||||||
|
| Archivo | Rol |
|
||||||
|
|---------|-----|
|
||||||
|
| `src/api/__init__.py` | Factory `create_app()` — recibe semáforos y callbacks |
|
||||||
|
| `src/api/main.py` | `create_fastapi_app()` — monta middleware CORS, auth, routers |
|
||||||
|
| `src/api/auth.py` | `ApiKeyMiddleware` — auth via header `X-API-Key` |
|
||||||
|
| `src/api/deps.py` | Inyección de dependencias (semáforos, funciones) |
|
||||||
|
| `src/api/models.py` | Schemas Pydantic + helpers `ok()`, `err()`, `slot_info()` |
|
||||||
|
| `src/api/errors.py` | Rate limiter token-bucket + exception handlers |
|
||||||
|
| `src/api/routes/__init__.py` | Agregación de routers |
|
||||||
|
| `src/api/routes/system.py` | `GET /health`, `GET /status` |
|
||||||
|
| `src/api/routes/galleries.py` | CRUD de galleries |
|
||||||
|
| `src/api/routes/queue.py` | Queue management |
|
||||||
|
| `src/api/routes/search.py` | Search endpoints |
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción | Auth | Rate Limit |
|
||||||
|
|--------|------|-------------|------|------------|
|
||||||
|
| `GET` | `/api/v1/health` | Health check simple (uptime, version) | ❌ Público | — |
|
||||||
|
| `GET` | `/api/v1/status` | Estado completo: cola, slots, activas, RAM, CPU, modelo actual | ✅ | — |
|
||||||
|
| `POST` | `/api/v1/galleries` | Enviar URL para procesamiento completo | ✅ | Submit |
|
||||||
|
| `POST` | `/api/v1/galleries/download` | Solo descarga (fase 1) | ✅ | Submit |
|
||||||
|
| `GET` | `/api/v1/galleries` | Lista galleries (filtro por status + paginación) | ✅ | — |
|
||||||
|
| `GET` | `/api/v1/galleries/{gid}` | Detalle de una gallery | ✅ | — |
|
||||||
|
| `GET` | `/api/v1/galleries/{gid}/summary` | Resumen completo: title, title_jpn, tags, artist, parody, cover URL, pipeline status | ✅ | — |
|
||||||
|
| `GET` | `/api/v1/galleries/{gid}/cover` | Foto de portada (página 1) como FileResponse | ✅ | — |
|
||||||
|
| `GET` | `/api/v1/galleries/{gid}/artifacts` | Archivos generados | ✅ | — |
|
||||||
|
| `DELETE` | `/api/v1/galleries/{gid}` | Cancelar y eliminar gallery | ✅ | — |
|
||||||
|
| `POST` | `/api/v1/search` | Buscar en nhentai/e-hentai (sin procesar) | ✅ | General |
|
||||||
|
| `POST` | `/api/v1/search/process` | Buscar + auto-encolar resultados no bloqueados | ✅ | Submit |
|
||||||
|
| `GET` | `/api/v1/queue` | Lista cola (filtro + paginación) | ✅ | — |
|
||||||
|
| `GET` | `/api/v1/queue/stats` | Estadísticas de cola + slots | ✅ | — |
|
||||||
|
| `POST` | `/api/v1/queue/{gid}/retry` | Reintentar gallery fallida | ✅ | — |
|
||||||
|
| `DELETE` | `/api/v1/queue` | Wipe completo (equivalente a `/wipe`) | ✅ | — |
|
||||||
|
|
||||||
|
Swagger UI en `GET /docs`, ReDoc en `GET /redoc`, schema en `GET /openapi.json`.
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
|
||||||
|
- **Middleware**: `ApiKeyMiddleware` (Starlette `BaseHTTPMiddleware`)
|
||||||
|
- **Header**: `X-API-Key`
|
||||||
|
- **Bypass paths**: `/api/v1/health` siempre público
|
||||||
|
- **Bypass IPs**: `127.0.0.1`, `::1`, `::ffff:127.0.0.1`
|
||||||
|
- **Dev mode**: si `api_key` está vacío en `.env`, no requiere auth
|
||||||
|
- **401 response**: `{"error": {"code": "unauthorized", "message": "Missing or invalid API key"}}`
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Token bucket in-memory por IP:
|
||||||
|
- **General**: `api_rate_limit_per_min` (default 60/min) — aplica a GETs y search
|
||||||
|
- **Submit**: `api_submit_limit_per_min` (default 10/min) — aplica a POST gallerias + search/process
|
||||||
|
- **429 response**: `{"error": {"code": "rate_limited", "message": "..."}}`
|
||||||
|
|
||||||
|
### NullBot
|
||||||
|
|
||||||
|
La API usa `_NullBot` como reemplazo de `telegram.Bot` para los mensajes de progreso. Los absorbe y los envía a logger en vez de a Telegram.
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
|
||||||
|
Permite todos los orígenes (`*`) para scripts locales. Lockear en producción vía nginx.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos del Pipeline (`src/translator/`)
|
||||||
|
|
||||||
|
### `preprocess.py` — Detección, OCR, Traducción, Máscaras
|
||||||
|
|
||||||
|
- `preprocess_gallery(gallery_dir, device)`: Procesa página por página
|
||||||
|
- Detecta texto (dynamic detection con 4 fallbacks)
|
||||||
|
- OCR con tesseract/manga-ocr
|
||||||
|
- LLM translate (proxy OpenAI-compatible)
|
||||||
|
- Genera máscaras de inpaint (por región, con `_rebuild_mask_per_region`)
|
||||||
|
- Chunks de 50 páginas con `gc.collect()` entre chunks
|
||||||
|
- RSS threshold: `min(max(total_mem * 0.7, 6.0), 24.0)` GB
|
||||||
|
- Timeout por página: 600s (env `PAGE_TIMEOUT`)
|
||||||
|
- Concurrencia dinámica: `set_preprocess_concurrent(n)`
|
||||||
|
|
||||||
|
### `refine.py` — LLM Polish
|
||||||
|
|
||||||
|
- `refine_gallery(gallery_dir, chunk_size=0)`:
|
||||||
|
- 2 pasadas de refinamiento LLM por página
|
||||||
|
- Chunking automático (>50 pages → chunks de 50)
|
||||||
|
- Timeout dinámico: `n_chunks * 120s` (mín 300s)
|
||||||
|
- `_call_llm()` con `max_tokens=14000`
|
||||||
|
- El modelo real es el mismo que usa traducción (seteado vía `os.environ["OPENAI_MODEL"]`)
|
||||||
|
- El default `mimo-v2.5-free` en refine.py es un **fallback muerto** — nunca se usa
|
||||||
|
|
||||||
|
### `inpaint_local.py` — Inpainting
|
||||||
|
|
||||||
|
- `inpaint_gallery_local(gallery_dir, device)`:
|
||||||
|
- Modelo global único (AOT para JP, lama_large para EN/KR/CN)
|
||||||
|
- Semaphore: `min(max(cpu_count // 2, 3), 8)` páginas concurrentes
|
||||||
|
- Missing/empty masks → copia original
|
||||||
|
- Failed → fallback a original
|
||||||
|
|
||||||
|
### `complete.py` — Render V14 + CBZ Packaging
|
||||||
|
|
||||||
|
**V14 Renderer** (PIL-based, producción):
|
||||||
|
- `render_page_v14(inpainted_img, regions_data, mask_gray) -> np.ndarray`:
|
||||||
|
- Flood-fill bounded bubble detection sobre imagen inpainted
|
||||||
|
- Binary-search font size con PIL `textbbox` (24-64px, fallback 12-23px)
|
||||||
|
- White stroke para legibilidad
|
||||||
|
- Iterative overlap resolution (3 pasadas, shift 25-35px)
|
||||||
|
- Character-by-character font fallback para símbolos no soportados
|
||||||
|
- CJK stripping para targets no-CJK
|
||||||
|
- Normaliza puntuación CJK (… → ..., ‼ → !!)
|
||||||
|
- Spanish word hyphenation (`_split_word_spanish`)
|
||||||
|
- `_draw_text_with_fallback`: si un carácter no está en el font principal, usa Arial Unicode
|
||||||
|
|
||||||
|
- `complete_gallery_cbz(gallery_dir, inpainted_dir, output_cbz) -> Path`:
|
||||||
|
- Procesa cada página: render V14 si tiene texto, inpainted direct si no
|
||||||
|
- Empaqueta en CBZ (JPEG quality 92)
|
||||||
|
- Pipeline state tracking por página
|
||||||
|
|
||||||
|
### `dynamic_detection.py` — Adaptive Detection
|
||||||
|
|
||||||
|
- `compute_adaptive_config(config_dict, image) -> dict`: Ajusta `detection_size`, `text_threshold`, `box_threshold`, `unclip_ratio` según:
|
||||||
|
- Tamaño de imagen: <1MP→1024, 1-3MP→1536, >3MP→2048
|
||||||
|
- Contraste: <30→th 0.1, 30-50→0.15, >50→0.2
|
||||||
|
- Text scale (Laplacian edge density)
|
||||||
|
|
||||||
|
- Fallback detector chain (4 intentos):
|
||||||
|
1. Config adaptativa
|
||||||
|
2. Detector `default` con thresholds bajos (0.15/0.2), 1536px
|
||||||
|
3. `ctd` con thresholds mínimos (0.1/0.15), 1024px
|
||||||
|
4. `default` con thresholds mínimos, 2048px
|
||||||
|
|
||||||
|
### `language.py` — Detección de Idioma + Config Routing
|
||||||
|
|
||||||
|
- `detect_language(tags) -> str`: Detecta idioma de tags (e-hentai `language:japanese` o nhentai `japanese`)
|
||||||
|
- `select_config_path(language) -> str`: Mapea idioma a archivo de configuración
|
||||||
|
- `write_metadata(gallery_dir, gid, title, tags, source, language)`: Escribe `source_metadata.json`
|
||||||
|
- Priority: Spanish > English > Korean > Chinese > Japanese
|
||||||
|
- Config files: japonés→`translate_config.json`, otros→ `translate_config_english/korean/chinese.json`
|
||||||
|
|
||||||
|
### `pipeline_state.py` — Tracking de Estado
|
||||||
|
|
||||||
|
```python
|
||||||
|
_active_galleries: dict[str, PipelineState] = {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `set_pipeline_state(gallery_id, phase, done, total)`: Actualiza estado (llamado desde preprocess, inpaint, render, mobi, upload)
|
||||||
|
- `get_active_galleries() -> dict`: Usado por `/status` y API `GET /status`
|
||||||
|
- `clear_pipeline_state(gallery_id)`: Limpia al completar/fallar
|
||||||
|
|
||||||
|
### `queue.py` — GalleryQueue
|
||||||
|
|
||||||
|
- JSON-backed singleton en `data/modal_queue/queue.json`
|
||||||
|
- Thread-safe via `threading.Lock` — múltiples instancias comparten estado
|
||||||
|
- Gallery dirs en `data/modal_queue/gallery_{gid}/` con `images/`, `masks/`, `regions/`
|
||||||
|
- Status: `pending → processing → completed/failed`
|
||||||
|
- Métodos: `add()`, `get()`, `mark_processing()`, `mark_completed()`, `mark_failed()`, `set_cbz_path()`, `set_priority()`, `reset_to_pending()`, `remove()`, `cleanup_completed()`, `get_pending()`, `get_incomplete()`, `get_failed()`
|
||||||
|
|
||||||
|
### `config.py` — TranslationConfig
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class TranslationConfig:
|
||||||
|
api_key: str = ""
|
||||||
|
api_base: str = "https://api.z.ai/api/coding/paas/v4"
|
||||||
|
model: str = "GLM-5.1"
|
||||||
|
target_lang: str = "ESP"
|
||||||
|
config_path: str = "translate_config.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `from_settings(settings)`: build desde `Settings` (usa `minimax_api_key` como fallback)
|
||||||
|
|
||||||
|
### `runner.py` — CLI Wrapper para manga-image-translator
|
||||||
|
|
||||||
|
- `run_translator(input_dir, output_dir, config)`: Corre `python -m manga_translator local`
|
||||||
|
- Parallel batch processing: divide imágenes en batches, corre concurrentemente
|
||||||
|
- CPU optimization flags: `OMP_NUM_THREADS`, `MKL_NUM_THREADS`, etc.
|
||||||
|
- Fallback: copia originales para páginas sin texto detectado
|
||||||
|
- Workers: `min(max(cpu_count // 2, 3), 8)`
|
||||||
|
|
||||||
|
### `__init__.py` — translate_manga()
|
||||||
|
|
||||||
|
- `translate_manga(cbz_path, config, output_dir=None) -> Path`: Alto nivel para CBZ
|
||||||
|
- Extrae CBZ → corre translator → repack → devuelve path del CBZ traducido
|
||||||
|
- On failure **RAISES** (no fallback al original)
|
||||||
|
|
||||||
|
### `_threadloop.py` — Thread-Local Event Loops
|
||||||
|
|
||||||
|
- Cada thread worker tiene su propio event loop persistente (evita `asyncio.run()` y memory leaks)
|
||||||
|
- `run_coro(coro)`: corre coroutine en el loop del thread actual
|
||||||
|
- `get_translator()`: crea/obtiene MangaTranslator thread-local con HTTP client propio
|
||||||
|
- Dummy 64x64 al init fuerza carga de modelos en el thread correcto
|
||||||
|
- PyTorch model weights SHARED globalmente (solo HTTP client es per-instance)
|
||||||
|
|
||||||
|
### `api_fallback.py` — API Fallback y Model Scoring
|
||||||
|
|
||||||
|
- Primary proxy: `http://127.0.0.1:6446/v1` (OpenAI-compatible local)
|
||||||
|
- NSFW fallback: NVIDIA API con `minimaxai/minimax-m3`
|
||||||
|
- Proxy health-check via `GET /v1/models` cada 30 segundos
|
||||||
|
- **Model scoring**: success +0.1 (max 2.0); retryable errors -0.2 (min 0.1); hard errors -0.5 (min 0.1) con streak penalty
|
||||||
|
- **Circuit breaker**:
|
||||||
|
- `503` / service unavailable → banned 120s
|
||||||
|
- `401` / unauthorized → banned 1800s (30 min — dead model)
|
||||||
|
- `pick_model()` filtra modelos baneados
|
||||||
|
- Default target: `nvidia/nemotron-3-ultra-550b-a55b:free`
|
||||||
|
- Blocklist: `stepfun/step-3.7-flash:free` (returns empty/None)
|
||||||
|
- Scores persisten en `data/model_scores.json`
|
||||||
|
- `setup_api(pick_model())` setea `os.environ["OPENAI_MODEL"]` **antes** de cualquier refine call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configs de Traducción por Idioma
|
||||||
|
|
||||||
|
| Config | Detector | Detection Size | Inpainter | Inpaint Size | Font Offset | Mask Dilation/Kernel |
|
||||||
|
|--------|----------|---------------|-----------|-------------|-------------|---------------------|
|
||||||
|
| `translate_config.json` (JP) | ctd | 2048 | default (AOT) | 1024 | +10 | 35/5 |
|
||||||
|
| `translate_config_english.json` (EN) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** |
|
||||||
|
| `translate_config_korean.json` (KR) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** |
|
||||||
|
| `translate_config_chinese.json` (CN) | default | 2048 | **lama_large** | 2048 | +10 | **55/8** |
|
||||||
|
|
||||||
|
- Todos usan renderer `manga2eng`, OCR `48px`, target `ESP`, translator `chatgpt`
|
||||||
|
- EN/KR/CN requieren `lama_large` con dilation/kernel más alto (texto no-CJK ocupa más espacio)
|
||||||
|
- JP usa detector CTD (entrenado en kanji); otros usan default (dbconvnext)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Colas
|
||||||
|
|
||||||
|
### UrlQueue (`src/url_queue.py`) — Cola del Bot
|
||||||
|
|
||||||
|
- JSON-backed (thread-safe)
|
||||||
|
- Persiste en `data/bot_queue.json`
|
||||||
|
- Items clave: `gid`, `url`, `token`, `title`, `work_key`, `is_spanish`, `filecount`, `status`
|
||||||
|
- Status: `pending → processing → completed/failed`
|
||||||
|
- Crash recovery: items en "processing" al cargar disco se resetean a "pending"
|
||||||
|
- Dedup via `work_key`: fingerprint canónico de `title_jpn` (tier 1) o `artist + clean_title` (tier 2)
|
||||||
|
- `media_id_cache` separada (`data/media_id_cache.json`) mapea e-hentai gid → nhentai media_id
|
||||||
|
- Funciones clave: `add()`, `get()`, `find_duplicate()`, `claim_next_pending()`, `mark_completed()`, `mark_failed()`, `reset_to_pending()`, `get_pending()`, `get_all()`
|
||||||
|
- `build_work_key(tags, title, title_jpn) -> str`: Construye key canónico
|
||||||
|
- `reload_from_disk()`: **module-level function** para recargar desde disco
|
||||||
|
- `reload_from_disk()` en `queue.py`: Force-reload para scripts externos
|
||||||
|
|
||||||
|
### GalleryQueue (`src/translator/queue.py`) — Cola del Pipeline
|
||||||
|
|
||||||
|
- JSON-backed singleton en `data/modal_queue/queue.json` con estado compartido module-level
|
||||||
|
- Thread-safe via `threading.Lock`
|
||||||
|
- Gallery dirs en `data/modal_queue/gallery_{gid}/` con `images/`, `masks/`, `regions/`
|
||||||
|
- Diferencia clave con UrlQueue: esta es para el **pipeline de traducción local**, no para el bot
|
||||||
|
- `QueuedGallery`: `gallery_id`, `title`, `source`, `num_pages`, `status`, `cbz_path`, `priority`, `language`, `token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline Orchestration (`src/pipeline.py`)
|
||||||
|
|
||||||
|
- `run_pipeline_once(settings)`: Entry point único
|
||||||
|
- Retry failures → preprocess → inpaint → render → MOBI → deliver
|
||||||
|
- Llamado desde `cli.py worker` (loop) y `cli.py prioridad`/`manga` (inline)
|
||||||
|
- Paralelismo: `min(max(cpu_count // 4, 3), 6)` galleries en paralelo
|
||||||
|
- Timeout por gallery: `max(num_pages * 120, 1800)` segundos
|
||||||
|
- Permanent error patterns (no retry): "opencv", "content filtered", "no images found"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scrapers
|
||||||
|
|
||||||
|
### nhentai (`src/nhentai_scraper/`)
|
||||||
|
|
||||||
|
- Scrapling `StealthySession` para Cloudflare bypass
|
||||||
|
- CDN download preferido: `i.nhentai.net/galleries/{media_id}/{page}.{ext}` (~0.1s/page)
|
||||||
|
- e-hentai fallback para no-Spanish
|
||||||
|
- Content filter: skip non-manga, non-JP source, <10 pages
|
||||||
|
- Rate limiting + retry con exponential backoff
|
||||||
|
- Funciones: `scrape_search`, `search_nhentai`, `_fetch_gallery_v2`, `should_download`, `fetch_gallery_links_httpx_async`, `safe_nh_api_get`
|
||||||
|
|
||||||
|
**Dedup** (`src/nhentai_scraper/dedup.py`):
|
||||||
|
- `select_winners(raw_galleries, state_db, notify_fn) -> DedupReport`:
|
||||||
|
- Phase 1: Enrich each raw gallery with v2 metadata
|
||||||
|
- Phase 1b: Bridge galleries without title_jpn via EN-title matching
|
||||||
|
- Phase 2: Group by fingerprint
|
||||||
|
- Phase 3: DB cross-reference + select winner per group
|
||||||
|
- Winner selection: Spanish > English > Japanese > Chinese, then page count, then higher GID
|
||||||
|
- `DedupReport`: `groups`, `no_fingerprint`, `winners`, `stats`
|
||||||
|
- `format_dry_run_table(report)`: Render tabla legible para dry-run
|
||||||
|
- `detect_language(tags, title_jpn)`: Detecta idioma via title markers (más confiable que tags)
|
||||||
|
- Priority 1: title_jpn markers (`[スペイン翻訳]`, `[英訳]`, etc.)
|
||||||
|
- Priority 2: tags (bare or prefixed)
|
||||||
|
|
||||||
|
### e-hentai (`src/hentai_scraper/` + `src/ehentai_search.py`)
|
||||||
|
|
||||||
|
- API method: `gdata` (no legacy `gmetadata`)
|
||||||
|
- Front page scrape para scheduler diario; metadata fetch via API
|
||||||
|
- `search_spanish_version(title_jpn, artist_tag, title)`: Busca versión española en e-hentai
|
||||||
|
- Step 1: Búsqueda específica (artist + title keywords + language:spanish)
|
||||||
|
- Step 2: Fallback a búsqueda amplia (artist + language:spanish) + CJK title matching
|
||||||
|
- Match por fingerprint de título (CJK o EN)
|
||||||
|
- `scrape_front_page(category_mask, max_pages)`: Scrapea front page de e-hentai
|
||||||
|
- `fetch_metadata_batch(galleries)`: Fetch metadata batch via API (25 por lote)
|
||||||
|
|
||||||
|
### EhentaiMangaScheduler (`src/ehentai_manga_scheduler.py`)
|
||||||
|
|
||||||
|
Scheduler diario que reemplaza a `HentaiScheduler` + `NhentaiScheduler` legacy.
|
||||||
|
|
||||||
|
```
|
||||||
|
Front page e-hentai (Manga + Doujinshi)
|
||||||
|
→ Metadata batch API
|
||||||
|
→ Filter: only Manga/Doujinshi, not processed, content-safe
|
||||||
|
→ Dedup + fingerprint grouping (ESP > EN > JP > CN)
|
||||||
|
→ Por cada gallery:
|
||||||
|
├─ Spanish tag → download (nhentai CDN preferido, fallback e-hentai) → MOBI → upload
|
||||||
|
└─ Not Spanish → search_spanish_version()
|
||||||
|
├─ Found Spanish → download esa versión → MOBI → upload
|
||||||
|
└─ Not found → download de nhentai (o e-hentai) + GalleryQueue para traducción
|
||||||
|
```
|
||||||
|
|
||||||
|
- `_probe_nhentai_cdn(gid)`: Verifica si gallery existe en nhentai CDN
|
||||||
|
- `_resolve_nhentai_media_id(gid, title)`: Resuelve media_id correcto (fast: gid, slow: v2 API search)
|
||||||
|
- `_search_nhentai_v2(title)`: Busca en nhentai v2 API por título
|
||||||
|
- `_download_and_upload(meta)`: Download + KCC + Nextcloud upload
|
||||||
|
- `_download_and_translate(meta)`: Download + GalleryQueue encolado para traducción
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fansub Pipeline (`src/fansub/`)
|
||||||
|
|
||||||
|
Pipeline alternativo para nhentai galleries (independiente de `src/translator/`).
|
||||||
|
|
||||||
|
```
|
||||||
|
nhentai gallery
|
||||||
|
→ RT-DETR ONNX detection (class 0=bodies, 1=text, 2=frames)
|
||||||
|
→ MiniMax VLM OCR/translation
|
||||||
|
→ TELEA inpainting
|
||||||
|
→ Pillow typesetting
|
||||||
|
→ CBZ
|
||||||
|
```
|
||||||
|
|
||||||
|
- ONNX model en `/tmp/rtdetr/model.onnx` (descargado en Docker build)
|
||||||
|
- Activado via `fansub_enabled=True` en `nhentai_scraper/scheduler.py`
|
||||||
|
- Text regions grouped via Union-Find clustering (25px proximity)
|
||||||
|
- Fallback font: `fonts/Anime_Ace_3.ttf`
|
||||||
|
- `FansubPipeline` class en `__init__.py`, `run_fansub_pipeline()` en `pipeline.py`
|
||||||
|
- Módulos: `detector.py`, `text_detector.py`, `translator.py`, `redrawer.py`, `typesetter.py`, `cleaner.py`, `packager.py`, `config.py`
|
||||||
|
- Requirements propios: `src/fansub/requirements_fansub.txt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Database (`src/base_state.py`)
|
||||||
|
|
||||||
|
- SQLite via `BaseStateDB` — nunca escribir raw SQL fuera de este módulo
|
||||||
|
- Subclasses:
|
||||||
|
- `HentaiStateDB` — e-hentai gallery state
|
||||||
|
- `NhentaiStateDB` — nhentai gallery state
|
||||||
|
- `MangaStateDB` — manga (MangaWatcher) state
|
||||||
|
- Status: `pending → downloading → downloaded → processing → completed → failed`
|
||||||
|
- `find_by_fingerprint(fp)`: Cross-db dedup por fingerprint de title_jpn
|
||||||
|
|
||||||
|
**Dual State Tracking (KNOWN ISSUE)**: Tanto SQLite (`NhentaiStateDB`/`HentaiStateDB`) como GalleryQueue/UrlQueue trackean estado de galleries SIN reconciliación cruzada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
### Nextcloud (`src/nextcloud_uploader.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
send_to_kindle(path, settings, subfolder)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `sudo cp + chown www-data:www-data` al data dir de Nextcloud
|
||||||
|
- `docker exec -u 33 nextcloud occ files:scan --path=...` para indexar
|
||||||
|
- No WebDAV — copia directa al filesystem
|
||||||
|
|
||||||
|
### Local (`src/local_uploader.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
send_to_kindle(path, settings, subfolder)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `shutil.copy2` a `output_dir/{subfolder}/`
|
||||||
|
- Backend default cuando `delivery_backend=local`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications (`src/notifier.py`)
|
||||||
|
|
||||||
|
- Protocol-based: `Notifier` Protocol class
|
||||||
|
- Implementaciones:
|
||||||
|
- `ConsoleNotifier` — log a stdout
|
||||||
|
- `TelegramNotifier` — envía mensajes a Telegram
|
||||||
|
- `NullNotifier` — no-op (default)
|
||||||
|
- Registro global via `utils.set_notifier(notifier)` al startup
|
||||||
|
- Uso: `utils.notify(message=...)` desde cualquier módulo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU/Device Auto-Detection (`src/device.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_device() -> str: # "cuda" | "mps" | "cpu"
|
||||||
|
def prefer_gpu() -> bool: # get_device() != "cpu"
|
||||||
|
```
|
||||||
|
|
||||||
|
- No config, no env vars — detecta en runtime via `torch.cuda.is_available()` / `torch.backends.mps.is_available()`
|
||||||
|
- Usado por: `_threadloop.py` (MangaTranslator `use_gpu`), `inpaint_local.py` (inpainting device), `artifacts.py`/`process.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utilidades Compartidas (`src/utils.py`)
|
||||||
|
|
||||||
|
| Función | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `set_notifier(notifier)` | Registra notificador activo |
|
||||||
|
| `notify(message)` | Despacha mensaje via notificador activo |
|
||||||
|
| `gallery_date_folder()` | Fecha UTC como `M_D_YYYY` (portable, sin zero-padding) |
|
||||||
|
| `sanitize_filename(name, max_len)` | Sanitiza nombre de archivo (elimina chars inseguros) |
|
||||||
|
| `format_gallery_name(lang, title, gid)` | Formato `[LANG] (title) (gallery_id)` |
|
||||||
|
| `normalize_title_for_dedup(title)` | Normaliza título EN para dedup cross-source |
|
||||||
|
| `normalize_jp_title_for_dedup(title_jpn)` | Normaliza título JP para dedup (strip brackets, chapter markers, etc.) |
|
||||||
|
| `title_has_english(title)` | Check si título tiene `[English]` |
|
||||||
|
| `title_has_spanish(title)` | Check si título tiene `[Spanish]` |
|
||||||
|
| `format_summary(downloaded, failed)` | Formatea resumen de descargas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Otros Módulos
|
||||||
|
|
||||||
|
### `src/converter.py`
|
||||||
|
|
||||||
|
Conversión de formatos: EPUB, PDF, CBR, CBZ, ZIP → Kindle format.
|
||||||
|
- `is_image_pdf(src)`: Detecta si PDF es imagen vs texto
|
||||||
|
- `convert(src, fmt)`: Detecta formato y convierte
|
||||||
|
- `_fetch_metadata(stem)`: Fetch metadata via `fetch-ebook-metadata`
|
||||||
|
- `_safe_extract(zf, dest)`: ZIP extraction segura (previene path traversal)
|
||||||
|
|
||||||
|
### `src/file_handler.py`
|
||||||
|
|
||||||
|
- `download_file(update, tmp_dir)`: Descarga archivo de Telegram
|
||||||
|
- `download_from_url(url, tmp_dir, timeout)`: Stream-download desde URL
|
||||||
|
- `cleanup_tmp_dir(tmp_dir)`: Elimina directorio temporal
|
||||||
|
|
||||||
|
### `src/cleaner.py`
|
||||||
|
|
||||||
|
- `clean_old_files(artifacts_dir, data_dir, max_age_hours, dry_run) -> CleanupStats`:
|
||||||
|
- Elimina archivos >72h de `output/artifacts/` y `data/work_*`
|
||||||
|
- Protege `bot_queue.json`, `manga_state.db`, `failed_galleries.json`, `model_scores.json`, `media_id_cache.json`
|
||||||
|
- Soportado como script standalone: `python -m src.cleaner --hours 72 --dry-run`
|
||||||
|
|
||||||
|
### `src/libgen.py`
|
||||||
|
|
||||||
|
Búsqueda en Library Genesis:
|
||||||
|
- `search_books(query, by_author=False) -> list[Book]`: Busca y devuelve top 5 (EPUB first)
|
||||||
|
- `resolve_download_url(book) -> str | None`: Resuelve URL de descarga directa
|
||||||
|
- `Book`: id, md5, title, author, year, pages, language, extension, size, mirrors
|
||||||
|
- Usa BeautifulSoup para parsear HTML de resultados
|
||||||
|
|
||||||
|
### `src/manga_watcher.py`
|
||||||
|
|
||||||
|
- `MangaWatcher(config, bot_app)`: Vigila carpeta de Nextcloud para nuevos mangas
|
||||||
|
- Polling loop cada `watch_interval` segundos
|
||||||
|
- `_validate(filepath)`: Verifica extensión y contenido (imágenes dentro de ZIP/CBR)
|
||||||
|
- `_translate_if_enabled(filepath)`: Traduce si `translation_enabled=True`
|
||||||
|
- `_convert(input_path)`: KCC → MOBI
|
||||||
|
- `_upload_to_nextcloud(output_dir, title)`: Upload a Nextcloud
|
||||||
|
- Trackea estado via `MangaStateDB`
|
||||||
|
|
||||||
|
### `src/exceptions.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
KindleError(Exception) # Base
|
||||||
|
├─ ConversionError # Conversión fallida
|
||||||
|
├─ UploadError # Upload fallido
|
||||||
|
├─ ScrapingError # Scraping/download fallido
|
||||||
|
└─ TranslationError # Traducción fallida
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts (`scripts/`)
|
||||||
|
|
||||||
|
| Script | Descripción |
|
||||||
|
|--------|-------------|
|
||||||
|
| `batch_convert_cbz.py` | Batch KCC conversion de CBZs en Nextcloud |
|
||||||
|
| `download_type90.py` | Descargar TYPE.90 español, convertir, subir |
|
||||||
|
| `llama_server.ps1` | PowerShell management de llama.cpp server local |
|
||||||
|
| `test_pipeline_653011.py` | Test end-to-end nhentai pipeline |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KCC Wrapper (`src/kcc.py`)
|
||||||
|
|
||||||
|
- `kcc-c2e` en su propio grupo de procesos con timeout (**600s default**)
|
||||||
|
- Si el proceso cuelga: `kill -- -$PGID` (Linux) o `taskkill /T /F` (Windows)
|
||||||
|
- File-based completion detection (espera archivo `.mobi`, polling)
|
||||||
|
- Flags: `--format MOBI --profile KPW34 --manga-style --stretch --splitter 2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Feedback Loop (`visual_feedback.py`)
|
||||||
|
|
||||||
|
- Vision LLM (xiaomi/mimo-v2.5) evalúa legibilidad del texto renderizado por burbuja
|
||||||
|
- Verdictos: READABLE / TOO_SMALL / OVERFLOWING / EMPTY
|
||||||
|
- Auto-ajusta `font_size_offset`, `font_size_minimum`, `font_size_maximum`
|
||||||
|
- Sanity bounds: offset [-20, 50], minimum [6, 60], maximum [25, 100]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows Encoding
|
||||||
|
|
||||||
|
Set `PYTHONUTF8=1` o `sys.stdin.reconfigure(encoding='utf-8')` para evitar problemas cp1252. Sin esto, `asyncio.create_subprocess_exec` con `PIPE` puede corromper output de manga-translator en Windows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Bugs & Design Issues
|
||||||
|
|
||||||
|
1. `scraper.py` dead code: `fetch_gallery_links_httpx()` raises `NotImplementedError` — nunca se llama
|
||||||
|
2. `scraper.py` dead parameter: `should_download()` acepta `translation_enabled` pero nunca lo usa
|
||||||
|
3. `scraper.py` code duplication: `_fetch_gallery_httpx()` y `_fetch_gallery_sync()` tienen lógica de parsing casi idéntica
|
||||||
|
4. Dual state tracking: SQLite (`NhentaiStateDB`/`HentaiStateDB`) Y GalleryQueue/UrlQueue trackean estado de galleries sin reconciliación cruzada
|
||||||
|
5. `process.py` coexiste con `artifacts.py` — versión anterior del pipeline
|
||||||
|
6. `bot.py` (legacy, 26KB) coexiste con `bot_artifacts.py` (producción, ~30KB)
|
||||||
|
7. Hardcoded paths: `docker exec -u 33 nextcloud` en `nextcloud_uploader.py`
|
||||||
|
8. No centralized rate limiting: cada scraper maneja su propio rate limit
|
||||||
|
9. `mark_processing` en `base_state.py` NO incrementa `retry_count` (se hace por separado)
|
||||||
|
10. `url_queue.py` dead code: `_build_work_key_for_item` tiene un `return` inalcanzable después de un docstring duplicado de `reload_from_disk`
|
||||||
|
11. API REST no tiene autenticación por defecto en dev (`api_key` vacío = sin auth)
|
||||||
|
12. `_render_*.py` y `_smart_render_*.py` y `_vision_feedback*.py` son experimentales sin entry point — coexisten como archivos muertos en la raíz
|
||||||
|
13. `src/converter.py` usa `subprocess` para `ebook-convert` y `fetch-ebook-metadata` de Calibre — puede fallar si no está instalado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to Add
|
||||||
|
|
||||||
|
- No CI/CD pipelines, no pre-commit hooks, no linter/formatter configs
|
||||||
|
- No WebDAV endpoints (replaced by local filesystem copy)
|
||||||
|
- No new entry points without discussion
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data/covers && chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
RUN mkdir .next
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV DB_PATH=/app/data/worst-scan.db
|
||||||
|
ENV COVERS_DIR=/app/data/covers
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: worst-scan-web
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
|
- WEB_PASSWORD=${WEB_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
Generated
+7005
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "worst-scan-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^13.0.1",
|
||||||
|
"jose": "^6.2.4",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-react": "^1.26.0",
|
||||||
|
"next": "16.2.11",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"swr": "^2.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.11",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== worst-scan-web setup ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "[INFO] .env created from .env.example — edit it with your API settings."
|
||||||
|
else
|
||||||
|
echo "[OK] .env already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Choose install method:"
|
||||||
|
echo " 1) Local (npm run dev)"
|
||||||
|
echo " 2) Docker Compose"
|
||||||
|
echo ""
|
||||||
|
read -rp "Method [1/2]: " method
|
||||||
|
|
||||||
|
if [ "$method" = "2" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Docker build & start ==="
|
||||||
|
docker compose up -d --build
|
||||||
|
echo ""
|
||||||
|
echo "Web running at http://localhost:3000"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "=== Local install ==="
|
||||||
|
npm install
|
||||||
|
echo ""
|
||||||
|
echo "Run: npm run dev"
|
||||||
|
echo "Or: npm run build && npm start"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, FormEvent } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Save } from "lucide-react"
|
||||||
|
|
||||||
|
export default function EditPostPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [summary, setSummary] = useState("")
|
||||||
|
const [slug, setSlug] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/posts/${id}`)
|
||||||
|
const data = await res.json()
|
||||||
|
const post = data?.data
|
||||||
|
if (post) {
|
||||||
|
setTitle(post.title)
|
||||||
|
setSummary(post.summary || "")
|
||||||
|
setSlug(post.slug)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
async function handleSave(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/posts/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, summary, slug }),
|
||||||
|
})
|
||||||
|
router.push("/admin/posts")
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="py-20 text-center text-sm text-[var(--muted)]">Cargando...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Link
|
||||||
|
href="/admin/posts"
|
||||||
|
className="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" />
|
||||||
|
Volver a posts
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mb-6 text-xl font-bold">Editar Post</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Título</label>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} className="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Slug</label>
|
||||||
|
<input value={slug} onChange={(e) => setSlug(e.target.value)} className="input font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Resumen</label>
|
||||||
|
<textarea
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
className="input resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={saving} className="btn-primary">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? "Guardando..." : "Guardar"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { RefreshCw, FileText, CheckCircle, XCircle, ExternalLink } from "lucide-react"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
export default function AdminPostsPage() {
|
||||||
|
const { data, isLoading, mutate } = useSWR("/api/posts", fetcher, { refreshInterval: 15000 })
|
||||||
|
const [publishing, setPublishing] = useState<Set<number>>(new Set())
|
||||||
|
const [deleting, setDeleting] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const posts = data?.data || []
|
||||||
|
|
||||||
|
async function handlePublish(id: number, current: number) {
|
||||||
|
setPublishing((prev) => new Set(prev).add(id))
|
||||||
|
await fetch(`/api/posts/${id}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ publish: current === 0 }),
|
||||||
|
})
|
||||||
|
mutate()
|
||||||
|
setPublishing((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("¿Eliminar este post permanentemente?")) return
|
||||||
|
setDeleting((prev) => new Set(prev).add(id))
|
||||||
|
await fetch(`/api/posts/${id}`, { method: "DELETE" })
|
||||||
|
mutate()
|
||||||
|
setDeleting((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Posts</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">{posts.length} publicaciones</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => mutate()} disabled={isLoading} className="btn-ghost">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<EmptyState message="No hay posts" icon={<FileText className="mb-3 h-10 w-10 text-[var(--muted)]" />} />
|
||||||
|
) : (
|
||||||
|
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||||
|
{posts.map((post: {
|
||||||
|
id: number
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
published: number
|
||||||
|
artist: string | null
|
||||||
|
source: string | null
|
||||||
|
created_at: string
|
||||||
|
}) => (
|
||||||
|
<div key={post.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublish(post.id, post.published)}
|
||||||
|
disabled={publishing.has(post.id)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
title={post.published ? "Publicado" : "No publicado"}
|
||||||
|
>
|
||||||
|
{post.published ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-[var(--success)]" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-5 w-5 text-[var(--muted)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/p/${post.slug}`}
|
||||||
|
className="font-medium hover:text-[var(--accent)] transition-colors line-clamp-1"
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[var(--muted)]">
|
||||||
|
<span>{post.gid}</span>
|
||||||
|
{post.artist && <span>{post.artist}</span>}
|
||||||
|
{post.source && <span>{post.source}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link href={`/gallery/${post.gid}`} className="btn-ghost p-1.5">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(post.id)}
|
||||||
|
disabled={deleting.has(post.id)}
|
||||||
|
className="btn-ghost p-1.5 text-[var(--error)] hover:bg-[var(--error-subtle)]"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useGalleries } from "@/hooks/use-galleries"
|
||||||
|
import { GalleryGrid } from "@/components/gallery-grid"
|
||||||
|
import { FilterBar } from "@/components/filter-bar"
|
||||||
|
|
||||||
|
export default function FeedPage() {
|
||||||
|
const [status, setStatus] = useState("completed")
|
||||||
|
const { galleries, isLoading, mutate } = useGalleries(status, 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold">Feed</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{isLoading && galleries.length === 0
|
||||||
|
? "Cargando galleries..."
|
||||||
|
: `${galleries.length} galleries`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
current={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
onRefresh={() => mutate()}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && galleries.length === 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="card flex gap-4 overflow-hidden p-0">
|
||||||
|
<div className="skeleton h-36 w-24 flex-shrink-0" />
|
||||||
|
<div className="flex flex-1 flex-col gap-2 py-3 pr-3">
|
||||||
|
<div className="skeleton h-4 w-3/4" />
|
||||||
|
<div className="skeleton h-3 w-1/4" />
|
||||||
|
<div className="skeleton mt-auto h-3 w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GalleryGrid items={galleries} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { BookOpen, ExternalLink, Trash2 } from "lucide-react"
|
||||||
|
import { CoverImage } from "@/components/cover-image"
|
||||||
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
import type { GallerySummary, GalleryArtifacts } from "@/lib/types"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
export default function GalleryDetailPage() {
|
||||||
|
const { gid } = useParams<{ gid: string }>()
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const { data: summaryData, isLoading } = useSWR(
|
||||||
|
`/api/proxy/galleries/${gid}/summary`,
|
||||||
|
fetcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: artifactsData } = useSWR(
|
||||||
|
`/api/proxy/galleries/${gid}/artifacts`,
|
||||||
|
fetcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = summaryData?.data as GallerySummary | undefined
|
||||||
|
const artifacts = artifactsData?.data as GalleryArtifacts | undefined
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm("¿Eliminar esta gallery?")) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/proxy/galleries/${gid}`, { method: "DELETE" })
|
||||||
|
window.location.href = "/feed"
|
||||||
|
} catch {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div className="skeleton h-56 w-40 flex-shrink-0 rounded-lg" />
|
||||||
|
<div className="flex flex-1 flex-col gap-3">
|
||||||
|
<div className="skeleton h-6 w-3/4" />
|
||||||
|
<div className="skeleton h-4 w-1/2" />
|
||||||
|
<div className="skeleton h-4 w-1/3" />
|
||||||
|
<div className="skeleton mt-auto h-9 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-sm text-[var(--error)]">Gallery no encontrada</p>
|
||||||
|
<Link href="/feed" className="btn-ghost mt-4">
|
||||||
|
Volver al feed
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="card mb-6 flex flex-col gap-6 overflow-hidden p-0 sm:flex-row">
|
||||||
|
<CoverImage
|
||||||
|
gid={gid}
|
||||||
|
coverUrl={summary.cover.external_url}
|
||||||
|
alt={summary.title}
|
||||||
|
className="h-56 w-40 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 px-6 pb-6 pt-0 sm:py-6 sm:pl-0">
|
||||||
|
<h1 className="text-xl font-bold">{summary.title}</h1>
|
||||||
|
{summary.title_jpn && (
|
||||||
|
<p className="text-sm text-[var(--muted)]">{summary.title_jpn}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{summary.artist && <TagBadge tag={`artist:${summary.artist}`} href={`/tag/${encodeURIComponent(`artist:${summary.artist}`)}`} />}
|
||||||
|
{summary.parody && <TagBadge tag={`parody:${summary.parody}`} href={`/tag/${encodeURIComponent(`parody:${summary.parody}`)}`} />}
|
||||||
|
{summary.is_spanish && (
|
||||||
|
<span className="tag-pill bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||||
|
ESP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{summary.num_pages} páginas · {summary.source}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-2">
|
||||||
|
<Link href={`/gallery/${gid}/read`} className="btn-primary">
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Leer
|
||||||
|
</Link>
|
||||||
|
{summary.url && (
|
||||||
|
<a href={summary.url} target="_blank" rel="noopener noreferrer" className="btn-ghost">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Original
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button onClick={handleDelete} disabled={deleting} className="btn-danger">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleting ? "..." : "Eliminar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.tags && summary.tags.length > 0 && (
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">Tags</h2>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{summary.tags.map((tag) => (
|
||||||
|
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifacts && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||||
|
Archivos ({artifacts.file_count} archivos, {(artifacts.total_size_mb).toFixed(1)} MB)
|
||||||
|
</h2>
|
||||||
|
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||||
|
{Object.entries(artifacts.files).map(([dir, files]) =>
|
||||||
|
Array.isArray(files) && files.length ? (
|
||||||
|
<details key={dir} className="group">
|
||||||
|
<summary className="flex cursor-pointer items-center gap-2 px-4 py-2.5 text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
||||||
|
<span className="font-medium">{dir}</span>
|
||||||
|
<span className="text-xs text-[var(--muted)]">({files.length})</span>
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-[var(--border)] px-4 py-2">
|
||||||
|
<p className="mb-2 text-xs text-[var(--muted)]">
|
||||||
|
{files.slice(0, 20).map((f) => (
|
||||||
|
<span key={f} className="block py-0.5 font-mono text-[10px]">{f}</span>
|
||||||
|
))}
|
||||||
|
{files.length > 20 && (
|
||||||
|
<span className="block py-1 text-[10px] text-[var(--muted)]">
|
||||||
|
... y {files.length - 20} más
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ReaderPage() {
|
||||||
|
const { gid } = useParams<{ gid: string }>()
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pageUrls, setPageUrls] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showControls, setShowControls] = useState(true)
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/proxy/galleries/${gid}/summary`)
|
||||||
|
const data = await res.json()
|
||||||
|
const summary = data?.data
|
||||||
|
setTitle(summary?.title || `g/${gid}`)
|
||||||
|
|
||||||
|
if (!summary) { setError("Gallery no encontrada"); setLoading(false); return }
|
||||||
|
|
||||||
|
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)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artRes = await fetch(`/api/proxy/galleries/${gid}/artifacts`)
|
||||||
|
const artData = await artRes.json()
|
||||||
|
const rendered = artData?.data?.files?.rendered
|
||||||
|
if (rendered?.length) {
|
||||||
|
const urls = rendered.map(() => `/api/proxy/galleries/${gid}/cover`)
|
||||||
|
setPageUrls(urls)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coverRes = await fetch(`/api/proxy/galleries/${gid}/cover`, { method: "HEAD" })
|
||||||
|
if (coverRes.ok) {
|
||||||
|
setPageUrls([`/api/proxy/galleries/${gid}/cover`])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
setError("No hay imágenes disponibles. La API del pipeline solo expone la portada.")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
}, [gid])
|
||||||
|
|
||||||
|
const totalPages = pageUrls.length
|
||||||
|
const goTo = useCallback((n: number) => setPage(Math.max(0, Math.min(n, totalPages - 1))), [totalPages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft") 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 === "-") setZoom((z) => Math.max(z - 0.25, 0.25))
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [page, goTo])
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(() => {
|
||||||
|
setShowControls(true)
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
|
hideTimer.current = setTimeout(() => setShowControls(false), 2000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-40 text-[var(--muted)]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg py-20 text-center">
|
||||||
|
<div className="card p-8">
|
||||||
|
<p className="mb-4 text-sm text-[var(--error)]">{error}</p>
|
||||||
|
<p className="text-xs text-[var(--muted)] mb-4">
|
||||||
|
La API del pipeline solo sirve la página 1 vía <code className="text-[var(--accent)]">/galleries/{"{gid}"}/cover</code>.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`/gallery/${gid}`}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Volver al detalle
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[calc(100vh-3rem)] flex-col bg-black/40"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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 ${
|
||||||
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm text-[var(--muted)]">{title}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => setZoom((z) => Math.max(z - 0.25, 0.25))} className="btn-ghost p-1">
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center text-xs text-[var(--muted)]">{Math.round(zoom * 100)}%</span>
|
||||||
|
<button onClick={() => setZoom((z) => Math.min(z + 0.25, 3))} className="btn-ghost p-1">
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<span className="text-xs text-[var(--muted)]">{page + 1} / {totalPages}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-auto">
|
||||||
|
<div className="flex items-center gap-4 px-4">
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
||||||
|
showControls ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-6 w-6" />
|
||||||
|
</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
|
||||||
|
onClick={() => goTo(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
||||||
|
showControls ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<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 ${
|
||||||
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 100) }).map((_, i) => (
|
||||||
|
<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)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{totalPages > 100 && (
|
||||||
|
<span className="text-[10px] text-[var(--muted)] ml-1">+{totalPages - 100}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages === 1 && (
|
||||||
|
<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)]">
|
||||||
|
Solo portada disponible. El pipeline solo expone la página 1 vía /cover.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Sidebar } from "@/components/layout/sidebar"
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="ml-56 flex-1 p-6 lg:p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { RefreshCw, RotateCcw } from "lucide-react"
|
||||||
|
import { FilterBar } from "@/components/filter-bar"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import type { QueueItem, SystemStatus } from "@/lib/types"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
function SlotGauge({ label, used, max }: { label: string; used: number; max: number }) {
|
||||||
|
const pct = max > 0 ? (used / max) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-16 text-[var(--muted)]">{label}</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--border)]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${
|
||||||
|
pct >= 100 ? "bg-[var(--error)]" : "bg-[var(--accent)]"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-10 text-right text-[var(--muted)]">
|
||||||
|
{used}/{max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color }}>{value}</div>
|
||||||
|
<div className="text-xs text-[var(--muted)] mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueuePage() {
|
||||||
|
const [status, setStatus] = useState("")
|
||||||
|
const [retrying, setRetrying] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const { data: queueData, isLoading, mutate: mutateQueue } = useSWR(
|
||||||
|
`/api/proxy/queue?status=${status}&page=1&per_page=100`,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: statusData, mutate: mutateStatus } = useSWR(
|
||||||
|
"/api/proxy/status",
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = (queueData?.data || []) as QueueItem[]
|
||||||
|
const sysStatus = statusData?.data as SystemStatus | undefined
|
||||||
|
|
||||||
|
async function handleRetry(gid: string) {
|
||||||
|
setRetrying((prev) => new Set(prev).add(gid))
|
||||||
|
try {
|
||||||
|
await fetch(`/api/proxy/queue/${gid}/retry`, { method: "POST" })
|
||||||
|
mutateQueue()
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setRetrying((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(gid)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = sysStatus?.slots || { download: { used: 0, max: 2 }, translate: { used: 0, max: 2 } }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold">Cola</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Estado actual del pipeline ({items.length} items)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<SlotGauge label="Download" {...slots.download} />
|
||||||
|
<SlotGauge label="Translate" {...slots.translate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sysStatus && (
|
||||||
|
<div className="mb-4 grid grid-cols-4 gap-3">
|
||||||
|
<StatBox label="Pendientes" value={sysStatus.queue.pending} color="var(--warning)" />
|
||||||
|
<StatBox label="Procesando" value={sysStatus.queue.processing} color="var(--accent)" />
|
||||||
|
<StatBox label="Completadas" value={sysStatus.queue.completed} color="var(--success)" />
|
||||||
|
<StatBox label="Fallidas" value={sysStatus.queue.failed} color="var(--error)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
current={status}
|
||||||
|
onChange={(s) => setStatus(s)}
|
||||||
|
onRefresh={() => { mutateQueue(); mutateStatus() }}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState message="Cola vacía" />
|
||||||
|
) : (
|
||||||
|
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.gid}
|
||||||
|
className="flex items-center gap-3 px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/gallery/${item.gid}`}
|
||||||
|
className="min-w-0 flex-1 truncate font-medium hover:text-[var(--accent)] transition-colors"
|
||||||
|
>
|
||||||
|
{item.title || item.gid}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`w-20 text-center text-xs font-medium capitalize ${
|
||||||
|
item.status === "completed"
|
||||||
|
? "text-[var(--success)]"
|
||||||
|
: item.status === "failed"
|
||||||
|
? "text-[var(--error)]"
|
||||||
|
: item.status === "processing"
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--warning)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="w-12 text-center text-xs text-[var(--muted)]">
|
||||||
|
{item.priority > 0 ? `P${item.priority}` : "-"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="w-32 truncate text-xs text-[var(--muted)]">
|
||||||
|
{item.error || "-"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{item.status === "failed" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetry(item.gid)}
|
||||||
|
disabled={retrying.has(item.gid)}
|
||||||
|
className="btn-ghost py-1 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className={`h-3 w-3 ${retrying.has(item.gid) ? "animate-spin" : ""}`} />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SearchForm } from "@/components/search-form"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold">Buscar</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">Buscá en nhentai / e-hentai y encolá resultados</p>
|
||||||
|
</div>
|
||||||
|
<SearchForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SubmitForm } from "@/components/submit-form"
|
||||||
|
import { Send } from "lucide-react"
|
||||||
|
|
||||||
|
export default function SubmitPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold">Enviar</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">Encolá URLs de nhentai / e-hentai para procesar</p>
|
||||||
|
</div>
|
||||||
|
<SubmitForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Image } from "lucide-react"
|
||||||
|
|
||||||
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-[var(--background)]/80 backdrop-blur-lg">
|
||||||
|
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||||
|
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
|
<Link href="/feed" className="text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</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)]">
|
||||||
|
worst-scan · traducción automática de manga
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getPostBySlug, getAllPosts } from "@/lib/db"
|
||||||
|
import { PostCard } from "@/components/post-card"
|
||||||
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function PostDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>
|
||||||
|
}) {
|
||||||
|
const { slug } = await params
|
||||||
|
const post = getPostBySlug(slug)
|
||||||
|
|
||||||
|
if (!post) notFound()
|
||||||
|
|
||||||
|
const related = getAllPosts(true)
|
||||||
|
.filter((p) => p.id !== post.id)
|
||||||
|
.slice(0, 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="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" />
|
||||||
|
Volver
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden p-0">
|
||||||
|
<div className="flex flex-col gap-0 md:flex-row">
|
||||||
|
<div className="md:w-80 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={`/api/cover/${post.gid}`}
|
||||||
|
alt={post.title}
|
||||||
|
className="h-auto w-full object-cover md:h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold leading-tight">{post.title}</h1>
|
||||||
|
{post.title_jpn && (
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
{post.artist && (
|
||||||
|
<TagBadge tag={`artist:${post.artist}`} href={`/tag/${encodeURIComponent(`artist:${post.artist}`)}`} />
|
||||||
|
)}
|
||||||
|
{post.parody && (
|
||||||
|
<TagBadge tag={`parody:${post.parody}`} href={`/tag/${encodeURIComponent(`parody:${post.parody}`)}`} />
|
||||||
|
)}
|
||||||
|
{post.source && (
|
||||||
|
<span className="tag-pill border border-[var(--border)] text-[var(--muted)]">
|
||||||
|
{post.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.summary && (
|
||||||
|
<div className="text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line">
|
||||||
|
{post.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{post.tags.map((tag: string) => (
|
||||||
|
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/gallery/${post.gid}/read`}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Leer
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/api/proxy/galleries/${post.gid}/artifacts`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Archivos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||||
|
Más publicaciones
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
{related.map((p) => (
|
||||||
|
<PostCard key={p.id} post={p} compact />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getAllPosts } from "@/lib/db"
|
||||||
|
import { PostCard } from "@/components/post-card"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { BookOpen } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default function PublicHomePage() {
|
||||||
|
const posts = getAllPosts(true)
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<EmptyState message="Todavía no hay publicaciones" icon={<BookOpen className="mb-3 h-10 w-10 text-[var(--muted)]" />} />
|
||||||
|
<p className="mt-4 text-center text-xs text-[var(--muted)]">
|
||||||
|
Las publicaciones aparecen automáticamente cuando el pipeline completa traducciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Publicaciones</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
Traducciones automáticas generadas por worst-scan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getPostsByTag } from "@/lib/db"
|
||||||
|
import { PostCard } from "@/components/post-card"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ArrowLeft, Tag } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function TagPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ tag: string }>
|
||||||
|
}) {
|
||||||
|
const { tag } = await params
|
||||||
|
const decodedTag = decodeURIComponent(tag)
|
||||||
|
const posts = getPostsByTag(decodedTag, true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="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" />
|
||||||
|
Todas las publicaciones
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4 text-[var(--accent)]" />
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">{decodedTag}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
{posts.length} {posts.length === 1 ? "publicación" : "publicaciones"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<EmptyState message="No hay publicaciones con este tag" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { createSession, sessionCookieOptions } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const webPassword = process.env.WEB_PASSWORD
|
||||||
|
if (!webPassword) {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const opts = sessionCookieOptions()
|
||||||
|
cookieStore.set(opts.name, await createSession(), opts.options)
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = await req.json()
|
||||||
|
if (password !== webPassword) {
|
||||||
|
return Response.json({ error: "Invalid password" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const opts = sessionCookieOptions()
|
||||||
|
cookieStore.set(opts.name, await createSession(), opts.options)
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { sessionCookieOptions } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const opts = sessionCookieOptions()
|
||||||
|
cookieStore.delete(opts.name)
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import { getCoverPath, getCoverContentType, cacheCover } from "@/lib/cover-cache"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ gid: string }> },
|
||||||
|
) {
|
||||||
|
const { gid } = await params
|
||||||
|
|
||||||
|
let coverPath = getCoverPath(gid)
|
||||||
|
|
||||||
|
if (!coverPath) {
|
||||||
|
try {
|
||||||
|
const proxyUrl = `http://127.0.0.1:${process.env.PORT || 3000}/api/proxy/galleries/${gid}/cover`
|
||||||
|
coverPath = await cacheCover(gid, proxyUrl)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coverPath || !fs.existsSync(coverPath)) {
|
||||||
|
return Response.json({ error: "Cover not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = fs.readFileSync(coverPath)
|
||||||
|
const contentType = getCoverContentType(gid)
|
||||||
|
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { pollOnce } from "@/lib/poller"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await pollOnce()
|
||||||
|
return Response.json({ ok: true, newPosts: result.newPosts })
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ ok: false, error: String(e) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import { publishPost } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const publish = body.publish !== false
|
||||||
|
const post = publishPost(Number(id), publish)
|
||||||
|
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||||
|
return Response.json({ data: post })
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import { getPostById, updatePost, deletePost } from "@/lib/db"
|
||||||
|
import { deleteCover } from "@/lib/cover-cache"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const post = getPostById(Number(id))
|
||||||
|
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||||
|
return Response.json({ data: post })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
const post = updatePost(Number(id), body)
|
||||||
|
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||||
|
return Response.json({ data: post })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const post = getPostById(Number(id))
|
||||||
|
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||||
|
|
||||||
|
deleteCover(post.gid)
|
||||||
|
const deleted = deletePost(Number(id))
|
||||||
|
if (!deleted) return Response.json({ error: "Failed to delete" }, { status: 500 })
|
||||||
|
|
||||||
|
return Response.json({ ok: true }, { status: 200 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
import { getAllPosts, createPost, getPostByGid } from "@/lib/db"
|
||||||
|
import { slugify } from "@/lib/slug"
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const publishedOnly = req.nextUrl.searchParams.get("published") === "1"
|
||||||
|
const posts = getAllPosts(publishedOnly)
|
||||||
|
return Response.json({ data: posts })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
if (!body.gid || !body.title) {
|
||||||
|
return Response.json({ error: "gid and title are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getPostByGid(body.gid)
|
||||||
|
if (existing) {
|
||||||
|
return Response.json({ error: "Post already exists", data: existing }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = body.slug || slugify(body.title, body.gid)
|
||||||
|
|
||||||
|
const post = createPost({
|
||||||
|
gid: body.gid,
|
||||||
|
title: body.title,
|
||||||
|
title_jpn: body.title_jpn,
|
||||||
|
artist: body.artist,
|
||||||
|
parody: body.parody,
|
||||||
|
tags: body.tags,
|
||||||
|
num_pages: body.num_pages,
|
||||||
|
source: body.source,
|
||||||
|
cover_url: body.cover_url,
|
||||||
|
summary: body.summary,
|
||||||
|
slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ data: post }, { status: 201 })
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json({ error: String(e) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"
|
||||||
|
const API_KEY = process.env.API_KEY || ""
|
||||||
|
|
||||||
|
async function proxy(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
const subpath = path.join("/")
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const qs = url.search
|
||||||
|
const target = `${API_BASE}/${subpath}${qs}`
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (API_KEY) {
|
||||||
|
headers["X-API-Key"] = API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.method !== "GET" && request.method !== "HEAD"
|
||||||
|
? await request.blob()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (body && request.headers.get("content-type")) {
|
||||||
|
headers["Content-Type"] = request.headers.get("content-type")!
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(target, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
for (const [k, v] of res.headers) {
|
||||||
|
if (!["content-encoding", "content-length", "transfer-encoding"].includes(k.toLowerCase())) {
|
||||||
|
responseHeaders.set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: { code: "proxy_error", message: String(e) } },
|
||||||
|
{ status: 502 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxy
|
||||||
|
export const POST = proxy
|
||||||
|
export const PUT = proxy
|
||||||
|
export const DELETE = proxy
|
||||||
|
export const PATCH = proxy
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,166 @@
|
|||||||
|
@import "tailwindcss" source("../../src");
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2") format("woff2");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter Fallback";
|
||||||
|
src: local("system-ui"), local("-apple-system"), local("sans-serif");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #09090b;
|
||||||
|
--surface: #18181b;
|
||||||
|
--surface-hover: #1f1f23;
|
||||||
|
--surface-active: #27272a;
|
||||||
|
--border: #27272a;
|
||||||
|
--border-hover: #3f3f46;
|
||||||
|
--foreground: #fafafa;
|
||||||
|
--muted: #a1a1aa;
|
||||||
|
--muted-light: #d4d4d8;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #4f46e5;
|
||||||
|
--accent-subtle: rgba(99, 102, 241, 0.1);
|
||||||
|
--success: #22c55e;
|
||||||
|
--success-subtle: rgba(34, 197, 94, 0.1);
|
||||||
|
--warning: #eab308;
|
||||||
|
--warning-subtle: rgba(234, 179, 8, 0.1);
|
||||||
|
--error: #ef4444;
|
||||||
|
--error-subtle: rgba(239, 68, 68, 0.1);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #27272a transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: "Inter", "Inter Fallback", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card-hover {
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility tag-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 450;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-primary {
|
||||||
|
@apply btn;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-ghost {
|
||||||
|
@apply btn;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-danger {
|
||||||
|
@apply btn;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--error-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility input {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import "./globals.css"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "worst-scan",
|
||||||
|
description: "Traducción automática de manga — worst-scan fansub",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="es">
|
||||||
|
<body className="min-h-screen bg-[var(--background)] text-[var(--foreground)] antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, FormEvent } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Image, LogIn } from "lucide-react"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.error || "Contraseña incorrecta")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="card p-8">
|
||||||
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)]">
|
||||||
|
<Image className="h-6 w-6 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">worst-scan</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">Panel de control</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Contraseña"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-[var(--error)] bg-[var(--error-subtle)] rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
{loading ? "..." : "Entrar"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-[var(--muted)]">
|
||||||
|
worst-scan · traducción automática de manga
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ImageIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function CoverImage({
|
||||||
|
gid,
|
||||||
|
coverUrl,
|
||||||
|
alt,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
gid: string
|
||||||
|
coverUrl?: string
|
||||||
|
alt: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const src = coverUrl || `/api/proxy/galleries/${gid}/cover`
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center bg-[var(--surface)] ${className}`}>
|
||||||
|
<ImageIcon className="h-6 w-6 text-[var(--muted)]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden ${className}`}>
|
||||||
|
{!loaded && <div className="skeleton absolute inset-0" />}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Inbox } from "lucide-react"
|
||||||
|
|
||||||
|
export function EmptyState({ message = "No hay nada aquí", icon }: { message?: string; icon?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border)] py-20">
|
||||||
|
{icon || <Inbox className="mb-3 h-10 w-10 text-[var(--muted)]" />}
|
||||||
|
<p className="text-sm text-[var(--muted)]">{message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
|
const statuses = ["completed", "processing", "pending", "failed", ""]
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
"": "Todas",
|
||||||
|
completed: "Completadas",
|
||||||
|
processing: "Activas",
|
||||||
|
pending: "Pendientes",
|
||||||
|
failed: "Fallidas",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
current,
|
||||||
|
onChange,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
current: string
|
||||||
|
onChange: (s: string) => void
|
||||||
|
onRefresh?: () => void
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center gap-1.5">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onChange(s)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
current === s
|
||||||
|
? "bg-[var(--accent)] text-white shadow-sm"
|
||||||
|
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] border border-transparent hover:border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labels[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{onRefresh && (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-ghost p-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { BookOpen } from "lucide-react"
|
||||||
|
import { CoverImage } from "@/components/cover-image"
|
||||||
|
import type { GalleryItem } from "@/lib/types"
|
||||||
|
|
||||||
|
export function GalleryCard({ item }: { item: GalleryItem }) {
|
||||||
|
return (
|
||||||
|
<div className="card card-hover group flex overflow-hidden p-0">
|
||||||
|
<Link href={`/gallery/${item.gid}`} className="flex flex-1 gap-4">
|
||||||
|
<CoverImage
|
||||||
|
gid={item.gid}
|
||||||
|
alt={item.title}
|
||||||
|
className="h-36 w-24 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-3">
|
||||||
|
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{item.is_spanish && (
|
||||||
|
<span className="tag-pill w-fit bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||||
|
ESP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||||
|
<span>{item.pages} pág</span>
|
||||||
|
<span
|
||||||
|
className={`capitalize ${
|
||||||
|
item.status === "completed"
|
||||||
|
? "text-[var(--success)]"
|
||||||
|
: item.status === "failed"
|
||||||
|
? "text-[var(--error)]"
|
||||||
|
: item.status === "processing"
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--warning)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/gallery/${item.gid}/read`}
|
||||||
|
className="btn-primary text-xs py-1 px-2.5"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
Leer
|
||||||
|
</Link>
|
||||||
|
{item.status === "failed" && (
|
||||||
|
<span className="text-[10px] text-[var(--error)]">{item.error || "error"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { GalleryCard } from "@/components/gallery-card"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import type { GalleryItem } from "@/lib/types"
|
||||||
|
|
||||||
|
export function GalleryGrid({ items }: { items: GalleryItem[] }) {
|
||||||
|
if (!items.length) return <EmptyState message="No se encontraron galleries" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<GalleryCard key={item.gid} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, LogOut } from "lucide-react"
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
|
||||||
|
{ href: "/submit", label: "Enviar", icon: Send },
|
||||||
|
{ href: "/search", label: "Buscar", icon: Search },
|
||||||
|
{ href: "/queue", label: "Cola", icon: ListOrdered },
|
||||||
|
{ href: "/admin/posts", label: "Posts", icon: FileText },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-[var(--border)] px-5 py-4">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||||
|
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
||||||
|
{links.map(({ href, label, icon: Icon }) => {
|
||||||
|
const isActive = pathname.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all ${
|
||||||
|
isActive
|
||||||
|
? "bg-[var(--surface)] font-medium text-[var(--foreground)]"
|
||||||
|
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border)] p-3">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-all hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import type { Post } from "@/lib/db"
|
||||||
|
import { formatDate } from "@/lib/utils"
|
||||||
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
|
||||||
|
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/p/${post.slug}`}
|
||||||
|
className="card card-hover group flex gap-4 overflow-hidden p-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/cover/${post.gid}`}
|
||||||
|
alt={post.title}
|
||||||
|
className="h-32 w-24 flex-shrink-0 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
|
||||||
|
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
{post.artist && (
|
||||||
|
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-1 items-end gap-2">
|
||||||
|
{post.source && (
|
||||||
|
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
||||||
|
)}
|
||||||
|
{post.published_at && (
|
||||||
|
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/p/${post.slug}`}
|
||||||
|
className="card card-hover group flex flex-col overflow-hidden p-0"
|
||||||
|
>
|
||||||
|
<div className="aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||||
|
<img
|
||||||
|
src={`/api/cover/${post.gid}`}
|
||||||
|
alt={post.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
{post.artist && (
|
||||||
|
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||||
|
)}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{post.tags.slice(0, 3).map((tag) => (
|
||||||
|
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex items-center gap-2 text-[10px] text-[var(--muted)]">
|
||||||
|
{post.num_pages > 0 && <span>{post.num_pages} pág</span>}
|
||||||
|
{post.published_at && <span>{formatDate(post.published_at)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Search, RotateCcw } from "lucide-react"
|
||||||
|
import { CoverImage } from "@/components/cover-image"
|
||||||
|
import { TagBadge } from "@/components/tag-badge"
|
||||||
|
import type { SearchResult } from "@/lib/types"
|
||||||
|
|
||||||
|
export function SearchForm() {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [source, setSource] = useState<"nhentai" | "ehentai">("nhentai")
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [meta, setMeta] = useState<{ total: number; blocked: number; passed: number } | null>(null)
|
||||||
|
const [queuing, setQueuing] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!query.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setResults([])
|
||||||
|
setMeta(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/proxy/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ query: query.trim(), source }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setResults(data.data || [])
|
||||||
|
setMeta(data.meta || null)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQueue(gid: string, url: string) {
|
||||||
|
setQueuing((prev) => new Set(prev).add(gid))
|
||||||
|
try {
|
||||||
|
await fetch("/api/proxy/galleries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setQueuing((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(gid)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQueueAll() {
|
||||||
|
const valid = results.filter((r) => !r.blocked)
|
||||||
|
for (const r of valid) {
|
||||||
|
await handleQueue(r.gid, r.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="Buscar en nhentai o e-hentai..."
|
||||||
|
className="input pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value as "nhentai" | "ehentai")}
|
||||||
|
className="input w-32"
|
||||||
|
>
|
||||||
|
<option value="nhentai">nhentai</option>
|
||||||
|
<option value="ehentai">e-hentai</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Buscar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-[var(--muted)]">{meta.total} resultados</span>
|
||||||
|
{meta.blocked > 0 && (
|
||||||
|
<span className="text-[var(--warning)]">{meta.blocked} bloqueados</span>
|
||||||
|
)}
|
||||||
|
{results.filter((r) => !r.blocked).length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleQueueAll}
|
||||||
|
className="btn-primary ml-auto bg-emerald-600 hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Encolar todo ({results.filter((r) => !r.blocked).length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.gid}
|
||||||
|
className={`card overflow-hidden p-3 ${
|
||||||
|
r.blocked ? "opacity-50 border-red-900/50" : "card-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<CoverImage
|
||||||
|
gid={r.gid}
|
||||||
|
alt={r.title}
|
||||||
|
className="h-28 w-20 flex-shrink-0 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<h3 className="truncate text-sm font-medium">{r.title}</h3>
|
||||||
|
<span className="text-xs text-[var(--muted)]">{r.pages} páginas</span>
|
||||||
|
{r.blocked && (
|
||||||
|
<span className="text-xs text-[var(--error)]">{r.blocked_reason}</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto flex flex-wrap gap-1">
|
||||||
|
{r.tags.slice(0, 4).map((t) => (
|
||||||
|
<TagBadge key={t} tag={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!r.blocked && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleQueue(r.gid, r.url)}
|
||||||
|
disabled={queuing.has(r.gid)}
|
||||||
|
className="btn-primary mt-1 w-fit text-xs py-1 px-2.5"
|
||||||
|
>
|
||||||
|
{queuing.has(r.gid) ? (
|
||||||
|
<RotateCcw className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Encolar"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Send, CheckCircle, XCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export function SubmitForm() {
|
||||||
|
const [urls, setUrls] = useState("")
|
||||||
|
const [results, setResults] = useState<{ gid: string; status: string; title?: string }[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setLoading(true)
|
||||||
|
setResults([])
|
||||||
|
|
||||||
|
const lines = urls.split("\n").map((l) => l.trim()).filter(Boolean)
|
||||||
|
const batch: { gid: string; status: string }[] = []
|
||||||
|
|
||||||
|
for (const url of lines) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/proxy/galleries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
batch.push({
|
||||||
|
gid: data?.data?.gid || "?",
|
||||||
|
status: res.ok ? (data?.data?.status || "accepted") : (data?.error?.message || `HTTP ${res.status}`),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
batch.push({ gid: "?", status: String(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(batch)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
value={urls}
|
||||||
|
onChange={(e) => setUrls(e.target.value)}
|
||||||
|
placeholder="Pegá URLs de nhentai / e-hentai (una por línea)"
|
||||||
|
rows={6}
|
||||||
|
className="input resize-y min-h-[140px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !urls.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{loading ? "Enviando..." : "Encolar"}
|
||||||
|
</button>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<span className="text-sm text-[var(--muted)]">
|
||||||
|
{results.filter((r) => r.status === "accepted" || r.status !== "?").length} de {results.length} ok
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||||
|
{results.map((r, i) => {
|
||||||
|
const ok = r.status === "accepted" || r.gid !== "?"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2.5 text-sm ${
|
||||||
|
ok ? "" : "bg-[var(--error-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-[var(--success)] flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-[var(--error)] flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-xs text-[var(--muted)]">{r.gid}</span>
|
||||||
|
<span className={ok ? "text-[var(--muted)]" : "text-[var(--error)]"}>{r.status}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
artist: "bg-rose-900/30 text-rose-300 border-rose-800/40",
|
||||||
|
parody: "bg-violet-900/30 text-violet-300 border-violet-800/40",
|
||||||
|
language: "bg-emerald-900/30 text-emerald-300 border-emerald-800/40",
|
||||||
|
category: "bg-amber-900/30 text-amber-300 border-amber-800/40",
|
||||||
|
character: "bg-cyan-900/30 text-cyan-300 border-cyan-800/40",
|
||||||
|
group: "bg-orange-900/30 text-orange-300 border-orange-800/40",
|
||||||
|
tag: "bg-zinc-800 text-zinc-400 border-zinc-700/40",
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
const prefix = tag.split(":")[0]
|
||||||
|
return colors[prefix] || colors.tag
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagBadge({ tag, href }: { tag: string; href?: string }) {
|
||||||
|
const cls = `tag-pill border ${tagColor(tag)}`
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`${cls} hover:brightness-125 transition-all`}>
|
||||||
|
{tag}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={cls}>{tag}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import useSWR from "swr"
|
||||||
|
import type { GalleryItem } from "@/lib/types"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
export function useGalleries(status?: string, page = 1) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), per_page: "50" })
|
||||||
|
if (status) params.set("status", status)
|
||||||
|
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
`/api/proxy/galleries?${params}`,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: status === "completed" ? 30000 : 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
galleries: (data?.data || []) as GalleryItem[],
|
||||||
|
meta: data?.meta as { total: number; page: number; per_page: number } | undefined,
|
||||||
|
isLoading,
|
||||||
|
isError: !!error,
|
||||||
|
mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGallery(gid: string) {
|
||||||
|
const { data, error, isLoading } = useSWR(
|
||||||
|
gid ? `/api/proxy/galleries/${gid}/summary` : null,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 5000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: data?.data as import("@/lib/types").GallerySummary | undefined,
|
||||||
|
isLoading,
|
||||||
|
isError: !!error,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import useSWR from "swr"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
export function useQueue(status?: string, page = 1) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), per_page: "50" })
|
||||||
|
if (status) params.set("status", status)
|
||||||
|
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
`/api/proxy/queue?${params}`,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = (data?.data || []) as import("@/lib/types").QueueItem[]
|
||||||
|
const meta = data?.meta as { total: number; page: number; per_page: number } | undefined
|
||||||
|
|
||||||
|
return { items, meta, isLoading, isError: !!error, mutate }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueueStats() {
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
"/api/proxy/queue/stats",
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: data?.data as import("@/lib/types").QueueStats | undefined,
|
||||||
|
isLoading,
|
||||||
|
isError: !!error,
|
||||||
|
mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import useSWR from "swr"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||||
|
|
||||||
|
export function useSystemStatus() {
|
||||||
|
const { data, error, isLoading, mutate } = useSWR(
|
||||||
|
"/api/proxy/status",
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: 10000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data?.data as import("@/lib/types").SystemStatus | undefined,
|
||||||
|
isLoading,
|
||||||
|
isError: !!error,
|
||||||
|
mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const { startPoller } = await import("./lib/poller")
|
||||||
|
startPoller()
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
GalleryArtifacts,
|
||||||
|
GalleryDetail,
|
||||||
|
GalleryItem,
|
||||||
|
GallerySummary,
|
||||||
|
PaginatedResponse,
|
||||||
|
QueueItem,
|
||||||
|
QueueStats,
|
||||||
|
SearchResult,
|
||||||
|
SystemStatus,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"
|
||||||
|
const API_KEY = process.env.API_KEY || ""
|
||||||
|
|
||||||
|
async function fetchApi<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE}${path}`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
}
|
||||||
|
if (API_KEY) {
|
||||||
|
headers["X-API-Key"] = API_KEY
|
||||||
|
}
|
||||||
|
if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) {
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, { ...init, headers })
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) throw new Error("Not found")
|
||||||
|
if (res.status === 429) throw new Error("Rate limited")
|
||||||
|
if (res.status === 409) throw new Error("Already processing")
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body?.error?.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
health: () =>
|
||||||
|
fetchApi<ApiResponse<{ status: string; version: string; uptime_s: number }>>("/health"),
|
||||||
|
|
||||||
|
status: () =>
|
||||||
|
fetchApi<ApiResponse<SystemStatus>>("/status"),
|
||||||
|
|
||||||
|
galleries: {
|
||||||
|
list: (status?: string, page = 1, perPage = 20) => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||||
|
if (status) params.set("status", status)
|
||||||
|
return fetchApi<PaginatedResponse<GalleryItem>>(`/galleries?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (gid: string) =>
|
||||||
|
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
|
||||||
|
|
||||||
|
summary: (gid: string) =>
|
||||||
|
fetchApi<ApiResponse<GallerySummary>>(`/galleries/${gid}/summary`),
|
||||||
|
|
||||||
|
artifacts: (gid: string) =>
|
||||||
|
fetchApi<ApiResponse<GalleryArtifacts>>(`/galleries/${gid}/artifacts`),
|
||||||
|
|
||||||
|
submit: (url: string, opts?: { skipTranslate?: boolean; skipMobi?: boolean; skipEsSearch?: boolean }) =>
|
||||||
|
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>("/galleries", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
skip_translate: opts?.skipTranslate ?? false,
|
||||||
|
skip_mobi: opts?.skipMobi ?? false,
|
||||||
|
skip_es_search: opts?.skipEsSearch ?? false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
download: (url: string, opts?: { skipEsSearch?: boolean }) =>
|
||||||
|
fetchApi<ApiResponse<{ gid: string; status: string }>>("/galleries/download", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
skip_es_search: opts?.skipEsSearch ?? false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (gid: string) =>
|
||||||
|
fetchApi<void>(`/galleries/${gid}`, { method: "DELETE" }),
|
||||||
|
},
|
||||||
|
|
||||||
|
search: {
|
||||||
|
query: (query: string, source: "nhentai" | "ehentai" = "nhentai", filter = true, maxPages = 1) =>
|
||||||
|
fetchApi<{ data: SearchResult[]; meta: { total: number; blocked: number; passed: number } }>("/search", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ query, source, filter, max_pages: maxPages }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
process: (
|
||||||
|
query: string,
|
||||||
|
source: "nhentai" | "ehentai" = "nhentai",
|
||||||
|
opts?: { skipTranslate?: boolean; skipMobi?: boolean; maxGalleries?: number }
|
||||||
|
) =>
|
||||||
|
fetchApi<ApiResponse<{
|
||||||
|
search_id: string
|
||||||
|
total_found: number
|
||||||
|
blocked: number
|
||||||
|
duplicate: number
|
||||||
|
queued: number
|
||||||
|
galleries: { gid: string; status: string }[]
|
||||||
|
}>>("/search/process", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
source,
|
||||||
|
skip_translate: opts?.skipTranslate ?? false,
|
||||||
|
skip_mobi: opts?.skipMobi ?? false,
|
||||||
|
max_galleries: opts?.maxGalleries ?? 25,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
queue: {
|
||||||
|
list: (status?: string, page = 1, perPage = 20) => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||||
|
if (status) params.set("status", status)
|
||||||
|
return fetchApi<PaginatedResponse<QueueItem>>(`/queue?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: () =>
|
||||||
|
fetchApi<ApiResponse<QueueStats>>("/queue/stats"),
|
||||||
|
|
||||||
|
retry: (gid: string) =>
|
||||||
|
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>(`/queue/${gid}/retry`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
|
||||||
|
wipe: () =>
|
||||||
|
fetchApi<void>("/queue", { method: "DELETE" }),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose"
|
||||||
|
|
||||||
|
const SECRET = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
const COOKIE_NAME = "session"
|
||||||
|
|
||||||
|
export interface SessionPayload {
|
||||||
|
authenticated: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(): Promise<string> {
|
||||||
|
return new SignJWT({ authenticated: true, timestamp: Date.now() })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("7d")
|
||||||
|
.sign(SECRET)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySession(token: string): Promise<SessionPayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, SECRET)
|
||||||
|
return payload as unknown as SessionPayload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieOptions(): { name: string; options: { httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } } {
|
||||||
|
return {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import JSZip from "jszip"
|
||||||
|
|
||||||
|
const imageExts = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"])
|
||||||
|
|
||||||
|
export async function loadPagesFromCbz(
|
||||||
|
url: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ pages: string[]; pageCount: number }> {
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch CBZ: ${response.status}`)
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const zip = await JSZip.loadAsync(blob)
|
||||||
|
|
||||||
|
const imageEntries = Object.entries(zip.files)
|
||||||
|
.filter(([name, file]) => {
|
||||||
|
const ext = name.toLowerCase().slice(name.lastIndexOf("."))
|
||||||
|
return !file.dir && imageExts.has(ext)
|
||||||
|
})
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const numA = parseInt(a.match(/(\d+)/)?.[1] || "0", 10)
|
||||||
|
const numB = parseInt(b.match(/(\d+)/)?.[1] || "0", 10)
|
||||||
|
return numA - numB
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageCount = imageEntries.length
|
||||||
|
const pages: string[] = []
|
||||||
|
|
||||||
|
for (const [, file] of imageEntries) {
|
||||||
|
const blob = await file.async("blob")
|
||||||
|
pages.push(URL.createObjectURL(blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pages, pageCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokePageUrls(urls: string[]) {
|
||||||
|
for (const url of urls) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers")
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
fs.mkdirSync(COVERS_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromContentType(ct: string | null): string {
|
||||||
|
if (!ct) return ".jpg"
|
||||||
|
const m = ct.match(/image\/(\w+)/)
|
||||||
|
if (!m) return ".jpg"
|
||||||
|
const exts: Record<string, string> = {
|
||||||
|
jpeg: ".jpg",
|
||||||
|
png: ".png",
|
||||||
|
webp: ".webp",
|
||||||
|
gif: ".gif",
|
||||||
|
}
|
||||||
|
return exts[m[1]] || ".jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheCover(gid: string, proxyUrl: string): Promise<string | null> {
|
||||||
|
ensureDir()
|
||||||
|
try {
|
||||||
|
const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(15000) })
|
||||||
|
if (!res.ok) return null
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer())
|
||||||
|
const ext = extFromContentType(res.headers.get("content-type"))
|
||||||
|
const filePath = path.join(COVERS_DIR, `${gid}${ext}`)
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, buffer)
|
||||||
|
return filePath
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverPath(gid: string): string | null {
|
||||||
|
ensureDir()
|
||||||
|
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
|
||||||
|
if (files.length === 0) return null
|
||||||
|
return path.join(COVERS_DIR, files[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverContentType(gid: string): string {
|
||||||
|
const fp = getCoverPath(gid)
|
||||||
|
if (!fp) return "image/jpeg"
|
||||||
|
const ext = path.extname(fp).toLowerCase()
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
}
|
||||||
|
return m[ext] || "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCover(gid: string): void {
|
||||||
|
const fp = getCoverPath(gid)
|
||||||
|
if (fp) fs.unlinkSync(fp)
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
import Database from "better-sqlite3"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db")
|
||||||
|
|
||||||
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
|
function getDb(): Database.Database {
|
||||||
|
if (!db) {
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||||
|
db = new Database(DB_PATH)
|
||||||
|
db.pragma("journal_mode = WAL")
|
||||||
|
db.pragma("foreign_keys = ON")
|
||||||
|
migrate(db)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(db: Database.Database) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
gid TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
title_jpn TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
parody TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
num_pages INTEGER DEFAULT 0,
|
||||||
|
source TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
published INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
published_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_gid ON posts(gid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
id: number
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
title_jpn: string | null
|
||||||
|
artist: string | null
|
||||||
|
parody: string | null
|
||||||
|
tags: string[]
|
||||||
|
num_pages: number
|
||||||
|
source: string | null
|
||||||
|
cover_url: string | null
|
||||||
|
summary: string | null
|
||||||
|
slug: string
|
||||||
|
published: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
published_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostInput {
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
title_jpn?: string
|
||||||
|
artist?: string
|
||||||
|
parody?: string
|
||||||
|
tags?: string[]
|
||||||
|
num_pages?: number
|
||||||
|
source?: string
|
||||||
|
cover_url?: string
|
||||||
|
summary?: string
|
||||||
|
slug: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToPost(row: Record<string, unknown>): Post {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
tags: typeof row.tags === "string" ? JSON.parse(row.tags as string) : [],
|
||||||
|
} as unknown as Post
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPosts(publishedOnly = false): Post[] {
|
||||||
|
const d = getDb()
|
||||||
|
const q = publishedOnly
|
||||||
|
? "SELECT * FROM posts WHERE published = 1 ORDER BY published_at DESC"
|
||||||
|
: "SELECT * FROM posts ORDER BY created_at DESC"
|
||||||
|
return (d.prepare(q).all() as Record<string, unknown>[]).map(rowToPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostById(id: number): Post | null {
|
||||||
|
const d = getDb()
|
||||||
|
const row = d.prepare("SELECT * FROM posts WHERE id = ?").get(id) as Record<string, unknown> | undefined
|
||||||
|
return row ? rowToPost(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostByGid(gid: string): Post | null {
|
||||||
|
const d = getDb()
|
||||||
|
const row = d.prepare("SELECT * FROM posts WHERE gid = ?").get(gid) as Record<string, unknown> | undefined
|
||||||
|
return row ? rowToPost(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostBySlug(slug: string): Post | null {
|
||||||
|
const d = getDb()
|
||||||
|
const row = d.prepare("SELECT * FROM posts WHERE slug = ?").get(slug) as Record<string, unknown> | undefined
|
||||||
|
return row ? rowToPost(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPost(input: PostInput): Post {
|
||||||
|
const d = getDb()
|
||||||
|
const published = input.published ?? 1
|
||||||
|
const publishedAt = published ? "datetime('now')" : null
|
||||||
|
const stmt = d.prepare(`
|
||||||
|
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
|
||||||
|
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
|
||||||
|
`)
|
||||||
|
const result = stmt.run({
|
||||||
|
gid: input.gid,
|
||||||
|
title: input.title,
|
||||||
|
title_jpn: input.title_jpn || null,
|
||||||
|
artist: input.artist || null,
|
||||||
|
parody: input.parody || null,
|
||||||
|
tags: JSON.stringify(input.tags || []),
|
||||||
|
num_pages: input.num_pages || 0,
|
||||||
|
source: input.source || null,
|
||||||
|
cover_url: input.cover_url || null,
|
||||||
|
summary: input.summary || null,
|
||||||
|
slug: input.slug,
|
||||||
|
published,
|
||||||
|
})
|
||||||
|
return getPostById(result.lastInsertRowid as number)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePost(id: number, updates: Partial<PostInput & { published: number }>): Post | null {
|
||||||
|
const d = getDb()
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: Record<string, unknown> = { id }
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
if (v !== undefined) {
|
||||||
|
if (k === "tags") {
|
||||||
|
fields.push("tags = @tags")
|
||||||
|
values.tags = JSON.stringify(v)
|
||||||
|
} else {
|
||||||
|
fields.push(`${k} = @${k}`)
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return getPostById(id)
|
||||||
|
|
||||||
|
fields.push("updated_at = datetime('now')")
|
||||||
|
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
|
||||||
|
return getPostById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishPost(id: number, publish: boolean): Post | null {
|
||||||
|
const d = getDb()
|
||||||
|
if (publish) {
|
||||||
|
d.prepare("UPDATE posts SET published = 1, published_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(id)
|
||||||
|
} else {
|
||||||
|
d.prepare("UPDATE posts SET published = 0, published_at = NULL, updated_at = datetime('now') WHERE id = ?").run(id)
|
||||||
|
}
|
||||||
|
return getPostById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePost(id: number): boolean {
|
||||||
|
const d = getDb()
|
||||||
|
const result = d.prepare("DELETE FROM posts WHERE id = ?").run(id)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostsByTag(tag: string, publishedOnly = true): Post[] {
|
||||||
|
const d = getDb()
|
||||||
|
const q = publishedOnly
|
||||||
|
? "SELECT * FROM posts WHERE published = 1 AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY published_at DESC"
|
||||||
|
: "SELECT * FROM posts WHERE EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY created_at DESC"
|
||||||
|
return (d.prepare(q).all(tag) as Record<string, unknown>[]).map(rowToPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostCount(): number {
|
||||||
|
const d = getDb()
|
||||||
|
const row = d.prepare("SELECT COUNT(*) as count FROM posts").get() as { count: number }
|
||||||
|
return row.count
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGidsNotInPosts(): string[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import "server-only"
|
||||||
|
import { api } from "./api"
|
||||||
|
import { slugify } from "./slug"
|
||||||
|
import { createPost, getPostByGid } from "./db"
|
||||||
|
import { cacheCover } from "./cover-cache"
|
||||||
|
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
const POLL_INTERVAL_MS = 60_000
|
||||||
|
|
||||||
|
export function startPoller() {
|
||||||
|
if (intervalId) return
|
||||||
|
pollOnce()
|
||||||
|
intervalId = setInterval(pollOnce, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPoller() {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||||
|
let newPosts = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.galleries.list("completed", 1, 100)
|
||||||
|
const galleries = res.data || []
|
||||||
|
|
||||||
|
for (const g of galleries) {
|
||||||
|
try {
|
||||||
|
const existing = getPostByGid(g.gid)
|
||||||
|
if (existing) continue
|
||||||
|
|
||||||
|
const sumRes = await api.galleries.summary(g.gid)
|
||||||
|
const summary = sumRes?.data
|
||||||
|
if (!summary) continue
|
||||||
|
|
||||||
|
const slug = slugify(summary.title, g.gid)
|
||||||
|
|
||||||
|
await createPost({
|
||||||
|
gid: g.gid,
|
||||||
|
title: summary.title,
|
||||||
|
title_jpn: summary.title_jpn,
|
||||||
|
artist: summary.artist,
|
||||||
|
parody: summary.parody,
|
||||||
|
tags: summary.tags,
|
||||||
|
num_pages: summary.num_pages,
|
||||||
|
source: summary.source,
|
||||||
|
cover_url: summary.cover?.external_url,
|
||||||
|
summary: generateSummary(summary),
|
||||||
|
slug,
|
||||||
|
published: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
|
||||||
|
|
||||||
|
newPosts++
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newPosts }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSummary(summary: {
|
||||||
|
title: string
|
||||||
|
title_jpn?: string
|
||||||
|
artist?: string
|
||||||
|
parody?: string
|
||||||
|
tags?: string[]
|
||||||
|
num_pages?: number
|
||||||
|
source?: string
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (summary.title) parts.push(`**${summary.title}**`)
|
||||||
|
if (summary.title_jpn) parts.push(summary.title_jpn)
|
||||||
|
if (summary.artist) parts.push(`Artista: ${summary.artist}`)
|
||||||
|
if (summary.parody) parts.push(`Franquicia: ${summary.parody}`)
|
||||||
|
if (summary.num_pages) parts.push(`${summary.num_pages} páginas`)
|
||||||
|
if (summary.source) parts.push(`Fuente: ${summary.source}`)
|
||||||
|
|
||||||
|
return parts.join("\n\n") || "Sin resumen disponible."
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function slugify(text: string, gid?: string): string {
|
||||||
|
let slug = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\[.*?\]/g, "")
|
||||||
|
.replace(/\(.*?\)/g, "")
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 80)
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
slug = `gallery`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gid) {
|
||||||
|
slug = `${slug}-${gid.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return slug
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export interface GalleryItem {
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
status: string
|
||||||
|
phase: string
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
is_spanish: boolean
|
||||||
|
pages: number
|
||||||
|
priority: number
|
||||||
|
added_at: string
|
||||||
|
retry_count: number
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GallerySummary {
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
title_jpn: string
|
||||||
|
num_pages: number
|
||||||
|
is_spanish: boolean
|
||||||
|
url: string
|
||||||
|
source: string
|
||||||
|
status: string
|
||||||
|
phase: string
|
||||||
|
tags: string[]
|
||||||
|
artist: string
|
||||||
|
parody: string
|
||||||
|
cover: {
|
||||||
|
local_endpoint: string
|
||||||
|
local_available: boolean
|
||||||
|
external_url?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryDetail {
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
status: string
|
||||||
|
phase: string
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
is_spanish: boolean
|
||||||
|
pages: number
|
||||||
|
work_dir: string | null
|
||||||
|
has_cbz: boolean
|
||||||
|
cbz_path: string | null
|
||||||
|
queued_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryArtifacts {
|
||||||
|
gid: string
|
||||||
|
artifacts_dir: string
|
||||||
|
files: {
|
||||||
|
source_cbz: string
|
||||||
|
originals: string[]
|
||||||
|
images: string[]
|
||||||
|
masks: string[]
|
||||||
|
regions: string[]
|
||||||
|
inpainted: string[]
|
||||||
|
rendered: string[]
|
||||||
|
}
|
||||||
|
file_count: number
|
||||||
|
total_size_mb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotInfo {
|
||||||
|
max: number
|
||||||
|
used: number
|
||||||
|
free: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
active_galleries: {
|
||||||
|
gid: string
|
||||||
|
phase: string
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
|
active_count: number
|
||||||
|
stats: {
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
slots: {
|
||||||
|
download: SlotInfo
|
||||||
|
translate: SlotInfo
|
||||||
|
}
|
||||||
|
queue: {
|
||||||
|
pending: number
|
||||||
|
processing: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
failed_galleries: {
|
||||||
|
url: string
|
||||||
|
gid: string
|
||||||
|
error: string
|
||||||
|
timestamp: string
|
||||||
|
}[]
|
||||||
|
resources: {
|
||||||
|
rss_mb: number
|
||||||
|
cpu_load: number[]
|
||||||
|
cpu_count: number
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
current: string
|
||||||
|
score: number
|
||||||
|
proxy_health: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueStats {
|
||||||
|
pending: number
|
||||||
|
processing: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
total: number
|
||||||
|
download_slots: SlotInfo
|
||||||
|
translate_slots: SlotInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
gid: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
priority: number
|
||||||
|
added_at: string
|
||||||
|
retry_count: number
|
||||||
|
is_spanish: boolean
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
gid: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
pages: number
|
||||||
|
tags: string[]
|
||||||
|
blocked: boolean
|
||||||
|
blocked_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
meta: {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
if (!iso) return ""
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString("es-AR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(mb: number): string {
|
||||||
|
if (mb < 1) return `${Math.round(mb * 1024)} KB`
|
||||||
|
return `${mb.toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTag(tags: string[], prefix: string): string {
|
||||||
|
for (const t of tags) {
|
||||||
|
const lower = t.toLowerCase()
|
||||||
|
if (lower.startsWith(prefix)) return t.slice(prefix.length).trim()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { verifySession } from "@/lib/auth"
|
||||||
|
|
||||||
|
const publicPaths = [
|
||||||
|
"/login",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/proxy/health",
|
||||||
|
"/api/posts",
|
||||||
|
"/api/cover",
|
||||||
|
"/api/cron",
|
||||||
|
"/_next",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl
|
||||||
|
|
||||||
|
const isPublic = publicPaths.some((p) => pathname.startsWith(p))
|
||||||
|
if (isPublic) return NextResponse.next()
|
||||||
|
|
||||||
|
const isPublicPage = pathname === "/" || pathname.startsWith("/p/") || pathname.startsWith("/tag/")
|
||||||
|
if (isPublicPage) return NextResponse.next()
|
||||||
|
|
||||||
|
const webPassword = process.env.WEB_PASSWORD
|
||||||
|
if (!webPassword) return NextResponse.next()
|
||||||
|
|
||||||
|
const session = req.cookies.get("session")?.value
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verifySession(session)
|
||||||
|
if (!payload || !payload.authenticated) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user