[BETA] QOL Updates (Reader AutoScroll WIP)
@@ -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
|
||||||
@@ -120,29 +120,12 @@
|
|||||||
"type": "github"
|
"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": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"crane": "crane",
|
"crane": "crane",
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nix-appimage": "nix-appimage",
|
"nix-appimage": "nix-appimage",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,18 +110,14 @@
|
|||||||
cargoToml = ./src-tauri/Cargo.toml;
|
cargoToml = ./src-tauri/Cargo.toml;
|
||||||
cargoLock = ./src-tauri/Cargo.lock;
|
cargoLock = ./src-tauri/Cargo.lock;
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
|
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
pkg-config
|
pkg-config
|
||||||
wrapGAppsHook3
|
wrapGAppsHook3
|
||||||
];
|
];
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r ${frontend} ../dist
|
cp -r ${frontend} ../dist
|
||||||
'';
|
'';
|
||||||
|
|
||||||
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,9 +125,7 @@
|
|||||||
|
|
||||||
moku = craneLib.buildPackage (commonArgs // {
|
moku = craneLib.buildPackage (commonArgs // {
|
||||||
inherit cargoArtifacts;
|
inherit cargoArtifacts;
|
||||||
|
|
||||||
meta.mainProgram = "moku";
|
meta.mainProgram = "moku";
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
wrapProgram $out/bin/moku \
|
wrapProgram $out/bin/moku \
|
||||||
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
--prefix XDG_DATA_DIRS : "${lib.makeSearchPath "share/gsettings-schemas" [
|
||||||
@@ -153,7 +147,6 @@
|
|||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = runtimeLibs;
|
buildInputs = runtimeLibs;
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
rustToolchain
|
rustToolchain
|
||||||
pkg-config
|
pkg-config
|
||||||
@@ -163,7 +156,6 @@
|
|||||||
suwayomi-server
|
suwayomi-server
|
||||||
xdg-utils
|
xdg-utils
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 577 B |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#fff</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 374 B |
|
Before Width: | Height: | Size: 946 B |
|
Before Width: | Height: | Size: 946 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 528 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 946 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
@@ -2,5 +2,12 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"beforeDevCommand": "pnpm dev"
|
"beforeDevCommand": "pnpm dev"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"devtools": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,13 +51,20 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [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 style = useCallback(() => {
|
||||||
const menuW = 200;
|
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||||
const menuH = items.length * 32;
|
const scaledX = x / zoom;
|
||||||
const left = x + menuW > window.innerWidth ? x - menuW : x;
|
const scaledY = y / zoom;
|
||||||
const top = y + menuH > window.innerHeight ? y - menuH : y;
|
const menuW = 200;
|
||||||
return { left, top };
|
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]);
|
}, [x, y, items.length]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
import { useEffect, useState, useMemo, useCallback, memo } from "react";
|
||||||
import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react";
|
import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
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 { useStore } from "../../store";
|
||||||
import type { LibraryFilter } from "../../store";
|
import type { LibraryFilter } from "../../store";
|
||||||
import type { Manga } from "../../lib/types";
|
import type { Manga } from "../../lib/types";
|
||||||
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
import s from "./Library.module.css";
|
import s from "./Library.module.css";
|
||||||
|
|
||||||
const INITIAL_PAGE_SIZE = 48;
|
const INITIAL_PAGE_SIZE = 48;
|
||||||
@@ -14,14 +15,16 @@ const PAGE_INCREMENT = 48;
|
|||||||
const MangaCard = memo(function MangaCard({
|
const MangaCard = memo(function MangaCard({
|
||||||
manga,
|
manga,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
cropCovers,
|
cropCovers,
|
||||||
}: {
|
}: {
|
||||||
manga: Manga;
|
manga: Manga;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onContextMenu: (e: React.MouseEvent) => void;
|
||||||
cropCovers: boolean;
|
cropCovers: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button className={s.card} onClick={onClick}>
|
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||||
<div className={s.coverWrap}>
|
<div className={s.coverWrap}>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(manga.thumbnailUrl)}
|
src={thumbUrl(manga.thumbnailUrl)}
|
||||||
@@ -46,6 +49,7 @@ export default function Library() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
||||||
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
@@ -99,6 +103,37 @@ export default function Library() {
|
|||||||
[setActiveManga]
|
[setActiveManga]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function removeFromLibrary(manga: Manga) {
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
|
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCtx(e: React.MouseEvent, m: Manga) {
|
||||||
|
e.preventDefault();
|
||||||
|
const menuW = 200;
|
||||||
|
const menuH = 96;
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
|
||||||
|
setCtx({ x, y, manga: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Open",
|
||||||
|
onClick: () => setActiveManga(m),
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||||
|
danger: m.inLibrary,
|
||||||
|
onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
|
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
||||||
|
.catch(console.error),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// All genres present in current library
|
// All genres present in current library
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
@@ -208,6 +243,7 @@ export default function Library() {
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
manga={m}
|
manga={m}
|
||||||
onClick={handleCardClick(m)}
|
onClick={handleCardClick(m)}
|
||||||
|
onContextMenu={(e) => openCtx(e, m)}
|
||||||
cropCovers={settings.libraryCropCovers}
|
cropCovers={settings.libraryCropCovers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -225,6 +261,14 @@ export default function Library() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{ctx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctx.x}
|
||||||
|
y={ctx.y}
|
||||||
|
items={buildCtxItems(ctx.manga)}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,23 @@
|
|||||||
transform: translateZ(0); will-change: transform;
|
transform: translateZ(0); will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── UI autohide ── */
|
||||||
|
.uiHidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.topbar, .bottombar {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Topbar ── */
|
/* ── Topbar ── */
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex; align-items: center; gap: var(--sp-1);
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
padding: 0 var(--sp-3); height: 40px;
|
padding: 0 var(--sp-3); height: 40px;
|
||||||
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
background: var(--bg-void); border-bottom: 1px solid var(--border-dim);
|
||||||
flex-shrink: 0; overflow: hidden;
|
flex-shrink: 0; overflow: visible;
|
||||||
|
position: relative; z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconBtn {
|
.iconBtn {
|
||||||
@@ -55,15 +66,61 @@
|
|||||||
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
.modeBtnLabel { text-transform: capitalize; }
|
.modeBtnLabel { text-transform: capitalize; }
|
||||||
|
|
||||||
|
/* ── Zoom ── */
|
||||||
|
.zoomWrap {
|
||||||
|
position: relative; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.zoomBtn {
|
.zoomBtn {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
letter-spacing: var(--tracking-wide); color: var(--text-faint);
|
||||||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||||||
flex-shrink: 0; min-width: 36px; text-align: center;
|
min-width: 36px; text-align: center;
|
||||||
transition: color var(--t-base), background var(--t-base);
|
transition: color var(--t-base), background var(--t-base);
|
||||||
}
|
}
|
||||||
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.zoomPopover {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 100; min-width: 160px;
|
||||||
|
animation: scaleIn 0.1s ease both; transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomSlider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 140px; height: 3px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 2px; outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.zoomSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zoomSlider::-moz-range-thumb {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%; border: none;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomResetBtn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 2px var(--sp-2); border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
/* ── Viewer ── */
|
/* ── Viewer ── */
|
||||||
.viewer {
|
.viewer {
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||||
@@ -163,11 +220,26 @@
|
|||||||
|
|
||||||
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
.dlRow { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.dlInput {
|
.dlStepper {
|
||||||
width: 48px; padding: 4px var(--sp-2);
|
display: flex; align-items: center; gap: 2px;
|
||||||
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-sm); color: var(--text-secondary);
|
border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0;
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
|
||||||
text-align: center; outline: none;
|
|
||||||
}
|
}
|
||||||
.dlInput:focus { border-color: var(--border-focus); }
|
|
||||||
|
.dlStepBtn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 22px; height: 28px;
|
||||||
|
font-size: var(--text-base); color: var(--text-muted);
|
||||||
|
background: none; border: none; cursor: pointer; line-height: 1;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.dlStepBtn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
|
||||||
|
.dlStepVal {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary); min-width: 24px; text-align: center;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
/* Viewer focus — suppress outline since we're handling keys ourselves */
|
||||||
|
.viewer:focus { outline: none; }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||||
Square, Columns, Rows, Download, ArrowsLeftRight,
|
Square, Rows, Download, ArrowsLeftRight,
|
||||||
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
@@ -17,7 +17,6 @@ function preloadImage(url: string) {
|
|||||||
const img = new Image(); img.src = url;
|
const img = new Image(); img.src = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns aspect ratio once image loads; wide (>1.2 w:h) = likely double spread
|
|
||||||
function measureAspect(url: string): Promise<number> {
|
function measureAspect(url: string): Promise<number> {
|
||||||
return new Promise((res) => {
|
return new Promise((res) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -64,10 +63,15 @@ function DownloadModal({
|
|||||||
Next chapters
|
Next chapters
|
||||||
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
<span className={s.dlSub}>{Math.min(nextN, remaining.length)} queued</span>
|
||||||
</button>
|
</button>
|
||||||
<input type="number" className={s.dlInput} min={1}
|
<div className={s.dlStepper} onClick={(e) => e.stopPropagation()}>
|
||||||
max={remaining.length || 1} value={nextN}
|
<button className={s.dlStepBtn}
|
||||||
onChange={(e) => setNextN(Math.max(1, Number(e.target.value)))}
|
onClick={() => setNextN((n) => Math.max(1, n - 1))}
|
||||||
onClick={(e) => e.stopPropagation()} />
|
disabled={nextN <= 1}>−</button>
|
||||||
|
<span className={s.dlStepVal}>{nextN}</span>
|
||||||
|
<button className={s.dlStepBtn}
|
||||||
|
onClick={() => setNextN((n) => Math.min(remaining.length || 1, n + 1))}
|
||||||
|
disabled={nextN >= remaining.length}>+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={s.dlOption} disabled={busy || !remaining.length}
|
<button className={s.dlOption} disabled={busy || !remaining.length}
|
||||||
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
onClick={() => run(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, {
|
||||||
@@ -81,17 +85,61 @@ function DownloadModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zoom slider popover ───────────────────────────────────────────────────────
|
||||||
|
function ZoomPopover({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.zoomPopover} ref={ref}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.zoomSlider}
|
||||||
|
min={200}
|
||||||
|
max={2400}
|
||||||
|
step={50}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<button className={s.zoomResetBtn} onClick={onReset}>
|
||||||
|
{Math.round((value / 900) * 100)}%
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reader ────────────────────────────────────────────────────────────────────
|
// ── Reader ────────────────────────────────────────────────────────────────────
|
||||||
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);
|
||||||
const pageNumRef = useRef(1);
|
const pageNumRef = useRef(1);
|
||||||
const pageCache = useRef<Map<number, string[]>>(new Map());
|
const pageCache = useRef<Map<number, string[]>>(new Map());
|
||||||
const aspectCache = useRef<Map<string, number>>(new Map());
|
const aspectCache = useRef<Map<string, number>>(new Map());
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const uiRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
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);
|
||||||
|
const [zoomOpen, setZoomOpen] = useState(false);
|
||||||
|
const [uiVisible, setUiVisible] = useState(true);
|
||||||
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[][]>([]);
|
||||||
|
|
||||||
@@ -102,14 +150,38 @@ export default function Reader() {
|
|||||||
updateSettings, addHistory,
|
updateSettings, addHistory,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
const kb = settings.keybinds;
|
const kb = settings.keybinds;
|
||||||
const rtl = settings.readingDirection === "rtl";
|
const rtl = settings.readingDirection === "rtl";
|
||||||
const fit = settings.fitMode ?? "width";
|
const fit = settings.fitMode ?? "width";
|
||||||
const style = settings.pageStyle ?? "single";
|
const style = settings.pageStyle ?? "single";
|
||||||
const maxW = settings.maxPageWidth ?? 900;
|
const maxW = settings.maxPageWidth ?? 900;
|
||||||
|
const autoNext = settings.autoNextChapter ?? false;
|
||||||
|
|
||||||
useEffect(() => { pageNumRef.current = pageNumber; }, [pageNumber]);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Load pages ──────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChapter) return;
|
if (!activeChapter) return;
|
||||||
@@ -127,8 +199,8 @@ export default function Reader() {
|
|||||||
}, [activeChapter?.id]);
|
}, [activeChapter?.id]);
|
||||||
|
|
||||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
// ── Double-page grouping ─────────────────────────────────────────────────────
|
||||||
// Rule: page 1 (cover) always solo. Wide pages (aspect>1.2) always solo.
|
// Page 1 (cover) always solo. Wide pages (aspect > 1.2) always solo.
|
||||||
// Normal portrait pages pair with next portrait page.
|
// Remaining portrait pages pair left-to-right: [2,3], [4,5], ...
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -145,18 +217,20 @@ export default function Reader() {
|
|||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const groups: number[][] = [];
|
const groups: number[][] = [];
|
||||||
// Page 1 always solo (cover)
|
|
||||||
groups.push([1]);
|
groups.push([1]);
|
||||||
let i = 2;
|
let i = 2;
|
||||||
while (i <= pageUrls.length) {
|
while (i <= pageUrls.length) {
|
||||||
const a = aspects[i - 1];
|
const a = aspects[i - 1];
|
||||||
if (a > 1.2 || i === pageUrls.length) {
|
if (a > 1.2) {
|
||||||
// Wide or last page — solo
|
groups.push([i]); i++;
|
||||||
|
} else if (i === pageUrls.length) {
|
||||||
groups.push([i]); i++;
|
groups.push([i]); i++;
|
||||||
} else {
|
} else {
|
||||||
const next = aspects[i]; // aspects[i] = page i+1 (0-indexed)
|
const nextA = aspects[i];
|
||||||
if (next !== undefined && next <= 1.2) {
|
if (nextA !== undefined && nextA <= 1.2) {
|
||||||
groups.push([i, i + 1]); i += 2;
|
// Book order: left page is i, right page is i+1
|
||||||
|
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
|
||||||
|
i += 2;
|
||||||
} else {
|
} else {
|
||||||
groups.push([i]); i++;
|
groups.push([i]); i++;
|
||||||
}
|
}
|
||||||
@@ -165,12 +239,7 @@ export default function Reader() {
|
|||||||
setPageGroups(groups);
|
setPageGroups(groups);
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pageUrls, style, settings.offsetDoubleSpreads]);
|
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
||||||
|
|
||||||
const currentGroup = useMemo(() => {
|
|
||||||
if (style !== "double" || !pageGroups.length) return null;
|
|
||||||
return pageGroups.find((g) => g.includes(pageNumber)) ?? null;
|
|
||||||
}, [pageGroups, pageNumber, style]);
|
|
||||||
|
|
||||||
// ── Preload ─────────────────────────────────────────────────────────────────
|
// ── Preload ─────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,7 +299,7 @@ export default function Reader() {
|
|||||||
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
const gi = pageGroups.findIndex((g) => g.includes(pageNumber));
|
||||||
if (forward) {
|
if (forward) {
|
||||||
if (gi < pageGroups.length - 1) setPageNumber(pageGroups[gi + 1][0]);
|
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 closeReader();
|
||||||
} else {
|
} else {
|
||||||
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
if (gi > 0) setPageNumber(pageGroups[gi - 1][0]);
|
||||||
@@ -240,9 +309,14 @@ export default function Reader() {
|
|||||||
|
|
||||||
const goForward = useCallback(() => {
|
const goForward = useCallback(() => {
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
if (pageNumber < lastPage) setPageNumber(pageNumber + 1);
|
if (pageNumber < lastPage) {
|
||||||
else if (adjacent.next) openReader(adjacent.next, activeChapterList);
|
setPageNumber(pageNumber + 1);
|
||||||
else closeReader();
|
} else if (adjacent.next) {
|
||||||
|
setPageNumber(1);
|
||||||
|
openReader(adjacent.next, activeChapterList);
|
||||||
|
} else {
|
||||||
|
closeReader();
|
||||||
|
}
|
||||||
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
@@ -255,8 +329,9 @@ export default function Reader() {
|
|||||||
const goPrev = rtl ? goForward : goBack;
|
const goPrev = rtl ? goForward : goBack;
|
||||||
|
|
||||||
function cycleStyle() {
|
function cycleStyle() {
|
||||||
const cycle = ["single", "double", "longstrip"] as const;
|
const cycle = ["single", "longstrip"] as const;
|
||||||
const next = cycle[(cycle.indexOf(style as any) + 1) % cycle.length];
|
const cur = style === "double" ? "single" : style;
|
||||||
|
const next = cycle[(cycle.indexOf(cur as any) + 1) % cycle.length];
|
||||||
updateSettings({ pageStyle: next });
|
updateSettings({ pageStyle: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +340,7 @@ export default function Reader() {
|
|||||||
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
|
updateSettings({ fitMode: cycle[(cycle.indexOf(fit) + 1) % cycle.length] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+scroll → zoom maxPageWidth
|
// ── Ctrl+scroll → zoom ───────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
if (!e.ctrlKey) return;
|
if (!e.ctrlKey) return;
|
||||||
@@ -277,54 +352,78 @@ export default function Reader() {
|
|||||||
return () => window.removeEventListener("wheel", onWheel);
|
return () => window.removeEventListener("wheel", onWheel);
|
||||||
}, [maxW]);
|
}, [maxW]);
|
||||||
|
|
||||||
// Keybinds
|
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
||||||
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;
|
||||||
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
if (e.key === "Escape") {
|
||||||
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; }
|
||||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
if (dlOpen) { e.preventDefault(); setDlOpen(false); return; }
|
||||||
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); }
|
if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
|
||||||
else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); }
|
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
|
||||||
else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
|
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); }
|
||||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
|
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); }
|
||||||
else if (matchesKeybind(e, kb.toggleReadingDirection)){ e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); }
|
else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); }
|
||||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); }
|
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);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("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(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || style !== "longstrip") return;
|
if (!el || style !== "longstrip") return;
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const midY = el.scrollTop + el.clientHeight * 0.5;
|
const imgs = Array.from(el.querySelectorAll("img[data-page]")) as HTMLElement[];
|
||||||
let cumH = 0;
|
|
||||||
const children = Array.from(el.children) as HTMLElement[];
|
// Find the image whose center is closest to the viewport center
|
||||||
for (let i = 0; i < children.length; i++) {
|
const viewMid = el.scrollTop + el.clientHeight * 0.5;
|
||||||
cumH += children[i].clientHeight;
|
let closest = 0;
|
||||||
if (cumH >= midY) {
|
let closestDist = Infinity;
|
||||||
const n = i + 1;
|
for (let i = 0; i < imgs.length; i++) {
|
||||||
if (n !== pageNumRef.current) setPageNumber(n);
|
const imgMid = imgs[i].offsetTop + imgs[i].offsetHeight * 0.5;
|
||||||
break;
|
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(() => {
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
|
||||||
|
}, [activeChapter?.id, style]);
|
||||||
|
|
||||||
function handleTap(e: React.MouseEvent) {
|
function handleTap(e: React.MouseEvent) {
|
||||||
if (style === "longstrip") return;
|
if (style === "longstrip") return;
|
||||||
const x = e.clientX / window.innerWidth;
|
const x = e.clientX / window.innerWidth;
|
||||||
@@ -344,25 +443,6 @@ export default function Reader() {
|
|||||||
settings.optimizeContrast && s.optimizeContrast,
|
settings.optimizeContrast && s.optimizeContrast,
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
// ── Double page render ────────────────────────────────────────────────────────
|
|
||||||
function renderDouble() {
|
|
||||||
if (!currentGroup) {
|
|
||||||
return <img src={pageUrls[pageNumber - 1]} alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />;
|
|
||||||
}
|
|
||||||
const ordered = rtl ? [...currentGroup].reverse() : currentGroup;
|
|
||||||
const [left, right] = ordered;
|
|
||||||
return (
|
|
||||||
<div className={s.doubleWrap}>
|
|
||||||
<img src={pageUrls[left - 1]} alt={`Page ${left}`}
|
|
||||||
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapLeft : ""].join(" ")} decoding="async" />
|
|
||||||
{right && (
|
|
||||||
<img src={pageUrls[right - 1]} alt={`Page ${right}`}
|
|
||||||
className={[imgCls, s.pageHalf, settings.pageGap ? s.gapRight : ""].join(" ")} decoding="async" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||||
const fitIcon =
|
const fitIcon =
|
||||||
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
fit === "width" ? <ArrowsLeftRight size={14} weight="light" /> :
|
||||||
@@ -372,10 +452,7 @@ export default function Reader() {
|
|||||||
|
|
||||||
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
const fitLabel = { width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit];
|
||||||
|
|
||||||
const styleIcon =
|
const styleIcon = style === "single" ? <Square size={14} weight="light" /> : <Rows size={14} weight="light" />;
|
||||||
style === "single" ? <Square size={14} weight="light" /> :
|
|
||||||
style === "double" ? <Columns size={14} weight="light" /> :
|
|
||||||
<Rows size={14} weight="light" />;
|
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className={s.center}>
|
<div className={s.center}>
|
||||||
@@ -388,14 +465,28 @@ export default function Reader() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div
|
||||||
|
className={s.root}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const fromTop = e.clientY;
|
||||||
|
const fromBottom = window.innerHeight - e.clientY;
|
||||||
|
if (fromTop < 60 || fromBottom < 60) showUi();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* ── Topbar ── */}
|
{/* ── Topbar ── */}
|
||||||
<div className={s.topbar}>
|
<div
|
||||||
|
ref={uiRef}
|
||||||
|
className={[s.topbar, uiVisible ? "" : s.uiHidden].join(" ")}
|
||||||
|
>
|
||||||
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
|
<button className={s.iconBtn} onClick={closeReader} title="Close reader">
|
||||||
<X size={15} weight="light" />
|
<X size={15} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<button className={s.iconBtn} onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
|
<button
|
||||||
disabled={!adjacent.prev} title="Previous chapter">
|
className={s.iconBtn}
|
||||||
|
onClick={() => adjacent.prev && openReader(adjacent.prev, activeChapterList)}
|
||||||
|
disabled={!adjacent.prev}
|
||||||
|
title="Previous chapter"
|
||||||
|
>
|
||||||
<CaretLeft size={14} weight="light" />
|
<CaretLeft size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
<span className={s.chLabel}>
|
<span className={s.chLabel}>
|
||||||
@@ -404,8 +495,12 @@ export default function Reader() {
|
|||||||
<span>{activeChapter?.name}</span>
|
<span>{activeChapter?.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
|
<span className={s.pageLabel}>{pageNumber} / {lastPage || "…"}</span>
|
||||||
<button className={s.iconBtn} onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
|
<button
|
||||||
disabled={!adjacent.next} title="Next chapter">
|
className={s.iconBtn}
|
||||||
|
onClick={() => adjacent.next && openReader(adjacent.next, activeChapterList)}
|
||||||
|
disabled={!adjacent.next}
|
||||||
|
title="Next chapter"
|
||||||
|
>
|
||||||
<CaretRight size={14} weight="light" />
|
<CaretRight size={14} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -417,17 +512,31 @@ export default function Reader() {
|
|||||||
<span className={s.modeBtnLabel}>{fitLabel}</span>
|
<span className={s.modeBtnLabel}>{fitLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Zoom — click resets */}
|
{/* Zoom */}
|
||||||
<button className={s.zoomBtn} onClick={() => updateSettings({ maxPageWidth: 900 })}
|
<div className={s.zoomWrap}>
|
||||||
title="Click to reset zoom (Ctrl+scroll to zoom)">
|
<button
|
||||||
{Math.round((maxW / 900) * 100)}%
|
className={s.zoomBtn}
|
||||||
</button>
|
onClick={() => setZoomOpen((o) => !o)}
|
||||||
|
title="Zoom (click for slider, Ctrl+scroll)"
|
||||||
|
>
|
||||||
|
{Math.round((maxW / 900) * 100)}%
|
||||||
|
</button>
|
||||||
|
{zoomOpen && (
|
||||||
|
<ZoomPopover
|
||||||
|
value={maxW}
|
||||||
|
onChange={(v) => updateSettings({ maxPageWidth: v })}
|
||||||
|
onReset={() => updateSettings({ maxPageWidth: 900 })}
|
||||||
|
onClose={() => setZoomOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* RTL */}
|
{/* RTL */}
|
||||||
<button
|
<button
|
||||||
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
className={[s.modeBtn, rtl ? s.modeBtnActive : ""].join(" ")}
|
||||||
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
|
onClick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}
|
||||||
title={`Direction: ${rtl ? "RTL" : "LTR"}`}>
|
title={`Direction: ${rtl ? "RTL" : "LTR"}`}
|
||||||
|
>
|
||||||
<ArrowsLeftRight size={14} weight="light" />
|
<ArrowsLeftRight size={14} weight="light" />
|
||||||
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
<span className={s.modeBtnLabel}>{rtl ? "RTL" : "LTR"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -438,16 +547,28 @@ export default function Reader() {
|
|||||||
<span className={s.modeBtnLabel}>{style}</span>
|
<span className={s.modeBtnLabel}>{style}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Page gap toggle — only meaningful in double/longstrip */}
|
{/* Page gap toggle */}
|
||||||
{style !== "single" && (
|
{style !== "single" && (
|
||||||
<button
|
<button
|
||||||
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
className={[s.modeBtn, settings.pageGap ? s.modeBtnActive : ""].join(" ")}
|
||||||
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
|
onClick={() => updateSettings({ pageGap: !settings.pageGap })}
|
||||||
title="Toggle page gap">
|
title="Toggle page gap"
|
||||||
|
>
|
||||||
<span className={s.modeBtnLabel}>Gap</span>
|
<span className={s.modeBtnLabel}>Gap</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Auto-next chapter */}
|
||||||
|
{style === "longstrip" && (
|
||||||
|
<button
|
||||||
|
className={[s.modeBtn, autoNext ? s.modeBtnActive : ""].join(" ")}
|
||||||
|
onClick={() => updateSettings({ autoNextChapter: !autoNext })}
|
||||||
|
title="Auto-advance to next chapter"
|
||||||
|
>
|
||||||
|
<span className={s.modeBtnLabel}>Auto</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Download */}
|
{/* Download */}
|
||||||
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
<button className={s.modeBtn} onClick={() => setDlOpen(true)} title="Download options">
|
||||||
<Download size={14} weight="light" />
|
<Download size={14} weight="light" />
|
||||||
@@ -457,29 +578,42 @@ export default function Reader() {
|
|||||||
{/* ── Viewer ── */}
|
{/* ── Viewer ── */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={[
|
className={[s.viewer, style === "longstrip" ? s.viewerStrip : ""].join(" ")}
|
||||||
s.viewer,
|
|
||||||
style === "longstrip" ? s.viewerStrip : "",
|
|
||||||
].join(" ")}
|
|
||||||
style={cssVars}
|
style={cssVars}
|
||||||
|
tabIndex={-1}
|
||||||
onClick={handleTap}
|
onClick={handleTap}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === " " && style === "longstrip") {
|
||||||
|
e.preventDefault();
|
||||||
|
containerRef.current?.scrollBy({ top: containerRef.current.clientHeight * 0.85, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{style === "longstrip" ? (
|
{style === "longstrip" ? (
|
||||||
pageUrls.map((url, i) => (
|
pageUrls.map((url, i) => (
|
||||||
<img key={i} src={url} alt={`Page ${i + 1}`}
|
<img
|
||||||
|
key={`${activeChapter?.id}-${i}`}
|
||||||
|
src={url}
|
||||||
|
alt={`Page ${i + 1}`}
|
||||||
|
data-page={i + 1}
|
||||||
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
className={[imgCls, settings.pageGap ? s.stripGap : ""].join(" ")}
|
||||||
loading={i < 3 ? "eager" : "lazy"} decoding="async" />
|
loading={i < 3 ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : style === "double" ? (
|
|
||||||
renderDouble()
|
|
||||||
) : (
|
) : (
|
||||||
<img key={pageNumber} src={pageUrls[pageNumber - 1]}
|
<img
|
||||||
alt={`Page ${pageNumber}`} className={imgCls} decoding="async" />
|
key={pageNumber}
|
||||||
|
src={pageUrls[pageNumber - 1]}
|
||||||
|
alt={`Page ${pageNumber}`}
|
||||||
|
className={imgCls}
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Bottom nav ── */}
|
{/* ── Bottom nav ── */}
|
||||||
<div className={s.bottombar}>
|
<div className={[s.bottombar, uiVisible ? "" : s.uiHidden].join(" ")}>
|
||||||
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
<button className={s.navBtn} onClick={goPrev} disabled={pageNumber === 1 && !adjacent.prev}>
|
||||||
<ArrowLeft size={13} weight="light" />
|
<ArrowLeft size={13} weight="light" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -516,3 +516,61 @@
|
|||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
|
/* ── 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); }
|
||||||
@@ -50,6 +50,8 @@ export default function SeriesDetail() {
|
|||||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
||||||
const [chapterPage, setChapterPage] = useState(1);
|
const [chapterPage, setChapterPage] = useState(1);
|
||||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||||
|
const [jumpOpen, setJumpOpen] = useState(false);
|
||||||
|
const [jumpInput, setJumpInput] = useState("");
|
||||||
|
|
||||||
const sortDir = settings.chapterSortDir;
|
const sortDir = settings.chapterSortDir;
|
||||||
|
|
||||||
@@ -104,10 +106,13 @@ export default function SeriesDetail() {
|
|||||||
const continueChapter = useMemo(() => {
|
const continueChapter = useMemo(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
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);
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
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);
|
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 };
|
return { chapter: asc[0], type: "reread" as const };
|
||||||
}, [chapters]);
|
}, [chapters]);
|
||||||
|
|
||||||
@@ -291,49 +296,6 @@ export default function SeriesDetail() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chapters.length > 0 && (
|
|
||||||
<div className={s.downloadSection}>
|
|
||||||
<button className={s.downloadToggle} onClick={() => setDlOpen((p) => !p)}>
|
|
||||||
<Download size={13} weight="light" />
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
{dlOpen && (
|
|
||||||
<div className={s.downloadMenu}>
|
|
||||||
{continueChapter && (
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const from = sortedChapters.indexOf(continueChapter.chapter);
|
|
||||||
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>From current</span>
|
|
||||||
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Unread chapters</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
|
||||||
</button>
|
|
||||||
<button className={s.dlItem}
|
|
||||||
onClick={() => {
|
|
||||||
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
|
||||||
enqueueMultiple(ids);
|
|
||||||
setDlOpen(false);
|
|
||||||
}}>
|
|
||||||
<span>Download all</span>
|
|
||||||
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className={s.chapterCount}>
|
<p className={s.chapterCount}>
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
</p>
|
</p>
|
||||||
@@ -387,21 +349,103 @@ export default function SeriesDetail() {
|
|||||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
<div className={s.listHeaderRight}>
|
||||||
<div className={s.pagination}>
|
{/* Jump to chapter */}
|
||||||
<button
|
{chapters.length > 1 && (
|
||||||
className={s.pageBtn}
|
<div className={s.jumpWrap}>
|
||||||
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
{!jumpOpen ? (
|
||||||
disabled={chapterPage === 1}
|
<button className={s.jumpToggle} onClick={() => { setJumpOpen(true); setJumpInput(""); }}>
|
||||||
>←</button>
|
Go to…
|
||||||
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
</button>
|
||||||
<button
|
) : (
|
||||||
className={s.pageBtn}
|
<div className={s.jumpRow}>
|
||||||
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
<input
|
||||||
disabled={chapterPage === totalPages}
|
className={s.jumpInput}
|
||||||
>→</button>
|
type="text"
|
||||||
</div>
|
placeholder="Ch. #"
|
||||||
)}
|
value={jumpInput}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className={s.jumpCancel} onClick={() => setJumpOpen(false)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download menu */}
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<div className={s.dlWrap}>
|
||||||
|
<button className={s.dlToggleBtn} onClick={() => setDlOpen((p) => !p)}>
|
||||||
|
<Download size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{dlOpen && (
|
||||||
|
<div className={s.dlDropdown}>
|
||||||
|
{continueChapter && (
|
||||||
|
<button className={s.dlItem}
|
||||||
|
onClick={() => {
|
||||||
|
const from = sortedChapters.indexOf(continueChapter.chapter);
|
||||||
|
const ids = sortedChapters.slice(from).filter((c) => !c.isDownloaded).map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
setDlOpen(false);
|
||||||
|
}}>
|
||||||
|
<span>From current</span>
|
||||||
|
<span className={s.dlItemSub}>Ch.{continueChapter.chapter.chapterNumber} onwards</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className={s.dlItem}
|
||||||
|
onClick={() => {
|
||||||
|
const ids = sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
setDlOpen(false);
|
||||||
|
}}>
|
||||||
|
<span>Unread chapters</span>
|
||||||
|
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isRead && !c.isDownloaded).length} remaining</span>
|
||||||
|
</button>
|
||||||
|
<button className={s.dlItem}
|
||||||
|
onClick={() => {
|
||||||
|
const ids = sortedChapters.filter((c) => !c.isDownloaded).map((c) => c.id);
|
||||||
|
enqueueMultiple(ids);
|
||||||
|
setDlOpen(false);
|
||||||
|
}}>
|
||||||
|
<span>Download all</span>
|
||||||
|
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className={s.pagination}>
|
||||||
|
<button
|
||||||
|
className={s.pageBtn}
|
||||||
|
onClick={() => setChapterPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={chapterPage === 1}
|
||||||
|
>←</button>
|
||||||
|
<span className={s.pageNum}>{chapterPage} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
className={s.pageBtn}
|
||||||
|
onClick={() => setChapterPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={chapterPage === totalPages}
|
||||||
|
>→</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.list}>
|
<div className={s.list}>
|
||||||
|
|||||||
@@ -159,20 +159,45 @@
|
|||||||
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
|
min-width: 28px; text-align: center; letter-spacing: var(--tracking-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Select ── */
|
/* ─── Select (custom) ── */
|
||||||
.select {
|
.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);
|
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);
|
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);
|
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
|
||||||
appearance: none;
|
text-align: left;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.select:focus { border-color: var(--border-focus); }
|
.selectBtn:hover { border-color: var(--border-focus); }
|
||||||
.select option { background: var(--bg-raised); color: var(--text-secondary); }
|
|
||||||
|
.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 ── */
|
/* ─── Scale ── */
|
||||||
.scaleRow {
|
.scaleRow {
|
||||||
|
|||||||
@@ -62,15 +62,51 @@ function SelectRow({ value, options, onChange, label, description }: {
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<div className={s.stepRow}>
|
<div className={s.stepRow}>
|
||||||
<div className={s.toggleInfo}>
|
<div className={s.toggleInfo}>
|
||||||
<span className={s.toggleLabel}>{label}</span>
|
<span className={s.toggleLabel}>{label}</span>
|
||||||
{description && <span className={s.toggleDesc}>{description}</span>}
|
{description && <span className={s.toggleDesc}>{description}</span>}
|
||||||
</div>
|
</div>
|
||||||
<select className={s.select} value={value} onChange={(e) => onChange(e.target.value)}>
|
<div className={s.selectWrap} ref={ref}>
|
||||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
<button className={s.selectBtn} onClick={() => setOpen((o) => !o)}>
|
||||||
</select>
|
<span>{selected?.label ?? value}</span>
|
||||||
|
<svg className={[s.selectCaret, open ? s.selectCaretOpen : ""].join(" ")} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||||
|
<path d="M0 0l5 6 5-6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={s.selectMenu}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
className={[s.selectOption, o.value === value ? s.selectOptionActive : ""].join(" ")}
|
||||||
|
onClick={() => { onChange(o.value); setOpen(false); }}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,11 +179,10 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
|
|||||||
<p className={s.sectionTitle}>Page Layout</p>
|
<p className={s.sectionTitle}>Page Layout</p>
|
||||||
<SelectRow label="Default layout"
|
<SelectRow label="Default layout"
|
||||||
description="How chapters open by default"
|
description="How chapters open by default"
|
||||||
value={settings.pageStyle}
|
value={settings.pageStyle === "double" ? "single" : settings.pageStyle}
|
||||||
options={[
|
options={[
|
||||||
{ value: "single", label: "Single page" },
|
{ value: "single", label: "Single page" },
|
||||||
{ value: "double", label: "Double page" },
|
{ value: "longstrip", label: "Long strip" },
|
||||||
{ value: "longstrip", label: "Long strip" },
|
|
||||||
]}
|
]}
|
||||||
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
|
onChange={(v) => update({ pageStyle: v as Settings["pageStyle"] })} />
|
||||||
<SelectRow label="Reading direction"
|
<SelectRow label="Reading direction"
|
||||||
@@ -158,12 +193,8 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
|
|||||||
{ value: "rtl", label: "Right to left" },
|
{ value: "rtl", label: "Right to left" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
|
onChange={(v) => update({ readingDirection: v as Settings["readingDirection"] })} />
|
||||||
<Toggle label="Offset double spreads"
|
|
||||||
description="Shift double-page groups so spreads align correctly"
|
|
||||||
checked={settings.offsetDoubleSpreads}
|
|
||||||
onChange={(v) => update({ offsetDoubleSpreads: v })} />
|
|
||||||
<Toggle label="Page gap"
|
<Toggle label="Page gap"
|
||||||
description="Add spacing between pages in double and longstrip modes"
|
description="Add spacing between pages in longstrip mode"
|
||||||
checked={settings.pageGap}
|
checked={settings.pageGap}
|
||||||
onChange={(v) => update({ pageGap: v })} />
|
onChange={(v) => update({ pageGap: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +205,9 @@ function ReaderTab({ settings, update }: { settings: Settings; update: (p: Parti
|
|||||||
description="How pages are sized to fit the screen"
|
description="How pages are sized to fit the screen"
|
||||||
value={settings.fitMode ?? "width"}
|
value={settings.fitMode ?? "width"}
|
||||||
options={[
|
options={[
|
||||||
{ value: "width", label: "Fit width" },
|
{ value: "width", label: "Fit width" },
|
||||||
{ value: "height", label: "Fit height" },
|
{ value: "height", label: "Fit height" },
|
||||||
{ value: "screen", label: "Fit screen" },
|
{ value: "screen", label: "Fit screen" },
|
||||||
{ value: "original", label: "Original (1:1)" },
|
{ value: "original", label: "Original (1:1)" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => update({ fitMode: v as FitMode })} />
|
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"
|
description="Mark a chapter as read when you reach the last page"
|
||||||
checked={settings.autoMarkRead}
|
checked={settings.autoMarkRead}
|
||||||
onChange={(v) => update({ autoMarkRead: v })} />
|
onChange={(v) => update({ autoMarkRead: v })} />
|
||||||
|
<Toggle label="Auto-advance chapters"
|
||||||
|
description="Automatically open the next chapter at the end of a long strip"
|
||||||
|
checked={settings.autoNextChapter ?? false}
|
||||||
|
onChange={(v) => update({ autoNextChapter: v })} />
|
||||||
<Stepper label="Pages to preload"
|
<Stepper label="Pages to preload"
|
||||||
description="Images loaded ahead of the current page"
|
description="Images loaded ahead of the current page"
|
||||||
value={settings.preloadPages} min={0} max={10}
|
value={settings.preloadPages} min={0} max={10}
|
||||||
@@ -252,24 +287,24 @@ function LibraryTab({ settings, update }: { settings: Settings; update: (p: Part
|
|||||||
description="Language variant shown first when an extension has multiple"
|
description="Language variant shown first when an extension has multiple"
|
||||||
value={settings.preferredExtensionLang ?? "en"}
|
value={settings.preferredExtensionLang ?? "en"}
|
||||||
options={[
|
options={[
|
||||||
{ value: "en", label: "English" },
|
{ value: "en", label: "English" },
|
||||||
{ value: "es", label: "Spanish" },
|
{ value: "es", label: "Spanish" },
|
||||||
{ value: "fr", label: "French" },
|
{ value: "fr", label: "French" },
|
||||||
{ value: "de", label: "German" },
|
{ value: "de", label: "German" },
|
||||||
{ value: "pt-br", label: "Portuguese (BR)" },
|
{ value: "pt-br", label: "Portuguese (BR)" },
|
||||||
{ value: "it", label: "Italian" },
|
{ value: "it", label: "Italian" },
|
||||||
{ value: "ru", label: "Russian" },
|
{ value: "ru", label: "Russian" },
|
||||||
{ value: "ar", label: "Arabic" },
|
{ value: "ar", label: "Arabic" },
|
||||||
{ value: "tr", label: "Turkish" },
|
{ value: "tr", label: "Turkish" },
|
||||||
{ value: "zh", label: "Chinese (Simplified)" },
|
{ value: "zh", label: "Chinese (Simplified)" },
|
||||||
{ value: "zh-hant", label: "Chinese (Traditional)" },
|
{ value: "zh-hant", label: "Chinese (Traditional)" },
|
||||||
{ value: "ko", label: "Korean" },
|
{ value: "ko", label: "Korean" },
|
||||||
{ value: "ja", label: "Japanese" },
|
{ value: "ja", label: "Japanese" },
|
||||||
{ value: "id", label: "Indonesian" },
|
{ value: "id", label: "Indonesian" },
|
||||||
{ value: "vi", label: "Vietnamese" },
|
{ value: "vi", label: "Vietnamese" },
|
||||||
{ value: "th", label: "Thai" },
|
{ value: "th", label: "Thai" },
|
||||||
{ value: "pl", label: "Polish" },
|
{ value: "pl", label: "Polish" },
|
||||||
{ value: "nl", label: "Dutch" },
|
{ value: "nl", label: "Dutch" },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => update({ preferredExtensionLang: v })} />
|
onChange={(v) => update({ preferredExtensionLang: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface Settings {
|
|||||||
offsetDoubleSpreads: boolean;
|
offsetDoubleSpreads: boolean;
|
||||||
preloadPages: number;
|
preloadPages: number;
|
||||||
autoMarkRead: boolean;
|
autoMarkRead: boolean;
|
||||||
|
autoNextChapter: boolean;
|
||||||
libraryCropCovers: boolean;
|
libraryCropCovers: boolean;
|
||||||
libraryPageSize: number;
|
libraryPageSize: number;
|
||||||
showNsfw: boolean;
|
showNsfw: boolean;
|
||||||
@@ -61,6 +62,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
offsetDoubleSpreads: false,
|
offsetDoubleSpreads: false,
|
||||||
preloadPages: 3,
|
preloadPages: 3,
|
||||||
autoMarkRead: true,
|
autoMarkRead: true,
|
||||||
|
autoNextChapter: false,
|
||||||
libraryCropCovers: true,
|
libraryCropCovers: true,
|
||||||
libraryPageSize: 48,
|
libraryPageSize: 48,
|
||||||
showNsfw: false,
|
showNsfw: false,
|
||||||
|
|||||||