{
+ if (e.key === " " && style === "longstrip") {
+ e.preventDefault();
+ containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
+ }
+ }}
>
{style === "longstrip" ? (
pageUrls.map((url, i) => (
-

+ loading={i < 3 ? "eager" : "lazy"}
+ decoding="async"
+ />
))
- ) : style === "double" ? (
- renderDouble()
) : (
-

+

)}
{/* ── Bottom nav ── */}
-
+
diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css
index 96cf85d..b072477 100644
--- a/src/components/pages/SeriesDetail.module.css
+++ b/src/components/pages/SeriesDetail.module.css
@@ -515,4 +515,62 @@
color: var(--text-secondary);
border-color: var(--border-strong);
background: var(--bg-raised);
-}
\ No newline at end of file
+}
+/* ── List header right controls ── */
+.listHeaderRight {
+ display: flex; align-items: center; gap: var(--sp-2);
+}
+
+/* ── Download dropdown (in list header) ── */
+.dlWrap { position: relative; }
+
+.dlToggleBtn {
+ display: flex; align-items: center; justify-content: center;
+ width: 28px; height: 28px; border-radius: var(--radius-md);
+ border: 1px solid var(--border-dim); color: var(--text-muted);
+ background: none; cursor: pointer;
+ transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
+}
+.dlToggleBtn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
+
+.dlDropdown {
+ position: absolute; top: calc(100% + 4px); right: 0;
+ background: var(--bg-raised); border: 1px solid var(--border-base);
+ border-radius: var(--radius-lg); padding: var(--sp-1);
+ display: flex; flex-direction: column; gap: 1px;
+ min-width: 180px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+ animation: scaleIn 0.1s ease both; transform-origin: top right;
+ z-index: 50;
+}
+
+/* ── Jump to chapter (in list header) ── */
+.jumpWrap { position: relative; }
+
+.jumpToggle {
+ padding: 4px 8px;
+ border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
+ background: none; color: var(--text-faint);
+ font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
+ cursor: pointer; white-space: nowrap;
+ transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
+}
+.jumpToggle:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
+
+.jumpRow { display: flex; align-items: center; gap: 4px; }
+
+.jumpInput {
+ width: 72px; padding: 4px 8px;
+ background: var(--bg-raised); border: 1px solid var(--border-focus);
+ border-radius: var(--radius-sm); color: var(--text-secondary);
+ font-family: var(--font-ui); font-size: var(--text-xs);
+ outline: none;
+}
+
+.jumpCancel {
+ display: flex; align-items: center; justify-content: center;
+ width: 22px; height: 22px; border-radius: var(--radius-sm);
+ color: var(--text-faint); font-size: 10px; background: none;
+ transition: color var(--t-base), background var(--t-base);
+}
+.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
\ No newline at end of file
diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx
index 8f0b1b3..c12af30 100644
--- a/src/components/pages/SeriesDetail.tsx
+++ b/src/components/pages/SeriesDetail.tsx
@@ -50,6 +50,8 @@ export default function SeriesDetail() {
const [togglingLibrary, setTogglingLibrary] = useState(false);
const [chapterPage, setChapterPage] = useState(1);
const [ctx, setCtx] = useState
(null);
+ const [jumpOpen, setJumpOpen] = useState(false);
+ const [jumpInput, setJumpInput] = useState("");
const sortDir = settings.chapterSortDir;
@@ -104,10 +106,13 @@ export default function SeriesDetail() {
const continueChapter = useMemo(() => {
if (!chapters.length) return null;
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
+ const anyRead = asc.some((c) => c.isRead);
+ // In-progress: started but not finished
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const };
+ // If any chapter is read, user is continuing — find next unread
const firstUnread = asc.find((c) => !c.isRead);
- if (firstUnread) return { chapter: firstUnread, type: "start" as const };
+ if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const };
return { chapter: asc[0], type: "reread" as const };
}, [chapters]);
@@ -291,49 +296,6 @@ export default function SeriesDetail() {
)}
- {chapters.length > 0 && (
-
-
- {dlOpen && (
-
- {continueChapter && (
-
- )}
-
-
-
- )}
-
- )}
-
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
@@ -387,21 +349,103 @@ export default function SeriesDetail() {
{sortDir === "desc" ? "Newest first" : "Oldest first"}
- {totalPages > 1 && (
-
-
- {chapterPage} / {totalPages}
-
-
- )}
+
+ {/* Jump to chapter */}
+ {chapters.length > 1 && (
+
+ {!jumpOpen ? (
+
+ ) : (
+
+ setJumpInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") { setJumpOpen(false); return; }
+ if (e.key === "Enter") {
+ const num = parseFloat(jumpInput);
+ if (!isNaN(num)) {
+ const target = sortedChapters.find((c) => c.chapterNumber === num)
+ ?? sortedChapters.reduce((best, c) =>
+ Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best
+ , sortedChapters[0]);
+ if (target) openReader(target, sortedChapters);
+ }
+ setJumpOpen(false);
+ }
+ }}
+ />
+
+
+ )}
+
+ )}
+
+ {/* Download menu */}
+ {chapters.length > 0 && (
+
+
+ {dlOpen && (
+
+ {continueChapter && (
+
+ )}
+
+
+
+ )}
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ {chapterPage} / {totalPages}
+
+
+ )}
+
diff --git a/src/components/settings/Settings.module.css b/src/components/settings/Settings.module.css
index 1a8000c..6acc032 100644
--- a/src/components/settings/Settings.module.css
+++ b/src/components/settings/Settings.module.css
@@ -159,20 +159,45 @@
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
}
-/* ─── Select ── */
-.select {
+/* ─── Select (custom) ── */
+.selectWrap { position: relative; flex-shrink: 0; min-width: 130px; }
+
+.selectBtn {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
+ width: 100%; padding: 5px 10px;
background: var(--bg-raised); border: 1px solid var(--border-strong);
- border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-secondary);
+ border-radius: var(--radius-md); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
- outline: none; cursor: pointer; flex-shrink: 0; transition: border-color var(--t-base);
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M0 0l5 6 5-6' fill='%23888'/%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 8px center;
- padding-right: 24px;
+ cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
+ text-align: left;
}
-.select:focus { border-color: var(--border-focus); }
-.select option { background: var(--bg-raised); color: var(--text-secondary); }
+.selectBtn:hover { border-color: var(--border-focus); }
+
+.selectCaret {
+ color: var(--text-faint); flex-shrink: 0;
+ transition: transform var(--t-base);
+}
+.selectCaretOpen { transform: rotate(180deg); }
+
+.selectMenu {
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0;
+ background: var(--bg-raised); border: 1px solid var(--border-base);
+ border-radius: var(--radius-md); padding: var(--sp-1);
+ display: flex; flex-direction: column; gap: 1px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
+ z-index: 200; animation: scaleIn 0.1s ease both; transform-origin: top center;
+}
+
+.selectOption {
+ padding: 6px 10px; border-radius: var(--radius-sm);
+ font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
+ color: var(--text-secondary); background: none; border: none;
+ cursor: pointer; text-align: left;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+.selectOption:hover { background: var(--bg-overlay); color: var(--text-primary); }
+.selectOptionActive { color: var(--accent-fg); background: var(--accent-muted); }
+.selectOptionActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
/* ─── Scale ── */
.scaleRow {
diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx
index ea58e21..e2b517e 100644
--- a/src/components/settings/Settings.tsx
+++ b/src/components/settings/Settings.tsx
@@ -62,15 +62,51 @@ function SelectRow({ value, options, onChange, label, description }: {
label: string;
description?: string;
}) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef
(null);
+ const selected = options.find((o) => o.value === value);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
+ document.addEventListener("mousedown", handler);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", handler);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
return (
{label}
{description && {description}}
-
+
+
+ {open && (
+
+ {options.map((o) => (
+
+ ))}
+
+ )}
+
);
}
@@ -143,11 +179,10 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
Page Layout
update({ pageStyle: v as Settings["pageStyle"] })} />
update({ readingDirection: v as Settings["readingDirection"] })} />
- update({ offsetDoubleSpreads: v })} />
update({ pageGap: v })} />
@@ -174,9 +205,9 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
description="How pages are sized to fit the screen"
value={settings.fitMode ?? "width"}
options={[
- { value: "width", label: "Fit width" },
- { value: "height", label: "Fit height" },
- { value: "screen", label: "Fit screen" },
+ { value: "width", label: "Fit width" },
+ { value: "height", label: "Fit height" },
+ { value: "screen", label: "Fit screen" },
{ value: "original", label: "Original (1:1)" },
]}
onChange={(v) => update({ fitMode: v as FitMode })} />
@@ -203,6 +234,10 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
description="Mark a chapter as read when you reach the last page"
checked={settings.autoMarkRead}
onChange={(v) => update({ autoMarkRead: v })} />
+
update({ autoNextChapter: v })} />
update({ preferredExtensionLang: v })} />
diff --git a/src/store/index.ts b/src/store/index.ts
index 4ca39fe..953e152 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -36,6 +36,7 @@ export interface Settings {
offsetDoubleSpreads: boolean;
preloadPages: number;
autoMarkRead: boolean;
+ autoNextChapter: boolean;
libraryCropCovers: boolean;
libraryPageSize: number;
showNsfw: boolean;
@@ -61,6 +62,7 @@ export const DEFAULT_SETTINGS: Settings = {
offsetDoubleSpreads: false,
preloadPages: 3,
autoMarkRead: true,
+ autoNextChapter: false,
libraryCropCovers: true,
libraryPageSize: 48,
showNsfw: false,