diff --git a/backend/Dockerfile b/backend/Dockerfile index 6512aa5b..64970d63 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,10 +14,12 @@ RUN pip install --no-cache-dir -r requirements.txt && \ COPY . . RUN chown -R appuser:appgroup /app -# Create gallery-dl config directory under /app (accessible by any user) +# Create gallery-dl config directory under /app (owner: appuser, no world access) RUN mkdir -p /app/config && \ echo '{}' > /app/config/gallery-dl.json && \ - chmod 777 /app/config /app/config/gallery-dl.json + chown -R appuser:appgroup /app/config && \ + chmod 750 /app/config && \ + chmod 640 /app/config/gallery-dl.json USER appuser diff --git a/backend/core/config.py b/backend/core/config.py index 3f88391f..25fc23f3 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -41,9 +41,10 @@ class Settings(BaseSettings): # gallery-dl config (bind-mounted) gallery_dl_config: str = "/app/config/gallery-dl.json" - # Pixiv OAuth + # Pixiv OAuth (public Android app credentials; override via env if needed) pixiv_client_id: str = "MOBrBDS8blbauoSck0ZfDbtuzpyT" pixiv_client_secret: str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" + # To override: set PIXIV_CLIENT_ID and PIXIV_CLIENT_SECRET in .env model_config = {"env_file": ".env", "case_sensitive": False} diff --git a/backend/db/models.py b/backend/db/models.py index 18d61b8a..f0ff768d 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -10,6 +10,7 @@ LargeBinary, SmallInteger, Text, + UniqueConstraint, ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -174,7 +175,52 @@ class ApiToken(Base): user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) name: Mapped[str | None] = mapped_column(Text) token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False) - token_plain: Mapped[str | None] = mapped_column(Text) created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now()) last_used_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True)) + + +class BrowseHistory(Base): + __tablename__ = "browse_history" + __table_args__ = (UniqueConstraint("user_id", "source", "source_id"),) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + source: Mapped[str] = mapped_column(Text, nullable=False) + source_id: Mapped[str] = mapped_column(Text, nullable=False) + title: Mapped[str | None] = mapped_column(Text) + thumb: Mapped[str | None] = mapped_column(Text) + gid: Mapped[int | None] = mapped_column(BigInteger) + token: Mapped[str | None] = mapped_column(Text) + viewed_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class SavedSearch(Base): + __tablename__ = "saved_searches" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + name: Mapped[str] = mapped_column(Text, nullable=False) + query: Mapped[str] = mapped_column(Text, default="") + params: Mapped[dict] = mapped_column(JSONB, default=dict) + created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class TagTranslation(Base): + __tablename__ = "tag_translations" + __table_args__ = (UniqueConstraint("namespace", "name", "language"),) + + namespace: Mapped[str] = mapped_column(Text, primary_key=True) + name: Mapped[str] = mapped_column(Text, primary_key=True) + language: Mapped[str] = mapped_column(Text, primary_key=True, default="zh") + translation: Mapped[str] = mapped_column(Text, nullable=False) + + +class BlockedTag(Base): + __tablename__ = "blocked_tags" + __table_args__ = (UniqueConstraint("user_id", "namespace", "name"),) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + namespace: Mapped[str] = mapped_column(Text, nullable=False) + name: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/main.py b/backend/main.py index f550862c..888b9d57 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,6 +15,7 @@ eh, export, external, + history, import_router, library, search, @@ -78,6 +79,7 @@ async def lifespan(app: FastAPI): app.include_router(import_router.router, prefix="/api/import") app.include_router(export.router, prefix="/api/export") app.include_router(external.router, prefix="/api/external/v1") +app.include_router(history.router, prefix="/api/history") @app.get("/api/health") diff --git a/backend/routers/download.py b/backend/routers/download.py index 1aaad594..b640adda 100644 --- a/backend/routers/download.py +++ b/backend/routers/download.py @@ -46,16 +46,17 @@ async def enqueue_download( _: dict = Depends(require_auth), db: AsyncSession = Depends(get_db), ): - """Create a DB download record and enqueue an ARQ job.""" + """Create a DB download record and enqueue an ARQ job. + + Order: ARQ enqueue first, then DB commit. If ARQ fails we never create the + DB record. If DB insert fails after a successful ARQ enqueue we log a + warning — the ARQ job will time out naturally without a matching DB record. + """ job_id = uuid.uuid4() source = _detect_source(req.url) - - # DB record + ARQ enqueue in one logical unit initial_progress = {"total": req.total} if req.total is not None else None - job = DownloadJob(id=job_id, url=req.url, source=source, status="queued", progress=initial_progress) - db.add(job) - await db.flush() + # 1. Enqueue ARQ job first — if this fails, no DB record is created. arq = request.app.state.arq try: await arq.enqueue_job( @@ -67,11 +68,24 @@ async def enqueue_download( req.total, _job_id=str(job_id), ) - except Exception: - await db.rollback() + except Exception as exc: + logger.error("[enqueue] ARQ enqueue failed: %s", exc) raise HTTPException(status_code=503, detail="Failed to enqueue download job") - await db.commit() + # 2. Persist DB record. If this fails, log a warning; the ARQ job will + # eventually time out without a matching DB row. + try: + job = DownloadJob(id=job_id, url=req.url, source=source, status="queued", progress=initial_progress) + db.add(job) + await db.commit() + except Exception as exc: + logger.warning( + "[enqueue] ARQ job %s enqueued but DB insert failed: %s — job will time out naturally", + job_id, + exc, + ) + raise HTTPException(status_code=500, detail="Job enqueued but failed to persist to database") + return {"job_id": str(job_id), "status": "queued"} @@ -164,6 +178,18 @@ async def pause_resume_job( except (ValueError, TypeError): raise HTTPException(status_code=500, detail="Corrupted PID value in Redis") + # Validate that the PID actually belongs to a gallery-dl process before signalling. + try: + with open(f"/proc/{pid}/cmdline", "rb") as fh: + cmdline = fh.read() + if b"gallery-dl" not in cmdline: + logger.warning("[pause_resume] pid %d cmdline does not contain gallery-dl; clearing stale PID", pid) + await redis.delete(f"download:pid:{job_id}") + raise HTTPException(status_code=400, detail="Process is no longer a gallery-dl process") + except FileNotFoundError: + await redis.delete(f"download:pid:{job_id}") + raise HTTPException(status_code=400, detail="Process no longer exists") + try: if body.action == "pause": if job.status != "running": @@ -176,6 +202,7 @@ async def pause_resume_job( os.kill(pid, signal.SIGCONT) job.status = "running" except ProcessLookupError: + await redis.delete(f"download:pid:{job_id}") raise HTTPException(status_code=400, detail="Process no longer exists") except PermissionError: raise HTTPException(status_code=500, detail="Insufficient permission to signal process") @@ -202,8 +229,17 @@ async def cancel_job( if pid_bytes: try: pid = int(pid_bytes) - os.kill(pid, signal.SIGTERM) - except (ProcessLookupError, ValueError, PermissionError) as exc: + # Verify PID belongs to gallery-dl before sending signal + try: + with open(f"/proc/{pid}/cmdline", "rb") as fh: + cmdline = fh.read() + if b"gallery-dl" not in cmdline: + logger.warning("[cancel] pid %d is not gallery-dl; skipping signal", pid) + else: + os.kill(pid, signal.SIGTERM) + except FileNotFoundError: + pass # Process already gone + except (ValueError, PermissionError) as exc: logger.warning("[cancel] failed to kill pid %s: %s", pid_bytes, exc) await redis.delete(f"download:pid:{job_id}") diff --git a/backend/routers/eh.py b/backend/routers/eh.py index 9d9e8fb8..2f18a503 100644 --- a/backend/routers/eh.py +++ b/backend/routers/eh.py @@ -1,5 +1,6 @@ """E-Hentai / ExHentai API proxy endpoints.""" +import asyncio import hashlib import json import logging @@ -8,10 +9,13 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import JSONResponse +from sqlalchemy import select from core.auth import require_auth from core.config import settings as app_settings +from core.database import async_session from core.redis_client import eh_semaphore, get_redis +from db.models import BlockedTag from services import cache from services.cache import push_system_alert from services.credential import get_credential @@ -21,6 +25,27 @@ router = APIRouter(tags=["e-hentai"]) +# ── Blocked tag helpers ─────────────────────────────────────────────── + + +async def _get_blocked_tags(user_id: int) -> set[str]: + """Return set of 'namespace:name' blocked tag strings for the user.""" + async with async_session() as session: + rows = ( + await session.execute( + select(BlockedTag.namespace, BlockedTag.name).where(BlockedTag.user_id == user_id) + ) + ).all() + return {f"{r.namespace}:{r.name}" for r in rows} + + +def _filter_blocked(galleries: list[dict], blocked: set[str]) -> list[dict]: + """Filter out galleries that contain any blocked tag.""" + if not blocked: + return galleries + return [g for g in galleries if not blocked.intersection(set(g.get("tags", [])))] + + async def _make_client() -> EhClient: """Load EH cookies from DB and return a configured client (guest if no creds).""" cred_json = await get_credential("ehentai") @@ -44,19 +69,31 @@ async def search( min_rating: int | None = Query(default=None, ge=2, le=5), page_from: int | None = Query(default=None), page_to: int | None = Query(default=None), - _: dict = Depends(require_auth), + language: str | None = Query(default=None), + auth: dict = Depends(require_auth), ): """Search E-Hentai galleries (scrape + gdata batch).""" - cache_key = f"eh:search:{q}:{page}:{category}:{f_cats}:{advance}:{adv_search}:{min_rating}:{page_from}:{page_to}" + # Prepend language filter to query if specified + effective_q = q + if language: + lang_tag = f"language:{language}" + effective_q = f"{lang_tag} {q}".strip() if q else lang_tag + + cache_key = f"eh:search:{effective_q}:{page}:{category}:{f_cats}:{advance}:{adv_search}:{min_rating}:{page_from}:{page_to}" cached = await cache.get_json(cache_key) if cached: + # Apply blocked tag filter from cache too + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and cached.get("galleries"): + cached["galleries"] = _filter_blocked(cached["galleries"], blocked) return cached client = await _make_client() async with client: try: result = await client.search( - query=q, + query=effective_q, page=page, category=category, f_cats=f_cats, @@ -77,9 +114,137 @@ async def search( raise HTTPException(status_code=503, detail=str(e)) await cache.set_json(cache_key, result, 300) + + # Filter blocked tags after caching (cache stores unfiltered, filter per user) + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and result.get("galleries"): + result["galleries"] = _filter_blocked(result["galleries"], blocked) + + return result + + +# ── Popular ────────────────────────────────────────────────────────── + + +@router.get("/popular") +async def get_popular( + auth: dict = Depends(require_auth), +): + """Get EH popular galleries (scrape /popular, cached 5min).""" + cache_key = "eh:popular" + cached = await cache.get_json(cache_key) + if cached: + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and cached.get("galleries"): + cached["galleries"] = _filter_blocked(cached["galleries"], blocked) + return cached + + client = await _make_client() + async with client: + try: + result = await client.get_popular() + except PermissionError as e: + detail = str(e) + if "Sad Panda" in detail or "509" in detail: + await push_system_alert(detail) + raise HTTPException(status_code=403, detail=detail) + await push_system_alert("E-Hentai cookie invalid or expired") + raise HTTPException(status_code=401, detail="EH cookie invalid") + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + + await cache.set_json(cache_key, result, 300) # 5min + + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and result.get("galleries"): + result["galleries"] = _filter_blocked(result["galleries"], blocked) + + return result + + +# ── Top Lists ───────────────────────────────────────────────────────── + + +_VALID_TL = {11, 12, 13, 14, 15} + + +@router.get("/toplists") +async def get_toplist( + tl: int = Query(default=11, description="11=All-Time, 12=Past Year, 13=Past Month, 14=Yesterday, 15=Past Hour"), + page: int = Query(default=0, ge=0), + auth: dict = Depends(require_auth), +): + """Get EH top list galleries (scrape /toplist.php, cached 10min).""" + if tl not in _VALID_TL: + raise HTTPException(status_code=400, detail=f"Invalid tl value. Must be one of {sorted(_VALID_TL)}") + + cache_key = f"eh:toplist:{tl}:{page}" + cached = await cache.get_json(cache_key) + if cached: + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and cached.get("galleries"): + cached["galleries"] = _filter_blocked(cached["galleries"], blocked) + return cached + + client = await _make_client() + async with client: + try: + result = await client.get_toplist(tl=tl, page=page) + except PermissionError as e: + detail = str(e) + if "Sad Panda" in detail or "509" in detail: + await push_system_alert(detail) + raise HTTPException(status_code=403, detail=detail) + await push_system_alert("E-Hentai cookie invalid or expired") + raise HTTPException(status_code=401, detail="EH cookie invalid") + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + + await cache.set_json(cache_key, result, 600) # 10min + + user_id = auth["user_id"] + blocked = await _get_blocked_tags(user_id) + if blocked and result.get("galleries"): + result["galleries"] = _filter_blocked(result["galleries"], blocked) + return result +# ── Gallery Comments ────────────────────────────────────────────────── + + +@router.get("/gallery/{gid}/{token}/comments") +async def get_gallery_comments( + gid: int, + token: str, + _: dict = Depends(require_auth), +): + """Scrape gallery comments (read-only, cached 10min).""" + cache_key = f"eh:comments:{gid}" + cached = await cache.get_json(cache_key) + if cached is not None: + return {"gid": gid, "comments": cached} + + client = await _make_client() + async with client: + try: + comments = await client.get_comments(gid, token) + except PermissionError as e: + detail = str(e) + await push_system_alert(detail) + status_code = 403 if "Sad Panda" in detail or "509" in detail else 401 + raise HTTPException(status_code=status_code, detail=detail) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + + await cache.set_json(cache_key, comments, 600) # 10min + return {"gid": gid, "comments": comments} + + # ── Gallery metadata ───────────────────────────────────────────────── @@ -371,7 +536,8 @@ async def remove_favorite( # ── Thumbnail proxy ─────────────────────────────────────────────────── -_ALLOWED_THUMB_HOSTS = {"ehgt.org", "e-hentai.org", "exhentai.org", "ul.ehgt.org"} +_thumb_semaphore = asyncio.Semaphore(4) +_ALLOWED_THUMB_HOSTS = {"ehgt.org", "e-hentai.org", "exhentai.org", "ul.ehgt.org", "hath.network"} @router.get("/thumb-proxy") @@ -400,14 +566,15 @@ async def thumb_proxy( cred_json = await get_credential("ehentai") cookies = json.loads(cred_json) if cred_json else {} - try: - async with httpx.AsyncClient(cookies=cookies, timeout=15) as client: - resp = await client.get(url, headers={"Referer": "https://e-hentai.org/"}) - resp.raise_for_status() - content = resp.content - media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0] - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"Thumbnail fetch failed: {exc}") + async with _thumb_semaphore: + try: + async with httpx.AsyncClient(cookies=cookies, timeout=15) as client: + resp = await client.get(url, headers={"Referer": "https://e-hentai.org/"}) + resp.raise_for_status() + content = resp.content + media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0] + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Thumbnail fetch failed: {exc}") await get_redis().setex(cache_key, 86400, content) # 24h return Response( diff --git a/backend/routers/external.py b/backend/routers/external.py index 9908a6b4..2450d361 100644 --- a/backend/routers/external.py +++ b/backend/routers/external.py @@ -2,6 +2,7 @@ import hashlib import shutil +import time import uuid as _uuid import psutil @@ -10,6 +11,7 @@ from core.config import settings from core.database import async_session +from core.redis_client import get_redis from db.models import ApiToken, DownloadJob, Gallery, Image, Tag router = APIRouter(tags=["external"]) @@ -40,6 +42,27 @@ async def verify_api_token(x_api_token: str = Header(...)): return {"user_id": token.user_id, "token_id": token.id} +# ── Rate limiter ────────────────────────────────────────────────────── + +_RATE_LIMIT_REQUESTS = 10 # max requests per window +_RATE_LIMIT_WINDOW = 60 # window size in seconds + + +async def _check_rate_limit(token_id: int) -> None: + """Redis-based sliding-window rate limiter scoped to a token per minute.""" + minute = int(time.time()) // _RATE_LIMIT_WINDOW + key = f"ratelimit:ext:{token_id}:{minute}" + r = get_redis() + count = await r.incr(key) + if count == 1: + await r.expire(key, _RATE_LIMIT_WINDOW) + if count > _RATE_LIMIT_REQUESTS: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: max {_RATE_LIMIT_REQUESTS} requests per minute", + ) + + # ── Status ──────────────────────────────────────────────────────────── @@ -240,6 +263,7 @@ async def enqueue_download( token_data: dict = Depends(verify_api_token), ): """Enqueue a download job via external API.""" + await _check_rate_limit(token_data["token_id"]) job_id = _uuid.uuid4() async with async_session() as session: diff --git a/backend/routers/history.py b/backend/routers/history.py new file mode 100644 index 00000000..b342c117 --- /dev/null +++ b/backend/routers/history.py @@ -0,0 +1,148 @@ +"""Browse history endpoints.""" + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import delete, desc, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from core.auth import require_auth +from core.database import async_session +from db.models import BrowseHistory + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["history"]) + + +class HistoryRecord(BaseModel): + source: str + source_id: str + title: str | None = None + thumb: str | None = None + gid: int | None = None + token: str | None = None + + +@router.get("/") +async def list_history( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + auth: dict = Depends(require_auth), +): + """List browse history for the current user, newest first.""" + user_id = auth["user_id"] + async with async_session() as session: + total = ( + await session.execute( + select(func.count()).select_from( + select(BrowseHistory).where(BrowseHistory.user_id == user_id).subquery() + ) + ) + ).scalar_one() + rows = ( + await session.execute( + select(BrowseHistory) + .where(BrowseHistory.user_id == user_id) + .order_by(desc(BrowseHistory.viewed_at)) + .limit(limit) + .offset(offset) + ) + ).scalars().all() + return { + "items": [_h(r) for r in rows], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.post("/", status_code=201) +async def record_history( + body: HistoryRecord, + auth: dict = Depends(require_auth), +): + """Record a gallery view. Upserts by (user_id, source, source_id).""" + user_id = auth["user_id"] + now = datetime.now(UTC) + stmt = ( + pg_insert(BrowseHistory) + .values( + user_id=user_id, + source=body.source, + source_id=body.source_id, + title=body.title, + thumb=body.thumb, + gid=body.gid, + token=body.token, + viewed_at=now, + ) + .on_conflict_do_update( + index_elements=["user_id", "source", "source_id"], + set_={ + "title": body.title, + "thumb": body.thumb, + "gid": body.gid, + "token": body.token, + "viewed_at": now, + }, + ) + ) + async with async_session() as session: + await session.execute(stmt) + await session.commit() + return {"status": "ok"} + + +@router.delete("/") +async def clear_history( + auth: dict = Depends(require_auth), +): + """Clear all browse history for the current user.""" + user_id = auth["user_id"] + async with async_session() as session: + result = await session.execute( + delete(BrowseHistory).where(BrowseHistory.user_id == user_id) + ) + await session.commit() + return {"status": "ok", "deleted": result.rowcount} + + +@router.delete("/{entry_id}") +async def delete_history_entry( + entry_id: int, + auth: dict = Depends(require_auth), +): + """Delete a single browse history entry.""" + user_id = auth["user_id"] + async with async_session() as session: + row = ( + await session.execute( + select(BrowseHistory).where( + BrowseHistory.id == entry_id, + BrowseHistory.user_id == user_id, + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="History entry not found") + await session.delete(row) + await session.commit() + return {"status": "ok"} + + +# ── Helper ──────────────────────────────────────────────────────────── + + +def _h(r: BrowseHistory) -> dict: + return { + "id": r.id, + "source": r.source, + "source_id": r.source_id, + "title": r.title, + "thumb": r.thumb, + "gid": r.gid, + "token": r.token, + "viewed_at": r.viewed_at.isoformat() if r.viewed_at else None, + } diff --git a/backend/routers/import_router.py b/backend/routers/import_router.py index e41bd7bd..e475508c 100644 --- a/backend/routers/import_router.py +++ b/backend/routers/import_router.py @@ -89,9 +89,12 @@ async def start_import( if req.mode not in ("link", "copy"): raise HTTPException(status_code=400, detail="Invalid import mode") - resolved = Path(req.source_dir).resolve() - allowed = Path(settings.data_gallery_path).resolve() - if not resolved.is_relative_to(allowed): + # Use os.path.realpath to resolve symlinks before validating containment. + # Path.resolve() follows symlinks too, but os.path.realpath is explicit + # and consistent across Python versions. + real_source = os.path.realpath(req.source_dir) + real_allowed = os.path.realpath(settings.data_gallery_path) + if not real_source.startswith(real_allowed + os.sep) and real_source != real_allowed: raise HTTPException(status_code=400, detail="source_dir must be within the gallery path") # Create DB entry diff --git a/backend/routers/library.py b/backend/routers/library.py index 895bf670..d0fb5e6f 100644 --- a/backend/routers/library.py +++ b/backend/routers/library.py @@ -8,13 +8,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from sqlalchemy import and_, desc, func, not_, or_, select +from sqlalchemy import ARRAY, Text, and_, cast, desc, func, not_, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from core.auth import require_auth from core.database import get_db -from db.models import Gallery, Image, ReadProgress +from db.models import BlockedTag, Gallery, Image, ReadProgress logger = logging.getLogger(__name__) router = APIRouter(tags=["library"]) @@ -44,6 +44,16 @@ def _decode_cursor(cursor: str) -> dict: # ── Gallery list ───────────────────────────────────────────────────── +async def _get_blocked_tag_strings(db: AsyncSession, user_id: int) -> list[str]: + """Return list of 'namespace:name' blocked tag strings for the user.""" + rows = ( + await db.execute( + select(BlockedTag.namespace, BlockedTag.name).where(BlockedTag.user_id == user_id) + ) + ).all() + return [f"{r.namespace}:{r.name}" for r in rows] + + @router.get("/galleries") async def list_galleries( q: str = Query(default=""), @@ -56,7 +66,7 @@ async def list_galleries( limit: int = Query(default=20, ge=1, le=100), sort: Literal["added_at", "rating", "pages"] = Query(default="added_at"), cursor: str | None = Query(default=None), - _: dict = Depends(require_auth), + auth: dict = Depends(require_auth), db: AsyncSession = Depends(get_db), ): """ @@ -86,6 +96,12 @@ async def list_galleries( if q: stmt = stmt.where(Gallery.title.ilike(f"%{q}%")) + # Filter out galleries containing blocked tags + user_id = auth["user_id"] + blocked_tags = await _get_blocked_tag_strings(db, user_id) + if blocked_tags: + stmt = stmt.where(not_(Gallery.tags_array.overlap(cast(blocked_tags, ARRAY(Text))))) + sort_col = {"added_at": Gallery.added_at, "rating": Gallery.rating, "pages": Gallery.pages}[sort] if cursor is not None: @@ -242,6 +258,12 @@ async def delete_gallery( db: AsyncSession = Depends(get_db), ): """Delete a gallery and its associated files (images + thumbnails).""" + import asyncio + import shutil + from pathlib import Path + + from core.config import settings as app_settings + g = await _get_or_404(db, gallery_id) # Collect file paths before deleting DB records @@ -252,38 +274,55 @@ async def delete_gallery( await db.delete(g) await db.commit() - # Best-effort file cleanup - import shutil - from pathlib import Path - deleted_files = 0 - for row in image_rows: - for path_str in (row.file_path, row.thumb_path): - if path_str: - p = Path(path_str) + allowed_gallery = Path(app_settings.data_gallery_path).resolve() + allowed_thumbs = Path(app_settings.data_thumbs_path).resolve() + + def _is_safe_path(p: Path) -> bool: + """Return True only if path is within an allowed base directory.""" + try: + resolved = p.resolve() + return resolved.is_relative_to(allowed_gallery) or resolved.is_relative_to(allowed_thumbs) + except (OSError, ValueError): + return False + + def _delete_files() -> int: + deleted = 0 + for row in image_rows: + for path_str in (row.file_path, row.thumb_path): + if path_str: + p = Path(path_str) + if not _is_safe_path(p): + logger.warning("[delete_gallery] skipping unsafe path: %s", path_str) + continue + try: + if p.is_file(): + p.unlink() + deleted += 1 + except OSError: + pass + # Clean up thumb directory (hash-based dir like /data/thumbs/ab/abcdef.../) + if row.thumb_path: + thumb_dir = Path(row.thumb_path).parent + if _is_safe_path(thumb_dir): + try: + if thumb_dir.is_dir() and not any(thumb_dir.iterdir()): + thumb_dir.rmdir() + except OSError: + pass + + # Try to remove gallery directory if empty + if image_rows and image_rows[0].file_path: + gallery_dir = Path(image_rows[0].file_path).parent + if _is_safe_path(gallery_dir): try: - if p.is_file(): - p.unlink() - deleted_files += 1 + if gallery_dir.is_dir() and not any(gallery_dir.iterdir()): + gallery_dir.rmdir() except OSError: pass - # Clean up thumb directory (hash-based dir like /data/thumbs/ab/abcdef.../) - if row.thumb_path: - thumb_dir = Path(row.thumb_path).parent - try: - if thumb_dir.is_dir() and not any(thumb_dir.iterdir()): - thumb_dir.rmdir() - except OSError: - pass - - # Try to remove gallery directory if empty - if image_rows and image_rows[0].file_path: - gallery_dir = Path(image_rows[0].file_path).parent - try: - if gallery_dir.is_dir() and not any(gallery_dir.iterdir()): - gallery_dir.rmdir() - except OSError: - pass + return deleted + + deleted_files = await asyncio.to_thread(_delete_files) return {"status": "ok", "deleted_files": deleted_files} diff --git a/backend/routers/search.py b/backend/routers/search.py index 93f58dde..5313ded7 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -4,11 +4,12 @@ import json from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import ARRAY, Text, and_, asc, cast, desc, func, or_, select from core.auth import require_auth from core.database import async_session -from db.models import Gallery +from db.models import Gallery, SavedSearch router = APIRouter(tags=["search"]) @@ -250,3 +251,111 @@ def _row_to_item(r: Gallery) -> dict: "query": q, "items": [_row_to_item(r) for r in rows], } + + +# ── Saved Searches ──────────────────────────────────────────────────── + + +class SavedSearchCreate(BaseModel): + name: str + query: str = "" + params: dict = {} + + +class SavedSearchRename(BaseModel): + name: str + + +@router.get("/saved") +async def list_saved_searches( + auth: dict = Depends(require_auth), +): + """List saved searches for the current user.""" + user_id = auth["user_id"] + async with async_session() as session: + rows = ( + await session.execute( + select(SavedSearch) + .where(SavedSearch.user_id == user_id) + .order_by(desc(SavedSearch.created_at)) + ) + ).scalars().all() + return {"searches": [_ss(r) for r in rows]} + + +@router.post("/saved", status_code=201) +async def create_saved_search( + body: SavedSearchCreate, + auth: dict = Depends(require_auth), +): + """Save a search for the current user.""" + user_id = auth["user_id"] + async with async_session() as session: + row = SavedSearch( + user_id=user_id, + name=body.name, + query=body.query, + params=body.params, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return _ss(row) + + +@router.delete("/saved/{saved_id}") +async def delete_saved_search( + saved_id: int, + auth: dict = Depends(require_auth), +): + """Delete a saved search.""" + user_id = auth["user_id"] + async with async_session() as session: + row = ( + await session.execute( + select(SavedSearch).where( + SavedSearch.id == saved_id, + SavedSearch.user_id == user_id, + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Saved search not found") + await session.delete(row) + await session.commit() + return {"status": "ok"} + + +@router.patch("/saved/{saved_id}") +async def rename_saved_search( + saved_id: int, + body: SavedSearchRename, + auth: dict = Depends(require_auth), +): + """Rename a saved search.""" + user_id = auth["user_id"] + async with async_session() as session: + row = ( + await session.execute( + select(SavedSearch).where( + SavedSearch.id == saved_id, + SavedSearch.user_id == user_id, + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Saved search not found") + row.name = body.name + await session.commit() + await session.refresh(row) + return _ss(row) + + +def _ss(r: SavedSearch) -> dict: + return { + "id": r.id, + "name": r.name, + "query": r.query, + "params": r.params or {}, + "created_at": r.created_at.isoformat() if r.created_at else None, + } diff --git a/backend/routers/settings.py b/backend/routers/settings.py index e8856cf1..e2b0d563 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -416,8 +416,8 @@ async def create_token( async with async_session() as session: result = await session.execute( text(""" - INSERT INTO api_tokens (user_id, name, token_hash, token_plain, expires_at) - VALUES (:uid, :name, :hash, NULL, :exp) + INSERT INTO api_tokens (user_id, name, token_hash, expires_at) + VALUES (:uid, :name, :hash, :exp) RETURNING id, created_at """), { diff --git a/backend/routers/system.py b/backend/routers/system.py index c18a0584..5c341fac 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -48,3 +48,93 @@ async def system_info(_: dict = Depends(require_auth)): "eh_max_concurrency": settings.eh_max_concurrency, "tag_model_enabled": settings.tag_model_enabled, } + + +# ── Cache management ────────────────────────────────────────────────── + +_CACHE_PATTERNS: dict[str, str] = { + "eh_search": "eh:search:*", + "eh_gallery": "eh:gallery:*", + "eh_image": "thumb:proxied:*", + "thumbs": "thumb:cdn:*", +} + + +async def _count_keys(pattern: str) -> int: + """Count Redis keys matching a glob pattern (uses SCAN to avoid blocking).""" + r = get_redis() + count = 0 + cursor = 0 + while True: + cursor, keys = await r.scan(cursor, match=pattern, count=200) + count += len(keys) + if cursor == 0: + break + return count + + +async def _delete_keys(pattern: str) -> int: + """Delete all Redis keys matching a glob pattern via SCAN + DEL.""" + r = get_redis() + deleted = 0 + cursor = 0 + while True: + cursor, keys = await r.scan(cursor, match=pattern, count=200) + if keys: + deleted += await r.delete(*keys) + if cursor == 0: + break + return deleted + + +@router.get("/cache") +async def get_cache_stats(_: dict = Depends(require_auth)): + """Return Redis memory usage and key counts by category.""" + r = get_redis() + + # Memory info + info = await r.info("memory") + used_memory = info.get("used_memory", 0) + used_memory_human = info.get("used_memory_human", "N/A") + + # Key counts + total_keys = await r.dbsize() + breakdown = {} + for category, pattern in _CACHE_PATTERNS.items(): + breakdown[category] = await _count_keys(pattern) + breakdown["sessions"] = await _count_keys("session:*") + + return { + "total_memory": used_memory, + "total_memory_human": used_memory_human, + "total_keys": total_keys, + "breakdown": breakdown, + } + + +@router.delete("/cache") +async def clear_cache(_: dict = Depends(require_auth)): + """Clear all EH cache (search, gallery, images, thumbs). Does not clear sessions.""" + deleted = 0 + for pattern in _CACHE_PATTERNS.values(): + deleted += await _delete_keys(pattern) + # Also clear popular/toplist/comments/favorites + for pattern in ("eh:popular", "eh:toplist:*", "eh:comments:*", "eh:favorites:*", + "eh:previews:*", "eh:imagelist:*"): + deleted += await _delete_keys(pattern) + return {"status": "ok", "deleted_keys": deleted} + + +@router.delete("/cache/{category}") +async def clear_cache_category( + category: str, + _: dict = Depends(require_auth), +): + """Clear a specific cache category: eh_search, eh_gallery, eh_image, thumbs.""" + if category not in _CACHE_PATTERNS: + raise HTTPException( + status_code=400, + detail=f"Unknown category. Valid: {list(_CACHE_PATTERNS.keys())}", + ) + deleted = await _delete_keys(_CACHE_PATTERNS[category]) + return {"status": "ok", "category": category, "deleted_keys": deleted} diff --git a/backend/routers/tag.py b/backend/routers/tag.py index 86790a91..7752e097 100644 --- a/backend/routers/tag.py +++ b/backend/routers/tag.py @@ -5,11 +5,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from sqlalchemy import desc, func, or_, select +from sqlalchemy import delete, desc, func, or_, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from core.auth import require_auth from core.database import async_session -from db.models import Tag, TagAlias, TagImplication +from db.models import BlockedTag, Tag, TagAlias, TagImplication, TagTranslation router = APIRouter(tags=["tags"]) @@ -252,21 +253,36 @@ async def list_implications( ] +async def _has_cycle(session, from_id: int, target_id: int) -> bool: + """ + BFS from `from_id` along existing implications. + Returns True if `target_id` is reachable (i.e. adding target→from would create a cycle). + """ + visited: set[int] = set() + queue: list[int] = [from_id] + while queue: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + if current == target_id: + return True + rows = ( + await session.execute( + select(TagImplication.consequent_id).where(TagImplication.antecedent_id == current) + ) + ).scalars().all() + queue.extend(r for r in rows if r not in visited) + return False + + @router.post("/implications") async def create_implication(req: ImplicationRequest, _: dict = Depends(require_auth)): if req.antecedent_id == req.consequent_id: raise HTTPException(status_code=400, detail="Cannot imply self") async with async_session() as session: - # Check for circular implication (simple 1-hop check) - result = ( - await session.execute( - select(TagImplication).where( - TagImplication.antecedent_id == req.consequent_id, - TagImplication.consequent_id == req.antecedent_id, - ) - ) - ).scalar_one_or_none() - if result: + # Check for circular implication via BFS (detects chains of any length) + if await _has_cycle(session, req.consequent_id, req.antecedent_id): raise HTTPException(status_code=400, detail="Circular implication detected") # Check if already exists @@ -308,3 +324,213 @@ async def delete_implication( session.delete(impl) await session.commit() return {"status": "ok"} + + +# ── Tag Autocomplete ────────────────────────────────────────────────── + + +@router.get("/autocomplete") +async def autocomplete_tags( + q: str = Query(default="", description="Tag name prefix or 'namespace:name' prefix"), + limit: int = Query(default=10, ge=1, le=30), + _: dict = Depends(require_auth), +): + """Return tags matching the given prefix, ordered by count DESC.""" + if not q: + return [] + + async with async_session() as session: + # Support 'namespace:name' prefix format + if ":" in q: + ns, name_prefix = q.split(":", 1) + query = ( + select(Tag) + .where(Tag.namespace.ilike(f"{ns}%"), Tag.name.ilike(f"{name_prefix}%")) + .order_by(desc(Tag.count), desc(Tag.id)) + .limit(limit) + ) + else: + query = ( + select(Tag) + .where(Tag.name.ilike(f"{q}%")) + .order_by(desc(Tag.count), desc(Tag.id)) + .limit(limit) + ) + rows = (await session.execute(query)).scalars().all() + + return [{"id": r.id, "namespace": r.namespace, "name": r.name, "count": r.count} for r in rows] + + +# ── Tag Translations ────────────────────────────────────────────────── + + +class TranslationUpsert(BaseModel): + namespace: str + name: str + language: str = "zh" + translation: str + + +class TranslationBatchImport(BaseModel): + translations: list[TranslationUpsert] + + +@router.get("/translations") +async def get_translations( + tags: str = Query(default="", description="Comma-separated 'namespace:name' list"), + language: str = Query(default="zh"), + _: dict = Depends(require_auth), +): + """Batch look up translations for a list of tags.""" + if not tags: + return {} + + tag_pairs = [] + for item in tags.split(","): + item = item.strip() + if ":" in item: + ns, name = item.split(":", 1) + tag_pairs.append((ns.strip(), name.strip())) + + if not tag_pairs: + return {} + + async with async_session() as session: + # Fetch all matching translations in one query + from sqlalchemy import and_, tuple_ + + namespaces = [p[0] for p in tag_pairs] + names = [p[1] for p in tag_pairs] + rows = ( + await session.execute( + select(TagTranslation).where( + TagTranslation.language == language, + tuple_(TagTranslation.namespace, TagTranslation.name).in_(tag_pairs), + ) + ) + ).scalars().all() + + result = {} + for r in rows: + result[f"{r.namespace}:{r.name}"] = r.translation + return result + + +@router.post("/translations") +async def upsert_translation( + body: TranslationUpsert, + _: dict = Depends(require_auth), +): + """Upsert a single tag translation.""" + async with async_session() as session: + stmt = ( + pg_insert(TagTranslation) + .values( + namespace=body.namespace, + name=body.name, + language=body.language, + translation=body.translation, + ) + .on_conflict_do_update( + index_elements=["namespace", "name", "language"], + set_={"translation": body.translation}, + ) + ) + await session.execute(stmt) + await session.commit() + return {"status": "ok"} + + +@router.post("/translations/batch") +async def batch_import_translations( + body: TranslationBatchImport, + _: dict = Depends(require_auth), +): + """Bulk upsert tag translations.""" + if not body.translations: + return {"status": "ok", "count": 0} + + async with async_session() as session: + for item in body.translations: + stmt = ( + pg_insert(TagTranslation) + .values( + namespace=item.namespace, + name=item.name, + language=item.language, + translation=item.translation, + ) + .on_conflict_do_update( + index_elements=["namespace", "name", "language"], + set_={"translation": item.translation}, + ) + ) + await session.execute(stmt) + await session.commit() + return {"status": "ok", "count": len(body.translations)} + + +# ── Blocked Tags ────────────────────────────────────────────────────── + + +class BlockedTagCreate(BaseModel): + namespace: str + name: str + + +@router.get("/blocked") +async def list_blocked_tags( + auth: dict = Depends(require_auth), +): + """List blocked tags for the current user.""" + user_id = auth["user_id"] + async with async_session() as session: + rows = ( + await session.execute( + select(BlockedTag).where(BlockedTag.user_id == user_id) + ) + ).scalars().all() + return [{"id": r.id, "namespace": r.namespace, "name": r.name} for r in rows] + + +@router.post("/blocked", status_code=201) +async def add_blocked_tag( + body: BlockedTagCreate, + auth: dict = Depends(require_auth), +): + """Add a blocked tag for the current user.""" + user_id = auth["user_id"] + async with async_session() as session: + # Use upsert to handle duplicate gracefully + stmt = ( + pg_insert(BlockedTag) + .values(user_id=user_id, namespace=body.namespace, name=body.name) + .on_conflict_do_nothing(index_elements=["user_id", "namespace", "name"]) + .returning(BlockedTag.id) + ) + result = (await session.execute(stmt)).scalar_one_or_none() + await session.commit() + return {"status": "ok", "id": result} + + +@router.delete("/blocked/{blocked_id}") +async def remove_blocked_tag( + blocked_id: int, + auth: dict = Depends(require_auth), +): + """Remove a blocked tag.""" + user_id = auth["user_id"] + async with async_session() as session: + row = ( + await session.execute( + select(BlockedTag).where( + BlockedTag.id == blocked_id, + BlockedTag.user_id == user_id, + ) + ) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Blocked tag not found") + await session.delete(row) + await session.commit() + return {"status": "ok"} diff --git a/backend/services/eh_client.py b/backend/services/eh_client.py index 5795baeb..7ab9211f 100644 --- a/backend/services/eh_client.py +++ b/backend/services/eh_client.py @@ -8,6 +8,7 @@ import logging import re +from collections import defaultdict from typing import Any from urllib.parse import urlencode @@ -59,6 +60,15 @@ r".*?/s/[0-9a-f]+/\d+-(\d+)", re.DOTALL, ) +# New format (2024+):
+# Order in style: width → height → background (with url + offset) +_NEW_PREVIEW_RE = re.compile( + r']+href="[^"]+/s/[0-9a-f]{10}/\d+-(\d+)"[^>]*>' + r']+style="[^"]*' + r'width:\s*(\d+)px[^"]*height:\s*(\d+)px[^"]*' + r'url\(([^)]+)\)\s*(-?\d+)px', + re.DOTALL, +) def _chunks(lst: list, n: int): @@ -283,23 +293,56 @@ def _parse_detail_html(self, html: str) -> tuple[dict[int, str], dict[int, str]] page_num = int(match.group(3)) token_map[page_num] = ptoken - # Extract preview thumbnails — try large previews first - large_matches = list(_LARGE_PREVIEW_RE.finditer(html)) - if large_matches: - for match in large_matches: + # Extract preview thumbnails — try new format first (2024+), then legacy formats + new_matches = list(_NEW_PREVIEW_RE.finditer(html)) + if new_matches: + # New format: background url() with optional sprite offset + # Groups: (page_num, width, height, thumb_url, offset_x) + for match in new_matches: page_num = int(match.group(1)) - thumb_url = match.group(2) - preview_map[page_num] = thumb_url + width = int(match.group(2)) + height = int(match.group(3)) + thumb_url = match.group(4) + offset_x = int(match.group(5)) + # Always store as sprite format — even offset 0 is part of the sprite sheet + preview_map[page_num] = f"{thumb_url}|{offset_x}|{width}|{height}" + + # Normalize cell heights per sprite URL — the sprite image has a single + # height, but CSS may declare different heights for individual cells + # (e.g., cover page 150px vs normal 278px). Use max height per sprite. + sprite_heights: dict[str, int] = defaultdict(int) + for page_num, val in preview_map.items(): + parts = val.split('|') + if len(parts) == 4: + sprite_url = parts[0] + h = int(parts[3]) + if h > sprite_heights[sprite_url]: + sprite_heights[sprite_url] = h + for page_num in list(preview_map.keys()): + parts = preview_map[page_num].split('|') + if len(parts) == 4: + sprite_url = parts[0] + max_h = sprite_heights[sprite_url] + if int(parts[3]) != max_h: + preview_map[page_num] = f"{parts[0]}|{parts[1]}|{parts[2]}|{max_h}" else: - # Normal previews (CSS sprite sheets) - # Store as "url|offsetX|width|height" for frontend to render - for match in _NORMAL_PREVIEW_RE.finditer(html): - sprite_url = match.group(1) - offset_x = int(match.group(2)) - width = int(match.group(3)) - height = int(match.group(4)) - page_num = int(match.group(5)) - preview_map[page_num] = f"{sprite_url}|{offset_x}|{width}|{height}" + # Legacy large previews:
N + large_matches = list(_LARGE_PREVIEW_RE.finditer(html)) + if large_matches: + for match in large_matches: + page_num = int(match.group(1)) + thumb_url = match.group(2) + preview_map[page_num] = thumb_url + else: + # Legacy normal previews (CSS sprite sheets with gdtm class) + # Store as "url|offsetX|width|height" for frontend to render + for match in _NORMAL_PREVIEW_RE.finditer(html): + sprite_url = match.group(1) + offset_x = int(match.group(2)) + width = int(match.group(3)) + height = int(match.group(4)) + page_num = int(match.group(5)) + preview_map[page_num] = f"{sprite_url}|{offset_x}|{width}|{height}" return token_map, preview_map @@ -635,6 +678,91 @@ async def remove_favorite(self, gid: int, token: str) -> bool: resp.raise_for_status() return True + async def get_popular(self) -> dict: + """ + Scrape E-H popular page and return galleries. + GET {base_url}/popular + """ + resp = await self._http.get(f"{self.base_url}/popular") + resp.raise_for_status() + self._check_auth(resp.text, resp) + + matches = list({(int(g), t) for g, t in _GALLERY_URL_RE.findall(resp.text)}) + if not matches: + return {"galleries": []} + + gid_list = [[gid, tok] for gid, tok in matches] + galleries = await self._gdata(gid_list) + return {"galleries": galleries} + + async def get_toplist(self, tl: int, page: int = 0) -> dict: + """ + Scrape E-H top list page. + GET {base_url}/toplist.php?tl={tl}&p={page} + tl: 11=All-Time, 12=Past Year, 13=Past Month, 14=Yesterday, 15=Past Hour + """ + resp = await self._http.get(f"{self.base_url}/toplist.php?tl={tl}&p={page}") + resp.raise_for_status() + self._check_auth(resp.text, resp) + + matches = list({(int(g), t) for g, t in _GALLERY_URL_RE.findall(resp.text)}) + total_match = _TOTAL_COUNT_RE.search(resp.text) + total = int(total_match.group(1).replace(",", "")) if total_match else len(matches) + + if not matches: + return {"galleries": [], "total": total, "page": page} + + gid_list = [[gid, tok] for gid, tok in matches] + galleries = await self._gdata(gid_list) + return {"galleries": galleries, "total": total, "page": page} + + async def get_comments(self, gid: int, token: str) -> list[dict]: + """ + Scrape gallery comments from gallery detail page. + Returns list of {poster, posted_at, text, score}. + """ + url = f"{self.base_url}/g/{gid}/{token}/?p=0" + resp = await self._http.get(url) + resp.raise_for_status() + self._check_auth(resp.text, resp) + + soup = BeautifulSoup(resp.text, "lxml") + comments: list[dict] = [] + + for c1 in soup.select("div.c1"): + c3 = c1.find("div", class_="c3") + c6 = c1.find("div", class_="c6") + c5 = c1.find("div", class_="c5") + + poster = "" + posted_at = "" + if c3: + c3_text = c3.get_text(" ", strip=True) + # "Posted on {date} UTC by: {poster}" + by_match = re.search(r"by:\s*(.+)$", c3_text) + if by_match: + poster = by_match.group(1).strip() + date_match = re.search(r"Posted on\s+(.+?)\s+UTC", c3_text) + if date_match: + posted_at = date_match.group(1).strip() + + text = c6.decode_contents().strip() if c6 else "" + score_text = c5.get_text(strip=True) if c5 else "" + score: int | None = None + if score_text: + score_match = re.search(r"([+-]?\d+)", score_text) + if score_match: + score = int(score_match.group(1)) + + comments.append({ + "poster": poster, + "posted_at": posted_at, + "text": text, + "score": score, + }) + + return comments + async def check_cookies(self) -> bool: """Verify that the current cookies give authenticated access.""" try: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2a1f2aa8..5f633f5d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -184,6 +184,15 @@ async def _noop_lifespan(app): last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, + """ + CREATE TABLE IF NOT EXISTS blocked_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + namespace TEXT NOT NULL, + name TEXT NOT NULL, + UNIQUE (user_id, namespace, name) + ) + """, ] # --------------------------------------------------------------------------- diff --git a/backend/worker.py b/backend/worker.py index 3d5dad98..2993ae8e 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -271,7 +271,10 @@ async def _build_gallery_dl_config(url: str) -> None: if token: config["extractor"]["pixiv"] = {"refresh-token": token} - Path(settings.gallery_dl_config).write_text(json.dumps(config, indent=2)) + config_path = Path(settings.gallery_dl_config) + tmp_path = config_path.with_suffix(".tmp") + tmp_path.write_text(json.dumps(config, indent=2)) + os.rename(tmp_path, config_path) def _detect_source(url: str) -> str: @@ -680,7 +683,9 @@ async def thumbnail_job(ctx: dict, gallery_id: int) -> dict: continue thumb = rgb.copy() thumb.thumbnail((size, size * 2), PILImage.LANCZOS) - thumb.save(str(dest), "WEBP", quality=85) + tmp = dest.with_suffix(".tmp") + thumb.save(str(tmp), "WEBP", quality=85) + os.rename(tmp, dest) img.thumb_path = str(thumb_dir / "thumb_160.webp") processed += 1 diff --git a/db/init.sql b/db/init.sql index 8e360631..f0d58389 100644 --- a/db/init.sql +++ b/db/init.sql @@ -156,3 +156,52 @@ CREATE INDEX IF NOT EXISTS idx_tags_count ON tags (count DESC); CREATE INDEX IF NOT EXISTS idx_gallery_tags_tag ON gallery_tags (tag_id); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags (tag_id); CREATE INDEX IF NOT EXISTS idx_download_jobs_status ON download_jobs (status); + +-- ── Browse History ──────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS browse_history ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + title TEXT, + thumb TEXT, + gid BIGINT, + token TEXT, + viewed_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (user_id, source, source_id) +); +CREATE INDEX IF NOT EXISTS idx_browse_history_user ON browse_history (user_id, viewed_at DESC); + +-- ── Saved Searches ──────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS saved_searches ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + query TEXT DEFAULT '', + params JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_saved_searches_user ON saved_searches (user_id, created_at DESC); + +-- ── Tag Translations ────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS tag_translations ( + namespace TEXT NOT NULL, + name TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'zh', + translation TEXT NOT NULL, + PRIMARY KEY (namespace, name, language) +); + +-- ── Blocked Tags ────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS blocked_tags ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + namespace TEXT NOT NULL, + name TEXT NOT NULL, + UNIQUE (user_id, namespace, name) +); +CREATE INDEX IF NOT EXISTS idx_blocked_tags_user ON blocked_tags (user_id); diff --git a/docker-compose.yml b/docker-compose.yml index 10babfd3..3475a6eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: - ./data/thumbs:/data/thumbs - ./data/training:/data/training - ./data/avatars:/data/avatars + # host: chown 1042:1042 ./config/gallery-dl && chmod 750 ./config/gallery-dl - ./config/gallery-dl:/home/appuser/.config/gallery-dl depends_on: postgres: @@ -74,6 +75,7 @@ services: - ./data/gallery:/data/gallery - ./data/thumbs:/data/thumbs - ./data/training:/data/training + # host: chown 1042:1042 ./config/gallery-dl && chmod 750 ./config/gallery-dl - ./config/gallery-dl:/home/appuser/.config/gallery-dl depends_on: postgres: diff --git a/nginx/nginx.conf b/nginx/nginx.conf index b8b1fcaa..3e378dcc 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -23,7 +23,7 @@ http { limit_req_zone $binary_remote_addr zone=download_zone:10m rate=2r/s; # Nginx proxy cache for EH thumbnail CDN images - proxy_cache_path /tmp/nginx_thumb_cache levels=1:2 keys_zone=thumb_cache:10m + proxy_cache_path /var/cache/nginx/thumb_cache levels=1:2 keys_zone=thumb_cache:10m max_size=512m inactive=7d use_temp_path=off; upstream api { @@ -56,6 +56,7 @@ http { location /media/thumbs/ { auth_request /_auth; alias /data/thumbs/; + disable_symlinks on; expires 7d; add_header Cache-Control "private, immutable"; add_header X-Content-Type-Options "nosniff" always; @@ -64,6 +65,7 @@ http { location /media/gallery/ { auth_request /_auth; alias /data/gallery/; + disable_symlinks on; expires 1d; add_header Cache-Control "private"; add_header X-Content-Type-Options "nosniff" always; @@ -72,6 +74,7 @@ http { location /media/avatars/ { auth_request /_auth; alias /data/avatars/; + disable_symlinks on; expires 1d; add_header Cache-Control "private"; add_header X-Content-Type-Options "nosniff" always; diff --git a/pwa/public/offline.html b/pwa/public/offline.html new file mode 100644 index 00000000..7962bd0a --- /dev/null +++ b/pwa/public/offline.html @@ -0,0 +1,69 @@ + + + + + + 目前離線 — Jyzrox + + + +
+
📵
+

