mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
[BETA] Integrated Infinite Scroll & Added Chapter Grid View
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img src="src/assets/rounded-logo.png" width="120" />
|
||||
<img src="src/assets/rounded-logo.png" width="96" />
|
||||
<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>
|
||||
<tr>
|
||||
@@ -21,18 +21,65 @@
|
||||
|
||||
## Features
|
||||
|
||||
- Library management with cover art browsing
|
||||
- Full manga reader with keyboard navigation
|
||||
- Chapter download queue
|
||||
- Extension and source management
|
||||
- Source migration with read progress transfer
|
||||
- Cross-source search
|
||||
- Reading history tracking
|
||||
### Reader
|
||||
- **Single**, **double-page**, and **longstrip** reading modes
|
||||
- **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
|
||||
- Fit modes: fit width, fit height, fit screen, and 1:1 original
|
||||
- Per-series zoom control via Ctrl+scroll or a slider popover
|
||||
- RTL / LTR reading direction toggle
|
||||
- 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
|
||||
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running 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.
|
||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
||||
|
||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -42,7 +89,7 @@
|
||||
nix run github:Youwes09/moku
|
||||
```
|
||||
|
||||
Or add to your flake:
|
||||
Add to your flake:
|
||||
|
||||
```nix
|
||||
inputs.moku.url = "github:Youwes09/moku";
|
||||
@@ -57,6 +104,8 @@ nix build
|
||||
./result/bin/moku
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -65,18 +114,24 @@ pnpm install
|
||||
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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [Vite](https://vitejs.dev) | Frontend build |
|
||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||
| [Zustand](https://zustand-demo.pmnd.rs) | State management |
|
||||
| [Phosphor Icons](https://phosphoricons.com) | Icon set |
|
||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
@@ -85,4 +140,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
|
||||
|
||||
## 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
@@ -10,7 +10,7 @@ import {
|
||||
ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD,
|
||||
} from "../../lib/queries";
|
||||
import { useStore, type FitMode } from "../../store";
|
||||
import { matchesKeybind } from "../../lib/keybinds";
|
||||
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
|
||||
import s from "./Reader.module.css";
|
||||
|
||||
function preloadImage(url: string) {
|
||||
@@ -126,6 +126,16 @@ function ZoomPopover({
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rafRef = useRef(0);
|
||||
@@ -135,6 +145,11 @@ export default function Reader() {
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(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 [error, setError] = useState<string | null>(null);
|
||||
const [dlOpen, setDlOpen] = useState(false);
|
||||
@@ -143,6 +158,18 @@ export default function Reader() {
|
||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
||||
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 {
|
||||
activeManga, activeChapter, activeChapterList,
|
||||
pageUrls, pageNumber, settings,
|
||||
@@ -182,19 +209,53 @@ export default function Reader() {
|
||||
containerRef.current?.focus({ preventScroll: true });
|
||||
}, [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 ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter) return;
|
||||
setLoading(true); setError(null); setPageGroups([]);
|
||||
const cached = pageCache.current.get(activeChapter.id);
|
||||
if (cached) { setPageUrls(cached); setLoading(false); return; }
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id })
|
||||
.then((d) => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(activeChapter.id, urls);
|
||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||
appendedRef.current = new Set();
|
||||
|
||||
fetchPages(activeChapter.id)
|
||||
.then((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));
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
@@ -263,13 +324,9 @@ export default function Reader() {
|
||||
|
||||
useEffect(() => {
|
||||
const preload = (id: number) => {
|
||||
if (pageCache.current.has(id)) return;
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id })
|
||||
.then((d) => {
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(id, urls);
|
||||
urls.slice(0, 2).forEach(preloadImage);
|
||||
}).catch(() => {});
|
||||
fetchPages(id)
|
||||
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
|
||||
.catch(() => {});
|
||||
};
|
||||
if (adjacent.next) preload(adjacent.next.id);
|
||||
if (adjacent.prev) preload(adjacent.prev.id);
|
||||
@@ -277,6 +334,33 @@ export default function Reader() {
|
||||
|
||||
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 ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter || !lastPage) return;
|
||||
@@ -356,27 +440,36 @@ export default function Reader() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
|
||||
// Escape: close overlays in priority order, then exit reader
|
||||
if (e.key === "Escape") {
|
||||
if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; }
|
||||
if (dlOpen) { e.preventDefault(); setDlOpen(false); return; }
|
||||
e.preventDefault();
|
||||
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(); }
|
||||
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(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
||||
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
||||
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
||||
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
||||
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||
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(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
||||
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.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);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]);
|
||||
|
||||
// ── 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(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || style !== "longstrip") return;
|
||||
@@ -399,11 +492,63 @@ export default function Reader() {
|
||||
const n = closest + 1;
|
||||
if (n !== pageNumRef.current) setPageNumber(n);
|
||||
|
||||
// Auto-advance: within 80px of bottom and next chapter exists
|
||||
if (autoNext && adjacent.next) {
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||
if (nearBottom) openReader(adjacent.next, activeChapterList);
|
||||
// ── Infinite append ──────────────────────────────────────────────────
|
||||
if (!autoNext) {
|
||||
// Classic behavior: jump to next chapter at the very end of scroll
|
||||
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);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [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(() => {
|
||||
if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||
}, [activeChapter?.id, style]);
|
||||
if (style === "longstrip" && containerRef.current) {
|
||||
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) {
|
||||
if (style === "longstrip") return;
|
||||
@@ -492,9 +658,9 @@ export default function Reader() {
|
||||
<span className={s.chLabel}>
|
||||
<span className={s.chTitle}>{activeManga?.title}</span>
|
||||
<span className={s.chSep}>/</span>
|
||||
<span>{activeChapter?.name}</span>
|
||||
<span>{displayChapter?.name}</span>
|
||||
</span>
|
||||
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
|
||||
<span className={s.pageLabel}>{visibleChunkPage} / {visibleChunkLastPage || "…"}</span>
|
||||
<button
|
||||
className={s.iconBtn}
|
||||
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
|
||||
@@ -590,17 +756,30 @@ export default function Reader() {
|
||||
}}
|
||||
>
|
||||
{style === "longstrip" ? (
|
||||
pageUrls.map((url, i) => (
|
||||
<img
|
||||
key={`${activeChapter?.id}-${i}`}
|
||||
src={url}
|
||||
alt={`Page ${i + 1}`}
|
||||
data-page={i + 1}
|
||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
||||
loading={i < 3 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{(autoNext && stripChapters.length > 0 ? stripChapters : [{
|
||||
chapterId: activeChapter?.id ?? 0,
|
||||
chapterName: activeChapter?.name ?? "",
|
||||
urls: pageUrls,
|
||||
startGlobalIdx: 0,
|
||||
}]).map((chunk) =>
|
||||
chunk.urls.map((url, i) => {
|
||||
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
|
||||
key={pageNumber}
|
||||
|
||||
@@ -573,4 +573,124 @@
|
||||
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); }
|
||||
.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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||
List, SquaresFour,
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ export default function SeriesDetail() {
|
||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||
const [jumpOpen, setJumpOpen] = useState(false);
|
||||
const [jumpInput, setJumpInput] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
|
||||
const sortDir = settings.chapterSortDir;
|
||||
|
||||
@@ -334,20 +336,33 @@ export default function SeriesDetail() {
|
||||
{/* ── Chapter list ── */}
|
||||
<div className={s.listWrap}>
|
||||
<div className={s.listHeader}>
|
||||
<button
|
||||
className={s.sortBtn}
|
||||
onClick={() => {
|
||||
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
|
||||
setChapterPage(1);
|
||||
}}
|
||||
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
|
||||
>
|
||||
{sortDir === "desc"
|
||||
? <SortDescending size={14} weight="light" />
|
||||
: <SortAscending size={14} weight="light" />
|
||||
}
|
||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||
</button>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)" }}>
|
||||
<button
|
||||
className={s.sortBtn}
|
||||
onClick={() => {
|
||||
updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" });
|
||||
setChapterPage(1);
|
||||
}}
|
||||
title={sortDir === "desc" ? "Newest first" : "Oldest first"}
|
||||
>
|
||||
{sortDir === "desc"
|
||||
? <SortDescending size={14} weight="light" />
|
||||
: <SortAscending size={14} weight="light" />
|
||||
}
|
||||
<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}>
|
||||
{/* Jump to chapter */}
|
||||
@@ -448,14 +463,55 @@ export default function SeriesDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.list}>
|
||||
<div className={viewMode === "grid" ? s.grid : s.list}>
|
||||
{loadingChapters && chapters.length === 0 ? (
|
||||
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" ? (
|
||||
Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className={s.gridCellSkeleton}>
|
||||
<div className="skeleton" style={{ width: "60%", height: 10, borderRadius: 3 }} />
|
||||
</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) => {
|
||||
const idxInSorted = sortedChapters.indexOf(ch);
|
||||
|
||||
@@ -422,6 +422,12 @@ export default function SettingsModal() {
|
||||
const updateSettings = useStore((s) => s.updateSettings);
|
||||
const resetKeybinds = useStore((s) => s.resetKeybinds);
|
||||
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(
|
||||
(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>
|
||||
<button className={s.closeBtn} onClick={closeSettings}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
<div className={s.contentBody}>
|
||||
<div className={s.contentBody} ref={contentBodyRef}>
|
||||
{tab === "general" && <GeneralTab settings={settings} update={updateSettings} />}
|
||||
{tab === "reader" && <ReaderTab settings={settings} update={updateSettings} />}
|
||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||
|
||||
+20
-4
@@ -1,8 +1,24 @@
|
||||
const SUWAYOMI = "http://127.0.0.1:4567";
|
||||
const GQL = `${SUWAYOMI}/api/graphql`;
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
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 {
|
||||
return `${SUWAYOMI}${path}`;
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
@@ -28,7 +44,7 @@ export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(GQL, {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
|
||||
+41
-42
@@ -1,52 +1,45 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
export interface Keybinds {
|
||||
pageRight: string;
|
||||
pageLeft: string;
|
||||
firstPage: string;
|
||||
lastPage: string;
|
||||
chapterRight: string;
|
||||
chapterLeft: string;
|
||||
exitReader: string;
|
||||
close: string;
|
||||
toggleReadingDirection: string;
|
||||
togglePageStyle: string;
|
||||
toggleOffsetDoubleSpreads: string;
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
toggleSidebar: string;
|
||||
pageRight: string;
|
||||
pageLeft: string;
|
||||
firstPage: string;
|
||||
lastPage: string;
|
||||
chapterRight: string;
|
||||
chapterLeft: string;
|
||||
exitReader: string;
|
||||
toggleReadingDirection: string;
|
||||
togglePageStyle: string;
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
pageRight: "ArrowRight",
|
||||
pageLeft: "ArrowLeft",
|
||||
firstPage: "ctrl+ArrowLeft",
|
||||
lastPage: "ctrl+ArrowRight",
|
||||
chapterRight: "]",
|
||||
chapterLeft: "[",
|
||||
exitReader: "Backspace",
|
||||
close: "Escape",
|
||||
toggleReadingDirection: "d",
|
||||
togglePageStyle: "q",
|
||||
toggleOffsetDoubleSpreads: "u",
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
toggleSidebar: "s",
|
||||
pageRight: "ArrowRight",
|
||||
pageLeft: "ArrowLeft",
|
||||
firstPage: "ctrl+ArrowLeft",
|
||||
lastPage: "ctrl+ArrowRight",
|
||||
chapterRight: "]",
|
||||
chapterLeft: "[",
|
||||
exitReader: "Backspace",
|
||||
toggleReadingDirection: "d",
|
||||
togglePageStyle: "q",
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
pageRight: "Turn page right",
|
||||
pageLeft: "Turn page left",
|
||||
firstPage: "First page",
|
||||
lastPage: "Last page",
|
||||
chapterRight: "Change chapter right",
|
||||
chapterLeft: "Change chapter left",
|
||||
exitReader: "Exit reader",
|
||||
close: "Close",
|
||||
toggleReadingDirection: "Toggle reading direction",
|
||||
togglePageStyle: "Toggle page style",
|
||||
toggleOffsetDoubleSpreads: "Toggle double page offset",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Show settings menu",
|
||||
toggleSidebar: "Toggle sidebar",
|
||||
pageRight: "Turn page right",
|
||||
pageLeft: "Turn page left",
|
||||
firstPage: "Jump to first page",
|
||||
lastPage: "Jump to last page",
|
||||
chapterRight: "Next chapter",
|
||||
chapterLeft: "Previous chapter",
|
||||
exitReader: "Exit reader",
|
||||
toggleReadingDirection: "Toggle reading direction",
|
||||
togglePageStyle: "Toggle page style",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Open settings",
|
||||
};
|
||||
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
@@ -62,4 +55,10 @@ export function eventToKeybind(e: KeyboardEvent): string {
|
||||
|
||||
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||
return eventToKeybind(e) === bind;
|
||||
}
|
||||
|
||||
export async function toggleFullscreen(): Promise<void> {
|
||||
const win = getCurrentWindow();
|
||||
const isFs = await win.isFullscreen();
|
||||
await win.setFullscreen(!isFs);
|
||||
}
|
||||
Reference in New Issue
Block a user