diff --git a/Todo b/Todo index e69de29..aa7d937 100644 --- a/Todo +++ b/Todo @@ -0,0 +1,22 @@ +Todo: +1. Check all Keybind Toggles +2. Update ReadME with Comprehensive Feature List +3. Explore Manga Upscaler +4. Add Zoom-Slider for Zoom in Manga Reader + + +Bugs: +2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic) +3. Patch Chapters to Grid View +5. Fix Keybind Toggles + +Features: +1. Frecency based Manga Suggestions +2. Proper Explore Tab + + +Big Revisions: +1. Anime & Novel Support + +Test: +1. URL & Extension Additions \ No newline at end of file diff --git a/flake.lock b/flake.lock index f2895c9..b3e4811 100644 --- a/flake.lock +++ b/flake.lock @@ -120,29 +120,12 @@ "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1751274312, - "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.11", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "crane": "crane", "flake-parts": "flake-parts", "nix-appimage": "nix-appimage", "nixpkgs": "nixpkgs", - "nixpkgs-stable": "nixpkgs-stable", "rust-overlay": "rust-overlay" } }, diff --git a/flake.nix b/flake.nix index 3ee9f22..7e6b410 100644 --- a/flake.nix +++ b/flake.nix @@ -110,18 +110,14 @@ cargoToml = ./src-tauri/Cargo.toml; cargoLock = ./src-tauri/Cargo.lock; strictDeps = true; - buildInputs = runtimeLibs; - nativeBuildInputs = with pkgs; [ pkg-config wrapGAppsHook3 ]; - preBuild = '' cp -r ${frontend} ../dist ''; - WEBKIT_DISABLE_COMPOSITING_MODE = "1"; }; @@ -129,9 +125,7 @@ moku = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; - meta.mainProgram = "moku"; - postInstall = '' wrapProgram $out/bin/moku \ --prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [ @@ -153,7 +147,6 @@ devShells.default = pkgs.mkShell { buildInputs = runtimeLibs; - nativeBuildInputs = with pkgs; [ rustToolchain pkg-config @@ -163,7 +156,6 @@ suwayomi-server xdg-utils ]; - shellHook = '' export WEBKIT_DISABLE_COMPOSITING_MODE=1 export APPIMAGE_EXTRACT_AND_RUN=1 diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index eb00fcb..0000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 88abb37..0000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 4b3c956..0000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index eeb8903..0000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index da113eb..0000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 77d4c2e..0000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index 9cef848..0000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 09aaf2a..0000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index b17b33f..0000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24..0000000 --- a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 14d2123..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 6e40136..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index a30f348..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index a03036a..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index f09ce47..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 642780b..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 26c0293..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 3c9f7fa..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index ebaa520..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 50ad721..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 6fd630b..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 49270df..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 724879f..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 8957c13..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index e3b1135..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223..0000000 --- a/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 983a6a2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index 36dd0f2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index 36dd0f2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index fc94be6..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index f34584f..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 9779412..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 9779412..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index 7c668c6..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index 36dd0f2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index 7cc327a..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index 7cc327a..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 3c15d3c..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 3e3e73c..0000000 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 3c15d3c..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index 6fea863..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index bdf86ed..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 163db88..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index eca72fc..0000000 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json index 1434a4f..6f7aaec 100644 --- a/src-tauri/tauri.dev.conf.json +++ b/src-tauri/tauri.dev.conf.json @@ -2,5 +2,12 @@ "build": { "devUrl": "http://localhost:1420", "beforeDevCommand": "pnpm dev" + }, + "app": { + "windows": [ + { + "devtools": true + } + ] } } \ No newline at end of file diff --git a/src/components/context/ContextMenu.tsx b/src/components/context/ContextMenu.tsx index 2811baf..9dd648b 100644 --- a/src/components/context/ContextMenu.tsx +++ b/src/components/context/ContextMenu.tsx @@ -51,13 +51,20 @@ export default function ContextMenu({ x, y, items, onClose }: Props) { }; }, [onClose]); - // Adjust position so menu doesn't clip outside viewport + // Adjust position so menu doesn't clip outside viewport. + // Compensate for CSS zoom (applied via document.documentElement.style.zoom) + // because clientX/Y are pre-zoom pixels while `position:fixed` is post-zoom. const style = useCallback(() => { - const menuW = 200; - const menuH = items.length * 32; - const left = x + menuW > window.innerWidth ? x - menuW : x; - const top = y + menuH > window.innerHeight ? y - menuH : y; - return { left, top }; + const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1; + const scaledX = x / zoom; + const scaledY = y / zoom; + const menuW = 200; + const menuH = items.length * 36; + const vw = window.innerWidth / zoom; + const vh = window.innerHeight / zoom; + const left = scaledX + menuW > vw ? scaledX - menuW : scaledX; + const top = scaledY + menuH > vh ? scaledY - menuH : scaledY; + return { left: Math.max(4, left), top: Math.max(4, top) }; }, [x, y, items.length]); return createPortal( diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index 81df873..30a8544 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useMemo, useCallback, memo } from "react"; import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; -import { GET_LIBRARY, GET_ALL_MANGA } from "../../lib/queries"; +import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { useStore } from "../../store"; import type { LibraryFilter } from "../../store"; import type { Manga } from "../../lib/types"; +import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import s from "./Library.module.css"; const INITIAL_PAGE_SIZE = 48; @@ -14,14 +15,16 @@ const PAGE_INCREMENT = 48; const MangaCard = memo(function MangaCard({ manga, onClick, + onContextMenu, cropCovers, }: { manga: Manga; onClick: () => void; + onContextMenu: (e: React.MouseEvent) => void; cropCovers: boolean; }) { return ( - - setNextN(Math.max(1, Number(e.target.value)))} - onClick={(e) => e.stopPropagation()} /> +
e.stopPropagation()}> + + {nextN} + +
+ + ); +} + // ── Reader ──────────────────────────────────────────────────────────────────── export default function Reader() { - const containerRef = useRef(null); - const rafRef = useRef(0); - const pageNumRef = useRef(1); - const pageCache = useRef>(new Map()); - const aspectCache = useRef>(new Map()); + const containerRef = useRef(null); + const rafRef = useRef(0); + const pageNumRef = useRef(1); + const pageCache = useRef>(new Map()); + const aspectCache = useRef>(new Map()); + const hideTimerRef = useRef | null>(null); + const uiRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dlOpen, setDlOpen] = useState(false); + const [zoomOpen, setZoomOpen] = useState(false); + const [uiVisible, setUiVisible] = useState(true); const [markedRead, setMarkedRead] = useState>(new Set()); const [pageGroups, setPageGroups] = useState([]); @@ -102,14 +150,38 @@ export default function Reader() { updateSettings, addHistory, } = useStore(); - const kb = settings.keybinds; - const rtl = settings.readingDirection === "rtl"; - const fit = settings.fitMode ?? "width"; - const style = settings.pageStyle ?? "single"; - const maxW = settings.maxPageWidth ?? 900; + const kb = settings.keybinds; + 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; useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]); + // ── UI autohide ────────────────────────────────────────────────────────────── + const scheduleHide = useCallback(() => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000); + }, []); + + const showUi = useCallback(() => { + setUiVisible(true); + scheduleHide(); + }, [scheduleHide]); + + useEffect(() => { + scheduleHide(); + return () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }; + }, []); + + + + // ── Auto-focus viewer so spacebar/arrows work ─────────────────────────────── + useEffect(() => { + containerRef.current?.focus({ preventScroll: true }); + }, [activeChapter?.id]); + // ── Load pages ────────────────────────────────────────────────────────────── useEffect(() => { if (!activeChapter) return; @@ -127,8 +199,8 @@ export default function Reader() { }, [activeChapter?.id]); // ── Double-page grouping ───────────────────────────────────────────────────── - // Rule: page 1 (cover) always solo. Wide pages (aspect>1.2) always solo. - // Normal portrait pages pair with next portrait page. + // Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo. + // Remaining portrait pages pair left-to-right: [2,3], [4,5], ... useEffect(() => { if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; } let cancelled = false; @@ -145,18 +217,20 @@ export default function Reader() { } if (cancelled) return; const groups: number[][] = []; - // Page 1 always solo (cover) groups.push([1]); let i = 2; while (i <= pageUrls.length) { const a = aspects[i - 1]; - if (a > 1.2 || i === pageUrls.length) { - // Wide or last page — solo + if (a > 1.2) { + groups.push([i]); i++; + } else if (i === pageUrls.length) { groups.push([i]); i++; } else { - const next = aspects[i]; // aspects[i] = page i+1 (0-indexed) - if (next !== undefined && next <= 1.2) { - groups.push([i, i + 1]); i += 2; + const nextA = aspects[i]; + if (nextA !== undefined && nextA <= 1.2) { + // Book order: left page is i, right page is i+1 + groups.push(rtl ? [i + 1, i] : [i, i + 1]); + i += 2; } else { groups.push([i]); i++; } @@ -165,12 +239,7 @@ export default function Reader() { setPageGroups(groups); })(); return () => { cancelled = true; }; - }, [pageUrls, style, settings.offsetDoubleSpreads]); - - const currentGroup = useMemo(() => { - if (style !== "double" || !pageGroups.length) return null; - return pageGroups.find((g) => g.includes(pageNumber)) ?? null; - }, [pageGroups, pageNumber, style]); + }, [pageUrls, style, settings.offsetDoubleSpreads, rtl]); // ── Preload ───────────────────────────────────────────────────────────────── useEffect(() => { @@ -230,7 +299,7 @@ export default function Reader() { const gi = pageGroups.findIndex((g) => g.includes(pageNumber)); if (forward) { if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]); - else if (adjacent.next) openReader(adjacent.next, activeChapterList); + else if (adjacent.next) { setPageNumber(1); openReader(adjacent.next, activeChapterList); } else closeReader(); } else { if (gi > 0) setPageNumber(pageGroups[gi - 1][0]); @@ -240,9 +309,14 @@ export default function Reader() { const goForward = useCallback(() => { if (style === "double" && pageGroups.length) { advanceGroup(true); return; } - if (pageNumber < lastPage) setPageNumber(pageNumber + 1); - else if (adjacent.next) openReader(adjacent.next, activeChapterList); - else closeReader(); + if (pageNumber < lastPage) { + setPageNumber(pageNumber + 1); + } else if (adjacent.next) { + setPageNumber(1); + openReader(adjacent.next, activeChapterList); + } else { + closeReader(); + } }, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]); const goBack = useCallback(() => { @@ -255,8 +329,9 @@ export default function Reader() { const goPrev = rtl ? goForward : goBack; function cycleStyle() { - const cycle = ["single", "double", "longstrip"] as const; - const next = cycle[(cycle.indexOf(style as any) + 1) % cycle.length]; + const cycle = ["single", "longstrip"] as const; + const cur = style === "double" ? "single" : style; + const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length]; updateSettings({ pageStyle: next }); } @@ -265,7 +340,7 @@ export default function Reader() { updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] }); } - // Ctrl+scroll → zoom maxPageWidth + // ── Ctrl+scroll → zoom ─────────────────────────────────────────────────────── useEffect(() => { const onWheel = (e: WheelEvent) => { if (!e.ctrlKey) return; @@ -277,54 +352,78 @@ export default function Reader() { return () => window.removeEventListener("wheel", onWheel); }, [maxW]); - // Keybinds + // ── Keybinds ───────────────────────────────────────────────────────────────── useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.target as HTMLElement).tagName === "INPUT") 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(); } - else if (matchesKeybind(e, kb.toggleReadingDirection)){ e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } - else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } + if (e.key === "Escape") { + if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; } + if (dlOpen) { e.preventDefault(); setDlOpen(false); 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(); } + else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } + 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]); + }, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]); - // Longstrip scroll — rAF throttled, no flushSync + // ── Longstrip scroll tracker ───────────────────────────────────────────────── + // Tracks current page number and auto-advances to next chapter at end of scroll useEffect(() => { const el = containerRef.current; if (!el || style !== "longstrip") return; + const onScroll = () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { if (!el) return; - const midY = el.scrollTop + el.clientHeight * 0.5; - let cumH = 0; - const children = Array.from(el.children) as HTMLElement[]; - for (let i = 0; i < children.length; i++) { - cumH += children[i].clientHeight; - if (cumH >= midY) { - const n = i + 1; - if (n !== pageNumRef.current) setPageNumber(n); - break; - } + const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[]; + + // Find the image whose center is closest to the viewport center + const viewMid = el.scrollTop + el.clientHeight * 0.5; + let closest = 0; + let closestDist = Infinity; + for (let i = 0; i < imgs.length; i++) { + const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5; + const dist = Math.abs(imgMid - viewMid); + if (dist < closestDist) { closestDist = dist; closest = i; } + } + 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); } }); }; - el.addEventListener("scroll", onScroll, { passive: true }); - return () => { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); }; - }, [style]); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + cancelAnimationFrame(rafRef.current); + }; + }, [style, autoNext, adjacent.next?.id, activeChapterList]); + + // 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 + useEffect(() => { + if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; + }, [activeChapter?.id, style]); + function handleTap(e: React.MouseEvent) { if (style === "longstrip") return; const x = e.clientX / window.innerWidth; @@ -344,25 +443,6 @@ export default function Reader() { settings.optimizeContrast && s.optimizeContrast, ].filter(Boolean).join(" "); - // ── Double page render ──────────────────────────────────────────────────────── - function renderDouble() { - if (!currentGroup) { - return {`Page; - } - const ordered = rtl ? [...currentGroup].reverse() : currentGroup; - const [left, right] = ordered; - return ( -
- {`Page - {right && ( - {`Page - )} -
- ); - } - // ── Icons ──────────────────────────────────────────────────────────────────── const fitIcon = fit === "width" ? : @@ -372,10 +452,7 @@ export default function Reader() { const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]; - const styleIcon = - style === "single" ? : - style === "double" ? : - ; + const styleIcon = style === "single" ? : ; if (loading) return (
@@ -388,14 +465,28 @@ export default function Reader() { ); return ( -
+
{ + const fromTop = e.clientY; + const fromBottom = window.innerHeight - e.clientY; + if (fromTop < 60 || fromBottom < 60) showUi(); + }} + > {/* ── Topbar ── */} -
+
- @@ -404,8 +495,12 @@ export default function Reader() { {activeChapter?.name} {pageNumber} / {lastPage || "…"} - @@ -417,17 +512,31 @@ export default function Reader() { {fitLabel} - {/* Zoom — click resets */} - + {/* Zoom */} +
+ + {zoomOpen && ( + updateSettings({ maxPageWidth: v })} + onReset={() => updateSettings({ maxPageWidth: 900 })} + onClose={() => setZoomOpen(false)} + /> + )} +
{/* RTL */} @@ -438,16 +547,28 @@ export default function Reader() { {style} - {/* Page gap toggle — only meaningful in double/longstrip */} + {/* Page gap toggle */} {style !== "single" && ( )} + {/* Auto-next chapter */} + {style === "longstrip" && ( + + )} + {/* Download */} 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,