目前離線

+

請檢查網路連線後再試。若已恢復連線,點擊下方按鈕重新載入頁面。

+ +
+ + diff --git a/pwa/public/sw.js b/pwa/public/sw.js index 7c0ab333..172ab387 100644 --- a/pwa/public/sw.js +++ b/pwa/public/sw.js @@ -1,10 +1,11 @@ const CACHE_NAME = 'jyzrox-static-v1'; +const OFFLINE_URL = '/offline.html'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { - // Pre-cache core static assets - return cache.addAll(['/']); + // Pre-cache core static assets including offline fallback + return cache.addAll(['/', OFFLINE_URL]); }) ); self.skipWaiting(); @@ -23,15 +24,16 @@ self.addEventListener('activate', (event) => { self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') return; - + // Cache-first for images/media if possible, otherwise network-first if (event.request.url.includes('/media/') || event.request.url.includes('/thumbs/')) { event.respondWith( caches.match(event.request).then((cached) => { - if (cached) return cached; + // Only return cached response if it was a successful 2xx response + if (cached && cached.status >= 200 && cached.status < 300) return cached; return fetch(event.request).then((response) => { - // Clone the response so we can cache it and also send it back to the browser. - if (response.ok) { + // Only cache successful 2xx responses + if (response.status >= 200 && response.status < 300) { const resClone = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(event.request, resClone)); } @@ -40,9 +42,27 @@ self.addEventListener('fetch', (event) => { }) ); } else { - // Network first for other requests + // Network first for other requests; fall back to cache or offline page event.respondWith( - fetch(event.request).catch(() => caches.match(event.request)) + fetch(event.request) + .then((response) => { + // Don't cache error responses from network + if (response.status >= 200 && response.status < 300) { + const resClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, resClone)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(event.request); + // Only use cache if it contains a valid 2xx response + if (cached && cached.status >= 200 && cached.status < 300) return cached; + // For navigate requests show the offline fallback page + if (event.request.mode === 'navigate') { + return caches.match(OFFLINE_URL); + } + return new Response('', { status: 503 }); + }) ); } }); diff --git a/pwa/src/app/browse/[gid]/[token]/page.tsx b/pwa/src/app/browse/[gid]/[token]/page.tsx index bf4d12f2..5a3c54fd 100644 --- a/pwa/src/app/browse/[gid]/[token]/page.tsx +++ b/pwa/src/app/browse/[gid]/[token]/page.tsx @@ -8,6 +8,75 @@ import { LoadingSpinner } from '@/components/LoadingSpinner' import { RatingStars } from '@/components/RatingStars' import { toast } from 'sonner' import { t } from '@/lib/i18n' +import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react' +import type { EhComment } from '@/lib/types' + +// ── Preview grid with scaled sprite offsets ───────────────────────────── + +function PreviewGrid({ + thumbs, + onRead, +}: { + thumbs: { page: number; url: string; isSprite: boolean; offsetX?: number; width?: number; height?: number }[] + onRead: (page: number) => void +}) { + const gridRef = useRef(null) + const [cellSize, setCellSize] = useState({ w: 0, h: 0 }) + + useEffect(() => { + const grid = gridRef.current + if (!grid) return + const measure = () => { + const first = grid.firstElementChild as HTMLElement | null + if (first) setCellSize({ w: first.offsetWidth, h: first.offsetHeight }) + } + measure() + const obs = new ResizeObserver(measure) + obs.observe(grid) + return () => obs.disconnect() + }, [thumbs.length]) + + return ( +
+ {thumbs.map((thumb) => { + // Scale based on width only — backend normalizes sprite heights. + const tw = thumb.width ?? 200 + const th = thumb.height ?? 300 + const scale = cellSize.w ? cellSize.w / tw : 1 + const scaledH = th * scale + return ( + + ) + })} +
+ ) +} // Favorite category colors (from EhViewer) const FAV_COLORS = [ @@ -258,14 +327,6 @@ export default function EhGalleryDetailPage() { return (
- {/* Back button */} - - {/* ── Header section ── */}
{/* Cover */} @@ -423,41 +484,21 @@ export default function EhGalleryDetailPage() {

{t('browse.preview')} ({gallery.pages} pages)

-
- {previewThumbs.map((thumb) => ( - - ))} -
+
)}
+ + {/* Floating back button — bottom-right for easy thumb reach on mobile */} +
) } diff --git a/pwa/src/app/browse/page.tsx b/pwa/src/app/browse/page.tsx index 4633fe85..39dae335 100644 --- a/pwa/src/app/browse/page.tsx +++ b/pwa/src/app/browse/page.tsx @@ -12,6 +12,25 @@ import { RatingStars } from '@/components/RatingStars' import { Search as SearchIcon, X as XIcon, ChevronDown, ChevronUp } from 'lucide-react' import type { EhGallery, Credentials } from '@/lib/types' +// ── IntersectionObserver-based lazy image ────────────────────────────── + +function LazyImage({ src, alt, className }: { src: string; alt: string; className: string }) { + const [error, setError] = useState(false) + + if (error) { + return
+ } + + return ( + {alt} setError(true)} + /> + ) +} + // ── Search history (localStorage) ───────────────────────────────────── const HISTORY_KEY = 'eh_search_history' @@ -19,6 +38,7 @@ const HISTORY_ENABLED_KEY = 'eh_search_history_enabled' const MAX_HISTORY = 10 function getSearchHistory(): string[] { + if (typeof window === 'undefined') return [] try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]') } catch { @@ -27,6 +47,7 @@ function getSearchHistory(): string[] { } function addSearchHistory(query: string) { + if (typeof window === 'undefined') return if (!query.trim()) return if (localStorage.getItem(HISTORY_ENABLED_KEY) === 'false') return const history = getSearchHistory().filter((h) => h !== query) @@ -35,15 +56,18 @@ function addSearchHistory(query: string) { } function removeSearchHistoryItem(query: string) { + if (typeof window === 'undefined') return const history = getSearchHistory().filter((h) => h !== query) localStorage.setItem(HISTORY_KEY, JSON.stringify(history)) } function clearSearchHistory() { + if (typeof window === 'undefined') return localStorage.removeItem(HISTORY_KEY) } function isSearchHistoryEnabled(): boolean { + if (typeof window === 'undefined') return true return localStorage.getItem(HISTORY_ENABLED_KEY) !== 'false' } @@ -100,12 +124,7 @@ function ListCard({ gallery, onClick }: { gallery: EhGallery; onClick: () => voi {/* Thumbnail */}
{thumbSrc ? ( - {gallery.title} + ) : (
voi > {/* Thumbnail */} {thumbSrc ? ( - {gallery.title} + ) : (
('search') + const [activeTab, setActiveTab] = useState(initialTab) const [inputValue, setInputValue] = useState(initialQ) const [searchQuery, setSearchQuery] = useState(initialQ) const [category, setCategory] = useState(null) - const [page, setPage] = useState(0) + const [page, setPage] = useState(initialPage) const [viewMode, setViewMode] = useState('grid') const [selectedGallery, setSelectedGallery] = useState(null) const [downloadUrl, setDownloadUrl] = useState('') @@ -417,9 +436,9 @@ function BrowsePage() { const [pageTo, setPageTo] = useState('') // Favorites state (cursor-based pagination — EH favorites uses next/prev cursors, not page numbers) - const [favCat, setFavCat] = useState('all') + const [favCat, setFavCat] = useState(initialFavCat) const [favCursor, setFavCursor] = useState<{ next?: string; prev?: string }>({}) - const [favSearch, setFavSearch] = useState('') + const [favSearch, setFavSearch] = useState(initialFavSearch) // Infinite scroll state const [loadMode] = useState(getLoadMode) @@ -470,14 +489,33 @@ function BrowsePage() { }, []) // Sync URL ?q= changes (e.g. from tag clicks in detail page) + // Only react to the q param itself, not to other searchParams changes (page, tab, etc.) + // to avoid a feedback loop where the URL sync effect resets page to 0. + const urlQ = searchParams.get('q') || '' useEffect(() => { - const q = searchParams.get('q') || '' - if (q !== searchQuery) { - setInputValue(q) - setSearchQuery(q) + if (urlQ !== searchQuery) { + setInputValue(urlQ) + setSearchQuery(urlQ) setPage(0) } - }, [searchParams]) // eslint-disable-line react-hooks/exhaustive-deps + }, [urlQ]) // eslint-disable-line react-hooks/exhaustive-deps + + // Persist browse state in URL so back-navigation restores it + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + const params = new URLSearchParams() + if (searchQuery) params.set('q', searchQuery) + if (page > 0) params.set('page', String(page)) + if (activeTab !== 'search') params.set('tab', activeTab) + if (activeTab === 'favorites' && favCat !== 'all') params.set('favcat', favCat) + if (activeTab === 'favorites' && favSearch) params.set('favsearch', favSearch) + const qs = params.toString() + router.replace(qs ? `/browse?${qs}` : '/browse', { scroll: false }) + }, [searchQuery, page, activeTab, favCat, favSearch]) // eslint-disable-line react-hooks/exhaustive-deps // Compute f_cats bitmask from selected categories (multi-select) const computedFCats = (() => { @@ -523,6 +561,24 @@ function BrowsePage() { activeTab === 'favorites' && ehConfigured, ) + // Restore scroll position after back-navigation (once data is loaded) + const scrollRestoredRef = useRef(false) + useEffect(() => { + if (scrollRestoredRef.current) return + const hasData = activeTab === 'search' ? !!data : !!favData + if (!hasData) return + const savedY = sessionStorage.getItem('browse_scrollY') + if (savedY) { + scrollRestoredRef.current = true + sessionStorage.removeItem('browse_scrollY') + requestAnimationFrame(() => { + window.scrollTo(0, Number(savedY)) + }) + } else { + scrollRestoredRef.current = true + } + }, [data, favData, activeTab]) + // ── Infinite scroll: reset when search changes ───────── useEffect(() => { if (loadMode === 'scroll') { @@ -684,6 +740,7 @@ function BrowsePage() { const navigateToGallery = useCallback( (g: EhGallery) => { + sessionStorage.setItem('browse_scrollY', String(window.scrollY)) router.push(`/browse/${g.gid}/${g.token}`) }, [router], diff --git a/pwa/src/app/history/page.tsx b/pwa/src/app/history/page.tsx new file mode 100644 index 00000000..0930d3ad --- /dev/null +++ b/pwa/src/app/history/page.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { api } from '@/lib/api' +import { t } from '@/lib/i18n' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { toast } from 'sonner' +import { X, Trash2, Clock } from 'lucide-react' +import type { BrowseHistoryItem } from '@/lib/types' + +const PAGE_SIZE = 24 + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + return new Date(iso).toLocaleDateString() +} + +function sourceLabel(source: string): string { + if (source === 'ehentai' || source === 'exhentai') return t('history.source.ehentai') + if (source === 'local') return t('history.source.local') + return source +} + +function HistoryCard({ + item, + onDelete, + onClick, +}: { + item: BrowseHistoryItem + onDelete: (id: number) => void + onClick: () => void +}) { + const thumbSrc = item.thumb + ? item.source === 'ehentai' || item.source === 'exhentai' + ? `/api/eh/thumb-proxy?url=${encodeURIComponent(item.thumb)}` + : item.thumb + : null + + return ( +
+ + + {/* Delete button */} + +
+ ) +} + +export default function HistoryPage() { + const router = useRouter() + const [items, setItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [clearing, setClearing] = useState(false) + + const loadPage = useCallback(async (offset: number, replace: boolean) => { + if (offset === 0) setLoading(true) + else setLoadingMore(true) + try { + const data = await api.history.list({ limit: PAGE_SIZE, offset }) + setTotal(data.total) + if (replace) { + setItems(data.items) + } else { + setItems((prev) => [...prev, ...data.items]) + } + } catch (err) { + toast.error(err instanceof Error ? err.message : t('common.failedToLoad')) + } finally { + setLoading(false) + setLoadingMore(false) + } + }, []) + + useEffect(() => { + loadPage(0, true) + }, [loadPage]) + + const handleDelete = useCallback( + async (id: number) => { + try { + await api.history.delete(id) + toast.success(t('history.deleted')) + setItems((prev) => prev.filter((i) => i.id !== id)) + setTotal((t) => t - 1) + } catch (err) { + toast.error(err instanceof Error ? err.message : t('history.deleteFailed')) + } + }, + [], + ) + + const handleClearAll = useCallback(async () => { + if (!window.confirm(t('history.clearConfirm'))) return + setClearing(true) + try { + await api.history.clear() + toast.success(t('history.cleared')) + setItems([]) + setTotal(0) + } catch (err) { + toast.error(err instanceof Error ? err.message : t('history.clearFailed')) + } finally { + setClearing(false) + } + }, []) + + const handleClick = useCallback( + (item: BrowseHistoryItem) => { + if (item.gid != null && item.token) { + router.push(`/browse/${item.gid}/${item.token}`) + } else if (item.source === 'local') { + router.push(`/library/${item.source_id}`) + } + }, + [router], + ) + + const hasMore = items.length < total + + return ( +
+
+ {/* Header */} +
+
+

{t('history.title')}

+

{t('history.subtitle')}

+
+ {items.length > 0 && ( + + )} +
+ + {/* Count */} + {!loading && total > 0 && ( +

+ {items.length} / {total} +

+ )} + + {/* Content */} + {loading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

{t('history.noHistory')}

+

{t('history.noHistoryHint')}

+
+ ) : ( + <> +
+ {items.map((item) => ( + handleClick(item)} + /> + ))} +
+ + {/* Load More */} + {hasMore && ( +
+ +
+ )} + + )} +
+
+ ) +} diff --git a/pwa/src/app/login/page.tsx b/pwa/src/app/login/page.tsx index c0858dc5..5eb9683e 100644 --- a/pwa/src/app/login/page.tsx +++ b/pwa/src/app/login/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, FormEvent, useEffect, useRef } from 'react' +import { useState, FormEvent, useEffect } from 'react' import { useRouter } from 'next/navigation' import { api } from '@/lib/api' @@ -12,34 +12,35 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(true) - const mountedRef = useRef(true) - useEffect(() => { - return () => { - mountedRef.current = false - } - }, []) + const controller = new AbortController() - useEffect(() => { - // If we already have a valid session, go straight to dashboard - api.auth - .check() - .then(() => { + async function checkSession() { + try { + // If we already have a valid session, go straight to dashboard + await api.auth.check() + if (controller.signal.aborted) return router.replace('/') - }) - .catch(() => { + } catch { + if (controller.signal.aborted) return // Session invalid or missing — check if first-run setup needed - api.auth - .needsSetup() - .then((data) => { - if (!mountedRef.current) return - if (data.needs_setup) router.replace('/setup') - else setLoading(false) - }) - .catch(() => { - if (mountedRef.current) setLoading(false) - }) - }) + try { + const data = await api.auth.needsSetup() + if (controller.signal.aborted) return + if (data.needs_setup) router.replace('/setup') + else setLoading(false) + } catch { + if (controller.signal.aborted) return + setLoading(false) + } + } + } + + checkSession() + + return () => { + controller.abort() + } }, [router]) async function handleSubmit(e: FormEvent) { diff --git a/pwa/src/app/queue/page.tsx b/pwa/src/app/queue/page.tsx index 4f468c05..7ec8154c 100644 --- a/pwa/src/app/queue/page.tsx +++ b/pwa/src/app/queue/page.tsx @@ -179,7 +179,7 @@ export default function QueuePage() { const result = await enqueue({ url }) toast.success(`${t('queue.queuedSuccess')} (job: ${result.job_id})`) setUrlInput('') - mutate() + await mutate() } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to enqueue download') } @@ -189,7 +189,7 @@ export default function QueuePage() { async (id: string) => { try { await cancelJob(id) - mutate() + await mutate() } catch { toast.error(t('queue.cancelError')) } @@ -201,7 +201,7 @@ export default function QueuePage() { async (id: string, action: 'pause' | 'resume') => { try { await pauseJob({ id, action }) - mutate() + await mutate() } catch { toast.error(t('queue.pauseError')) } @@ -213,7 +213,7 @@ export default function QueuePage() { try { const result = await clearJobs() toast.success(t('queue.cleared', { count: String(result.deleted) })) - mutate() + await mutate() } catch { toast.error(t('queue.clearError')) } diff --git a/pwa/src/app/settings/page.tsx b/pwa/src/app/settings/page.tsx index 8c0e2bbb..9ab271a3 100644 --- a/pwa/src/app/settings/page.tsx +++ b/pwa/src/app/settings/page.tsx @@ -7,7 +7,9 @@ import { api } from '@/lib/api' import { useAuth } from '@/hooks/useAuth' import { LoadingSpinner } from '@/components/LoadingSpinner' import { t } from '@/lib/i18n' -import { Copy, Key } from 'lucide-react' +import { Copy, Key, BookOpen } from 'lucide-react' +import { loadReaderSettings, saveReaderSettings } from '@/components/Reader/hooks' +import type { ViewMode, ScaleMode, ReadingDirection } from '@/components/Reader/types' import type { SystemHealth, SystemInfo, @@ -17,7 +19,7 @@ import type { ApiTokenInfo, } from '@/lib/types' -type SectionKey = 'ehentai' | 'pixiv' | 'system' | 'account' | 'browse' | 'apiTokens' +type SectionKey = 'ehentai' | 'pixiv' | 'system' | 'account' | 'browse' | 'apiTokens' | 'reader' function SectionHeader({ title, @@ -145,6 +147,192 @@ function BrowseSettings({ onForceRerender }: { onForceRerender: () => void }) { ) } +// ── Reader Settings helpers ─────────────────────────────────────────── + +function ReaderToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) { + return ( + + ) +} + +function ReaderSettingRow({ + label, + desc, + children, +}: { + label: string + desc?: string + children: React.ReactNode +}) { + return ( +
+
+

{label}

+ {desc &&

{desc}

} +
+ {children} +
+ ) +} + +// ── Reader Settings sub-component ──────────────────────────────────── + +function ReaderSettingsSection({ onForceRerender }: { onForceRerender: () => void }) { + const s = loadReaderSettings() + + const selectClass = + 'bg-vault-input border border-vault-border rounded px-3 py-2 text-vault-text focus:outline-none focus:border-vault-accent text-sm' + + return ( +
+ {/* Auto Advance */} +
+

+ {t('reader.autoAdvance')} +

+
+ + { + saveReaderSettings({ autoAdvanceEnabled: !s.autoAdvanceEnabled }) + onForceRerender() + }} + /> + + {s.autoAdvanceEnabled && ( + +
+ { + saveReaderSettings({ autoAdvanceSeconds: Number(e.target.value) }) + onForceRerender() + }} + className="w-28 accent-vault-accent" + /> + + {s.autoAdvanceSeconds}s + +
+
+ )} +
+
+ + {/* Status Bar */} +
+

