mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
chore: init svelte rewrite scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<div>Explore.svelte</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div>Extensions.svelte</div>
|
||||
@@ -1,152 +0,0 @@
|
||||
.root {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
overflow: hidden; animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.searchClear {
|
||||
position: absolute; right: 7px;
|
||||
color: var(--text-faint); font-size: 14px; line-height: 1;
|
||||
background: none; border: none; cursor: pointer; padding: 2px;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.searchClear:hover { color: var(--text-muted); }
|
||||
|
||||
.clearBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.clearBtn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
|
||||
.statsBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--accent-fg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statDivider {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
||||
|
||||
.group { margin-bottom: var(--sp-5); }
|
||||
.groupLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row:hover .playIcon { opacity: 1; }
|
||||
|
||||
/* Thumb with session count badge */
|
||||
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||
.thumb {
|
||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
||||
object-fit: cover; display: block; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
.sessionBadge {
|
||||
position: absolute; bottom: -4px; right: -6px;
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 4px; border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.mangaTitle {
|
||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.chapterName {
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
||||
}
|
||||
.chapterRange {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
color: var(--text-muted); font-size: var(--text-sm);
|
||||
}
|
||||
.rangeSep {
|
||||
color: var(--text-faint); font-size: 10px; flex-shrink: 0;
|
||||
}
|
||||
.pageBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||
}
|
||||
.time {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.playIcon {
|
||||
color: var(--text-faint); flex-shrink: 0;
|
||||
opacity: 0; transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: var(--sp-2);
|
||||
}
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react";
|
||||
import { thumbUrl } from "../../lib/client";
|
||||
import { useStore, type HistoryEntry } from "../../store";
|
||||
import s from "./History.module.css";
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
// Estimate reading time: ~8 seconds per page, counted from chapter entries
|
||||
// Each unique chapter read ≈ pageCount pages (fallback 30 if unknown)
|
||||
function formatReadTime(minutes: number): string {
|
||||
if (minutes < 1) return "< 1 min";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export interface ReadingSession {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
thumbnailUrl: string;
|
||||
latestChapterId: number;
|
||||
latestChapterName: string;
|
||||
latestPageNumber: number;
|
||||
firstChapterName: string;
|
||||
chapterCount: number;
|
||||
readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): ReadingSession[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: ReadingSession[] = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const anchor = entries[i];
|
||||
const group: HistoryEntry[] = [anchor];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||
group.push(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const latest = group[0];
|
||||
const oldest = group[group.length - 1];
|
||||
sessions.push({
|
||||
mangaId: latest.mangaId,
|
||||
mangaTitle: latest.mangaTitle,
|
||||
thumbnailUrl: latest.thumbnailUrl,
|
||||
latestChapterId: latest.chapterId,
|
||||
latestChapterName: latest.chapterName,
|
||||
latestPageNumber: latest.pageNumber,
|
||||
firstChapterName: oldest.chapterName,
|
||||
chapterCount: group.length,
|
||||
readAt: latest.readAt,
|
||||
});
|
||||
i = j;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] {
|
||||
const groups = new Map<string, ReadingSession[]>();
|
||||
for (const sess of sessions) {
|
||||
const label = dayLabel(sess.readAt);
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
groups.get(label)!.push(sess);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function History() {
|
||||
const history = useStore((s) => s.history);
|
||||
const clearHistory = useStore((s) => s.clearHistory);
|
||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||
const openReader = useStore((s) => s.openReader);
|
||||
const activeChapterList = useStore((s) => s.activeChapterList);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return history;
|
||||
return history.filter(
|
||||
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||
);
|
||||
}, [history, search]);
|
||||
|
||||
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────────────
|
||||
const stats = useMemo(() => {
|
||||
if (!history.length) return null;
|
||||
// Unique chapters read
|
||||
const uniqueChapters = new Set(history.map((e) => e.chapterId)).size;
|
||||
// Unique manga read
|
||||
const uniqueManga = new Set(history.map((e) => e.mangaId)).size;
|
||||
// Estimated read time: average ~45 pages/chapter at ~6s/page = ~4.5 min/chapter
|
||||
const estimatedMinutes = Math.round(uniqueChapters * 4.5);
|
||||
return { uniqueChapters, uniqueManga, estimatedMinutes };
|
||||
}, [history]);
|
||||
|
||||
function resumeReading(session: ReadingSession) {
|
||||
// If the chapter list is available in store (user already visited this manga),
|
||||
// open the reader directly for a snappier experience
|
||||
const chapterInList = activeChapterList.find((c) => c.id === session.latestChapterId);
|
||||
if (chapterInList && activeChapterList.length > 0) {
|
||||
openReader(chapterInList, activeChapterList);
|
||||
} else {
|
||||
// Fall back to opening SeriesDetail — it will show the continue button
|
||||
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>History</h1>
|
||||
<div className={s.headerRight}>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||
)}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
<Trash size={14} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className={s.statsBar}>
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueChapters}</span>
|
||||
<span className={s.statLabel}>chapters read</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{stats.uniqueManga}</span>
|
||||
<span className={s.statLabel}>series</span>
|
||||
</span>
|
||||
<span className={s.statDivider} />
|
||||
<span className={s.statItem}>
|
||||
<span className={s.statVal}>{formatReadTime(stats.estimatedMinutes)}</span>
|
||||
<span className={s.statLabel}>est. read time</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.list}>
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((session) => (
|
||||
<button
|
||||
key={`${session.latestChapterId}-${session.readAt}`}
|
||||
className={s.row}
|
||||
onClick={() => resumeReading(session)}
|
||||
>
|
||||
<div className={s.thumbWrap}>
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||
{session.chapterCount > 1 && (
|
||||
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={s.info}>
|
||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||
<span className={s.chapterName}>
|
||||
{session.chapterCount > 1 ? (
|
||||
<span className={s.chapterRange}>
|
||||
{session.firstChapterName}
|
||||
<span className={s.rangeSep}>→</span>
|
||||
{session.latestChapterName}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{session.latestChapterName}
|
||||
{session.latestPageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||
<Play size={12} weight="fill" className={s.playIcon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
.root {
|
||||
padding: var(--sp-6);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
/* GPU acceleration for smooth scrolling */
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Filter tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
.tabCount {
|
||||
font-size: var(--text-2xs);
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px 5px 28px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
width: 180px;
|
||||
outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
|
||||
/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */
|
||||
.virtualRow {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
padding: 0 var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Individual card fills its flex slot */
|
||||
.card {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ghostCard {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
/* GPU-accelerated compositing */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
/* Hint to compositor */
|
||||
will-change: filter;
|
||||
}
|
||||
|
||||
.downloadedBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-muted);
|
||||
}
|
||||
|
||||
.unreadBadge {
|
||||
position: absolute;
|
||||
top: var(--sp-1);
|
||||
left: var(--sp-1);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* Skeleton grid still uses CSS grid since it's fixed 12 items */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
|
||||
/* Skeleton */
|
||||
.cardSkeleton { padding: 0; }
|
||||
|
||||
.coverSkeletonWrap {
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.titleSkeleton {
|
||||
height: 12px;
|
||||
margin-top: var(--sp-2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* Ghost cards fill trailing grid space without taking interaction */
|
||||
.ghostCard {
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
line-height: var(--leading-base);
|
||||
}
|
||||
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.errorDetail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
/* ── Tag filter ── */
|
||||
.tagPanel {
|
||||
display: flex; flex-wrap: wrap; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-6) var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tagChip {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
background: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tagChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.tagChipActive {
|
||||
background: var(--accent-muted); border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.tagChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.tagClear {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 3px 8px;
|
||||
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
|
||||
background: none; color: var(--color-error); cursor: pointer;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.tagClear:hover { background: var(--color-error-bg); }
|
||||
@@ -0,0 +1 @@
|
||||
<div>Library.svelte</div>
|
||||
@@ -1,444 +0,0 @@
|
||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
||||
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import s from "./Library.module.css";
|
||||
|
||||
const CARD_MIN_W = 130;
|
||||
const CARD_GAP = 16;
|
||||
const ROW_HEIGHT = 260;
|
||||
|
||||
function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MangaCard = memo(function MangaCard({
|
||||
manga, onClick, onContextMenu, cropCovers,
|
||||
}: {
|
||||
manga: Manga;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
cropCovers: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<FadeImg
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
objectFit={cropCovers ? "cover" : "contain"}
|
||||
/>
|
||||
{!!manga.downloadCount && (
|
||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||
)}
|
||||
{!!manga.unreadCount && (
|
||||
<span className={s.unreadBadge}>{manga.unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function fetchLibrary() {
|
||||
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||
);
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||
const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const folders = useStore((state) => state.settings.folders);
|
||||
const addFolder = useStore((state) => state.addFolder);
|
||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||
const activeChapter = useStore((state) => state.activeChapter);
|
||||
|
||||
|
||||
const prevChapterRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const wasOpen = prevChapterRef.current !== null;
|
||||
prevChapterRef.current = activeChapter?.id ?? null;
|
||||
if (!wasOpen || activeChapter) return;
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}, [activeChapter]);
|
||||
|
||||
const loadData = useCallback((showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
// Clear a previously failed cache entry so we actually retry the network call
|
||||
if (!cache.has(CACHE_KEYS.LIBRARY)) {
|
||||
// cache miss — fresh fetch, nothing to clear
|
||||
}
|
||||
fetchLibrary()
|
||||
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Initial load — delayed on first mount so the server has time to start.
|
||||
// retryCount bumps force a re-run; manual retries clear the cache first.
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
||||
loadData(false);
|
||||
|
||||
// Re-fetch when library cache is invalidated by other pages
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [retryCount]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [libraryFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeFolder = folders.find((f) => f.id === libraryFilter);
|
||||
if (activeFolder && !activeFolder.showTab) setLibraryFilter("library");
|
||||
}, [folders]);
|
||||
|
||||
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = allManga;
|
||||
if (libraryFilter === "library") {
|
||||
items = items.filter((m) => m.inLibrary);
|
||||
} else if (libraryFilter === "downloaded") {
|
||||
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
|
||||
} else if (!isBuiltinFilter) {
|
||||
const folder = folders.find((f) => f.id === libraryFilter);
|
||||
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
|
||||
}
|
||||
if (libraryTagFilter.length > 0)
|
||||
items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)));
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
||||
}
|
||||
return items;
|
||||
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
|
||||
|
||||
// ── Virtualizer setup ──────────────────────────────────────────────────────
|
||||
const [containerWidth, setContainerWidth] = useState(800);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const result: Manga[][] = [];
|
||||
for (let i = 0; i < filtered.length; i += cols)
|
||||
result.push(filtered.slice(i, i + cols));
|
||||
return result;
|
||||
}, [filtered, cols]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(m: Manga) => () => setActiveManga(m),
|
||||
[setActiveManga]
|
||||
);
|
||||
|
||||
async function removeFromLibrary(manga: Manga) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||
// Optimistic update first, then invalidate cache
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
try {
|
||||
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
|
||||
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||
const ids = downloadedChapters.map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
const x = Math.min(e.clientX, window.innerWidth - 208);
|
||||
const y = Math.min(e.clientY, window.innerHeight - 168);
|
||||
setCtx({ x, y, manga: m });
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||
const mangaFolderEntries: ContextMenuEntry[] = folders.map((f) => {
|
||||
const inFolder = f.mangaIds.includes(m.id);
|
||||
return {
|
||||
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Open",
|
||||
icon: <BookOpen size={13} weight="light" />,
|
||||
onClick: () => setActiveManga(m),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
danger: m.inLibrary,
|
||||
onClick: () => m.inLibrary
|
||||
? removeFromLibrary(m)
|
||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
{
|
||||
label: "Delete all downloads",
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
danger: true,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||
onClick: () => deleteAllDownloads(m),
|
||||
},
|
||||
...(folders.length > 0 ? [
|
||||
{ separator: true } as ContextMenuEntry,
|
||||
...mangaFolderEntries,
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) {
|
||||
const id = addFolder(name.trim());
|
||||
assignMangaToFolder(id, m.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyCtxItems(): ContextMenuEntry[] {
|
||||
return [
|
||||
{
|
||||
label: "New folder",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (name?.trim()) addFolder(name.trim());
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
|
||||
return Array.from(tagSet).sort();
|
||||
}, [allManga]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result: Record<string, number> = {
|
||||
all: allManga.length,
|
||||
library: allManga.filter((m) => m.inLibrary).length,
|
||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
||||
};
|
||||
folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; });
|
||||
return result;
|
||||
}, [allManga, folders]);
|
||||
|
||||
if (error) return (
|
||||
<div className={s.center}>
|
||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.root}
|
||||
ref={scrollRef}
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Library</h1>
|
||||
<div className={s.tabs}>
|
||||
{(["library", "downloaded", "all"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setLibraryFilter(f)}
|
||||
>
|
||||
{f === "library" ? (
|
||||
<><Books size={11} weight="bold" /> Saved</>
|
||||
) : f === "downloaded" ? (
|
||||
<><DownloadSimple size={11} weight="bold" /> Downloaded</>
|
||||
) : <>All</>}
|
||||
<span className={s.tabCount}>{counts[f]}</span>
|
||||
</button>
|
||||
))}
|
||||
{folders.filter((f) => f.showTab).map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setLibraryFilter(folder.id)}
|
||||
>
|
||||
<Folder size={11} weight="bold" />
|
||||
{folder.name}
|
||||
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.searchWrap}>
|
||||
<MagnifyingGlass size={13} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.search}
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className={s.tagPanel}>
|
||||
{libraryTagFilter.length > 0 && (
|
||||
<button className={s.tagClear} onClick={() => setLibraryTagFilter([])}>
|
||||
<X size={11} weight="bold" /> Clear
|
||||
</button>
|
||||
)}
|
||||
{allTags.map((tag) => {
|
||||
const active = libraryTagFilter.includes(tag);
|
||||
return (
|
||||
<button key={tag}
|
||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||
onClick={() => setGenreFilter(tag)}>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={[s.coverSkeletonWrap, "skeleton"].join(" ")} />
|
||||
<div className={[s.titleSkeleton, "skeleton"].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className={s.center}>
|
||||
{libraryFilter === "library"
|
||||
? "No manga saved to library, browse sources to add some."
|
||||
: libraryFilter === "downloaded"
|
||||
? "No downloaded manga."
|
||||
: !isBuiltinFilter
|
||||
? "No manga in this folder yet. Right-click manga to assign them."
|
||||
: "No manga found."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowManga = rows[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
className={s.virtualRow}
|
||||
>
|
||||
{rowManga.map((m) => (
|
||||
<MangaCard
|
||||
key={m.id}
|
||||
manga={m}
|
||||
onClick={handleCardClick(m)}
|
||||
onContextMenu={(e) => openCtx(e, m)}
|
||||
cropCovers={settings.libraryCropCovers}
|
||||
/>
|
||||
))}
|
||||
{virtualRow.index === rows.length - 1 &&
|
||||
Array.from({ length: cols - rowManga.length }).map((_, i) => (
|
||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
{emptyCtx && (
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,628 +0,0 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
width: 520px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modalTitleLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.modalTitleManga {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* ── Steps ── */
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
opacity: 0.4;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.stepActive { opacity: 1; }
|
||||
.stepDone { opacity: 0.6; }
|
||||
|
||||
.stepDot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepActive .stepDot {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stepActive .stepLabel { color: var(--text-secondary); }
|
||||
|
||||
.steps .step + .step::before {
|
||||
content: "›";
|
||||
color: var(--text-faint);
|
||||
margin-right: var(--sp-1);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centered {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-8);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
/* ── Source list ── */
|
||||
.sourceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 9px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.sourceIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
|
||||
.sourceName {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sourceMeta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.sourceArrow {
|
||||
color: var(--text-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.sourceRow:hover .sourceArrow { opacity: 1; }
|
||||
|
||||
/* ── Search step ── */
|
||||
.searchStep {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 7px 0;
|
||||
}
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.searchBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.backBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.backBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.resultRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px var(--sp-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.resultRow:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.resultRow:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.resultCoverWrap {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resultCover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.resultTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Skeletons */
|
||||
.skResult {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 7px var(--sp-2);
|
||||
}
|
||||
|
||||
.skCover {
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||
|
||||
/* ── Confirm step ── */
|
||||
.confirmStep {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.confirmRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.confirmManga {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.confirmCoverWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.confirmCover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.confirmTitle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.confirmSource {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmArrow { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.confirmStats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
}
|
||||
|
||||
.statRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.statVal {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.confirmNote {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
line-height: var(--leading-base);
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.migrateBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.migrateBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||
}
|
||||
/* ── Source context pill (step 2 header) ── */
|
||||
.searchContext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextName {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchContextChange {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.searchContextChange:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Result row: updated layout with similarity ── */
|
||||
.resultInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.bestMatchBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simBar {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.simLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Confirm step additions ── */
|
||||
.confirmDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmTag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.confirmTagNew {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.statGood { color: var(--color-success) !important; }
|
||||
.statWarn { color: #d97706 !important; }
|
||||
.statBad { color: var(--color-error) !important; }
|
||||
|
||||
.chapterDiff {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: #d97706;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
.warnBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: #d97706;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||
import s from "./MigrateModal.module.css";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
currentChapters: Chapter[];
|
||||
onClose: () => void;
|
||||
onMigrated: (newManga: Manga) => void;
|
||||
}
|
||||
|
||||
type Step = "source" | "search" | "confirm";
|
||||
|
||||
interface Match {
|
||||
manga: Manga;
|
||||
chapters: Chapter[];
|
||||
readCount: number;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
// Simple title similarity: normalise → word overlap / Jaccard
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wordsA = new Set(norm(a));
|
||||
const wordsB = new Set(norm(b));
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||
const union = new Set([...wordsA, ...wordsB]).size;
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) {
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id)))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingSources(false));
|
||||
}, []);
|
||||
|
||||
const searchSource = useCallback(async (src: Source, q: string) => {
|
||||
if (!src || !q.trim()) return;
|
||||
setSearching(true);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||
});
|
||||
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||
manga: m,
|
||||
similarity: titleSimilarity(manga.title, m.title),
|
||||
}));
|
||||
// Sort by similarity desc so best matches float to top
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
setResults(scored);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [manga.title]);
|
||||
|
||||
function pickSource(src: Source) {
|
||||
setSelectedSource(src);
|
||||
setStep("search");
|
||||
// Auto-search immediately with original title
|
||||
searchSource(src, query);
|
||||
}
|
||||
|
||||
async function selectMatch(m: Manga, similarity: number) {
|
||||
setLoadingMatchId(m.id);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||
const chapters = d.fetchChapters.chapters;
|
||||
const readCount = chapters.filter((c) => {
|
||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
return old?.isRead;
|
||||
}).length;
|
||||
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||
setStep("confirm");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingMatchId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
if (!selectedMatch) return;
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||
|
||||
for (const nc of newChapters) {
|
||||
const key = Math.round(nc.chapterNumber * 100);
|
||||
const old = oldByNum.get(key);
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
onMigrated({ ...newManga, inLibrary: true });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||
const totalCount = currentChapters.length;
|
||||
|
||||
const chapterDiff = selectedMatch
|
||||
? selectedMatch.chapters.length - totalCount
|
||||
: 0;
|
||||
|
||||
const STEPS: Step[] = ["source", "search", "confirm"];
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
return (
|
||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className={s.modal}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.modalTitle}>
|
||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* ── Step indicators ── */}
|
||||
<div className={s.steps}>
|
||||
{STEPS.map((st, i) => (
|
||||
<div key={st}
|
||||
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>
|
||||
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||
</span>
|
||||
<span className={s.stepLabel}>
|
||||
{st === "source" ? "Pick source"
|
||||
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||
: "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
|
||||
{/* ── Step 1: Pick source ── */}
|
||||
{step === "source" && (
|
||||
<div className={s.sourceList}>
|
||||
{loadingSources ? (
|
||||
<div className={s.centered}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||
) : (
|
||||
sources.map((src) => (
|
||||
<button key={src.id}
|
||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||
onClick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.sourceInfo}>
|
||||
<span className={s.sourceName}>{src.displayName}</span>
|
||||
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||
</div>
|
||||
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Search & pick match ── */}
|
||||
{step === "search" && (
|
||||
<div className={s.searchStep}>
|
||||
|
||||
{/* Source context pill */}
|
||||
{selectedSource && (
|
||||
<div className={s.searchContext}>
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.searchRow}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||
<input className={s.searchInput} value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button className={s.searchBtn}
|
||||
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
disabled={searching || !selectedSource}>
|
||||
{searching
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||
|
||||
<div className={s.results}>
|
||||
{searching && Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className={s.skResult}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={s.skMeta}>
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||
<button key={m.id} className={s.resultRow}
|
||||
onClick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<div className={s.resultCoverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||
</div>
|
||||
<div className={s.resultInfo}>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
<div className={s.resultMeta}>
|
||||
{idx === 0 && similarity > 0.5 && (
|
||||
<span className={s.bestMatchBadge}>
|
||||
<Sparkle size={9} weight="fill" /> Best match
|
||||
</span>
|
||||
)}
|
||||
<span className={s.simBar}>
|
||||
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||
</span>
|
||||
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
{loadingMatchId === m.id
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||
</button>
|
||||
))}
|
||||
{!searching && results.length === 0 && !error && (
|
||||
<div className={s.centered}>
|
||||
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Confirm ── */}
|
||||
{step === "confirm" && selectedMatch && (
|
||||
<div className={s.confirmStep}>
|
||||
<div className={s.confirmRow}>
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{manga.title}</p>
|
||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span className={s.confirmTag}>Current</span>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmDivider}>
|
||||
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||
</div>
|
||||
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmStats}>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Title match</span>
|
||||
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||
{Math.round(selectedMatch.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Chapters on new source</span>
|
||||
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||
{selectedMatch.chapters.length}
|
||||
{chapterDiff !== 0 && (
|
||||
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Read progress to carry over</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chapterDiff < -5 && (
|
||||
<div className={s.warnBox}>
|
||||
<Warning size={13} weight="light" />
|
||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={s.confirmNote}>
|
||||
The current entry will be removed from your library. Downloads are not transferred.
|
||||
</p>
|
||||
|
||||
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||
|
||||
<div className={s.confirmActions}>
|
||||
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
||||
Back
|
||||
</button>
|
||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||
{migrating
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
.root {
|
||||
position: fixed; inset: 0;
|
||||
background: #000;
|
||||
display: flex; flex-direction: column;
|
||||
z-index: var(--z-reader);
|
||||
transform: translateZ(0); will-change: transform;
|
||||
}
|
||||
|
||||
/* ── UI autohide ── */
|
||||
.uiHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.topbar, .bottombar {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
padding: 0 var(--sp-3); height: 40px;
|
||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; overflow: visible;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.iconBtn:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
.chLabel {
|
||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-size: var(--text-sm); color: var(--text-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.chSep { color: var(--text-faint); }
|
||||
|
||||
.pageLabel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topSep {
|
||||
width: 1px; height: 16px;
|
||||
background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.modeBtn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
color: var(--text-muted); flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.modeBtnLabel { text-transform: capitalize; }
|
||||
|
||||
/* ── Zoom ── */
|
||||
.zoomWrap {
|
||||
position: relative; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||
min-width: 36px; text-align: center;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
|
||||
.zoomPopover {
|
||||
position: absolute; top: calc(100% + 6px); left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 100; min-width: 160px;
|
||||
animation: scaleIn 0.1s ease both; transform-origin: top center;
|
||||
}
|
||||
|
||||
.zoomSlider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 140px; height: 3px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 2px; outline: none; cursor: pointer;
|
||||
}
|
||||
.zoomSlider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoomSlider::-moz-range-thumb {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%; border: none;
|
||||
background: var(--accent-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoomResetBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||
padding: 2px var(--sp-2); border-radius: var(--radius-sm);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
|
||||
/* ── Viewer ── */
|
||||
.viewer {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewerStrip {
|
||||
justify-content: flex-start;
|
||||
padding: var(--sp-4) 0;
|
||||
overflow-anchor: auto; /* browser preserves scroll pos when nodes are added/removed above */
|
||||
}
|
||||
|
||||
/* ── Images ── */
|
||||
.img {
|
||||
display: block; user-select: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; }
|
||||
|
||||
/* Fit modes.
|
||||
height: auto on .img is the load-bearing rule: the img element is given
|
||||
height={1000} as a layout hint while the image is loading (prevents reflow).
|
||||
Once the image is fully painted the browser must resolve height from the
|
||||
intrinsic dimensions, not the HTML attribute — `height: auto` enforces that. */
|
||||
.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; }
|
||||
.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; height: auto; }
|
||||
.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||
.fitOriginal { max-width: none; width: auto; height: auto; }
|
||||
|
||||
/* Longstrip */
|
||||
.stripGap { margin-bottom: 8px; }
|
||||
|
||||
/* ── Double page ── */
|
||||
.doubleWrap {
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
max-width: calc(var(--max-page-width) * 2);
|
||||
width: 100%;
|
||||
}
|
||||
.pageHalf { flex: 1; min-width: 0; object-fit: contain; }
|
||||
.gapLeft { margin-right: 2px; }
|
||||
.gapRight { margin-left: 2px; }
|
||||
|
||||
/* ── Bottom nav ── */
|
||||
.bottombar {
|
||||
display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
|
||||
padding: var(--sp-3); border-top: 1px solid var(--border-dim);
|
||||
background: var(--bg-void); flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-strong); color: var(--text-muted);
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.navBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
/* ── States ── */
|
||||
.center {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
position: fixed; inset: 0; background: #000;
|
||||
}
|
||||
.errorMsg { color: var(--color-error); font-size: var(--text-base); }
|
||||
|
||||
/* ── Download modal ── */
|
||||
.dlBackdrop {
|
||||
position: fixed; inset: 0;
|
||||
z-index: calc(var(--z-reader) + 10);
|
||||
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||
padding: 48px var(--sp-4) 0;
|
||||
}
|
||||
|
||||
.dlModal {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); padding: var(--sp-3);
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.12s ease both; transform-origin: top right;
|
||||
}
|
||||
|
||||
.dlTitle {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2);
|
||||
border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.dlOption {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
||||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); color: var(--text-secondary);
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dlOption:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.dlSub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
|
||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.dlStepper {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dlStepBtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 28px;
|
||||
font-size: var(--text-base); color: var(--text-muted);
|
||||
background: none; border: none; cursor: pointer; line-height: 1;
|
||||
transition: color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.dlStepBtn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.dlStepVal {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-secondary); min-width: 24px; text-align: center;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
/* Viewer focus — suppress outline since we're handling keys ourselves */
|
||||
.viewer:focus { outline: none; }
|
||||
@@ -1,989 +0,0 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import {
|
||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||
Square, Rows, Download, ArrowsLeftRight,
|
||||
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ,
|
||||
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore, type FitMode } from "../../store";
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS, type Keybinds } from "../../lib/keybinds";
|
||||
import s from "./Reader.module.css";
|
||||
|
||||
// ── Page cache (module-level, survives re-renders) ────────────────────────────
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const cacheOrder: number[] = [];
|
||||
const MAX_CACHED = 10;
|
||||
|
||||
function cacheTouch(id: number) {
|
||||
const i = cacheOrder.indexOf(id);
|
||||
if (i !== -1) cacheOrder.splice(i, 1);
|
||||
cacheOrder.push(id);
|
||||
}
|
||||
|
||||
function cacheEvict(keep: Set<number>) {
|
||||
while (pageCache.size > MAX_CACHED) {
|
||||
const victim = cacheOrder.find((id) => !keep.has(id));
|
||||
if (!victim) break;
|
||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
||||
pageCache.delete(victim);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
|
||||
const cached = pageCache.get(chapterId);
|
||||
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
|
||||
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
if (!inflight.has(chapterId)) {
|
||||
const p = gql<{ fetchChapterPages: { pages: string[] } }>(
|
||||
FETCH_CHAPTER_PAGES, { chapterId },
|
||||
).then((d) => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.set(chapterId, urls);
|
||||
cacheTouch(chapterId);
|
||||
return urls;
|
||||
}).finally(() => inflight.delete(chapterId));
|
||||
inflight.set(chapterId, p);
|
||||
}
|
||||
|
||||
const base = inflight.get(chapterId)!;
|
||||
|
||||
if (!signal) return base;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
base.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image helpers ─────────────────────────────────────────────────────────────
|
||||
const aspectCache = new Map<string, number>();
|
||||
|
||||
function preloadImage(url: string) { new Image().src = url; }
|
||||
|
||||
function decodeImage(url: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
||||
img.onerror = () => resolve();
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||
return new Promise((res) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Guard against 0 dimensions (image not fully decoded yet) and NaN
|
||||
const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
||||
aspectCache.set(url, ratio);
|
||||
res(ratio);
|
||||
};
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Download modal ────────────────────────────────────────────────────────────
|
||||
function DownloadModal({
|
||||
chapter, remaining, onClose,
|
||||
}: {
|
||||
chapter: { id: number; name: string; isDownloaded?: boolean };
|
||||
remaining: { id: number; isDownloaded?: boolean }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const addToast = useStore((s) => s.addToast);
|
||||
const [nextN, setNextN] = useState(5);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const queueable = remaining.filter((c) => !c.isDownloaded);
|
||||
const alreadyDl = !!chapter.isDownloaded;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, toastBody: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
addToast({ kind: "download", title: "Download queued", body: toastBody });
|
||||
} catch (e) {
|
||||
addToast({ kind: "error", title: "Queue failed", body: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={s.dlBackdrop} onClick={onClose}>
|
||||
<div className={s.dlModal} onClick={(e) => e.stopPropagation()}>
|
||||
<p className={s.dlTitle}>Download</p>
|
||||
<button className={s.dlOption} disabled={busy || alreadyDl}
|
||||
onClick={() => run(() => gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }), alreadyDl ? "" : chapter.name)}>
|
||||
This chapter
|
||||
<span className={s.dlSub}>{alreadyDl ? "Already downloaded" : chapter.name}</span>
|
||||
</button>
|
||||
<div className={s.dlRow}>
|
||||
<button className={s.dlOption} disabled={busy || queueable.length === 0}
|
||||
onClick={() => run(
|
||||
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, nextN).map((c) => c.id) }),
|
||||
`${Math.min(nextN, queueable.length)} chapters queued`,
|
||||
)}>
|
||||
Next chapters
|
||||
<span className={s.dlSub}>{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||
</button>
|
||||
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
||||
<button className={s.dlStepBtn} onClick={() => setNextN((n) => Math.max(1, n - 1))} disabled={nextN <= 1}>−</button>
|
||||
<span className={s.dlStepVal}>{nextN}</span>
|
||||
<button className={s.dlStepBtn} onClick={() => setNextN((n) => Math.min(queueable.length || 1, n + 1))} disabled={nextN >= queueable.length}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className={s.dlOption} disabled={busy || queueable.length === 0}
|
||||
onClick={() => run(
|
||||
() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map((c) => c.id) }),
|
||||
`${queueable.length} chapter${queueable.length !== 1 ? "s" : ""} queued`,
|
||||
)}>
|
||||
All remaining
|
||||
<span className={s.dlSub}>{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zoom popover ──────────────────────────────────────────────────────────────
|
||||
function ZoomPopover({ value, onChange, onReset, onClose }: {
|
||||
value: number; onChange: (v: number) => void; onReset: () => void; onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className={s.zoomPopover} ref={ref}>
|
||||
<input type="range" className={s.zoomSlider} min={200} max={2400} step={50} value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))} />
|
||||
<button className={s.zoomResetBtn} onClick={onReset}>{Math.round((value / 900) * 100)}%</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
startGlobalIdx: number;
|
||||
}
|
||||
|
||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
||||
export default function Reader() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const settingsRef = useRef<typeof settings | null>(null);
|
||||
const chapterListRef = useRef<typeof activeChapterList>([]);
|
||||
const loadingIdRef = useRef<number | null>(null);
|
||||
const markedReadRef = useRef<Set<number>>(new Set());
|
||||
const appendedRef = useRef<Set<number>>(new Set());
|
||||
const appendingRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const visibleChapterRef = useRef<number | null>(null);
|
||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||
const pageUrlsRef = useRef<string[]>([]);
|
||||
const activeChapterRef = useRef<typeof activeChapter>(null);
|
||||
const markReadOnNextRef = useRef(true);
|
||||
// Captured before a head-trim; useLayoutEffect restores scroll synchronously
|
||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [pageReady, setPageReady] = useState(false);
|
||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
|
||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||
|
||||
stripChaptersRef.current = stripChapters;
|
||||
|
||||
// Restore scroll position synchronously after a head-trim, before paint.
|
||||
// This is the only reliable way to prevent the visible jump — rAF fires
|
||||
// one frame too late and the user sees the incorrect position briefly.
|
||||
useLayoutEffect(() => {
|
||||
const anchor = scrollAnchorRef.current;
|
||||
if (!anchor || !containerRef.current) return;
|
||||
scrollAnchorRef.current = null;
|
||||
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
|
||||
// gained is negative when nodes were removed (scrollHeight shrank).
|
||||
// Subtract the same amount from scrollTop so visible content stays put.
|
||||
if (gained < 0) {
|
||||
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
|
||||
}
|
||||
}, [stripChapters]);
|
||||
|
||||
const {
|
||||
activeManga, activeChapter, activeChapterList,
|
||||
pageUrls, pageNumber, settings,
|
||||
setPageUrls, setPageNumber, closeReader, openReader, openSettings,
|
||||
updateSettings, addHistory,
|
||||
} = useStore();
|
||||
|
||||
const rtl = settings.readingDirection === "rtl";
|
||||
const fit = settings.fitMode ?? "width";
|
||||
const style = settings.pageStyle ?? "single";
|
||||
const maxW = settings.maxPageWidth ?? 900;
|
||||
const autoNext = settings.autoNextChapter ?? false;
|
||||
const markReadOnNext = settings.markReadOnNext ?? true;
|
||||
|
||||
settingsRef.current = settings;
|
||||
chapterListRef.current = activeChapterList;
|
||||
pageUrlsRef.current = pageUrls;
|
||||
activeChapterRef.current = activeChapter;
|
||||
markReadOnNextRef.current = markReadOnNext;
|
||||
|
||||
// Mark the current chapter read when the user manually skips to another chapter.
|
||||
// Uses refs only — safe to call from any callback without stale-closure issues.
|
||||
// markReadOnNext gates this; autoNextChapter does NOT block it because a manual
|
||||
// chapter-skip is always intentional regardless of the auto-advance setting.
|
||||
const maybeMarkCurrentRead = useCallback(() => {
|
||||
const ch = activeChapterRef.current;
|
||||
if (!ch) return;
|
||||
if (!markReadOnNextRef.current) return;
|
||||
if (markedReadRef.current.has(ch.id)) return;
|
||||
markedReadRef.current.add(ch.id);
|
||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true }).catch((e) => {
|
||||
markedReadRef.current.delete(ch.id);
|
||||
console.error("MARK_CHAPTER_READ (manual next) failed:", e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── UI autohide ──────────────────────────────────────────────────────────────
|
||||
const showUi = useCallback(() => {
|
||||
setUiVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
showUi();
|
||||
return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => { containerRef.current?.focus({ preventScroll: true }); }, [activeChapter?.id]);
|
||||
|
||||
// ── Load chapter ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter) {
|
||||
abortRef.current?.abort();
|
||||
appendedRef.current = new Set();
|
||||
appendingRef.current = false;
|
||||
markedReadRef.current = new Set();
|
||||
setStripChapters([]);
|
||||
setVisibleChapterId(null);
|
||||
visibleChapterRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
const targetId = activeChapter.id;
|
||||
loadingIdRef.current = targetId;
|
||||
appendedRef.current = new Set([targetId]);
|
||||
appendingRef.current = false;
|
||||
markedReadRef.current = new Set();
|
||||
// Clear stale aspect ratios — server URLs can return different images
|
||||
// after a re-fetch, and a stale cached ratio renders as a black/collapsed img.
|
||||
aspectCache.clear();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPageGroups([]);
|
||||
setPageReady(false);
|
||||
setStripChapters([]);
|
||||
setVisibleChapterId(null);
|
||||
visibleChapterRef.current = null;
|
||||
|
||||
fetchPages(targetId, ctrl.signal)
|
||||
.then(async (urls) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Don't block the render on decoding — set URLs immediately so the
|
||||
// browser can start painting the first image without waiting for the
|
||||
// full decode. The img element's own decoding="async" handles the rest.
|
||||
setPageUrls(urls);
|
||||
setPageReady(true);
|
||||
if (style === "longstrip" && autoNext) {
|
||||
const firstChunk: StripChapter = {
|
||||
chapterId: targetId,
|
||||
chapterName: activeChapter.name,
|
||||
urls,
|
||||
startGlobalIdx: 0,
|
||||
};
|
||||
setStripChapters([firstChunk]);
|
||||
setVisibleChapterId(targetId);
|
||||
visibleChapterRef.current = targetId;
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
// ── Append next chapter to the strip ────────────────────────────────────────
|
||||
const appendNextChapter = useCallback(() => {
|
||||
if (appendingRef.current) return;
|
||||
|
||||
const strip = stripChaptersRef.current;
|
||||
const lastChunk = strip[strip.length - 1];
|
||||
if (!lastChunk) return;
|
||||
|
||||
const list = chapterListRef.current;
|
||||
const lastIdx = list.findIndex((c) => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
|
||||
|
||||
const nextEntry = list[lastIdx + 1];
|
||||
if (!nextEntry || appendedRef.current.has(nextEntry.id)) return;
|
||||
|
||||
appendedRef.current.add(nextEntry.id);
|
||||
appendingRef.current = true;
|
||||
|
||||
fetchPages(nextEntry.id)
|
||||
.then((urls) => {
|
||||
// Kick off aspect measurement in background — don't block appending on it
|
||||
urls.forEach((url) => measureAspect(url).catch(() => {}));
|
||||
// Ensure the first several images are already in the browser cache
|
||||
// by the time React renders them — eliminates the blank-image flash
|
||||
// that occurs when a freshly appended chapter hasn't been prefetched.
|
||||
urls.slice(0, 6).forEach(preloadImage);
|
||||
return urls;
|
||||
})
|
||||
.then((urls) => {
|
||||
setStripChapters((cur) => {
|
||||
if (cur.some((c) => c.chapterId === nextEntry.id)) return cur;
|
||||
|
||||
const last = cur[cur.length - 1];
|
||||
const newStart = last ? last.startGlobalIdx + last.urls.length : 0;
|
||||
const updated = [...cur, {
|
||||
chapterId: nextEntry.id,
|
||||
chapterName: nextEntry.name,
|
||||
urls,
|
||||
startGlobalIdx: newStart,
|
||||
}];
|
||||
|
||||
if (updated.length > 3) {
|
||||
// Snapshot scroll position BEFORE React removes the nodes.
|
||||
// useLayoutEffect will restore it synchronously after the DOM
|
||||
// mutation, preventing any visible jump.
|
||||
if (containerRef.current) {
|
||||
scrollAnchorRef.current = {
|
||||
scrollTop: containerRef.current.scrollTop,
|
||||
scrollHeight: containerRef.current.scrollHeight,
|
||||
};
|
||||
}
|
||||
return updated.slice(-3);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
appendingRef.current = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("appendNextChapter failed:", err);
|
||||
appendedRef.current.delete(nextEntry.id);
|
||||
appendingRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Longstrip: scroll-driven page + chapter tracking + mark-as-read ──────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || style !== "longstrip") return;
|
||||
|
||||
const READ_LINE_PCT = 0.20;
|
||||
|
||||
const onScroll = () => {
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const readLineY = containerTop + el.clientHeight * READ_LINE_PCT;
|
||||
const imgs = el.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
|
||||
let activeLocalPage: number | null = null;
|
||||
let activeChId: number | null = null;
|
||||
|
||||
for (const img of imgs) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.top <= readLineY) {
|
||||
activeLocalPage = Number(img.dataset.localPage);
|
||||
activeChId = Number(img.dataset.chapter);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLocalPage === null && imgs.length > 0) {
|
||||
activeLocalPage = Number(imgs[0].dataset.localPage);
|
||||
activeChId = Number(imgs[0].dataset.chapter);
|
||||
}
|
||||
|
||||
if (activeLocalPage !== null) setPageNumber(activeLocalPage);
|
||||
|
||||
if (activeChId && activeChId !== visibleChapterRef.current) {
|
||||
visibleChapterRef.current = activeChId;
|
||||
setVisibleChapterId(activeChId);
|
||||
}
|
||||
|
||||
if (settingsRef.current?.autoMarkRead && activeLocalPage !== null && activeChId) {
|
||||
const strip = stripChaptersRef.current;
|
||||
const chunk = strip.find((c) => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : pageUrlsRef.current.length;
|
||||
if (total > 0 && activeLocalPage >= total - 1) {
|
||||
const ch = activeChId;
|
||||
if (!markedReadRef.current.has(ch)) {
|
||||
markedReadRef.current.add(ch);
|
||||
gql(MARK_CHAPTER_READ, { id: ch, isRead: true }).catch((e) => {
|
||||
markedReadRef.current.delete(ch);
|
||||
console.error("MARK_CHAPTER_READ failed for chapter", ch, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [style]);
|
||||
|
||||
// ── Longstrip: sentinel triggers append ──────────────────────────────────────
|
||||
// activeChapter?.id in deps ensures the observer reinstalls fresh on every
|
||||
// manga switch — without it, switching manga reuses the stale observer which
|
||||
// has already fired and won't re-fire for the new chapter's sentinel position.
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const el = containerRef.current;
|
||||
if (!sentinel || !el || style !== "longstrip" || !autoNext) return;
|
||||
if (stripChapters.length === 0) return;
|
||||
|
||||
// Trigger append when the user has scrolled through 80% of the current
|
||||
// strip — early enough that the next chapter is ready before they reach
|
||||
// the end. A fixed-pixel rootMargin can't express "80% of scrollHeight"
|
||||
// so we use a scroll listener for the threshold check, and keep the
|
||||
// IntersectionObserver only as a fallback for the absolute bottom.
|
||||
const onScroll80 = () => {
|
||||
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
||||
if (pct >= 0.8) appendNextChapter();
|
||||
};
|
||||
el.addEventListener("scroll", onScroll80, { passive: true });
|
||||
|
||||
// IntersectionObserver as hard backstop at the very bottom
|
||||
const obs = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
appendNextChapter();
|
||||
}, { root: el, rootMargin: "0px", threshold: 0 });
|
||||
|
||||
obs.observe(sentinel);
|
||||
|
||||
// Double-rAF ensures real image heights are committed before we measure.
|
||||
// Fires the 80% check once on mount so short/cached chapters that never
|
||||
// produce a scroll event still trigger an append.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return;
|
||||
const pct = (el.scrollTop + el.clientHeight) / el.scrollHeight;
|
||||
if (pct >= 0.8) appendNextChapter();
|
||||
});
|
||||
});
|
||||
|
||||
return () => { obs.disconnect(); el.removeEventListener("scroll", onScroll80); };
|
||||
}, [style, autoNext, stripChapters.length, activeChapter?.id, appendNextChapter]);
|
||||
// ^^^^^^^^^^^^^^^^^ reinstall on manga switch
|
||||
|
||||
// ── Mark last chapter read when reaching the very bottom ─────────────────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || style !== "longstrip") return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return;
|
||||
const last = stripChaptersRef.current[stripChaptersRef.current.length - 1];
|
||||
if (!last) return;
|
||||
if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) {
|
||||
markedReadRef.current.add(last.chapterId);
|
||||
gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [style]);
|
||||
|
||||
// Rebuild strip when autoNext is toggled while longstrip is active
|
||||
useEffect(() => {
|
||||
if (style !== "longstrip" || !pageUrls.length || !activeChapter) return;
|
||||
appendedRef.current = new Set([activeChapter.id]);
|
||||
appendingRef.current = false;
|
||||
if (autoNext) {
|
||||
setStripChapters([{
|
||||
chapterId: activeChapter.id,
|
||||
chapterName: activeChapter.name,
|
||||
urls: pageUrls,
|
||||
startGlobalIdx: 0,
|
||||
}]);
|
||||
setVisibleChapterId(activeChapter.id);
|
||||
visibleChapterRef.current = activeChapter.id;
|
||||
} else {
|
||||
setStripChapters([]);
|
||||
setVisibleChapterId(null);
|
||||
visibleChapterRef.current = null;
|
||||
}
|
||||
if (containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [autoNext, style]);
|
||||
|
||||
// Reset scroll on non-longstrip page change
|
||||
useEffect(() => {
|
||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [pageNumber, style]);
|
||||
|
||||
// Always scroll to top when a new chapter opens — even if pageNumber stays at 1
|
||||
// (navigating chapter→chapter while already on page 1 won't trigger the effect above).
|
||||
useEffect(() => {
|
||||
if (containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
// ── Preload adjacent pages ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const ahead = settings.preloadPages ?? 3;
|
||||
for (let i = 1; i <= ahead; i++) {
|
||||
const url = pageUrls[pageNumber - 1 + i];
|
||||
if (url) decodeImage(url);
|
||||
}
|
||||
const behind = pageUrls[pageNumber - 2];
|
||||
if (behind) preloadImage(behind);
|
||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
||||
|
||||
// ── Derived display values ───────────────────────────────────────────────────
|
||||
const lastPage = pageUrls.length;
|
||||
|
||||
const displayChapter = useMemo(() => {
|
||||
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
|
||||
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
|
||||
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
|
||||
|
||||
// ── Adjacent chapters + cache eviction ──────────────────────────────────────
|
||||
const adjacent = useMemo(() => {
|
||||
const ref = displayChapter ?? activeChapter;
|
||||
if (!ref || !activeChapterList.length)
|
||||
return { prev: null, next: null, remaining: [] };
|
||||
const idx = activeChapterList.findIndex((c) => c.id === ref.id);
|
||||
return {
|
||||
prev: idx > 0 ? activeChapterList[idx - 1] : null,
|
||||
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
|
||||
remaining: activeChapterList.slice(idx + 1),
|
||||
};
|
||||
}, [displayChapter, activeChapter, activeChapterList]);
|
||||
|
||||
// ── Prefetch next 3 chapters into pageCache so strip appends are instant ────
|
||||
// Fires whenever the active chapter changes. Fetches page URL lists for the
|
||||
// next 3 chapters in the background so appendNextChapter always gets a cache
|
||||
// hit instead of waiting on a network round-trip.
|
||||
useEffect(() => {
|
||||
if (!activeChapter || !activeChapterList.length) return;
|
||||
const idx = activeChapterList.findIndex((c) => c.id === activeChapter.id);
|
||||
if (idx < 0) return;
|
||||
|
||||
const PREFETCH_AHEAD = 3;
|
||||
const toPin: number[] = [activeChapter.id];
|
||||
|
||||
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
|
||||
const entry = activeChapterList[idx + i];
|
||||
if (!entry) break;
|
||||
toPin.push(entry.id);
|
||||
fetchPages(entry.id)
|
||||
.then((urls) => {
|
||||
// Preload the first several images of every prefetched chapter,
|
||||
// not just the immediate next one — chapters 2–3 ahead would
|
||||
// otherwise start loading cold when appended, causing blank flashes.
|
||||
// Fewer images for farther-ahead chapters to avoid wasting bandwidth.
|
||||
const preloadCount = i === 1 ? 8 : i === 2 ? 4 : 2;
|
||||
urls.slice(0, preloadCount).forEach(preloadImage);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Pin one chapter behind too so going back is fast
|
||||
if (idx > 0) {
|
||||
const prev = activeChapterList[idx - 1];
|
||||
toPin.push(prev.id);
|
||||
fetchPages(prev.id).catch(() => {});
|
||||
}
|
||||
|
||||
cacheEvict(new Set(toPin));
|
||||
}, [activeChapter?.id, activeChapterList]);
|
||||
|
||||
const visibleChunkLastPage = useMemo(() => {
|
||||
if (style !== "longstrip" || !autoNext) return lastPage;
|
||||
const chId = visibleChapterId ?? activeChapter?.id;
|
||||
const chunk = stripChapters.find((c) => c.chapterId === chId);
|
||||
return chunk?.urls.length ?? lastPage;
|
||||
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
|
||||
|
||||
const visibleChunkPage = pageNumber;
|
||||
|
||||
// ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter || !lastPage) return;
|
||||
if (activeManga) {
|
||||
addHistory({
|
||||
mangaId: activeManga.id, mangaTitle: activeManga.title,
|
||||
thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id,
|
||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
||||
});
|
||||
}
|
||||
if (style === "longstrip") return;
|
||||
if (settings.autoMarkRead && pageNumber === lastPage) {
|
||||
if (!markedReadRef.current.has(activeChapter.id)) {
|
||||
markedReadRef.current.add(activeChapter.id);
|
||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style]);
|
||||
|
||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
||||
let cancelled = false;
|
||||
const snap = pageUrls;
|
||||
Promise.all(snap.map(measureAspect)).then((aspects) => {
|
||||
if (cancelled || snap !== pageUrls) return;
|
||||
const offset = settings.offsetDoubleSpreads;
|
||||
const groups: number[][] = [[1]];
|
||||
if (offset) groups.push([2]);
|
||||
let i = offset ? 3 : 2;
|
||||
while (i <= snap.length) {
|
||||
const a = aspects[i - 1];
|
||||
const nextA = aspects[i] ?? 0;
|
||||
if (a > 1.2 || i === snap.length || nextA > 1.2) {
|
||||
groups.push([i++]);
|
||||
} else {
|
||||
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
setPageGroups(groups);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||
const advanceGroup = useCallback((forward: boolean) => {
|
||||
if (!pageGroups.length) return;
|
||||
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
||||
if (forward) {
|
||||
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
|
||||
else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); }
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
}
|
||||
}, [pageGroups, pageNumber, adjacent, activeChapterList]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (loading) return;
|
||||
// Longstrip: bottom arrows always switch chapters, not pages
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||
if (!pageUrls.length) return;
|
||||
if (pageNumber < lastPage) {
|
||||
decodeImage(pageUrls[pageNumber]).then(() => setPageNumber(pageNumber + 1));
|
||||
} else if (adjacent.next) {
|
||||
maybeMarkCurrentRead();
|
||||
setPageNumber(1); openReader(adjacent.next, activeChapterList);
|
||||
} else {
|
||||
closeReader();
|
||||
}
|
||||
}, [loading, style, pageNumber, lastPage, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup, maybeMarkCurrentRead]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (loading) return;
|
||||
// Longstrip: bottom arrows always switch chapters, not pages
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
return;
|
||||
}
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
if (!pageUrls.length) return;
|
||||
if (pageNumber > 1) {
|
||||
decodeImage(pageUrls[pageNumber - 2]).then(() => setPageNumber(pageNumber - 1));
|
||||
} else if (adjacent.prev) {
|
||||
openReader(adjacent.prev, activeChapterList);
|
||||
}
|
||||
}, [loading, style, pageNumber, pageUrls, adjacent, activeChapterList, pageGroups, advanceGroup]);
|
||||
|
||||
const goNext = rtl ? goBack : goForward;
|
||||
const goPrev = rtl ? goForward : goBack;
|
||||
|
||||
function cycleStyle() {
|
||||
const opts = ["single", "longstrip"] as const;
|
||||
const cur = style === "double" ? "single" : style;
|
||||
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
function cycleFit() {
|
||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
||||
};
|
||||
window.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => window.removeEventListener("wheel", onWheel);
|
||||
}, [maxW]);
|
||||
|
||||
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
||||
const goForwardRef = useRef(goForward);
|
||||
const goBackRef = useRef(goBack);
|
||||
const cycleStyleRef = useRef(cycleStyle);
|
||||
useEffect(() => { goForwardRef.current = goForward; }, [goForward]);
|
||||
useEffect(() => { goBackRef.current = goBack; }, [goBack]);
|
||||
useEffect(() => { cycleStyleRef.current = cycleStyle; });
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
const kb: Keybinds = settingsRef.current?.keybinds ?? DEFAULT_KEYBINDS;
|
||||
const maxW = settingsRef.current?.maxPageWidth ?? 900;
|
||||
const rtl = settingsRef.current?.readingDirection === "rtl";
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (zoomOpen) { setZoomOpen(false); return; }
|
||||
if (dlOpen) { setDlOpen(false); return; }
|
||||
closeReader(); return;
|
||||
}
|
||||
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); updateSettings({ maxPageWidth: Math.min(2400, maxW + 100) }); return; }
|
||||
if (e.ctrlKey && e.key === "-") { e.preventDefault(); updateSettings({ maxPageWidth: Math.max(200, maxW - 100) }); return; }
|
||||
if (e.ctrlKey && e.key === "0") { e.preventDefault(); updateSettings({ maxPageWidth: 900 }); return; }
|
||||
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForwardRef.current(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBackRef.current(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
||||
else if (matchesKeybind(e, kb.chapterRight)) {
|
||||
e.preventDefault();
|
||||
const list = chapterListRef.current;
|
||||
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
||||
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
|
||||
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
|
||||
}
|
||||
else if (matchesKeybind(e, kb.chapterLeft)) {
|
||||
e.preventDefault();
|
||||
const list = chapterListRef.current;
|
||||
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
|
||||
const prev = idx > 0 ? list[idx - 1] : null;
|
||||
if (prev) openReader(prev, list);
|
||||
}
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyleRef.current(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [zoomOpen, dlOpen, lastPage, maybeMarkCurrentRead]);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
function handleTap(e: React.MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
const x = e.clientX / window.innerWidth;
|
||||
if (!rtl) { if (x > 0.6) goForward(); else if (x < 0.4) goBack(); }
|
||||
else { if (x < 0.4) goForward(); else if (x > 0.6) goBack(); }
|
||||
}
|
||||
|
||||
const cssVars = { "--max-page-width": `${maxW}px` } as React.CSSProperties;
|
||||
const imgCls = [
|
||||
s.img,
|
||||
fit === "width" && s.fitWidth,
|
||||
fit === "height" && s.fitHeight,
|
||||
fit === "screen" && s.fitScreen,
|
||||
fit === "original" && s.fitOriginal,
|
||||
settings.optimizeContrast && s.optimizeContrast,
|
||||
].filter(Boolean).join(" ");
|
||||
const fitIcon =
|
||||
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
||||
fit === "height" ? <ArrowsVertical size={14} weight="light" /> :
|
||||
fit === "screen" ? <ArrowsIn size={14} weight="light" /> :
|
||||
<ArrowsOut size={14} weight="light" />;
|
||||
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
||||
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
|
||||
|
||||
const stripToRender: StripChapter[] = style === "longstrip"
|
||||
? (autoNext && stripChapters.length > 0
|
||||
? stripChapters
|
||||
: [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0 }])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={s.root} onMouseMove={(e) => {
|
||||
if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi();
|
||||
}}>
|
||||
{/* ── Topbar ── */}
|
||||
<div className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||
<button className={s.iconBtn} onClick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, activeChapterList); } }} disabled={!adjacent.prev} title="Previous chapter">
|
||||
<CaretLeft size={14} weight="light" />
|
||||
</button>
|
||||
<span className={s.chLabel}>
|
||||
<span className={s.chTitle}>{activeManga?.title}</span>
|
||||
<span className={s.chSep}>/</span>
|
||||
<span>{displayChapter?.name}</span>
|
||||
</span>
|
||||
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
||||
<button className={s.iconBtn} onClick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } }} disabled={!adjacent.next} title="Next chapter">
|
||||
<CaretRight size={14} weight="light" />
|
||||
</button>
|
||||
<div className={s.topSep} />
|
||||
<button className={s.modeBtn} onClick={cycleFit} title={`Fit mode: ${fitLabel}\nCtrl+scroll to zoom`}>
|
||||
{fitIcon}<span className={s.modeBtnLabel}>{fitLabel}</span>
|
||||
</button>
|
||||
<div className={s.zoomWrap}>
|
||||
<button className={s.zoomBtn} onClick={() => setZoomOpen((o) => !o)} title="Zoom">
|
||||
{Math.round((maxW / 900) * 100)}%
|
||||
</button>
|
||||
{zoomOpen && (
|
||||
<ZoomPopover value={maxW}
|
||||
onChange={(v) => updateSettings({ maxPageWidth: v })}
|
||||
onReset={() => updateSettings({ maxPageWidth: 900 })}
|
||||
onClose={() => setZoomOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
<button className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })} title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
|
||||
<ArrowsLeftRight size={14} weight="light" /><span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
||||
</button>
|
||||
<button className={s.modeBtn} onClick={cycleStyle} title={`Layout: ${style}`}>
|
||||
{styleIcon}<span className={s.modeBtnLabel}>{style}</span>
|
||||
</button>
|
||||
{style !== "single" && (
|
||||
<button className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ pageGap: !settings.pageGap })} title="Toggle page gap">
|
||||
<span className={s.modeBtnLabel}>Gap</span>
|
||||
</button>
|
||||
)}
|
||||
{style === "longstrip" && (
|
||||
<button className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ autoNextChapter: !autoNext })} title="Auto-advance to next chapter">
|
||||
<span className={s.modeBtnLabel}>Auto</span>
|
||||
</button>
|
||||
)}
|
||||
{!autoNext && (
|
||||
<button
|
||||
className={[s.modeBtn, markReadOnNext ? s.modeBtnActive : ""].join(" ")}
|
||||
onClick={() => updateSettings({ markReadOnNext: !markReadOnNext })}
|
||||
title={markReadOnNext
|
||||
? "Mark chapter read when advancing to next (click to disable)"
|
||||
: "Don't mark chapter read on next (click to enable)"}>
|
||||
<span className={s.modeBtnLabel}>Mk.Read</span>
|
||||
</button>
|
||||
)}
|
||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Viewer ── */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
|
||||
style={cssVars}
|
||||
tabIndex={-1}
|
||||
onClick={handleTap}
|
||||
onWheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " && style === "longstrip") {
|
||||
e.preventDefault();
|
||||
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<p className={s.errorMsg}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{style === "longstrip" ? (
|
||||
<>
|
||||
{stripToRender.map((chunk) =>
|
||||
chunk.urls.map((url, i) => {
|
||||
const localPage = i + 1;
|
||||
return (
|
||||
<img
|
||||
key={`${chunk.chapterId}-${i}`}
|
||||
src={url}
|
||||
alt={`${chunk.chapterName} – Page ${localPage}`}
|
||||
data-local-page={localPage}
|
||||
data-chapter={chunk.chapterId}
|
||||
data-total={chunk.urls.length}
|
||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
||||
loading={i < 3 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
height={1000}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} />
|
||||
</>
|
||||
) : (pageReady && (
|
||||
<img
|
||||
src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`}
|
||||
className={imgCls}
|
||||
decoding="async"
|
||||
style={{ transition: "opacity 0.1s ease" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom nav ── */}
|
||||
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||
<button className={s.navBtn} onClick={goPrev}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (pageNumber === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
<button className={s.navBtn} onClick={goNext}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (pageNumber === lastPage && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dlOpen && activeChapter && (
|
||||
<DownloadModal chapter={activeChapter} remaining={adjacent.remaining} onClose={() => setDlOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
/* ── Root ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
|
||||
.tabActive {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
}
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
|
||||
/* ── Keyword bar ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.keywordBar {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--sp-3) 0 var(--sp-2);
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 7px 0;
|
||||
}
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.clearBtn {
|
||||
color: var(--text-faint);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.clearBtn:hover { color: var(--text-muted); }
|
||||
|
||||
.advancedBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.searchBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid var(--accent-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Advanced filter panel ─────────────────────────────────────────────────── */
|
||||
|
||||
.advancedPanel {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.advancedHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.advancedTitle {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.advancedActions { display: flex; gap: var(--sp-1); }
|
||||
|
||||
.advancedLink {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.advancedLink:hover { opacity: 1; }
|
||||
|
||||
.langGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.langChip {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.langChipActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.advancedDivider {
|
||||
height: 1px;
|
||||
background: var(--border-dim);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.advancedCheck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox { accent-color: var(--accent-fg); cursor: pointer; }
|
||||
|
||||
.advancedFooter {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.advancedLinkStandalone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.advancedLinkStandalone:hover { opacity: 1; }
|
||||
|
||||
/* ── Empty states ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.emptyIcon { color: var(--text-faint); }
|
||||
.emptyText { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.emptyHint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
|
||||
/* ── Keyword results ───────────────────────────────────────────────────────── */
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sourceSection {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.sourceSection:last-child { border-bottom: none; }
|
||||
|
||||
.sourceHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.sourceIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.sourceName {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sourceLang {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.sourceError {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-error);
|
||||
padding: var(--sp-1) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Horizontal scroll row */
|
||||
.sourceRow {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--sp-1);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.sourceRow::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Manga card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .cardTitle { color: var(--text-primary); }
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
|
||||
.inLibBadge {
|
||||
position: absolute;
|
||||
bottom: var(--sp-1);
|
||||
right: var(--sp-1);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-muted);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-snug);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
|
||||
/* ── Skeleton ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.skCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.tagGrid .card { width: 100%; }
|
||||
.tagGrid .skCard { width: 100%; }
|
||||
|
||||
.skeleton { border-radius: var(--radius-sm); }
|
||||
|
||||
.skCover {
|
||||
aspect-ratio: 2 / 3;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.skTitle { height: 10px; width: 80%; }
|
||||
|
||||
/* ── Split root (Tag + Source tabs) ────────────────────────────────────────── */
|
||||
|
||||
.splitRoot {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Split sidebar ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.splitSidebar {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.splitSearchWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
|
||||
.splitSearchInput {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
min-width: 0;
|
||||
}
|
||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||
|
||||
.splitSearchClear {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.splitSearchClear:hover { color: var(--text-muted); }
|
||||
|
||||
.splitList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-dim) transparent;
|
||||
}
|
||||
|
||||
.splitItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: 7px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
|
||||
.splitItemActive {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
.splitItemActive:hover { background: var(--accent-muted); }
|
||||
|
||||
.splitItemLabel {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||
|
||||
.splitItemSource { gap: var(--sp-2); }
|
||||
|
||||
.splitEmpty {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
padding: var(--sp-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.splitLoading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
/* ── Split content ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.splitContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitContentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.splitSourceTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.splitContentTitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
|
||||
.splitResultCount {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.splitSourceIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
/* ── Tag active bar ────────────────────────────────────────────────────────── */
|
||||
|
||||
.tagActiveBar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tagPillRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tagPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.tagPillRemove {
|
||||
color: var(--accent-fg);
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.tagPillRemove:hover { opacity: 1; }
|
||||
|
||||
.tagBarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tagModeToggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tagModeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint);
|
||||
background: none;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-dim);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tagModeBtn:last-child { border-right: none; }
|
||||
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.tagClearAll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.tagClearAll:hover {
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent));
|
||||
}
|
||||
|
||||
.tagCheckMark {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent-fg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Grid results ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.tagGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ── Show more / load more ─────────────────────────────────────────────────── */
|
||||
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: none;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.loadMoreRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
/* ── Source tab: lang filter + browse bar ──────────────────────────────────── */
|
||||
|
||||
.langFilterRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sourceBrowseBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── NSFW badge ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.nsfwBadge {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-error);
|
||||
background: var(--color-error-bg, rgba(180, 60, 60, 0.08));
|
||||
border: 1px solid rgba(180, 60, 60, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 5px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<div>SeriesDetail.svelte</div>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user