]+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:

+ 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 (
+
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 ? (
-

+
) : (
voi
>
{/* Thumbnail */}
{thumbSrc ? (
-

+
) : (
('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