+ {t('reader.statusBar')} +

+
+ + { + saveReaderSettings({ statusBarEnabled: !s.statusBarEnabled }) + onForceRerender() + }} + /> + + {s.statusBarEnabled && ( + <> + + { + saveReaderSettings({ statusBarShowClock: !s.statusBarShowClock }) + onForceRerender() + }} + /> + + + { + saveReaderSettings({ statusBarShowProgress: !s.statusBarShowProgress }) + onForceRerender() + }} + /> + + + { + saveReaderSettings({ statusBarShowPageCount: !s.statusBarShowPageCount }) + onForceRerender() + }} + /> + + + )} +
+
+ + {/* Defaults */} +
+

Defaults

+
+ + + + + + + + + +
+
+
+ ) +} + export default function SettingsPage() { const { logout } = useAuth() const [activeSection, setActiveSection] = useState('ehentai') @@ -1047,6 +1235,31 @@ export default function SettingsPage() { )}
+ {/* ── Reader Settings ── */} +
+
+
+ +
+
+ +
+
+ {activeSection === 'reader' && ( + { + setActiveSection(null) + setTimeout(() => setActiveSection('reader'), 0) + }} + /> + )} +
+ {/* ── API Tokens ── */}
diff --git a/pwa/src/components/MobileNav.tsx b/pwa/src/components/MobileNav.tsx index 8753c8ca..a56c9d3d 100644 --- a/pwa/src/components/MobileNav.tsx +++ b/pwa/src/components/MobileNav.tsx @@ -8,6 +8,7 @@ import { LayoutDashboard, Search, BookOpen, + Clock, Download, Tags, Settings, @@ -28,6 +29,7 @@ const navLinks = [ { href: '/', label: () => t('nav.dashboard'), icon: LayoutDashboard }, { href: '/browse', label: () => t('nav.browse'), icon: Search }, { href: '/library', label: () => t('nav.library'), icon: BookOpen }, + { href: '/history', label: () => t('nav.history'), icon: Clock }, { href: '/queue', label: () => t('nav.queue'), icon: Download }, { href: '/tags', label: () => t('nav.tags'), icon: Tags }, { href: '/export', label: () => t('nav.export'), icon: PackageOpen }, diff --git a/pwa/src/components/Reader/hooks.ts b/pwa/src/components/Reader/hooks.ts index 279aad57..6072129a 100644 --- a/pwa/src/components/Reader/hooks.ts +++ b/pwa/src/components/Reader/hooks.ts @@ -1,8 +1,40 @@ 'use client' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' -import type { ReaderState, ReaderAction, ReaderImage, ViewMode } from './types' +import type { ReaderState, ReaderAction, ReaderImage, ViewMode, ScaleMode, ReadingDirection, ReaderSettings } from './types' +import { DEFAULT_READER_SETTINGS } from './types' import { api } from '@/lib/api' +// ── localStorage helpers ─────────────────────────────────────────────── + +export function loadReaderSettings(): ReaderSettings { + if (typeof window === 'undefined') return DEFAULT_READER_SETTINGS + try { + const raw = localStorage.getItem('reader_settings') + if (!raw) return DEFAULT_READER_SETTINGS + return { ...DEFAULT_READER_SETTINGS, ...JSON.parse(raw) } + } catch { + return DEFAULT_READER_SETTINGS + } +} + +export function saveReaderSettings(settings: Partial) { + if (typeof window === 'undefined') return + const current = loadReaderSettings() + localStorage.setItem('reader_settings', JSON.stringify({ ...current, ...settings })) +} + +function loadDirection(galleryId: number): ReadingDirection | null { + if (typeof window === 'undefined') return null + const val = localStorage.getItem(`reader_direction_${galleryId}`) + if (val === 'ltr' || val === 'rtl' || val === 'vertical') return val + return null +} + +function saveDirection(galleryId: number, dir: ReadingDirection) { + if (typeof window === 'undefined') return + localStorage.setItem(`reader_direction_${galleryId}`, dir) +} + // ── useReaderState ──────────────────────────────────────────────────── function readerReducer(state: ReaderState, action: ReaderAction): ReaderState { @@ -17,16 +49,25 @@ function readerReducer(state: ReaderState, action: ReaderAction): ReaderState { return { ...state, showOverlay: true } case 'HIDE_OVERLAY': return { ...state, showOverlay: false } + case 'SET_SCALE_MODE': + return { ...state, scaleMode: action.mode } + case 'SET_READING_DIRECTION': + return { ...state, readingDirection: action.direction } default: return state } } -export function useReaderState(initialPage: number, totalPages: number) { +export function useReaderState(initialPage: number, totalPages: number, galleryId: number) { + const settings = loadReaderSettings() + const savedDirection = loadDirection(galleryId) + const [state, dispatch] = useReducer(readerReducer, { currentPage: initialPage, - viewMode: 'single', + viewMode: settings.defaultViewMode, showOverlay: false, + scaleMode: settings.defaultScaleMode, + readingDirection: savedDirection ?? settings.defaultReadingDirection, } as ReaderState) const setPage = useCallback( @@ -45,6 +86,16 @@ export function useReaderState(initialPage: number, totalPages: number) { const toggleOverlay = useCallback(() => dispatch({ type: 'TOGGLE_OVERLAY' }), []) + const setScaleMode = useCallback((mode: ScaleMode) => dispatch({ type: 'SET_SCALE_MODE', mode }), []) + + const setReadingDirection = useCallback( + (direction: ReadingDirection) => { + dispatch({ type: 'SET_READING_DIRECTION', direction }) + saveDirection(galleryId, direction) + }, + [galleryId], + ) + return { state, setPage, @@ -52,6 +103,8 @@ export function useReaderState(initialPage: number, totalPages: number) { prevPage, setViewMode, toggleOverlay, + setScaleMode, + setReadingDirection, } } @@ -187,6 +240,7 @@ export function useTouchGesture( onSwipeLeft: () => void, onSwipeRight: () => void, threshold = 50, + isDisabled?: () => boolean, ) { const startX = useRef(0) const startY = useRef(0) @@ -196,11 +250,13 @@ export function useTouchGesture( if (!el) return const onStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return startX.current = e.touches[0].clientX startY.current = e.touches[0].clientY } const onEnd = (e: TouchEvent) => { + if (isDisabled?.()) return const dx = e.changedTouches[0].clientX - startX.current const dy = e.changedTouches[0].clientY - startY.current // Only trigger if horizontal swipe dominates @@ -216,22 +272,35 @@ export function useTouchGesture( el.removeEventListener('touchstart', onStart) el.removeEventListener('touchend', onEnd) } - }, [elementRef, onSwipeLeft, onSwipeRight, threshold]) + }, [elementRef, onSwipeLeft, onSwipeRight, threshold, isDisabled]) } // ── useKeyboardNav ──────────────────────────────────────────────────── -export function useKeyboardNav(onNext: () => void, onPrev: () => void) { +export function useKeyboardNav( + onNext: () => void, + onPrev: () => void, + readingDirection: ReadingDirection = 'ltr', +) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) return + const isRtl = readingDirection === 'rtl' switch (e.key) { case 'ArrowRight': + case 'd': + e.preventDefault() + isRtl ? onPrev() : onNext() + break + case 'ArrowLeft': + case 'a': + e.preventDefault() + isRtl ? onNext() : onPrev() + break case 'ArrowDown': e.preventDefault() onNext() break - case 'ArrowLeft': case 'ArrowUp': e.preventDefault() onPrev() @@ -240,13 +309,14 @@ export function useKeyboardNav(onNext: () => void, onPrev: () => void) { } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [onNext, onPrev]) + }, [onNext, onPrev, readingDirection]) } // ── useProgressSave ─────────────────────────────────────────────────── export function useProgressSave(galleryId: number, currentPage: number) { const timerRef = useRef>() + const retryRef = useRef>() useEffect(() => { // Skip progress save for proxy-only browsing (galleryId === 0) @@ -254,11 +324,268 @@ export function useProgressSave(galleryId: number, currentPage: number) { clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { - api.library.saveProgress(galleryId, currentPage).catch(() => { - /* silent */ + api.library.saveProgress(galleryId, currentPage).catch((err) => { + console.warn('[Reader] Failed to save progress, retrying in 5s:', err) + clearTimeout(retryRef.current) + retryRef.current = setTimeout(() => { + api.library.saveProgress(galleryId, currentPage).catch((retryErr) => { + console.warn('[Reader] Progress save retry also failed:', retryErr) + }) + }, 5000) }) }, 2000) // debounce 2 s - return () => clearTimeout(timerRef.current) + return () => { + clearTimeout(timerRef.current) + clearTimeout(retryRef.current) + } }, [galleryId, currentPage]) } + +// ── useAutoAdvance ──────────────────────────────────────────────────── + +export function useAutoAdvance( + enabled: boolean, + intervalSeconds: number, + nextPage: () => void, + isLastPage: boolean, + overlayVisible: boolean, +) { + const timerRef = useRef | null>(null) + const [countdown, setCountdown] = useState(intervalSeconds) + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current) + timerRef.current = null + } + }, []) + + // Reset countdown when page changes or interval changes + useEffect(() => { + setCountdown(intervalSeconds) + }, [intervalSeconds]) + + useEffect(() => { + if (!enabled || isLastPage || overlayVisible) { + clearTimer() + setCountdown(intervalSeconds) + return + } + + setCountdown(intervalSeconds) + + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + nextPage() + return intervalSeconds + } + return prev - 1 + }) + }, 1000) + + return clearTimer + }, [enabled, intervalSeconds, isLastPage, overlayVisible, nextPage, clearTimer]) + + // Reset countdown on manual page change (called externally) + const resetCountdown = useCallback(() => { + setCountdown(intervalSeconds) + }, [intervalSeconds]) + + return { countdown, resetCountdown } +} + +// ── useStatusBarClock ───────────────────────────────────────────────── + +export function useStatusBarClock(enabled: boolean): string { + const [time, setTime] = useState('') + + useEffect(() => { + if (!enabled) return + + const update = () => { + const now = new Date() + const h = now.getHours().toString().padStart(2, '0') + const m = now.getMinutes().toString().padStart(2, '0') + setTime(`${h}:${m}`) + } + + update() + + // Align to next minute boundary, then tick every 60s + const now = new Date() + const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + let intervalId: ReturnType | null = null + + const timeoutId = setTimeout(() => { + update() + intervalId = setInterval(update, 60_000) + }, msUntilNextMinute) + + return () => { + clearTimeout(timeoutId) + if (intervalId !== null) clearInterval(intervalId) + } + }, [enabled]) + + return time +} + +// ── usePinchZoom ────────────────────────────────────────────────────── + +interface PinchZoomState { + scale: number + translateX: number + translateY: number + isZoomed: boolean +} + +export function usePinchZoom(elementRef: React.RefObject) { + const [zoomState, setZoomState] = useState({ + scale: 1, + translateX: 0, + translateY: 0, + isZoomed: false, + }) + + const stateRef = useRef(zoomState) + useEffect(() => { + stateRef.current = zoomState + }) + + const lastTouchDistRef = useRef(null) + const lastTouchCenterRef = useRef<{ x: number; y: number } | null>(null) + const panStartRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null) + const lastTapRef = useRef(0) + const isPinchingRef = useRef(false) + + const clampTranslate = useCallback( + (scale: number, tx: number, ty: number, el: HTMLElement): { tx: number; ty: number } => { + const rect = el.getBoundingClientRect() + const maxTx = ((scale - 1) * rect.width) / 2 + const maxTy = ((scale - 1) * rect.height) / 2 + return { + tx: Math.max(-maxTx, Math.min(maxTx, tx)), + ty: Math.max(-maxTy, Math.min(maxTy, ty)), + } + }, + [], + ) + + const resetZoom = useCallback(() => { + setZoomState({ scale: 1, translateX: 0, translateY: 0, isZoomed: false }) + }, []) + + useEffect(() => { + const el = elementRef.current + if (!el) return + + const getTouchDist = (touches: TouchList) => { + const dx = touches[0].clientX - touches[1].clientX + const dy = touches[0].clientY - touches[1].clientY + return Math.sqrt(dx * dx + dy * dy) + } + + const getTouchCenter = (touches: TouchList) => ({ + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }) + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { + isPinchingRef.current = true + lastTouchDistRef.current = getTouchDist(e.touches) + lastTouchCenterRef.current = getTouchCenter(e.touches) + panStartRef.current = null + } else if (e.touches.length === 1 && stateRef.current.isZoomed) { + panStartRef.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + tx: stateRef.current.translateX, + ty: stateRef.current.translateY, + } + } + } + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length === 2 && lastTouchDistRef.current !== null) { + e.preventDefault() + const newDist = getTouchDist(e.touches) + const ratio = newDist / lastTouchDistRef.current + const { scale: currentScale, translateX, translateY } = stateRef.current + + const newScale = Math.max(1, Math.min(5, currentScale * ratio)) + const clamped = clampTranslate(newScale, translateX, translateY, el) + + setZoomState({ + scale: newScale, + translateX: clamped.tx, + translateY: clamped.ty, + isZoomed: newScale > 1.01, + }) + + lastTouchDistRef.current = newDist + } else if (e.touches.length === 1 && panStartRef.current && stateRef.current.isZoomed) { + e.preventDefault() + const dx = e.touches[0].clientX - panStartRef.current.x + const dy = e.touches[0].clientY - panStartRef.current.y + const newTx = panStartRef.current.tx + dx + const newTy = panStartRef.current.ty + dy + const clamped = clampTranslate(stateRef.current.scale, newTx, newTy, el) + + setZoomState((prev) => ({ + ...prev, + translateX: clamped.tx, + translateY: clamped.ty, + })) + } + } + + const onTouchEnd = (e: TouchEvent) => { + if (e.touches.length < 2) { + lastTouchDistRef.current = null + lastTouchCenterRef.current = null + + if (isPinchingRef.current) { + isPinchingRef.current = false + panStartRef.current = null + // If scale settled close to 1, reset + if (stateRef.current.scale < 1.05) { + resetZoom() + } + return + } + } + + if (e.touches.length === 0) { + panStartRef.current = null + } + } + + const onDoubleTap = (e: TouchEvent) => { + const now = Date.now() + if (now - lastTapRef.current < 300) { + e.preventDefault() + resetZoom() + } + lastTapRef.current = now + } + + el.addEventListener('touchstart', onTouchStart, { passive: false }) + el.addEventListener('touchmove', onTouchMove, { passive: false }) + el.addEventListener('touchend', onTouchEnd, { passive: true }) + el.addEventListener('touchstart', onDoubleTap, { passive: false }) + + return () => { + el.removeEventListener('touchstart', onTouchStart) + el.removeEventListener('touchmove', onTouchMove) + el.removeEventListener('touchend', onTouchEnd) + el.removeEventListener('touchstart', onDoubleTap) + } + }, [elementRef, clampTranslate, resetZoom]) + + const transform = `scale(${zoomState.scale}) translate(${zoomState.translateX / zoomState.scale}px, ${zoomState.translateY / zoomState.scale}px)` + + return { ...zoomState, transform, resetZoom } +} diff --git a/pwa/src/components/Reader/index.tsx b/pwa/src/components/Reader/index.tsx index 3700a492..3be5ae6d 100644 --- a/pwa/src/components/Reader/index.tsx +++ b/pwa/src/components/Reader/index.tsx @@ -1,14 +1,22 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { useRouter } from 'next/navigation' +import { X } from 'lucide-react' import type { GalleryImage } from '@/lib/types' -import type { ReaderImage, ViewMode } from './types' +import type { ReaderImage, ViewMode, ScaleMode, ReadingDirection, ReaderSettings } from './types' +import { DEFAULT_READER_SETTINGS } from './types' +import { t } from '@/lib/i18n' import { useReaderState, useSequentialPrefetch, useTouchGesture, useKeyboardNav, useProgressSave, + useAutoAdvance, + useStatusBarClock, + usePinchZoom, + loadReaderSettings, + saveReaderSettings, } from './hooks' // ── URL resolver ────────────────────────────────────────────────────── @@ -40,6 +48,36 @@ function Spinner({ className = '' }: { className?: string }) { ) } +// ── Scale mode CSS helpers ──────────────────────────────────────────── + +function getScaleContainerClass(scaleMode: ScaleMode): string { + switch (scaleMode) { + case 'fit-width': + return 'relative w-full overflow-y-auto overflow-x-hidden' + case 'fit-height': + return 'relative h-full overflow-x-auto overflow-y-hidden flex items-center' + case 'original': + return 'relative overflow-auto flex items-center justify-center' + case 'fit-both': + default: + return 'relative flex h-full w-full items-center justify-center overflow-hidden' + } +} + +function getScaleImageClass(scaleMode: ScaleMode): string { + switch (scaleMode) { + case 'fit-width': + return 'w-full h-auto block pointer-events-none' + case 'fit-height': + return 'h-screen w-auto block pointer-events-none' + case 'original': + return 'block pointer-events-none' + case 'fit-both': + default: + return 'max-h-full max-w-full object-contain pointer-events-none' + } +} + // ── Media element (image vs video) ─────────────────────────────────── function MediaElement({ @@ -107,7 +145,7 @@ interface ReaderProps { previews?: Record } -// ── Sub-components ──────────────────────────────────────────────────── +// ── SinglePageView ──────────────────────────────────────────────────── interface SinglePageViewProps { image: ReaderImage @@ -116,6 +154,8 @@ interface SinglePageViewProps { onPrev: () => void onToggleOverlay: () => void onImageLoaded: () => void + scaleMode: ScaleMode + readingDirection: ReadingDirection } function SinglePageView({ @@ -125,50 +165,91 @@ function SinglePageView({ onPrev, onToggleOverlay, onImageLoaded, + scaleMode, + readingDirection, }: SinglePageViewProps) { + const containerRef = useRef(null) + const { isZoomed, transform } = usePinchZoom(containerRef as React.RefObject) + + const leftAction = readingDirection === 'rtl' ? onNext : onPrev + const rightAction = readingDirection === 'rtl' ? onPrev : onNext + return ( -
- +
+
+ +
{isLoading && ( -
+
)} -
-
-
+ {!isZoomed && readingDirection === 'vertical' ? ( + <> +
+
+
+ + ) : !isZoomed ? ( + <> +
+
+
+ + ) : null}
) } +// ── WebtoonView ─────────────────────────────────────────────────────── + interface WebtoonViewProps { images: ReaderImage[] onPageChange: (page: number) => void onToggleOverlay: () => void + /** When this changes and differs from the last scroll-reported page, scroll to that page. */ + scrollToPage?: number } -function WebtoonView({ images, onPageChange, onToggleOverlay }: WebtoonViewProps) { +function WebtoonView({ images, onPageChange, onToggleOverlay, scrollToPage }: WebtoonViewProps) { const elRefs = useRef>(new Map()) const scrollRef = useRef(null) const [loadedPages, setLoadedPages] = useState>(new Set()) const lastPage = images.length > 0 ? images[images.length - 1].pageNum : 0 + // Tracks the last page number reported by the IntersectionObserver (i.e. from scrolling). + const lastReportedPage = useRef(0) useEffect(() => { if (typeof IntersectionObserver === 'undefined') return @@ -191,7 +272,13 @@ function WebtoonView({ images, onPageChange, onToggleOverlay }: WebtoonViewProps } if (topmost) { const pageNum = Number((topmost.target as HTMLElement).dataset.page) - if (!isNaN(pageNum)) onPageChange(pageNum) + if (!isNaN(pageNum)) { + // Update lastReportedPage BEFORE calling onPageChange so the + // scrollToPage effect can distinguish observer-driven changes + // from thumbnail-click-driven changes. + lastReportedPage.current = pageNum + onPageChange(pageNum) + } } }, { threshold: 0.5 }, @@ -201,6 +288,18 @@ function WebtoonView({ images, onPageChange, onToggleOverlay }: WebtoonViewProps return () => observer.disconnect() }, [images, onPageChange]) + // Scroll to a specific page when requested externally (e.g. thumbnail click). + // Only fires when scrollToPage differs from what the observer last reported, + // which means the change originated from outside (not from natural scrolling). + useEffect(() => { + if (scrollToPage != null && scrollToPage !== lastReportedPage.current) { + const el = elRefs.current.get(scrollToPage) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + }, [scrollToPage]) + const handleImageLoaded = useCallback((pageNum: number) => { setLoadedPages((prev) => new Set([...prev, pageNum])) }, []) @@ -240,6 +339,8 @@ function WebtoonView({ images, onPageChange, onToggleOverlay }: WebtoonViewProps ) } +// ── DoublePageView ──────────────────────────────────────────────────── + interface DoublePageViewProps { leftImage: ReaderImage rightImage: ReaderImage | null @@ -248,6 +349,8 @@ interface DoublePageViewProps { onPrev: () => void onToggleOverlay: () => void onImageLoaded: () => void + scaleMode: ScaleMode + readingDirection: ReadingDirection } function DoublePageView({ @@ -258,23 +361,44 @@ function DoublePageView({ onPrev, onToggleOverlay, onImageLoaded, + scaleMode, + readingDirection, }: DoublePageViewProps) { + const containerRef = useRef(null) + const { isZoomed, transform } = usePinchZoom(containerRef as React.RefObject) + + const leftAction = readingDirection === 'rtl' ? onNext : onPrev + const rightAction = readingDirection === 'rtl' ? onPrev : onNext + + // RTL: swap display order + const firstImage = readingDirection === 'rtl' ? rightImage : leftImage + const secondImage = readingDirection === 'rtl' ? leftImage : rightImage + + const imgClass = getScaleImageClass(scaleMode === 'fit-both' ? 'fit-both' : scaleMode) + return ( -
-
+
+
- + {firstImage ? ( + + ) : ( +
+ )}
- {rightImage ? ( + {secondImage ? ( @@ -284,83 +408,223 @@ function DoublePageView({
{isLoading && ( -
+
)} -
-
-
+ {!isZoomed && readingDirection === 'vertical' ? ( + <> +
+
+
+ + ) : !isZoomed ? ( + <> +
+
+
+ + ) : null}
) } +// ── ReaderOverlay ───────────────────────────────────────────────────── + interface ReaderOverlayProps { currentPage: number totalPages: number viewMode: ViewMode + scaleMode: ScaleMode + readingDirection: ReadingDirection + autoAdvanceEnabled: boolean + autoAdvanceSeconds: number onBack: () => void onViewModeChange: (mode: ViewMode) => void + onScaleModeChange: (mode: ScaleMode) => void + onReadingDirectionChange: (dir: ReadingDirection) => void + onAutoAdvanceToggle: () => void + onAutoAdvanceIntervalChange: (s: number) => void + onShowHelp: () => void } function ReaderOverlay({ currentPage, totalPages, viewMode, + scaleMode, + readingDirection, + autoAdvanceEnabled, + autoAdvanceSeconds, onBack, onViewModeChange, + onScaleModeChange, + onReadingDirectionChange, + onAutoAdvanceToggle, + onAutoAdvanceIntervalChange, + onShowHelp, }: ReaderOverlayProps) { - const VIEW_MODES: ViewMode[] = ['single', 'webtoon', 'double'] + const VIEW_MODES: { mode: ViewMode; label: string }[] = [ + { mode: 'single', label: t('reader.viewModeSingle') }, + { mode: 'webtoon', label: t('reader.viewModeWebtoon') }, + { mode: 'double', label: t('reader.viewModeDouble') }, + ] + + const SCALE_MODES: { mode: ScaleMode; label: string }[] = [ + { mode: 'fit-both', label: t('reader.scaleFitBoth') }, + { mode: 'fit-width', label: t('reader.scaleFitWidth') }, + { mode: 'fit-height', label: t('reader.scaleFitHeight') }, + { mode: 'original', label: t('reader.scaleOriginal') }, + ] + + const DIRECTIONS: { dir: ReadingDirection; label: string }[] = [ + { dir: 'ltr', label: t('reader.dirLtr') }, + { dir: 'rtl', label: t('reader.dirRtl') }, + { dir: 'vertical', label: t('reader.dirVertical') }, + ] + + const btnActive = 'bg-white text-black' + const btnInactive = 'bg-white/10 hover:bg-white/20 text-white' return ( -
- {/* Page indicator */} - - {currentPage} / {totalPages} - - - {/* Spacer */} -
- - {/* View mode buttons */} -
- {VIEW_MODES.map((m) => ( - - ))} +
+ {/* Row 1: page indicator (left) + help + close (right) */} +
+ + {currentPage} / {totalPages} + + +
+ + + +
- {/* Back button (right side) */} - + {/* View mode group */} +
+ {VIEW_MODES.map(({ mode, label }) => ( + + ))} +
+ + | + + {/* Scale mode group */} +
+ {SCALE_MODES.map(({ mode, label }) => ( + + ))} +
+ + | + + {/* Direction group */} +
+ {DIRECTIONS.map(({ dir, label }) => ( + + ))} +
+
+ + {/* Row 3: Auto advance controls */} +
+ {t('reader.autoAdvance')} + + {autoAdvanceEnabled && ( +
+ onAutoAdvanceIntervalChange(Number(e.target.value))} + className="w-24 accent-white" + /> + {autoAdvanceSeconds}s +
+ )} +
) } +// ── ThumbnailStrip ──────────────────────────────────────────────────── + interface ThumbnailStripProps { images: ReaderImage[] currentPage: number @@ -412,11 +676,18 @@ function ThumbnailStrip({ images, currentPage, onPageSelect, previews }: Thumbna if (previewRaw) { if (previewRaw.includes('|')) { - const [spriteUrl, ox] = previewRaw.split('|') + const parts = previewRaw.split('|') + const spriteUrl = parts[0] + const ox = Number(parts[1]) + const cellW = Number(parts[2]) || 200 + const cellH = Number(parts[3]) || 300 + // Scale based on width only — backend normalizes sprite heights. + const scale = 48 / cellW + const scaledOx = ox * scale spriteStyle = { backgroundImage: `url(/api/eh/thumb-proxy?url=${encodeURIComponent(spriteUrl)})`, - backgroundPosition: `${ox}px 0`, - backgroundSize: 'auto 100%', + backgroundPosition: `${scaledOx}px center`, + backgroundSize: `auto ${cellH * scale}px`, backgroundRepeat: 'no-repeat', width: '100%', height: '100%', @@ -446,7 +717,6 @@ function ThumbnailStrip({ images, currentPage, onPageSelect, previews }: Thumbna src={thumbSrc} alt={`Thumb ${img.pageNum}`} className="h-full w-full object-cover" - loading="lazy" /> ) : (
@@ -463,6 +733,134 @@ function ThumbnailStrip({ images, currentPage, onPageSelect, previews }: Thumbna ) } +// ── StatusBar ───────────────────────────────────────────────────────── + +interface StatusBarProps { + currentPage: number + totalPages: number + settings: ReaderSettings + countdown: number + autoAdvanceEnabled: boolean +} + +function StatusBar({ currentPage, totalPages, settings, countdown, autoAdvanceEnabled }: StatusBarProps) { + const clock = useStatusBarClock(settings.statusBarEnabled && settings.statusBarShowClock) + + if (!settings.statusBarEnabled) return null + + const progress = totalPages > 0 ? (currentPage / totalPages) * 100 : 0 + + return ( +
+ {settings.statusBarShowClock && clock && ( + {clock} + )} + + {settings.statusBarShowProgress && ( +
+
+
+ )} + + {settings.statusBarShowPageCount && ( + + {currentPage} / {totalPages} + + )} + + {autoAdvanceEnabled && ( + {countdown}s + )} +
+ ) +} + +// ── HelpOverlay ─────────────────────────────────────────────────────── + +interface HelpOverlayProps { + readingDirection: ReadingDirection + onDismiss: () => void +} + +function HelpOverlay({ readingDirection, onDismiss }: HelpOverlayProps) { + const isRtl = readingDirection === 'rtl' + const isVertical = readingDirection === 'vertical' + + const leftLabel = isRtl ? t('reader.helpTapRight') : t('reader.helpTapLeft') + const rightLabel = isRtl ? t('reader.helpTapLeft') : t('reader.helpTapRight') + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + onDismiss() + e.stopPropagation() + } + window.addEventListener('keydown', handler, { once: true }) + return () => window.removeEventListener('keydown', handler) + }, [onDismiss]) + + return ( +
+ {isVertical ? ( + /* Vertical mode: top/middle/bottom zones */ +
+ {/* Top zone — previous page */} +
+
{t('reader.helpTapLeft')}
+
+ {/* Middle zone — toggle controls */} +
+
{t('reader.helpTapCenter')}
+
+ {/* Bottom zone — next page */} +
+
{t('reader.helpTapRight')}
+
+
+ ) : ( + /* Horizontal mode: left/center/right zones */ +
+ {/* Left zone */} +
+
+
{leftLabel}
+
+
+ {/* Center zone */} +
+
+
{t('reader.helpTapCenter')}
+
+
+ {/* Right zone */} +
+
+
{rightLabel}
+
+
+
+ )} + + {/* Bottom info */} +
+
+

