[BETA] Integrated Infinite Scroll & Added Chapter Grid View

This commit is contained in:
Youwes09
2026-02-21 23:15:32 -06:00
parent b921b5eb99
commit 7ab3cf3df3
7 changed files with 564 additions and 133 deletions
+71 -16
View File
@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<img src="src/assets/rounded-logo.png" width="120" /> <img src="src/assets/rounded-logo.png" width="96" />
<h1>Moku</h1> <h1>Moku</h1>
<p>A fast, minimal manga reader frontend for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>, built with Tauri and React.</p> <p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and React.</p>
<table> <table>
<tr> <tr>
@@ -21,18 +21,65 @@
## Features ## Features
- Library management with cover art browsing ### Reader
- Full manga reader with keyboard navigation - **Single**, **double-page**, and **longstrip** reading modes
- Chapter download queue - **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon
- Extension and source management - Fit modes: fit width, fit height, fit screen, and 1:1 original
- Source migration with read progress transfer - Per-series zoom control via Ctrl+scroll or a slider popover
- Cross-source search - RTL / LTR reading direction toggle
- Reading history tracking - Configurable page gaps
- Full keyboard navigation with rebindable keybinds
- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges
- Chapter-relative page counter that updates live as you scroll through the infinite strip
- Auto-mark chapters as read when the last page is reached
### Library
- Grid view of your entire manga collection with lazy-loaded cover art
- Filter tabs: **Saved**, **Downloaded**, and **All**
- Genre tag filter chips — multi-select to narrow by any combination of tags
- In-line search
- Context menu: open, add/remove from library
### Series Detail
- Cover, author, artist, status badge, genres, and synopsis
- Read progress bar with percentage
- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page)
- Chapter list with scanlator, upload date, and in-progress page indicator
- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click
- Sort by newest or oldest first
- Jump-to-chapter input
- Bulk download menu: from current chapter, unread only, or all
- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here
- Collapsible source details panel with source ID, language, and source migration
### Search
- Cross-source search running up to 3 concurrent requests
- Language filter bar (preferred language default, per-language, or all)
- Results grouped by source with skeleton loading states
### Sources & Extensions
- Browse and search installed sources, grouped by extension with per-language expansion
- Extension manager: install, update, remove, and install from external APK URL
- Repo refresh with update count badge
### Downloads
- Download queue with live progress
### History
- Reading history grouped by day with relative timestamps
- Per-entry thumbnail, chapter name, and last-read page
- Full-text search across titles and chapter names
- One-click clear
---
## Requirements ## Requirements
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running at `http://127.0.0.1:4567`. [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
> Note: The application does also launch the server on start-up by itself, so only the package is required on path.
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
---
## Installation ## Installation
@@ -42,7 +89,7 @@
nix run github:Youwes09/moku nix run github:Youwes09/moku
``` ```
Or add to your flake: Add to your flake:
```nix ```nix
inputs.moku.url = "github:Youwes09/moku"; inputs.moku.url = "github:Youwes09/moku";
@@ -57,6 +104,8 @@ nix build
./result/bin/moku ./result/bin/moku
``` ```
---
## Development ## Development
```bash ```bash
@@ -65,18 +114,24 @@ pnpm install
pnpm tauri:dev pnpm tauri:dev
``` ```
> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to set the Vite dev server URL, keeping the release build config clean for `nix build`. > `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`.
---
## Stack ## Stack
| | | | | |
|---|---| |---|---|
| [Tauri v2](https://tauri.app) | App shell | | [Tauri v2](https://tauri.app) | Native app shell |
| [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI | | [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
| [Vite](https://vitejs.dev) | Frontend build | | [Vite](https://vitejs.dev) | Frontend bundler |
| [Zustand](https://zustand-demo.pmnd.rs) | State management | | [Zustand](https://zustand-demo.pmnd.rs) | State management |
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds | | [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
---
## License ## License
Distributed under the [Apache 2.0 License](./LICENSE). Distributed under the [Apache 2.0 License](./LICENSE).
@@ -85,4 +140,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
## Disclaimer ## Disclaimer
Moku does not host any content. The developer(s) of this application have no affiliation with the content providers available freely on the internet. Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
+227 -48
View File
@@ -10,7 +10,7 @@ import {
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries"; } from "../../lib/queries";
import { useStore, type FitMode } from "../../store"; import { useStore, type FitMode } from "../../store";
import { matchesKeybind } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
import s from "./Reader.module.css"; import s from "./Reader.module.css";
function preloadImage(url: string) { function preloadImage(url: string) {
@@ -126,6 +126,16 @@ function ZoomPopover({
} }
// ── Reader ──────────────────────────────────────────────────────────────────── // ── Reader ────────────────────────────────────────────────────────────────────
/** One chapter's worth of pages in the infinite strip */
interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
/** Global page index offset for pages in this strip chunk */
startGlobalIdx: number;
}
export default function Reader() { export default function Reader() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0); const rafRef = useRef(0);
@@ -135,6 +145,11 @@ export default function Reader() {
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const uiRef = useRef<HTMLDivElement>(null); const uiRef = useRef<HTMLDivElement>(null);
// Track which chapters are being fetched so we don't double-fire
const fetchingRef = useRef<Set<number>>(new Set());
// Whether we've already appended the next chapter into the strip
const appendedRef = useRef<Set<number>>(new Set());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dlOpen, setDlOpen] = useState(false); const [dlOpen, setDlOpen] = useState(false);
@@ -143,6 +158,18 @@ export default function Reader() {
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set()); const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
const [pageGroups, setPageGroups] = useState<number[][]>([]); const [pageGroups, setPageGroups] = useState<number[][]>([]);
/**
* The infinite strip: an ordered list of chapter chunks.
* In non-longstrip modes this is unused — only pageUrls matters.
*/
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
/**
* In longstrip autoNext mode, this tracks which chapter the user is
* currently reading (for topbar display) without triggering a full reload.
*/
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
const { const {
activeManga, activeChapter, activeChapterList, activeManga, activeChapter, activeChapterList,
pageUrls, pageNumber, settings, pageUrls, pageNumber, settings,
@@ -182,19 +209,53 @@ export default function Reader() {
containerRef.current?.focus({ preventScroll: true }); containerRef.current?.focus({ preventScroll: true });
}, [activeChapter?.id]); }, [activeChapter?.id]);
// ── Fetch helpers ────────────────────────────────────────────────────────────
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
const cached = pageCache.current.get(chapterId);
if (cached) return cached;
if (fetchingRef.current.has(chapterId)) {
// Poll until another in-flight fetch resolves
return new Promise((resolve) => {
const interval = setInterval(() => {
const c = pageCache.current.get(chapterId);
if (c) { clearInterval(interval); resolve(c); }
}, 50);
});
}
fetchingRef.current.add(chapterId);
const d = await gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId }
);
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(chapterId, urls);
fetchingRef.current.delete(chapterId);
return urls;
}, []);
// ── Load pages ────────────────────────────────────────────────────────────── // ── Load pages ──────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!activeChapter) return; if (!activeChapter) return;
setLoading(true); setError(null); setPageGroups([]); setLoading(true); setError(null); setPageGroups([]);
const cached = pageCache.current.get(activeChapter.id); // Reset strip state for new chapter navigation (non-scroll transitions)
if (cached) { setPageUrls(cached); setLoading(false); return; } appendedRef.current = new Set();
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id })
.then((d) => { fetchPages(activeChapter.id)
const urls = d.fetchChapterPages.pages.map(thumbUrl); .then((urls) => {
pageCache.current.set(activeChapter.id, urls);
setPageUrls(urls); setPageUrls(urls);
if (style === "longstrip" && autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
setStripChapters([]);
setVisibleChapterId(null);
}
}) })
.catch((e) => setError(e.message)) .catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [activeChapter?.id]); }, [activeChapter?.id]);
@@ -263,13 +324,9 @@ export default function Reader() {
useEffect(() => { useEffect(() => {
const preload = (id: number) => { const preload = (id: number) => {
if (pageCache.current.has(id)) return; fetchPages(id)
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id }) .then((urls) => urls.slice(0, 3).forEach(preloadImage))
.then((d) => { .catch(() => {});
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.current.set(id, urls);
urls.slice(0, 2).forEach(preloadImage);
}).catch(() => {});
}; };
if (adjacent.next) preload(adjacent.next.id); if (adjacent.next) preload(adjacent.next.id);
if (adjacent.prev) preload(adjacent.prev.id); if (adjacent.prev) preload(adjacent.prev.id);
@@ -277,6 +334,33 @@ export default function Reader() {
const lastPage = pageUrls.length; const lastPage = pageUrls.length;
/**
* In infinite-strip mode, the topbar shows whichever chapter the user is
* currently scrolled into rather than the "root" chapter we opened with.
*/
const displayChapter = useMemo(() => {
if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter;
return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter;
}, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]);
/**
* In infinite-strip mode, the "last page" shown in the topbar is relative
* to the currently visible chapter chunk.
*/
const visibleChunkLastPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
return chunk ? chunk.urls.length : lastPage;
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]);
/** Page number within the currently visible chapter chunk (for topbar) */
const visibleChunkPage = useMemo(() => {
if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber;
const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id));
if (!chunk) return pageNumber;
return Math.max(1, pageNumber - chunk.startGlobalIdx);
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
// ── Auto-mark read + history ───────────────────────────────────────────────── // ── Auto-mark read + history ─────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!activeChapter || !lastPage) return; if (!activeChapter || !lastPage) return;
@@ -356,27 +440,36 @@ export default function Reader() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return; if ((e.target as HTMLElement).tagName === "INPUT") return;
// Escape: close overlays in priority order, then exit reader
if (e.key === "Escape") { if (e.key === "Escape") {
if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; } e.preventDefault();
if (dlOpen) { e.preventDefault(); setDlOpen(false); return; } if (zoomOpen) { setZoomOpen(false); return; }
if (dlOpen) { setDlOpen(false); return; }
closeReader();
return;
} }
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); } else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); } else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); } else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); } else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); } else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } 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); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]); }, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]);
// ── Longstrip scroll tracker ───────────────────────────────────────────────── // ── Longstrip scroll tracker ─────────────────────────────────────────────────
// Tracks current page number and auto-advances to next chapter at end of scroll // Tracks current page number. In autoNext mode, appends the next chapter's
// pages directly into the strip (no re-render / scroll reset) so the flow
// is one seamless ribbon of images.
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el || style !== "longstrip") return; if (!el || style !== "longstrip") return;
@@ -399,11 +492,63 @@ export default function Reader() {
const n = closest + 1; const n = closest + 1;
if (n !== pageNumRef.current) setPageNumber(n); if (n !== pageNumRef.current) setPageNumber(n);
// Auto-advance: within 80px of bottom and next chapter exists // ── Infinite append ──────────────────────────────────────────────────
if (autoNext && adjacent.next) { if (!autoNext) {
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; // Classic behavior: jump to next chapter at the very end of scroll
if (nearBottom) openReader(adjacent.next, activeChapterList); const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
return;
} }
// Silently update visibleChapterId as we scroll into each chunk
for (const chunk of stripChapters) {
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
if (chunk.chapterId !== visibleChapterId) {
setVisibleChapterId(chunk.chapterId);
// Mark as read when we scroll into a new chapter
if (!markedRead.has(chunk.chapterId) && settings.autoMarkRead) {
const prevChunk = stripChapters[stripChapters.indexOf(chunk) - 1];
if (prevChunk) {
setMarkedRead((r) => new Set(r).add(prevChunk.chapterId));
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
}
}
}
break;
}
}
// Append next chapter 300px before we hit the bottom of the last chunk
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
if (!nearBottom) return;
// What's the last chapter currently in the strip?
const lastChunk = stripChapters[stripChapters.length - 1];
if (!lastChunk) return;
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return;
const nextChEntry = activeChapterList[lastChunkIdx + 1];
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
// Mark immediately so concurrent scroll events don't double-append
appendedRef.current.add(nextChEntry.id);
// Fetch (likely already cached from preload) then append to strip
fetchPages(nextChEntry.id).then((urls) => {
setStripChapters((prev) => {
const lastInPrev = prev[prev.length - 1];
const newStart = lastInPrev
? lastInPrev.startGlobalIdx + lastInPrev.urls.length
: 0;
return [
...prev,
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
];
});
}).catch(console.error);
}); });
}; };
@@ -412,17 +557,38 @@ export default function Reader() {
el.removeEventListener("scroll", onScroll); el.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
}; };
}, [style, autoNext, adjacent.next?.id, activeChapterList]); }, [style, autoNext, stripChapters, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
// Reset scroll position when switching chapters in non-longstrip modes // Reset scroll position when switching chapters in non-longstrip modes
useEffect(() => { useEffect(() => {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [pageNumber, style]); }, [pageNumber, style]);
// When switching to longstrip, reset scroll to top // When switching to longstrip, reset scroll to top and rebuild strip from current chapter
useEffect(() => { useEffect(() => {
if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; if (style === "longstrip" && containerRef.current) {
}, [activeChapter?.id, style]); containerRef.current.scrollTop = 0;
if (activeChapter && pageUrls.length > 0) {
appendedRef.current = new Set();
if (autoNext) {
setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls: pageUrls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id);
} else {
// Plain longstrip — no multi-chapter strip
setStripChapters([]);
setVisibleChapterId(null);
}
}
} else if (style !== "longstrip") {
setStripChapters([]);
setVisibleChapterId(null);
}
}, [activeChapter?.id, style, autoNext]);
function handleTap(e: React.MouseEvent) { function handleTap(e: React.MouseEvent) {
if (style === "longstrip") return; if (style === "longstrip") return;
@@ -492,9 +658,9 @@ export default function Reader() {
<span className={s.chLabel}> <span className={s.chLabel}>
<span className={s.chTitle}>{activeManga?.title}</span> <span className={s.chTitle}>{activeManga?.title}</span>
<span className={s.chSep}>/</span> <span className={s.chSep}>/</span>
<span>{activeChapter?.name}</span> <span>{displayChapter?.name}</span>
</span> </span>
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span> <span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
<button <button
className={s.iconBtn} className={s.iconBtn}
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
@@ -590,17 +756,30 @@ export default function Reader() {
}} }}
> >
{style === "longstrip" ? ( {style === "longstrip" ? (
pageUrls.map((url, i) => ( <>
<img {(autoNext && stripChapters.length > 0 ? stripChapters : [{
key={`${activeChapter?.id}-${i}`} chapterId: activeChapter?.id ?? 0,
src={url} chapterName: activeChapter?.name ?? "",
alt={`Page ${i + 1}`} urls: pageUrls,
data-page={i + 1} startGlobalIdx: 0,
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")} }]).map((chunk) =>
loading={i < 3 ? "eager" : "lazy"} chunk.urls.map((url, i) => {
decoding="async" const globalIdx = chunk.startGlobalIdx + i;
/> return (
)) <img
key={`${chunk.chapterId}-${i}`}
src={url}
alt={`${chunk.chapterName} Page ${i + 1}`}
data-page={globalIdx + 1}
data-chapter={chunk.chapterId}
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
loading={globalIdx < 3 ? "eager" : "lazy"}
decoding="async"
/>
);
})
)}
</>
) : ( ) : (
<img <img
key={pageNumber} key={pageNumber}
+121 -1
View File
@@ -573,4 +573,124 @@
color: var(--text-faint); font-size: 10px; background: none; color: var(--text-faint); font-size: 10px; background: none;
transition: color var(--t-base), background var(--t-base); transition: color var(--t-base), background var(--t-base);
} }
.jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); } .jumpCancel:hover { color: var(--text-muted); background: var(--bg-raised); }
/* ── View mode toggle ── */
.viewToggleBtn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
color: var(--text-faint);
background: none;
cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.viewToggleBtn:hover { color: var(--text-muted); background: var(--bg-raised); border-color: var(--border-dim); }
.viewToggleActive { color: var(--accent-fg) !important; background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
/* ── Chapter grid ── */
.grid {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 5px;
align-content: start;
}
.gridCell {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-raised);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast);
}
.gridCell:hover {
border-color: var(--accent);
background: var(--bg-overlay);
transform: scale(1.04);
z-index: 1;
}
/* Unread — subtle, inviting */
.gridCellNum {
font-family: var(--font-ui);
font-size: var(--text-2xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-tight);
color: var(--text-secondary);
line-height: 1;
position: relative;
z-index: 1;
}
/* Read — dimmed, clearly consumed */
.gridCellRead {
background: var(--bg-base);
border-color: var(--border-dim);
}
.gridCellRead .gridCellNum {
color: var(--text-faint);
}
.gridCellRead::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(135deg, transparent 60%, rgba(var(--accent-rgb, 100 130 255) / 0.08) 100%);
pointer-events: none;
}
/* In-progress — accent highlight on bottom edge */
.gridCellInProgress {
border-color: var(--accent-dim);
background: var(--bg-raised);
}
.gridCellInProgress .gridCellNum {
color: var(--accent-fg);
}
.gridCellInProgress::before {
content: "";
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px;
background: var(--accent);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* Read indicator dot (top-right corner) */
.gridCellDot {
position: absolute; top: 3px; right: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--text-faint);
}
/* Bookmark indicator dot */
.gridCellBookmarked { border-color: var(--accent-dim); }
.gridCellBookmarkDot {
position: absolute; top: 3px; left: 3px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--accent);
}
/* Spinner overlay for enqueueing */
.gridCellSpinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.3);
color: var(--text-faint);
}
/* Skeleton for grid loading state */
.gridCellSkeleton {
aspect-ratio: 1;
border-radius: var(--radius-sm);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
display: flex; align-items: center; justify-content: center;
padding: var(--sp-2);
}
+77 -21
View File
@@ -3,6 +3,7 @@ import {
ArrowLeft, BookmarkSimple, Download, CheckCircle, ArrowLeft, BookmarkSimple, Download, CheckCircle,
ArrowSquareOut, BookOpen, CircleNotch, Play, ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise, SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { import {
@@ -52,6 +53,7 @@ export default function SeriesDetail() {
const [ctx, setCtx] = useState<CtxState | null>(null); const [ctx, setCtx] = useState<CtxState | null>(null);
const [jumpOpen, setJumpOpen] = useState(false); const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState(""); const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const sortDir = settings.chapterSortDir; const sortDir = settings.chapterSortDir;
@@ -334,20 +336,33 @@ export default function SeriesDetail() {
{/* ── Chapter list ── */} {/* ── Chapter list ── */}
<div className={s.listWrap}> <div className={s.listWrap}>
<div className={s.listHeader}> <div className={s.listHeader}>
<button <div style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
className={s.sortBtn} <button
onClick={() => { className={s.sortBtn}
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); onClick={() => {
setChapterPage(1); updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
}} setChapterPage(1);
title={sortDir === "desc" ? "Newest first" : "Oldest first"} }}
> title={sortDir === "desc" ? "Newest first" : "Oldest first"}
{sortDir === "desc" >
? <SortDescending size={14} weight="light" /> {sortDir === "desc"
: <SortAscending size={14} weight="light" /> ? <SortDescending size={14} weight="light" />
} : <SortAscending size={14} weight="light" />
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span> }
</button> <span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
</button>
<button
className={[s.viewToggleBtn, viewMode === "grid" ? s.viewToggleActive : ""].join(" ")}
onClick={() => setViewMode((v) => v === "list" ? "grid" : "list")}
title={viewMode === "list" ? "Switch to grid view" : "Switch to list view"}
>
{viewMode === "list"
? <SquaresFour size={14} weight="light" />
: <List size={14} weight="light" />
}
</button>
</div>
<div className={s.listHeaderRight}> <div className={s.listHeaderRight}>
{/* Jump to chapter */} {/* Jump to chapter */}
@@ -448,14 +463,55 @@ export default function SeriesDetail() {
</div> </div>
</div> </div>
<div className={s.list}> <div className={viewMode === "grid" ? s.grid : s.list}>
{loadingChapters && chapters.length === 0 ? ( {loadingChapters && chapters.length === 0 ? (
Array.from({ length: 8 }).map((_, i) => ( viewMode === "grid" ? (
<div key={i} className={s.rowSkeleton}> Array.from({ length: 24 }).map((_, i) => (
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} /> <div key={i} className={s.gridCellSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} /> <div className="skeleton" style={{ width: "60%", height: 10, borderRadius: 3 }} />
</div> </div>
)) ))
) : (
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={s.rowSkeleton}>
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "55%", height: 12 }} />
<div className={["skeleton", s.skLine].join(" ")} style={{ width: "25%", height: 11 }} />
</div>
))
)
) : viewMode === "grid" ? (
sortedChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch);
const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0;
return (
<button
key={ch.id}
className={[
s.gridCell,
ch.isRead ? s.gridCellRead : "",
inProgress ? s.gridCellInProgress : "",
ch.isBookmarked ? s.gridCellBookmarked : "",
].filter(Boolean).join(" ")}
onClick={() => openReader(ch, sortedChapters)}
onContextMenu={(e) => openContextMenu(e, ch, idxInSorted)}
title={ch.name}
>
<span className={s.gridCellNum}>
{ch.chapterNumber % 1 === 0
? ch.chapterNumber.toFixed(0)
: ch.chapterNumber.toString()}
</span>
{ch.isRead && <span className={s.gridCellDot} />}
{inProgress && <span className={s.gridCellProgress} style={{ width: `${Math.min(100, ((ch.lastPageRead ?? 0) / 1) * 100)}%` }} />}
{ch.isBookmarked && <span className={s.gridCellBookmarkDot} />}
{enqueueing.has(ch.id) && (
<span className={s.gridCellSpinner}>
<CircleNotch size={10} weight="light" className="anim-spin" />
</span>
)}
</button>
);
})
) : ( ) : (
pageChapters.map((ch) => { pageChapters.map((ch) => {
const idxInSorted = sortedChapters.indexOf(ch); const idxInSorted = sortedChapters.indexOf(ch);
+7 -1
View File
@@ -422,6 +422,12 @@ export default function SettingsModal() {
const updateSettings = useStore((s) => s.updateSettings); const updateSettings = useStore((s) => s.updateSettings);
const resetKeybinds = useStore((s) => s.resetKeybinds); const resetKeybinds = useStore((s) => s.resetKeybinds);
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(null);
// Scroll to top on every tab switch
useEffect(() => {
contentBodyRef.current?.scrollTo({ top: 0 });
}, [tab]);
const handleBackdrop = useCallback( const handleBackdrop = useCallback(
(e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); }, (e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); },
@@ -455,7 +461,7 @@ export default function SettingsModal() {
<p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p> <p className={s.contentTitle}>{TABS.find((t) => t.id === tab)?.label}</p>
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button> <button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
</div> </div>
<div className={s.contentBody}> <div className={s.contentBody} ref={contentBodyRef}>
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />} {tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />} {tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />} {tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
+20 -4
View File
@@ -1,8 +1,24 @@
const SUWAYOMI = "http://127.0.0.1:4567"; const DEFAULT_URL = "http://127.0.0.1:4567";
const GQL = `${SUWAYOMI}/api/graphql`;
function getServerUrl(): string {
// Read from persisted Zustand store if available, fall back to default
try {
const raw = localStorage.getItem("moku-settings");
if (raw) {
const parsed = JSON.parse(raw);
const url = parsed?.state?.settings?.serverUrl;
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
}
} catch {}
return DEFAULT_URL;
}
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
export function thumbUrl(path: string): string { export function thumbUrl(path: string): string {
return `${SUWAYOMI}${path}`; if (!path) return "";
if (path.startsWith("http")) return path;
return `${getServerUrl()}${path}`;
} }
interface GQLResponse<T> { interface GQLResponse<T> {
@@ -28,7 +44,7 @@ export async function gql<T>(
query: string, query: string,
variables?: Record<string, unknown> variables?: Record<string, unknown>
): Promise<T> { ): Promise<T> {
const res = await fetchWithRetry(GQL, { const res = await fetchWithRetry(gqlUrl(), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
+41 -42
View File
@@ -1,52 +1,45 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
export interface Keybinds { export interface Keybinds {
pageRight: string; pageRight: string;
pageLeft: string; pageLeft: string;
firstPage: string; firstPage: string;
lastPage: string; lastPage: string;
chapterRight: string; chapterRight: string;
chapterLeft: string; chapterLeft: string;
exitReader: string; exitReader: string;
close: string; toggleReadingDirection: string;
toggleReadingDirection: string; togglePageStyle: string;
togglePageStyle: string; toggleFullscreen: string;
toggleOffsetDoubleSpreads: string; openSettings: string;
toggleFullscreen: string;
openSettings: string;
toggleSidebar: string;
} }
export const DEFAULT_KEYBINDS: Keybinds = { export const DEFAULT_KEYBINDS: Keybinds = {
pageRight: "ArrowRight", pageRight: "ArrowRight",
pageLeft: "ArrowLeft", pageLeft: "ArrowLeft",
firstPage: "ctrl+ArrowLeft", firstPage: "ctrl+ArrowLeft",
lastPage: "ctrl+ArrowRight", lastPage: "ctrl+ArrowRight",
chapterRight: "]", chapterRight: "]",
chapterLeft: "[", chapterLeft: "[",
exitReader: "Backspace", exitReader: "Backspace",
close: "Escape", toggleReadingDirection: "d",
toggleReadingDirection: "d", togglePageStyle: "q",
togglePageStyle: "q", toggleFullscreen: "f",
toggleOffsetDoubleSpreads: "u", openSettings: "o",
toggleFullscreen: "f",
openSettings: "o",
toggleSidebar: "s",
}; };
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = { export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
pageRight: "Turn page right", pageRight: "Turn page right",
pageLeft: "Turn page left", pageLeft: "Turn page left",
firstPage: "First page", firstPage: "Jump to first page",
lastPage: "Last page", lastPage: "Jump to last page",
chapterRight: "Change chapter right", chapterRight: "Next chapter",
chapterLeft: "Change chapter left", chapterLeft: "Previous chapter",
exitReader: "Exit reader", exitReader: "Exit reader",
close: "Close", toggleReadingDirection: "Toggle reading direction",
toggleReadingDirection: "Toggle reading direction", togglePageStyle: "Toggle page style",
togglePageStyle: "Toggle page style", toggleFullscreen: "Toggle fullscreen",
toggleOffsetDoubleSpreads: "Toggle double page offset", openSettings: "Open settings",
toggleFullscreen: "Toggle fullscreen",
openSettings: "Show settings menu",
toggleSidebar: "Toggle sidebar",
}; };
export function eventToKeybind(e: KeyboardEvent): string { export function eventToKeybind(e: KeyboardEvent): string {
@@ -62,4 +55,10 @@ export function eventToKeybind(e: KeyboardEvent): string {
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
return eventToKeybind(e) === bind; return eventToKeybind(e) === bind;
}
export async function toggleFullscreen(): Promise<void> {
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
} }