{t('reader.helpSwipe')}

+

{t('reader.helpKeyboard')}

+

{t('reader.helpDismiss')}

+
+
+
+ ) +} + // ── Reader (main component) ─────────────────────────────────────────── export default function Reader({ @@ -486,35 +884,116 @@ export default function Reader({ mediaType: img.media_type, })) - const { state, setPage, nextPage, prevPage, setViewMode, toggleOverlay } = useReaderState( - initialPage, - totalPages, + const { + state, + setPage, + nextPage: rawNextPage, + prevPage: rawPrevPage, + setViewMode, + toggleOverlay, + setScaleMode, + setReadingDirection, + } = useReaderState(initialPage, totalPages, galleryId) + + // Reading direction aware next/prev + const nextPage = useCallback(() => { + if (state.readingDirection === 'rtl') { + rawPrevPage() + } else { + rawNextPage() + } + }, [state.readingDirection, rawNextPage, rawPrevPage]) + + const prevPage = useCallback(() => { + if (state.readingDirection === 'rtl') { + rawNextPage() + } else { + rawPrevPage() + } + }, [state.readingDirection, rawNextPage, rawPrevPage]) + + // Reader settings (status bar, auto advance) + const [readerSettings, setReaderSettings] = useState(DEFAULT_READER_SETTINGS) + useEffect(() => { + setReaderSettings(loadReaderSettings()) + }, []) + + // Auto advance local state (overlay controls) + const [autoAdvanceEnabled, setAutoAdvanceEnabled] = useState(false) + const [autoAdvanceSeconds, setAutoAdvanceSeconds] = useState(5) + + useEffect(() => { + const s = loadReaderSettings() + setAutoAdvanceEnabled(s.autoAdvanceEnabled) + setAutoAdvanceSeconds(s.autoAdvanceSeconds) + }, []) + + const isLastPage = state.currentPage >= totalPages + + const { countdown, resetCountdown } = useAutoAdvance( + autoAdvanceEnabled, + autoAdvanceSeconds, + rawNextPage, + isLastPage, + state.showOverlay, ) + // Reset countdown on manual page change + useEffect(() => { + resetCountdown() + }, [state.currentPage, resetCountdown]) + const containerRef = useRef(null) // Track image loading state for single/double page views const [pageLoading, setPageLoading] = useState(false) const loadingPageRef = useRef(state.currentPage) + const loadingTimerRef = useRef | null>(null) - // When page changes, mark as loading (single/double only) + // When page changes, start a short timer before showing the spinner. + // If the image loads before the timer fires, the spinner never appears. useEffect(() => { if (state.viewMode !== 'webtoon' && state.currentPage !== loadingPageRef.current) { - setPageLoading(true) loadingPageRef.current = state.currentPage + if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current) + loadingTimerRef.current = setTimeout(() => { + setPageLoading(true) + }, 150) } }, [state.currentPage, state.viewMode]) + // Cleanup loading timer on unmount + useEffect(() => { + return () => { + if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current) + } + }, []) + const handleImageLoaded = useCallback(() => { + if (loadingTimerRef.current) { + clearTimeout(loadingTimerRef.current) + loadingTimerRef.current = null + } setPageLoading(false) }, []) useSequentialPrefetch(images, state.currentPage, isProxyMode) useProgressSave(galleryId, state.currentPage) - useTouchGesture(containerRef as React.RefObject, nextPage, prevPage) + // Swipe: respect RTL direction + const swipeLeft = useCallback(() => { + if (state.readingDirection === 'rtl') rawPrevPage() + else rawNextPage() + }, [state.readingDirection, rawNextPage, rawPrevPage]) - useKeyboardNav(nextPage, prevPage) + const swipeRight = useCallback(() => { + if (state.readingDirection === 'rtl') rawNextPage() + else rawPrevPage() + }, [state.readingDirection, rawNextPage, rawPrevPage]) + + useTouchGesture(containerRef as React.RefObject, swipeLeft, swipeRight) + + useKeyboardNav(rawNextPage, rawPrevPage, state.readingDirection) // Escape key to go back useEffect(() => { @@ -528,19 +1007,57 @@ export default function Reader({ const handleToggleOverlay = useCallback(() => toggleOverlay(), [toggleOverlay]) const handleBack = useCallback(() => router.back(), [router]) + // Help overlay + const [showHelp, setShowHelp] = useState(false) + + useEffect(() => { + if (typeof window === 'undefined') return + const shown = localStorage.getItem('reader_help_shown') + if (!shown) { + setShowHelp(true) + localStorage.setItem('reader_help_shown', '1') + } + }, []) + + const handleDismissHelp = useCallback(() => setShowHelp(false), []) + const handleShowHelp = useCallback(() => setShowHelp(true), []) + + const handleAutoAdvanceToggle = useCallback(() => { + const next = !autoAdvanceEnabled + setAutoAdvanceEnabled(next) + saveReaderSettings({ autoAdvanceEnabled: next }) + }, [autoAdvanceEnabled]) + + const handleAutoAdvanceInterval = useCallback((s: number) => { + setAutoAdvanceSeconds(s) + saveReaderSettings({ autoAdvanceSeconds: s }) + }, []) + const currentImage = images.find((i) => i.pageNum === state.currentPage) const nextImage = images.find((i) => i.pageNum === state.currentPage + 1) ?? null + // Offset for status bar (don't overlap thumbnail strip or overlay) + const statusBarBottomOffset = state.showOverlay ? 80 : 0 + return ( -
+
{/* Top overlay */} {state.showOverlay && ( )} @@ -550,10 +1067,12 @@ export default function Reader({ )} @@ -562,6 +1081,7 @@ export default function Reader({ images={images} onPageChange={setPage} onToggleOverlay={handleToggleOverlay} + scrollToPage={state.currentPage} /> )} @@ -574,11 +1094,13 @@ export default function Reader({ onPrev={() => setPage(state.currentPage - 2)} onToggleOverlay={handleToggleOverlay} onImageLoaded={handleImageLoaded} + scaleMode={state.scaleMode} + readingDirection={state.readingDirection} /> )}
- {/* Bottom thumbnail strip */} + {/* Bottom thumbnail strip (shown with overlay) */} {state.showOverlay && ( )} + + {/* Status bar (always visible unless disabled, offset when overlay+strip shown) */} + {!state.showOverlay && ( +
+ +
+ )} + + {/* Help overlay */} + {showHelp && ( + + )}
) } diff --git a/pwa/src/components/Reader/types.ts b/pwa/src/components/Reader/types.ts index 051d2734..2aa347ca 100644 --- a/pwa/src/components/Reader/types.ts +++ b/pwa/src/components/Reader/types.ts @@ -1,5 +1,9 @@ export type ViewMode = 'single' | 'webtoon' | 'double' +export type ScaleMode = 'fit-both' | 'fit-width' | 'fit-height' | 'original' + +export type ReadingDirection = 'ltr' | 'rtl' | 'vertical' + export interface ReaderImage { pageNum: number // 1-indexed url: string // resolved URL (local path or proxy API) @@ -13,6 +17,8 @@ export interface ReaderState { currentPage: number // 1-indexed viewMode: ViewMode showOverlay: boolean // show top/bottom controls + scaleMode: ScaleMode + readingDirection: ReadingDirection } export type ReaderAction = @@ -21,3 +27,30 @@ export type ReaderAction = | { type: 'TOGGLE_OVERLAY' } | { type: 'SHOW_OVERLAY' } | { type: 'HIDE_OVERLAY' } + | { type: 'SET_SCALE_MODE'; mode: ScaleMode } + | { type: 'SET_READING_DIRECTION'; direction: ReadingDirection } + +// localStorage-persisted reader settings (from settings page) +export interface ReaderSettings { + autoAdvanceEnabled: boolean + autoAdvanceSeconds: number // 2-30 + statusBarEnabled: boolean + statusBarShowClock: boolean + statusBarShowProgress: boolean + statusBarShowPageCount: boolean + defaultViewMode: ViewMode + defaultReadingDirection: ReadingDirection + defaultScaleMode: ScaleMode +} + +export const DEFAULT_READER_SETTINGS: ReaderSettings = { + autoAdvanceEnabled: false, + autoAdvanceSeconds: 5, + statusBarEnabled: true, + statusBarShowClock: true, + statusBarShowProgress: true, + statusBarShowPageCount: true, + defaultViewMode: 'single', + defaultReadingDirection: 'ltr', + defaultScaleMode: 'fit-both', +} diff --git a/pwa/src/components/Sidebar.tsx b/pwa/src/components/Sidebar.tsx index b20dbaac..d241c6b5 100644 --- a/pwa/src/components/Sidebar.tsx +++ b/pwa/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, Search, BookOpen, + Clock, Download, Tags, Settings, @@ -25,6 +26,7 @@ const navLinks = [ { href: '/', label: () => t('nav.dashboard'), icon: LayoutDashboard }, { href: '/browse', label: () => t('nav.browse'), icon: Search }, { href: '/library', label: () => t('nav.library'), icon: BookOpen }, + { href: '/history', label: () => t('nav.history'), icon: Clock }, { href: '/queue', label: () => t('nav.queue'), icon: Download }, { href: '/tags', label: () => t('nav.tags'), icon: Tags }, { href: '/export', label: () => t('nav.export'), icon: PackageOpen }, diff --git a/pwa/src/components/TagAutocomplete.tsx b/pwa/src/components/TagAutocomplete.tsx new file mode 100644 index 00000000..6275b307 --- /dev/null +++ b/pwa/src/components/TagAutocomplete.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { api } from '@/lib/api' +import { t } from '@/lib/i18n' +import type { TagItem } from '@/lib/types' + +interface TagAutocompleteProps { + /** Called when a tag is selected */ + onSelect: (tag: string) => void + /** Placeholder text */ + placeholder?: string + /** Input className override */ + className?: string + /** Clear input after selection */ + clearOnSelect?: boolean + /** Allow multiple selections (comma-joined) */ + multiple?: boolean + /** Initial value */ + value?: string + /** Controlled onChange for multiple mode */ + onChange?: (value: string) => void +} + +export function TagAutocomplete({ + onSelect, + placeholder, + className, + clearOnSelect = true, + multiple = false, + value, + onChange, +}: TagAutocompleteProps) { + const [query, setQuery] = useState(value ?? '') + const [suggestions, setSuggestions] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [highlightIdx, setHighlightIdx] = useState(-1) + const debounceRef = useRef | null>(null) + const containerRef = useRef(null) + + // Sync controlled value + useEffect(() => { + if (value !== undefined) setQuery(value) + }, [value]) + + // Debounced autocomplete fetch + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + const trimmed = query.trim() + if (!trimmed) { + setSuggestions([]) + setOpen(false) + return + } + debounceRef.current = setTimeout(async () => { + setLoading(true) + try { + const results = await api.tags.autocomplete(trimmed, 10) + setSuggestions(results) + setOpen(results.length > 0) + setHighlightIdx(-1) + } catch { + setSuggestions([]) + setOpen(false) + } finally { + setLoading(false) + } + }, 300) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [query]) + + // Close on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const handleSelect = useCallback( + (tag: TagItem) => { + const tagStr = `${tag.namespace}:${tag.name}` + onSelect(tagStr) + if (clearOnSelect) { + setQuery('') + onChange?.('') + } else { + setQuery(tagStr) + onChange?.(tagStr) + } + setSuggestions([]) + setOpen(false) + setHighlightIdx(-1) + }, + [onSelect, clearOnSelect, onChange], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!open) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setHighlightIdx((i) => Math.min(i + 1, suggestions.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHighlightIdx((i) => Math.max(i - 1, -1)) + } else if (e.key === 'Enter' && highlightIdx >= 0) { + e.preventDefault() + handleSelect(suggestions[highlightIdx]) + } else if (e.key === 'Escape') { + setOpen(false) + setHighlightIdx(-1) + } + }, + [open, suggestions, highlightIdx, handleSelect], + ) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setQuery(e.target.value) + onChange?.(e.target.value) + }, + [onChange], + ) + + return ( +
+ suggestions.length > 0 && setOpen(true)} + placeholder={placeholder ?? t('tag.autocomplete.placeholder')} + className={ + className ?? + 'w-full bg-vault-input border border-vault-border rounded px-3 py-1.5 text-sm text-vault-text placeholder-vault-text-muted focus:outline-none focus:border-vault-accent transition-colors' + } + autoComplete="off" + spellCheck={false} + /> + + {/* Loading indicator */} + {loading && ( +
+
+
+ )} + + {/* Dropdown */} + {open && suggestions.length > 0 && ( +
    + {suggestions.map((tag, idx) => ( +
  • + +
  • + ))} +
+ )} + + {/* No results hint (only show if user typed something and got nothing) */} + {open && suggestions.length === 0 && !loading && query.trim() && ( +
+ {t('tag.autocomplete.noResults')} +
+ )} +
+ ) +} diff --git a/pwa/src/hooks/useGalleries.ts b/pwa/src/hooks/useGalleries.ts index e58d5862..76db4733 100644 --- a/pwa/src/hooks/useGalleries.ts +++ b/pwa/src/hooks/useGalleries.ts @@ -73,3 +73,13 @@ export function useEhGalleryPreviews(gid: number | null, token: string | null) { { revalidateOnFocus: false }, ) } + +export function useEhPopular() { + return useSWR('eh/popular', () => api.eh.getPopular(), { revalidateOnFocus: false }) +} + +export function useEhToplist(tl: number, page = 0) { + return useSWR(['eh/toplist', tl, page], () => api.eh.getToplist({ tl, page }), { + revalidateOnFocus: false, + }) +} diff --git a/pwa/src/hooks/useTagTranslations.ts b/pwa/src/hooks/useTagTranslations.ts new file mode 100644 index 00000000..0d1111af --- /dev/null +++ b/pwa/src/hooks/useTagTranslations.ts @@ -0,0 +1,21 @@ +import useSWR from 'swr' +import { api } from '@/lib/api' + +/** + * Fetches Chinese translations for a list of tags. + * Tags should be in "namespace:name" format. + * Returns a Record mapping tag → translation. + * Cached indefinitely (no revalidation). + */ +export function useTagTranslations(tags: string[]) { + const key = tags.length > 0 ? ['tags/translations', tags.slice().sort().join(',')] : null + return useSWR( + key, + () => api.tags.getTranslations(tags), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 86400_000, // 24h + }, + ) +} diff --git a/pwa/src/lib/api.ts b/pwa/src/lib/api.ts index 3ec24ce5..8ef14421 100644 --- a/pwa/src/lib/api.ts +++ b/pwa/src/lib/api.ts @@ -20,10 +20,18 @@ import type { SystemInfo, TagAlias, TagImplication, + TagItem, + EhComment, + BrowseHistoryItem, + SavedSearch, + BlockedTag, + CacheStats, } from './types' // ── Base fetch ─────────────────────────────────────────────────────── +let isRedirecting = false + async function apiFetch(path: string, options: RequestInit = {}): Promise { const res = await fetch(path, { credentials: 'include', // always send vault_token cookie @@ -35,10 +43,11 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise // Stale session → redirect to login (skip if already on /login or /setup) if (res.status === 401 && typeof window !== 'undefined') { const p = window.location.pathname - if (p !== '/login' && p !== '/setup') { + if (p !== '/login' && p !== '/setup' && !isRedirecting) { + isRedirecting = true window.location.href = '/login' - return new Promise(() => {}) as T // never resolves; page is navigating } + throw new Error('Unauthorized') } const body = await res.json().catch(() => ({})) const msg = body?.detail || `HTTP ${res.status}` @@ -118,10 +127,11 @@ const auth = { if (!res.ok) { if (res.status === 401 && typeof window !== 'undefined') { const p = window.location.pathname - if (p !== '/login' && p !== '/setup') { + if (p !== '/login' && p !== '/setup' && !isRedirecting) { + isRedirecting = true window.location.href = '/login' - return new Promise(() => {}) as never } + throw new Error('Unauthorized') } const body = await res.json().catch(() => ({})) throw new Error(body?.detail || `HTTP ${res.status}`) @@ -177,6 +187,14 @@ const eh = { apiFetch<{ status: string }>(`/api/eh/favorites/${gid}/${token}`, { method: 'DELETE', }), + + getPopular: () => apiFetch('/api/eh/popular'), + + getToplist: (params: { tl?: number; page?: number } = {}) => + apiFetch(`/api/eh/toplists${qs(params as Record)}`), + + getComments: (gid: number, token: string) => + apiFetch<{ comments: EhComment[] }>(`/api/eh/gallery/${gid}/${token}/comments`), } // ── Library ─────────────────────────────────────────────────────────── @@ -316,11 +334,56 @@ const settings = { }), } +// ── History ─────────────────────────────────────────────────────────── + +const history = { + list: (params: { limit?: number; offset?: number } = {}) => + apiFetch<{ items: BrowseHistoryItem[]; total: number }>( + `/api/history/${qs(params as Record)}`, + ), + + record: (data: { + source: string + source_id: string + title: string + thumb?: string + gid?: number + token?: string + }) => apiFetch<{ status: string }>('/api/history/', { method: 'POST', body: JSON.stringify(data) }), + + clear: () => apiFetch<{ status: string }>('/api/history/', { method: 'DELETE' }), + + delete: (id: number) => + apiFetch<{ status: string }>(`/api/history/${id}`, { method: 'DELETE' }), +} + +// ── Saved Searches ──────────────────────────────────────────────────── + +const savedSearches = { + list: () => apiFetch<{ searches: SavedSearch[] }>('/api/search/saved'), + + create: (data: { name: string; query: string; params: Record }) => + apiFetch('/api/search/saved', { method: 'POST', body: JSON.stringify(data) }), + + delete: (id: number) => + apiFetch<{ status: string }>(`/api/search/saved/${id}`, { method: 'DELETE' }), + + rename: (id: number, name: string) => + apiFetch<{ status: string }>(`/api/search/saved/${id}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }), +} + // ── System ──────────────────────────────────────────────────────────── const system = { health: () => apiFetch('/api/system/health'), info: () => apiFetch('/api/system/info'), + getCache: () => apiFetch('/api/system/cache'), + clearCache: () => apiFetch<{ deleted_keys: number }>('/api/system/cache', { method: 'DELETE' }), + clearCacheCategory: (category: string) => + apiFetch<{ deleted_keys: number }>(`/api/system/cache/${category}`, { method: 'DELETE' }), } // ── Tags ───────────────────────────────────────────────────────────── @@ -363,6 +426,23 @@ const tags = { apiFetch<{ status: string }>(`/api/tags/implications${qs({ antecedent_id, consequent_id })}`, { method: 'DELETE', }), + + autocomplete: (q: string, limit = 10) => + apiFetch(`/api/tags/autocomplete${qs({ q, limit })}`), + + getTranslations: (tags: string[]) => + apiFetch>(`/api/tags/translations${qs({ tags: tags.join(',') })}`), + + listBlocked: () => apiFetch('/api/tags/blocked'), + + addBlocked: (namespace: string, name: string) => + apiFetch<{ status: string }>('/api/tags/blocked', { + method: 'POST', + body: JSON.stringify({ namespace, name }), + }), + + removeBlocked: (id: number) => + apiFetch<{ status: string }>(`/api/tags/blocked/${id}`, { method: 'DELETE' }), } // ── API Tokens ─────────────────────────────────────────────────────── @@ -405,4 +485,6 @@ export const api = { tags, tokens, export: exportApi, + history, + savedSearches, } diff --git a/pwa/src/lib/i18n.ts b/pwa/src/lib/i18n.ts index 60b03874..85c18d09 100644 --- a/pwa/src/lib/i18n.ts +++ b/pwa/src/lib/i18n.ts @@ -13,6 +13,7 @@ const dict: Record> = { 'nav.dashboard': 'Dashboard', 'nav.browse': 'Browse', 'nav.library': 'Library', + 'nav.history': 'History', 'nav.queue': 'Queue', 'nav.tags': 'Tags', 'nav.settings': 'Settings', @@ -289,6 +290,109 @@ const dict: Record> = { // ── Tags ── (extra) 'tags.selectTag': 'Select a tag to view details', + // ── Reader ── + 'reader.scaleFitBoth': 'Fit', + 'reader.scaleFitWidth': 'Width', + 'reader.scaleFitHeight': 'Height', + 'reader.scaleOriginal': 'Original', + 'reader.dirLtr': 'LTR', + 'reader.dirRtl': 'RTL', + 'reader.dirVertical': 'Vertical', + 'reader.autoAdvance': 'Auto Advance', + 'reader.autoAdvanceDesc': 'Automatically advance to the next page', + 'reader.autoAdvanceInterval': 'Interval (seconds)', + 'reader.statusBar': 'Status Bar', + 'reader.statusBarDesc': 'Show a thin info bar at the bottom of the reader', + 'reader.statusBarClock': 'Clock', + 'reader.statusBarProgress': 'Progress Bar', + 'reader.statusBarPageCount': 'Page Count', + 'reader.defaultViewMode': 'Default View Mode', + 'reader.defaultDirection': 'Default Reading Direction', + 'reader.defaultScaleMode': 'Default Scale Mode', + 'reader.helpTapLeft': 'Previous Page', + 'reader.helpTapCenter': 'Toggle Controls', + 'reader.helpTapRight': 'Next Page', + 'reader.helpSwipe': 'Swipe left / right to turn pages', + 'reader.helpKeyboard': 'Keyboard: \u2190/\u2192 or A/D', + 'reader.helpDismiss': 'Tap, swipe, or press any key to dismiss', + 'reader.helpButton': 'Help', + 'reader.viewModeSingle': 'Single', + 'reader.viewModeWebtoon': 'Webtoon', + 'reader.viewModeDouble': 'Double', + 'reader.scaleMode': 'Scale', + 'reader.direction': 'Direction', + 'settings.reader': '\u95b1\u8b80\u5668 / Reader', + + // ── Browse (extra) ── + 'browse.popularTab': 'Popular', + 'browse.toplistTab': 'Top Lists', + 'browse.allTime': 'All-Time', + 'browse.pastYear': 'Past Year', + 'browse.pastMonth': 'Past Month', + 'browse.yesterday': 'Yesterday', + 'browse.language': 'Language', + 'browse.allLanguages': 'All', + 'browse.saveSearch': 'Save', + 'browse.savedSearches': 'Saved Searches', + 'browse.saveSearchName': 'Search name', + 'browse.saveSearchSaved': 'Search saved', + 'browse.saveSearchFailed': 'Failed to save search', + 'browse.saveSearchDeleted': 'Saved search deleted', + 'browse.saveSearchDeleteFailed': 'Failed to delete saved search', + 'browse.noSavedSearches': 'No saved searches yet', + 'browse.comments': 'Comments', + 'browse.showComments': 'Show Comments', + 'browse.hideComments': 'Hide Comments', + 'browse.noComments': 'No comments yet.', + 'browse.commentScore': 'Score', + + // ── History ── + 'history.title': 'Browse History', + 'history.subtitle': '瀏覽歷史', + 'history.clearAll': 'Clear All', + 'history.clearConfirm': 'Are you sure you want to clear all history?', + 'history.cleared': 'History cleared', + 'history.clearFailed': 'Failed to clear history', + 'history.deleted': 'Item deleted', + 'history.deleteFailed': 'Failed to delete item', + 'history.noHistory': 'No browse history yet.', + 'history.noHistoryHint': 'Open a gallery to record your history.', + 'history.loadMore': 'Load More', + 'history.source.ehentai': 'E-Hentai', + 'history.source.local': 'Library', + + // ── Tag Autocomplete ── + 'tag.autocomplete.placeholder': 'Type to search tags...', + 'tag.autocomplete.noResults': 'No tags found', + + // ── Tag Translations ── + 'tags.translations': 'Tag Translations', + + // ── Tag Blocking ── + 'settings.tagBlocking': 'Tag Blocking', + 'settings.tagBlockingDesc': 'Hide galleries containing these tags', + 'settings.blockedTags': 'Blocked Tags', + 'settings.noBlockedTags': 'No blocked tags.', + 'settings.addBlockedTag': 'Block Tag', + 'settings.tagBlockAdded': 'Tag blocked', + 'settings.tagBlockAddFailed': 'Failed to block tag', + 'settings.tagBlockRemoved': 'Tag unblocked', + 'settings.tagBlockRemoveFailed': 'Failed to unblock tag', + + // ── Cache Management ── + 'settings.cache': 'Cache', + 'settings.cacheDesc': 'Redis cache usage and management', + 'settings.cacheMemory': 'Memory Usage', + 'settings.cacheKeys': 'Cached Keys', + 'settings.cacheBreakdown': 'Breakdown', + 'settings.clearCache': 'Clear All Cache', + 'settings.clearCacheConfirm': 'Clear all cached data? This cannot be undone.', + 'settings.clearCacheSuccess': '{count} keys deleted', + 'settings.clearCacheFailed': 'Failed to clear cache', + 'settings.clearCategory': 'Clear', + 'settings.cacheRefresh': 'Refresh', + 'settings.cacheLoading': 'Loading cache stats...', + // ── Common ── 'common.prev': 'Prev', 'common.next': 'Next', diff --git a/pwa/src/lib/types.ts b/pwa/src/lib/types.ts index 34c25b85..e4712cdb 100644 --- a/pwa/src/lib/types.ts +++ b/pwa/src/lib/types.ts @@ -176,6 +176,54 @@ export interface SystemInfo { tag_model_enabled: boolean } +// ── EH Comments ────────────────────────────────────────────────────── + +export interface EhComment { + poster: string + posted_at: string + text: string + score: number | null +} + +// ── Browse History ──────────────────────────────────────────────────── + +export interface BrowseHistoryItem { + id: number + source: string + source_id: string + title: string + thumb: string | null + gid: number | null + token: string | null + viewed_at: string +} + +// ── Saved Searches ──────────────────────────────────────────────────── + +export interface SavedSearch { + id: number + name: string + query: string + params: Record + created_at: string +} + +// ── Tag Blocking ────────────────────────────────────────────────────── + +export interface BlockedTag { + id: number + namespace: string + name: string +} + +// ── Cache Stats ─────────────────────────────────────────────────────── + +export interface CacheStats { + total_memory: string + total_keys: number + breakdown: Record +} + // ── WebSocket ──────────────────────────────────────────────────────── export interface WsMessage { diff --git a/scripts/restore.sh b/scripts/restore.sh index f2a94abc..b8888096 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -16,18 +16,53 @@ BACKUP_FILE="$1" DB_USER="vault" DB_NAME="vault" +# --- 驗證 backup 檔案 --- if [ ! -f "$BACKUP_FILE" ]; then echo "Error: File not found: $BACKUP_FILE" exit 1 fi +BACKUP_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || stat -f%z "$BACKUP_FILE") +if [ "$BACKUP_SIZE" -eq 0 ]; then + echo "Error: Backup file is empty: $BACKUP_FILE" + exit 1 +fi + +echo "" +echo "============================================" +echo " Jyzrox Database Restore" +echo "============================================" +echo " Restore from : $(basename "$BACKUP_FILE")" +echo " File size : $(du -sh "$BACKUP_FILE" | cut -f1)" +echo " Target DB : $DB_NAME" +echo "============================================" +echo "" echo "WARNING: This will DROP and recreate the '$DB_NAME' database!" -read -p "Are you sure? (yes/no): " CONFIRM -if [ "$CONFIRM" != "yes" ]; then +echo "All existing data will be permanently lost." +echo "" +read -rp "Type YES (all caps) to confirm: " CONFIRM +if [ "$CONFIRM" != "YES" ]; then echo "Aborted." exit 1 fi +# --- 自動備份當前資料庫(安全網)--- +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +SAFETY_BACKUP="backups/pre_restore_${TIMESTAMP}.sql.gz" +mkdir -p backups +echo "[restore] Creating safety backup of current database → $SAFETY_BACKUP ..." +docker compose exec -T postgres \ + pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$SAFETY_BACKUP" + +# 驗證 safety backup 非空 +SAFETY_SIZE=$(stat -c%s "$SAFETY_BACKUP" 2>/dev/null || stat -f%z "$SAFETY_BACKUP") +if [ "$SAFETY_SIZE" -eq 0 ]; then + echo "Error: Safety backup failed (empty file). Aborting restore." + rm -f "$SAFETY_BACKUP" + exit 1 +fi +echo "[restore] Safety backup OK ($(du -sh "$SAFETY_BACKUP" | cut -f1))" + echo "[restore] Stopping all services (except postgres)..." docker compose stop api worker pwa nginx @@ -38,10 +73,14 @@ docker compose exec -T postgres \ echo "[restore] Loading backup data..." gunzip -c "$BACKUP_FILE" | docker compose exec -T postgres \ - psql -U "$DB_USER" -d "$DB_NAME" + psql -U "$DB_USER" -d "$DB_NAME" --single-transaction echo "[restore] Restarting all services..." docker compose up -d api worker pwa nginx docker compose exec nginx nginx -s reload +echo "" echo "[restore] Done!" +echo "[restore] Safety backup retained at: $SAFETY_BACKUP" +echo "[restore] If anything looks wrong, restore from the safety backup:" +echo " $0 $SAFETY_BACKUP"