mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Reader & Tracking
This commit is contained in:
@@ -6,13 +6,13 @@
|
|||||||
House, Books, MagnifyingGlass, ClockCounterClockwise,
|
House, Books, MagnifyingGlass, ClockCounterClockwise,
|
||||||
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
|
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
|
|
||||||
|
|
||||||
const TABS: { path: string; label: string; icon: any }[] = [
|
const TABS: { path: string; label: string; icon: any }[] = [
|
||||||
{ path: '/', label: 'Home', icon: House },
|
{ path: '/', label: 'Home', icon: House },
|
||||||
{ path: '/library', label: 'Library', icon: Books },
|
{ path: '/library', label: 'Library', icon: Books },
|
||||||
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
|
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
|
||||||
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
|
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
|
||||||
|
{ path: '/recent', label: 'Recent', icon: ClockCounterClockwise },
|
||||||
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
|
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
|
||||||
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
|
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
|
||||||
]
|
]
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<aside class="root">
|
<aside class="root">
|
||||||
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home">
|
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home">
|
||||||
<div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
|
<div class="logo-icon"></div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
@@ -71,34 +71,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo { width: 56px; height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; margin-bottom: var(--sp-4); border-radius: var(--radius-lg); transition: opacity var(--t-base), transform var(--t-base); }
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: var(--sp-4);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: opacity var(--t-base), transform var(--t-base);
|
|
||||||
}
|
|
||||||
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
.logo:active { transform: scale(0.92); }
|
.logo:active { transform: scale(0.92); }
|
||||||
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon { width: 52px; height: 52px; background-color: var(--accent); mask-image: url("/src/lib/assets/moku-icon-wordmark.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; -webkit-mask-image: url("/src/lib/assets/moku-icon-wordmark.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; filter: drop-shadow(0 0 8px rgba(107,143,107,0.35)); pointer-events: none; }
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: contain;
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-position: center;
|
|
||||||
-webkit-mask-size: contain;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(107,143,107,0.35));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
|
import type { PinchTracker } from "$lib/components/reader/lib/pinchZoom";
|
||||||
|
import type { StripChapter } from "$lib/components/reader/lib/scrollHandler";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: string;
|
||||||
|
imgCls: string;
|
||||||
|
effectiveWidth: number | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
pageReady: boolean;
|
||||||
|
pageGroups: number[][];
|
||||||
|
currentGroup: number[];
|
||||||
|
stripToRender: StripChapter[];
|
||||||
|
fadingOut: boolean;
|
||||||
|
tapToToggleBar: boolean;
|
||||||
|
pinchZoomEnabled: boolean;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
onGetZoom: () => number;
|
||||||
|
onSetZoom: (z: number) => void;
|
||||||
|
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||||
|
onTap: (e: MouseEvent) => void;
|
||||||
|
onWheel: (e: WheelEvent) => void;
|
||||||
|
onToggleUi: () => void;
|
||||||
|
bindContainer: (el: HTMLDivElement) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||||
|
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||||
|
tapToToggleBar, pinchZoomEnabled, barPosition,
|
||||||
|
onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const LOAD_RADIUS = 5;
|
||||||
|
const UNLOAD_RADIUS = 10;
|
||||||
|
|
||||||
|
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
|
||||||
|
|
||||||
|
const flatPages = $derived.by<FlatPage[]>(() => {
|
||||||
|
const out: FlatPage[] = [];
|
||||||
|
for (const chunk of stripToRender) {
|
||||||
|
for (let i = 0; i < chunk.urls.length; i++) {
|
||||||
|
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let loadedSet = $state(new Set<number>());
|
||||||
|
let resolvedSrc = $state<Record<number, string>>({});
|
||||||
|
let revokeQueue: string[] = [];
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
const elementIndex = new Map<Element, number>();
|
||||||
|
|
||||||
|
let viewportCenter = $state(0);
|
||||||
|
|
||||||
|
function scheduleRevoke(src: string) {
|
||||||
|
if (!src || !src.startsWith("blob:")) return;
|
||||||
|
revokeQueue.push(src);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const url = revokeQueue.shift();
|
||||||
|
if (url) { try { URL.revokeObjectURL(url); } catch {} }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(idx: number) {
|
||||||
|
if (loadedSet.has(idx)) return;
|
||||||
|
const page = flatPages[idx];
|
||||||
|
if (!page) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.add(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
|
||||||
|
resolveUrl(page.url, priority).then(src => {
|
||||||
|
if (loadedSet.has(idx)) {
|
||||||
|
resolvedSrc = { ...resolvedSrc, [idx]: src };
|
||||||
|
} else {
|
||||||
|
scheduleRevoke(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadPage(idx: number) {
|
||||||
|
if (!loadedSet.has(idx)) return;
|
||||||
|
const newSet = new Set(loadedSet);
|
||||||
|
newSet.delete(idx);
|
||||||
|
loadedSet = newSet;
|
||||||
|
const oldSrc = resolvedSrc[idx];
|
||||||
|
if (oldSrc) {
|
||||||
|
const next = { ...resolvedSrc };
|
||||||
|
delete next[idx];
|
||||||
|
resolvedSrc = next;
|
||||||
|
scheduleRevoke(oldSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcWindow() {
|
||||||
|
const center = viewportCenter;
|
||||||
|
const lo = center - LOAD_RADIUS;
|
||||||
|
const hi = center + LOAD_RADIUS;
|
||||||
|
const evictLo = center - UNLOAD_RADIUS;
|
||||||
|
const evictHi = center + UNLOAD_RADIUS;
|
||||||
|
for (let i = 0; i < flatPages.length; i++) {
|
||||||
|
if (i >= lo && i <= hi) loadPage(i);
|
||||||
|
else if (i < evictLo || i > evictHi) unloadPage(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { void viewportCenter; recalcWindow(); });
|
||||||
|
$effect(() => { void flatPages.length; recalcWindow(); });
|
||||||
|
|
||||||
|
function setupObserver(containerEl: HTMLElement) {
|
||||||
|
observer?.disconnect();
|
||||||
|
elementIndex.clear();
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let best = -1;
|
||||||
|
let bestRatio = -1;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const idx = elementIndex.get(entry.target);
|
||||||
|
if (idx === undefined) continue;
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
|
||||||
|
bestRatio = entry.intersectionRatio;
|
||||||
|
best = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
|
||||||
|
},
|
||||||
|
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observePage(el: HTMLDivElement, idx: number) {
|
||||||
|
elementIndex.set(el, idx);
|
||||||
|
observer?.observe(el);
|
||||||
|
return {
|
||||||
|
update(newIdx: number) { elementIndex.set(el, newIdx); },
|
||||||
|
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset virtual load window when chapter changes
|
||||||
|
let lastChapterId = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const chapterId = readerState.activeChapter?.id ?? 0;
|
||||||
|
if (chapterId === lastChapterId) return;
|
||||||
|
lastChapterId = chapterId;
|
||||||
|
loadedSet = new Set<number>();
|
||||||
|
resolvedSrc = {};
|
||||||
|
const resume = readerState.resumePage;
|
||||||
|
viewportCenter = resume > 1 ? resume - 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const INSPECT_ZOOM_STEP = 0.15;
|
||||||
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
function getInspectImageEl(): HTMLElement | null {
|
||||||
|
if (!containerEl) return null;
|
||||||
|
return (
|
||||||
|
containerEl.querySelector<HTMLElement>(".inspect-wrap .double-wrap") ??
|
||||||
|
containerEl.querySelector<HTMLElement>(".inspect-wrap img")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInspectPan(scale: number, px: number, py: number): [number, number] {
|
||||||
|
const img = getInspectImageEl();
|
||||||
|
if (!img) return [px, py];
|
||||||
|
const maxX = Math.max(0, (img.offsetWidth * (scale - 1)) / 2);
|
||||||
|
const maxY = Math.max(0, (img.offsetHeight * (scale - 1)) / 2);
|
||||||
|
return [Math.max(-maxX, Math.min(maxX, px)), Math.max(-maxY, Math.min(maxY, py))];
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspectDragging = false;
|
||||||
|
let inspectDragMoved = false;
|
||||||
|
let inspectDragStartX = 0;
|
||||||
|
let inspectDragStartY = 0;
|
||||||
|
let inspectPanStartX = 0;
|
||||||
|
let inspectPanStartY = 0;
|
||||||
|
|
||||||
|
let stripDragging = $state(false);
|
||||||
|
let stripDragMoved = false;
|
||||||
|
let stripDragStartY = 0;
|
||||||
|
let stripScrollStart = 0;
|
||||||
|
|
||||||
|
let autoScrollPaused = false;
|
||||||
|
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
let midScrollActive = $state(false);
|
||||||
|
let midScrollOriginY = $state(0);
|
||||||
|
let midScrollCurrentY = 0;
|
||||||
|
let midScrollDisplayLevel = $state(0);
|
||||||
|
let midScrollRaf: number | null = null;
|
||||||
|
|
||||||
|
function startMidScroll(originY: number) {
|
||||||
|
midScrollActive = true;
|
||||||
|
midScrollOriginY = originY;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
|
||||||
|
const tick = () => {
|
||||||
|
if (!midScrollActive || !containerEl) return;
|
||||||
|
const dy = midScrollCurrentY - midScrollOriginY;
|
||||||
|
const deadZone = 24;
|
||||||
|
const excess = Math.max(0, Math.abs(dy) - deadZone);
|
||||||
|
const speed = Math.sign(dy) * excess * 0.12;
|
||||||
|
containerEl.scrollTop += speed;
|
||||||
|
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
|
||||||
|
midScrollRaf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
midScrollRaf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMidScroll() {
|
||||||
|
midScrollActive = false;
|
||||||
|
midScrollDisplayLevel = 0;
|
||||||
|
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseAutoScroll() {
|
||||||
|
autoScrollPaused = true;
|
||||||
|
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
|
||||||
|
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style !== "longstrip" || !settingsState.settings.autoScroll) return;
|
||||||
|
let rafId: number;
|
||||||
|
const tick = () => {
|
||||||
|
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (settingsState.settings.autoScrollSpeed ?? 5) * 0.5;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
});
|
||||||
|
|
||||||
|
let pinch: PinchTracker | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pinchZoomEnabled) {
|
||||||
|
pinch = createPinchTracker({
|
||||||
|
getZoom: onGetZoom,
|
||||||
|
setZoom: onSetZoom,
|
||||||
|
getInspectScale: () => readerState.inspectScale,
|
||||||
|
setInspectScale: (s) => { readerState.inspectScale = s; },
|
||||||
|
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0; },
|
||||||
|
isLongstrip: () => style === "longstrip",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pinch = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function onInspectMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
if (e.button === 1 && style === "longstrip") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (midScrollActive) {
|
||||||
|
stopMidScroll();
|
||||||
|
} else {
|
||||||
|
settingsState.settings.autoScroll = false;
|
||||||
|
startMidScroll(e.clientY);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (style === "longstrip") {
|
||||||
|
stripDragging = true;
|
||||||
|
stripDragMoved = false;
|
||||||
|
stripDragStartY = e.clientY;
|
||||||
|
stripScrollStart = containerEl?.scrollTop ?? 0;
|
||||||
|
pauseAutoScroll();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (readerState.inspectScale <= 1) return;
|
||||||
|
inspectDragging = true;
|
||||||
|
inspectDragMoved = false;
|
||||||
|
inspectDragStartX = e.clientX;
|
||||||
|
inspectDragStartY = e.clientY;
|
||||||
|
inspectPanStartX = readerState.inspectPanX;
|
||||||
|
inspectPanStartY = readerState.inspectPanY;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInspectMouseMove(e: MouseEvent) {
|
||||||
|
midScrollCurrentY = e.clientY;
|
||||||
|
if (stripDragging) {
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!inspectDragging) return;
|
||||||
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
|
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
|
||||||
|
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
|
||||||
|
readerState.inspectPanX = cx;
|
||||||
|
readerState.inspectPanY = cy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInspectMouseUp() {
|
||||||
|
stripDragging = false;
|
||||||
|
inspectDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerDown(e: PointerEvent) {
|
||||||
|
if ((e.target as Element).closest(".bar")) return;
|
||||||
|
pinch?.onPointerDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerMove(e: PointerEvent) {
|
||||||
|
if (pinch?.isPinching()) { pinch.onPointerMove(e); return; }
|
||||||
|
if (stripDragging) {
|
||||||
|
const dy = e.clientY - stripDragStartY;
|
||||||
|
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
|
||||||
|
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
|
||||||
|
}
|
||||||
|
if (inspectDragging) {
|
||||||
|
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||||
|
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||||
|
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
|
||||||
|
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
|
||||||
|
readerState.inspectPanX = cx;
|
||||||
|
readerState.inspectPanY = cy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPointerUp(e: PointerEvent) {
|
||||||
|
pinch?.onPointerUp(e);
|
||||||
|
if (!pinch?.isPinching()) { stripDragging = false; inspectDragging = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWheel(e: WheelEvent) {
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (e.ctrlKey) { onWheel(e); }
|
||||||
|
else pauseAutoScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.ctrlKey) { onWheel(e); return; }
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
||||||
|
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
|
||||||
|
if (next === readerState.inspectScale) return;
|
||||||
|
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
||||||
|
const img = getInspectImageEl();
|
||||||
|
const anchor = img ?? containerEl;
|
||||||
|
const rect = anchor?.getBoundingClientRect();
|
||||||
|
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||||
|
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||||
|
const ratio = next / readerState.inspectScale;
|
||||||
|
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
|
||||||
|
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
|
||||||
|
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
||||||
|
readerState.inspectScale = next;
|
||||||
|
readerState.inspectPanX = clampedX;
|
||||||
|
readerState.inspectPanY = clampedY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTap(e: MouseEvent) {
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||||
|
if (stripDragMoved) { stripDragMoved = false; return; }
|
||||||
|
onTap(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContainer(el: HTMLDivElement) {
|
||||||
|
containerEl = el;
|
||||||
|
bindContainer(el);
|
||||||
|
if (style === "longstrip") setupObserver(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" && containerEl) {
|
||||||
|
setupObserver(containerEl);
|
||||||
|
} else if (style !== "longstrip") {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
stopMidScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
use:setContainer
|
||||||
|
class="viewer"
|
||||||
|
class:strip={style === "longstrip"}
|
||||||
|
class:inspect-active={readerState.inspectScale > 1}
|
||||||
|
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={handleTap}
|
||||||
|
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
|
||||||
|
ondblclick={() => { if (tapToToggleBar) onToggleUi(); }}
|
||||||
|
onmousedown={onInspectMouseDown}
|
||||||
|
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
|
||||||
|
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||||
|
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === " " && style === "longstrip") {
|
||||||
|
e.preventDefault();
|
||||||
|
settingsState.settings.autoScroll = !settingsState.settings.autoScroll;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if midScrollActive}
|
||||||
|
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
|
||||||
|
<div class="midscroll-segments">
|
||||||
|
{#each [5,4,3,2,1] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
<div class="midscroll-origin-dot"></div>
|
||||||
|
{#each [1,2,3,4,5] as n}
|
||||||
|
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if style === "longstrip"}
|
||||||
|
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||||
|
{@const src = resolvedSrc[gi]}
|
||||||
|
{@const isLoaded = loadedSet.has(gi)}
|
||||||
|
<div class="strip-slot" use:observePage={gi}>
|
||||||
|
{#if isLoaded && src}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt="{page.chapterName} – Page {page.localIndex + 1}"
|
||||||
|
data-local-page={page.localIndex + 1}
|
||||||
|
data-chapter={page.chapterId}
|
||||||
|
data-total={page.total}
|
||||||
|
class="{imgCls}{settingsState.settings.pageGap ? ' strip-gap' : ''}"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
onload={(e) => {
|
||||||
|
const img = e.currentTarget as HTMLImageElement;
|
||||||
|
const slot = img.closest<HTMLElement>(".strip-slot");
|
||||||
|
if (slot && img.naturalWidth > 0) {
|
||||||
|
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="strip-placeholder" aria-hidden="true">
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
|
|
||||||
|
{:else if style === "fade" && pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if style === "double" && pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#if pageGroups.length}
|
||||||
|
<div class="double-wrap">
|
||||||
|
{#each currentGroup as pg, i (pg)}
|
||||||
|
{#await resolveUrl(readerState.pageUrls[pg - 1], 999)}
|
||||||
|
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
|
||||||
|
{/await}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="center-overlay">
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if pageReady}
|
||||||
|
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||||
|
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
|
||||||
|
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
|
||||||
|
{:then src}
|
||||||
|
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/>
|
||||||
|
<rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
|
||||||
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||||
|
.viewer:focus { outline: none; }
|
||||||
|
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||||
|
.viewer.inspect-active:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
:global(.pinch-active) .viewer { touch-action: none; }
|
||||||
|
|
||||||
|
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||||
|
|
||||||
|
.strip-slot { width: 100%; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
|
||||||
|
.strip-placeholder {
|
||||||
|
width: var(--effective-width, 100%);
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
aspect-ratio: var(--aspect, 0.667);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loader { border-radius: var(--radius-sm); display: flex; align-items: stretch; }
|
||||||
|
.page-loader-single {
|
||||||
|
width: min(100%, var(--effective-width, 100%));
|
||||||
|
max-width: var(--effective-width, 100%);
|
||||||
|
max-height: calc(var(--visual-vh, 100vh) - 80px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-skeleton { width: 100%; height: 100%; }
|
||||||
|
.panel-skeleton :global(.ps-r) {
|
||||||
|
stroke: var(--border-strong);
|
||||||
|
stroke-width: 0.8;
|
||||||
|
fill: none;
|
||||||
|
stroke-dasharray: 400;
|
||||||
|
stroke-dashoffset: 400;
|
||||||
|
animation: ps-shimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
|
||||||
|
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
|
||||||
|
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
|
||||||
|
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
|
||||||
|
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
@keyframes ps-shimmer {
|
||||||
|
0% { stroke-dashoffset: 400; opacity: 0.25; }
|
||||||
|
40% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
70% { stroke-dashoffset: 0; opacity: 0.55; }
|
||||||
|
100% { stroke-dashoffset: -400; opacity: 0.25; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.img { display: block; user-select: none; image-rendering: auto; }
|
||||||
|
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||||
|
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||||
|
:global(.fit-height) { max-height: calc(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||||
|
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 100vh) - 80px); object-fit: contain; height: auto; }
|
||||||
|
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||||
|
:global(.strip-gap) { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
||||||
|
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||||
|
.gap-left { margin-right: 2px; }
|
||||||
|
.gap-right { margin-left: 2px; }
|
||||||
|
|
||||||
|
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||||
|
|
||||||
|
.midscroll-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.midscroll-bar-right { right: 8px; }
|
||||||
|
.midscroll-bar-left { left: 8px; }
|
||||||
|
|
||||||
|
.midscroll-segments { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||||
|
.midscroll-origin-dot { width: 6px; height: 6px; border-radius: 50%; border: 1.5px solid var(--accent-fg); opacity: 0.6; flex-shrink: 0; margin: 2px 0; }
|
||||||
|
.midscroll-seg { width: 4px; height: 14px; border-radius: 2px; background: var(--border-strong); transition: background 0.06s ease; flex-shrink: 0; }
|
||||||
|
.midscroll-seg-lit { background: var(--accent-fg); }
|
||||||
|
.midscroll-stop { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); flex-shrink: 0; }
|
||||||
|
.midscroll-stop:hover { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack, tick } from "svelte";
|
||||||
|
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
|
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||||
|
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
||||||
|
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
|
||||||
|
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
||||||
|
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
||||||
|
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
||||||
|
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||||
|
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||||
|
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||||
|
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
||||||
|
import PageView from "$lib/components/reader/PageView.svelte";
|
||||||
|
import ReaderProgressBar from "$lib/components/reader/ReaderProgressBar.svelte";
|
||||||
|
import ReaderOverlay from "$lib/components/reader/ReaderOverlay.svelte";
|
||||||
|
import ReaderPresetPanel from "$lib/components/reader/ReaderPresetPanel.svelte";
|
||||||
|
|
||||||
|
const useBlob = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
|
|
||||||
|
const effectiveReaderSettings = $derived.by(() => {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
const override = mangaId != null ? (settingsState.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||||
|
return override ? { ...settingsState.settings, ...override } : settingsState.settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
|
||||||
|
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as ReaderSettings["fitMode"]);
|
||||||
|
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
||||||
|
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
|
||||||
|
const markOnNext = $derived(settingsState.settings.markReadOnNext ?? true);
|
||||||
|
const overlayBars = $derived(settingsState.settings.overlayBars ?? false);
|
||||||
|
const tapToToggleBar = $derived(settingsState.settings.tapToToggleBar ?? false);
|
||||||
|
const barPosition = $derived((settingsState.settings.barPosition ?? "top") as "top" | "left" | "right");
|
||||||
|
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
const lastPage = $derived(readerState.pageUrls.length);
|
||||||
|
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
||||||
|
const zoomPct = $derived(Math.round(zoom * 100));
|
||||||
|
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
||||||
|
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
||||||
|
|
||||||
|
const displayChapter = $derived(
|
||||||
|
style === "longstrip" && readerState.visibleChapterId
|
||||||
|
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
|
||||||
|
: readerState.activeChapter
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentBookmark = $derived(
|
||||||
|
readerState.activeManga
|
||||||
|
? readerState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
const isBookmarked = $derived(
|
||||||
|
!!currentBookmark &&
|
||||||
|
currentBookmark.chapterId === displayChapter?.id &&
|
||||||
|
currentBookmark.pageNumber === readerState.pageNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPageMarkers = $derived(displayChapter ? readerState.getMarkersForPage(displayChapter.id, readerState.pageNumber) : []);
|
||||||
|
const activeChapterMarkers = $derived(displayChapter ? readerState.getMarkersForChapter(displayChapter.id) : []);
|
||||||
|
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
|
||||||
|
|
||||||
|
const showResumeBanner = $derived(
|
||||||
|
readerState.resumeVisible && readerState.resumePage > 1 &&
|
||||||
|
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
|
||||||
|
);
|
||||||
|
|
||||||
|
const adjacent = $derived.by(() => {
|
||||||
|
const ref = displayChapter ?? readerState.activeChapter;
|
||||||
|
if (!ref || !readerState.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||||||
|
const idx = readerState.activeChapterList.findIndex(c => c.id === ref.id);
|
||||||
|
return {
|
||||||
|
prev: idx > 0 ? readerState.activeChapterList[idx - 1] : null,
|
||||||
|
next: idx < readerState.activeChapterList.length - 1 ? readerState.activeChapterList[idx + 1] : null,
|
||||||
|
remaining: readerState.activeChapterList.slice(idx + 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleChunkLastPage = $derived.by(() => {
|
||||||
|
if (style !== "longstrip") return lastPage;
|
||||||
|
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
||||||
|
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
|
||||||
|
return chunk?.urls.length ?? lastPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgCls = $derived([
|
||||||
|
"img",
|
||||||
|
fit === "width" && "fit-width",
|
||||||
|
fit === "height" && "fit-height",
|
||||||
|
fit === "screen" && "fit-screen",
|
||||||
|
fit === "original" && "fit-original",
|
||||||
|
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||||
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
|
const stripToRender = $derived(
|
||||||
|
style === "longstrip"
|
||||||
|
? (readerState.stripChapters.length > 0
|
||||||
|
? readerState.stripChapters
|
||||||
|
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentGroup = $derived.by(() => {
|
||||||
|
const group = style === "double" && readerState.pageGroups.length
|
||||||
|
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||||
|
: [readerState.pageNumber];
|
||||||
|
return rtl ? [...group].reverse() : group;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderPage = $derived.by(() => {
|
||||||
|
if (style === "double" && readerState.pageGroups.length)
|
||||||
|
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
|
||||||
|
return readerState.pageNumber;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderMax = $derived.by(() => {
|
||||||
|
if (style === "double" && readerState.pageGroups.length) return readerState.pageGroups.length;
|
||||||
|
if (style === "longstrip") return visibleChunkLastPage || 1;
|
||||||
|
return lastPage || 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
||||||
|
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
||||||
|
|
||||||
|
const perMangaEnabled = $derived(
|
||||||
|
readerState.activeManga?.id != null &&
|
||||||
|
!!(settingsState.settings.mangaReaderSettings ?? {})[readerState.activeManga.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | null = null;
|
||||||
|
let pageViewRef: PageView;
|
||||||
|
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
||||||
|
let hideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
let markedRead = new Set<number>();
|
||||||
|
let appending = false;
|
||||||
|
let abortCtrl = { current: null as AbortController | null };
|
||||||
|
let hasNavigated = false;
|
||||||
|
let startAtLastPageRef = { current: false };
|
||||||
|
let cleanupScroll: () => void = () => {};
|
||||||
|
let stripChaptersRef = readerState.stripChapters;
|
||||||
|
|
||||||
|
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
||||||
|
|
||||||
|
function maybeMarkCurrentRead() {
|
||||||
|
const ch = displayChapter ?? readerState.activeChapter;
|
||||||
|
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitMarkerAction() {
|
||||||
|
const ch = displayChapter;
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (!ch || !manga) return;
|
||||||
|
if (readerState.markerEditId) {
|
||||||
|
readerState.updateMarker(readerState.markerEditId, { note: readerState.markerNote.trim(), color: readerState.markerColor });
|
||||||
|
} else {
|
||||||
|
readerState.addMarker({
|
||||||
|
mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: ch.id, chapterName: ch.name,
|
||||||
|
pageNumber: readerState.pageNumber, note: readerState.markerNote.trim(), color: readerState.markerColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
readerState.clearMarkerPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCurrentMarker() {
|
||||||
|
if (readerState.markerEditId) readerState.removeMarker(readerState.markerEditId);
|
||||||
|
readerState.clearMarkerPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUi() {
|
||||||
|
readerState.uiVisible = true;
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
if (!tapToToggleBar) {
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
if (!readerState.markerOpen && !readerState.winOpen) readerState.uiVisible = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUiVisibility() {
|
||||||
|
if (readerState.uiVisible) {
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
readerState.uiVisible = false;
|
||||||
|
} else {
|
||||||
|
readerState.uiVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTap(e: MouseEvent) {
|
||||||
|
const x = e.clientX / window.innerWidth;
|
||||||
|
if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
captureZoomAnchor(containerEl, style, zoomAnchor);
|
||||||
|
applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? 0.05 : -0.05)) });
|
||||||
|
restoreZoomAnchor(containerEl, zoomAnchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAtLast = () => { startAtLastPageRef.current = true; };
|
||||||
|
|
||||||
|
const goNext = $derived(rtl
|
||||||
|
? () => goBack(style, adjacent, startAtLast)
|
||||||
|
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
||||||
|
const goPrev = $derived(rtl
|
||||||
|
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
||||||
|
: () => goBack(style, adjacent, startAtLast));
|
||||||
|
|
||||||
|
const onKey = createReaderKeyHandler({
|
||||||
|
goNext: () => goNext(),
|
||||||
|
goPrev: () => goPrev(),
|
||||||
|
closeReader: () => readerState.closeReader(),
|
||||||
|
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
||||||
|
lastPage: () => lastPage,
|
||||||
|
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
|
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||||
|
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||||
|
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||||
|
openSettings: () => { settingsState.settingsOpen = true; },
|
||||||
|
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
||||||
|
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
|
||||||
|
toggleMarker: () => {
|
||||||
|
if (currentPageMarkers.length > 0) {
|
||||||
|
const first = currentPageMarkers[0];
|
||||||
|
readerState.openMarker(first.id, first.note, first.color);
|
||||||
|
} else {
|
||||||
|
readerState.openMarker("", "", "yellow");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chapterNext: () => {
|
||||||
|
const ch = rtl ? adjacent.prev : adjacent.next;
|
||||||
|
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeChapterList); }
|
||||||
|
},
|
||||||
|
chapterPrev: () => {
|
||||||
|
const ch = rtl ? adjacent.next : adjacent.prev;
|
||||||
|
if (ch) readerState.openReader(ch, readerState.activeChapterList);
|
||||||
|
},
|
||||||
|
closePopovers: () => readerState.closeAllPopovers(),
|
||||||
|
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
||||||
|
|
||||||
|
function captureCurrentReaderSettings(): ReaderSettings {
|
||||||
|
return {
|
||||||
|
pageStyle: style,
|
||||||
|
fitMode: fit,
|
||||||
|
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
|
||||||
|
readerZoom: zoom,
|
||||||
|
pageGap: effectiveReaderSettings.pageGap ?? true,
|
||||||
|
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
|
||||||
|
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(patch: Partial<ReaderSettings>) {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
if (mangaId != null && (settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
readerState.setMangaReaderSettings(mangaId, { ...(settingsState.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
|
||||||
|
} else {
|
||||||
|
updateSettings(patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTogglePerManga() {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
if (mangaId == null) return;
|
||||||
|
if ((settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
readerState.clearMangaReaderSettings(mangaId);
|
||||||
|
} else {
|
||||||
|
readerState.setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSavePreset(name: string) {
|
||||||
|
readerState.saveReaderPreset(name, captureCurrentReaderSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyPreset(settings: ReaderSettings) {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
if (mangaId != null && (settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
||||||
|
readerState.setMangaReaderSettings(mangaId, settings);
|
||||||
|
} else {
|
||||||
|
updateSettings(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBarPositionChange(pos: "top" | "left" | "right") {
|
||||||
|
updateSettings({ barPosition: pos });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const ch = readerState.activeChapter;
|
||||||
|
if (ch) untrack(() => loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
||||||
|
const ch = readerState.activeChapter;
|
||||||
|
const urls = readerState.pageUrls;
|
||||||
|
const targetPg = untrack(() => readerState.resumePage);
|
||||||
|
appending = false;
|
||||||
|
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
||||||
|
readerState.visibleChapterId = ch.id;
|
||||||
|
tick().then(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
if (targetPg > 1) {
|
||||||
|
const chId = ch.id;
|
||||||
|
const scrollToResumePage = () => {
|
||||||
|
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
||||||
|
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
||||||
|
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
||||||
|
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
|
||||||
|
if (target.complete && target.naturalHeight > 0) doScroll();
|
||||||
|
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
||||||
|
};
|
||||||
|
scrollToResumePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
containerEl!.scrollTop = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const chId = readerState.visibleChapterId;
|
||||||
|
if (!chId || style !== "longstrip") return;
|
||||||
|
if (chId === readerState.activeChapter?.id) return;
|
||||||
|
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
||||||
|
if (wasAppended) {
|
||||||
|
untrack(() => {
|
||||||
|
readerState.resumePage = 0;
|
||||||
|
readerState.resumeVisible = false;
|
||||||
|
const prefs = getMangaPrefs(chId);
|
||||||
|
if (prefs.downloadAhead > 0) {
|
||||||
|
const list = readerState.activeChapterList;
|
||||||
|
const idx = list.findIndex(c => c.id === chId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||||
|
.filter(c => !c.downloaded && !c.read)
|
||||||
|
.map(c => c.id);
|
||||||
|
if (toQueue.length) {
|
||||||
|
const DL = `mutation EnqueueDl($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
||||||
|
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||||
|
}
|
||||||
|
fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query: DL, variables: { ids: toQueue } }) })
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
|
||||||
|
if (bookmark && bookmark.pageNumber > 1) {
|
||||||
|
untrack(() => {
|
||||||
|
readerState.resumePage = bookmark.pageNumber;
|
||||||
|
readerState.resumeDismissed = false;
|
||||||
|
readerState.resumeVisible = true;
|
||||||
|
readerState.stripResumeReady = true;
|
||||||
|
scheduleResumeDismiss();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
untrack(() => readerState.resetResume());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void style;
|
||||||
|
if (!containerEl) return;
|
||||||
|
untrack(() => {
|
||||||
|
cleanupScroll();
|
||||||
|
cleanupScroll = setupScrollTracking(containerEl!, {
|
||||||
|
onPageChange: (p) => { readerState.pageNumber = p; },
|
||||||
|
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
||||||
|
onMarkRead: (id) => markChapterRead(id, markedRead),
|
||||||
|
onAppend: () => {
|
||||||
|
if (appending || !readerState.stripChapters.length) return;
|
||||||
|
appending = true;
|
||||||
|
appendNextChapter(
|
||||||
|
stripChaptersRef,
|
||||||
|
readerState.activeChapterList,
|
||||||
|
(id) => fetchPages(id, useBlob),
|
||||||
|
(url) => preloadImage(url, useBlob),
|
||||||
|
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
||||||
|
() => { appending = false; },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getStripChapters: () => stripChaptersRef,
|
||||||
|
getPageUrls: () => readerState.pageUrls,
|
||||||
|
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (readerState.activeChapter && readerState.activeChapterList.length) {
|
||||||
|
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = readerState.activeChapterList[idx + 1];
|
||||||
|
const prev = readerState.activeChapterList[idx - 1];
|
||||||
|
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
||||||
|
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (style === "double" && readerState.pageUrls.length) {
|
||||||
|
let cancelled = false;
|
||||||
|
const snap = readerState.pageUrls;
|
||||||
|
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
||||||
|
if (cancelled || snap !== readerState.pageUrls) return;
|
||||||
|
readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
} else {
|
||||||
|
readerState.pageGroups = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const ahead = settingsState.settings.preloadPages ?? 3;
|
||||||
|
const current = readerState.pageUrls[readerState.pageNumber - 1];
|
||||||
|
const pageNum = readerState.pageNumber;
|
||||||
|
const urls = readerState.pageUrls;
|
||||||
|
if (!current) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (useBlob) {
|
||||||
|
import("$lib/core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||||
|
getBlobUrl(current, 999);
|
||||||
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
||||||
|
const behind = urls[pageNum - 2];
|
||||||
|
preloadBlobUrls(upcoming, ahead);
|
||||||
|
if (behind) preloadBlobUrls([behind], 0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i <= ahead; i++) {
|
||||||
|
const url = urls[pageNum - 1 + i];
|
||||||
|
if (url) preloadImage(url, useBlob);
|
||||||
|
}
|
||||||
|
const behind = urls[pageNum - 2];
|
||||||
|
if (behind) preloadImage(behind, useBlob);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (readerState.markerOpen || readerState.winOpen) {
|
||||||
|
readerState.uiVisible = true;
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (tapToToggleBar) {
|
||||||
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
||||||
|
readerState.uiVisible = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const ch = displayChapter ?? readerState.activeChapter;
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (ch && lastPage && manga) {
|
||||||
|
const { id: chapterId, name: chapterName } = ch;
|
||||||
|
const { id: mangaId, title: mangaTitle, thumbnailUrl: thumb } = manga;
|
||||||
|
const pageNum = readerState.pageNumber;
|
||||||
|
const atLast = pageNum === lastPage;
|
||||||
|
if (pageNum > 1) hasNavigated = true;
|
||||||
|
untrack(() => {
|
||||||
|
if (!hasNavigated) return;
|
||||||
|
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
||||||
|
if (settingsState.settings.autoBookmark ?? true) {
|
||||||
|
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||||
|
if (existing) readerState.removeBookmark(existing.chapterId);
|
||||||
|
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||||
|
}
|
||||||
|
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
function onFsChange() { readerState.isFullscreen = !!document.fullscreenElement; }
|
||||||
|
document.addEventListener("fullscreenchange", onFsChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
showUi();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||||
|
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||||
|
window.addEventListener("pointermove", pageViewRef.onPointerMove);
|
||||||
|
window.addEventListener("pointerup", pageViewRef.onPointerUp);
|
||||||
|
|
||||||
|
readerState.isFullscreen = !!document.fullscreenElement;
|
||||||
|
|
||||||
|
let roTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
const w = entries[0].contentRect.width;
|
||||||
|
if (roTimer) clearTimeout(roTimer);
|
||||||
|
roTimer = setTimeout(() => { readerState.containerWidth = w; roTimer = null; }, 50);
|
||||||
|
});
|
||||||
|
if (containerEl) ro.observe(containerEl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortCtrl.current?.abort();
|
||||||
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
if (roTimer) clearTimeout(roTimer);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||||
|
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||||
|
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
||||||
|
cleanupScroll();
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="root"
|
||||||
|
class:containerized
|
||||||
|
class:overlay-bars={overlayBars}
|
||||||
|
class:bar-left={barPosition === "left"}
|
||||||
|
class:bar-right={barPosition === "right"}
|
||||||
|
class:pinch-active={pinchZoomEnabled}
|
||||||
|
role="presentation"
|
||||||
|
onmousemove={(e) => {
|
||||||
|
if (!tapToToggleBar) {
|
||||||
|
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
|
||||||
|
if (barPosition === "left" && e.clientX < 60) showUi();
|
||||||
|
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReaderControls
|
||||||
|
{displayChapter} {adjacent} {visibleChunkLastPage}
|
||||||
|
{zoom} {zoomPct}
|
||||||
|
isFullscreen={readerState.isFullscreen}
|
||||||
|
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
||||||
|
uiVisible={readerState.uiVisible}
|
||||||
|
{barPosition}
|
||||||
|
progressBar={isVerticalBar ? progressBarSnippet : undefined}
|
||||||
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||||
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||||
|
onMaybeMarkRead={maybeMarkCurrentRead}
|
||||||
|
onToggleBookmark={() => toggleBookmark(displayChapter, readerState.pageNumber)}
|
||||||
|
onCommitMarker={commitMarkerAction}
|
||||||
|
onDeleteMarker={deleteCurrentMarker}
|
||||||
|
onClampZoom={clampZoom}
|
||||||
|
onApplySettings={applySettings}
|
||||||
|
onDlOpen={() => readerState.dlOpen = true}
|
||||||
|
onSettingsOpen={() => { settingsState.settingsOpen = true; }}
|
||||||
|
{perMangaEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if readerState.presetOpen}
|
||||||
|
<ReaderPresetPanel
|
||||||
|
{fit} {style} {rtl} {zoom} {zoomPct}
|
||||||
|
{perMangaEnabled}
|
||||||
|
{barPosition}
|
||||||
|
onBarPositionChange={handleBarPositionChange}
|
||||||
|
onTogglePerManga={handleTogglePerManga}
|
||||||
|
onApplySettings={applySettings}
|
||||||
|
onSavePreset={handleSavePreset}
|
||||||
|
onApplyPreset={handleApplyPreset}
|
||||||
|
onUpdatePreset={(id, patch) => readerState.updateReaderPreset(id, patch)}
|
||||||
|
onDeletePreset={(id) => readerState.deleteReaderPreset(id)}
|
||||||
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||||
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||||
|
onClampZoom={clampZoom}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ReaderOverlay
|
||||||
|
{showResumeBanner}
|
||||||
|
resumePage={readerState.resumePage}
|
||||||
|
resumeFading={readerState.resumeFading}
|
||||||
|
{adjacent}
|
||||||
|
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageView
|
||||||
|
bind:this={pageViewRef}
|
||||||
|
{style} {imgCls} {effectiveWidth}
|
||||||
|
loading={readerState.loading}
|
||||||
|
error={readerState.error}
|
||||||
|
pageReady={readerState.pageReady}
|
||||||
|
pageGroups={readerState.pageGroups}
|
||||||
|
{currentGroup} {stripToRender}
|
||||||
|
fadingOut={readerState.fadingOut}
|
||||||
|
{tapToToggleBar}
|
||||||
|
{pinchZoomEnabled}
|
||||||
|
{barPosition}
|
||||||
|
onGetZoom={() => zoom}
|
||||||
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
||||||
|
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||||
|
onTap={handleTap}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onToggleUi={toggleUiVisibility}
|
||||||
|
{bindContainer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#snippet progressBarSnippet()}
|
||||||
|
<ReaderProgressBar
|
||||||
|
{style}
|
||||||
|
loading={readerState.loading}
|
||||||
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||||
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||||
|
uiVisible={readerState.uiVisible}
|
||||||
|
{barPosition}
|
||||||
|
onGoPrev={goPrev}
|
||||||
|
onGoNext={goNext}
|
||||||
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !isVerticalBar}
|
||||||
|
<ReaderProgressBar
|
||||||
|
{style}
|
||||||
|
loading={readerState.loading}
|
||||||
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||||
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||||
|
uiVisible={readerState.uiVisible}
|
||||||
|
{barPosition}
|
||||||
|
onGoPrev={goPrev}
|
||||||
|
onGoNext={goNext}
|
||||||
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
||||||
|
.root.containerized { position: relative; inset: auto; flex: 1; height: 100%; z-index: 0; transform: none; will-change: auto; }
|
||||||
|
|
||||||
|
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
||||||
|
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
||||||
|
.root.overlay-bars :global(.viewer) { height: 100%; }
|
||||||
|
|
||||||
|
.root.bar-left :global(.viewer) { margin-left: 40px; }
|
||||||
|
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||||
|
|
||||||
|
.root.pinch-active :global(.viewer) { touch-action: none; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
X, CaretLeft, CaretRight, CaretUp, CaretDown,
|
||||||
|
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||||
|
Bookmark, MapPin, Download, Check, GearSix, Sliders,
|
||||||
|
} from "phosphor-svelte";
|
||||||
|
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import { cubicOut, cubicIn } from "svelte/easing";
|
||||||
|
import type { Chapter } from "$lib/types";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
displayChapter: Chapter | null;
|
||||||
|
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||||
|
visibleChunkLastPage: number;
|
||||||
|
zoom: number;
|
||||||
|
zoomPct: number;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
isBookmarked: boolean;
|
||||||
|
hasMarkerOnPage: boolean;
|
||||||
|
currentPageMarkers: { id: string; color: import("$lib/types/history").MarkerColor; note: string }[];
|
||||||
|
uiVisible: boolean;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
progressBar?: Snippet;
|
||||||
|
onCaptureZoomAnchor: () => void;
|
||||||
|
onRestoreZoomAnchor: () => void;
|
||||||
|
onMaybeMarkRead: () => void;
|
||||||
|
onToggleBookmark: () => void;
|
||||||
|
onCommitMarker: () => void;
|
||||||
|
onDeleteMarker: () => void;
|
||||||
|
onClampZoom: (z: number) => number;
|
||||||
|
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||||
|
onDlOpen: () => void;
|
||||||
|
onSettingsOpen: () => void;
|
||||||
|
perMangaEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
displayChapter, adjacent, visibleChunkLastPage,
|
||||||
|
zoom, zoomPct, isFullscreen,
|
||||||
|
isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
||||||
|
uiVisible,
|
||||||
|
barPosition, progressBar,
|
||||||
|
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||||
|
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||||
|
onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
|
||||||
|
perMangaEnabled,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
const popoverSide = $derived(
|
||||||
|
barPosition === "left" ? "right" :
|
||||||
|
barPosition === "right" ? "left" :
|
||||||
|
"bottom"
|
||||||
|
);
|
||||||
|
|
||||||
|
function adjustZoom(delta: number) {
|
||||||
|
onCaptureZoomAnchor();
|
||||||
|
onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
|
||||||
|
onRestoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
onCaptureZoomAnchor();
|
||||||
|
onApplySettings({ readerZoom: 1.0 });
|
||||||
|
onRestoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
|
||||||
|
else await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
let wcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function wcResetTimer() {
|
||||||
|
if (wcTimer) clearTimeout(wcTimer);
|
||||||
|
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (readerState.winOpen) wcResetTimer();
|
||||||
|
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
|
||||||
|
return () => { if (wcTimer) clearTimeout(wcTimer); };
|
||||||
|
});
|
||||||
|
|
||||||
|
function openMarkerPopover() {
|
||||||
|
if (currentPageMarkers.length > 0) {
|
||||||
|
const first = currentPageMarkers[0];
|
||||||
|
readerState.openMarker(first.id, first.note, first.color);
|
||||||
|
} else {
|
||||||
|
readerState.openMarker("", "", "yellow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chapterHover = $state(false);
|
||||||
|
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showChapterPopover() {
|
||||||
|
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
|
||||||
|
chapterHover = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideChapterPopover() {
|
||||||
|
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bar"
|
||||||
|
class:bar-top={barPosition === "top"}
|
||||||
|
class:bar-left={barPosition === "left"}
|
||||||
|
class:bar-right={barPosition === "right"}
|
||||||
|
class:hidden={!uiVisible}
|
||||||
|
>
|
||||||
|
<div class="bar-start">
|
||||||
|
<button class="icon-btn" onclick={() => readerState.closeReader()} title="Close reader">
|
||||||
|
<X size={15} weight="light" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn"
|
||||||
|
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
|
||||||
|
disabled={!adjacent.prev}>
|
||||||
|
{#if isVertical}
|
||||||
|
<CaretUp size={14} weight="light" />
|
||||||
|
{:else}
|
||||||
|
<CaretLeft size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ch-hover-wrap"
|
||||||
|
onmouseenter={showChapterPopover}
|
||||||
|
onmouseleave={hideChapterPopover}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<button class="ch-pill" title="{readerState.activeManga?.title} / {displayChapter?.name}">
|
||||||
|
{#if isVertical}
|
||||||
|
<span class="ch-info"></span>
|
||||||
|
{:else}
|
||||||
|
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
|
||||||
|
<span class="ch-marquee-content">
|
||||||
|
<span class="ch-title">{readerState.activeManga?.title}</span>
|
||||||
|
<span class="ch-sep">/</span>
|
||||||
|
<span class="ch-name">{displayChapter?.name}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="ch-page">p.{readerState.pageNumber} / {visibleChunkLastPage}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if chapterHover && !isVertical}
|
||||||
|
<div class="ch-popover ch-popover-{popoverSide}">
|
||||||
|
<span class="ch-pop-title">{readerState.activeManga?.title}</span>
|
||||||
|
<span class="ch-pop-sep">/</span>
|
||||||
|
<span class="ch-pop-name">{displayChapter?.name}</span>
|
||||||
|
<span class="ch-pop-page">p.{readerState.pageNumber} / {visibleChunkLastPage}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="icon-btn"
|
||||||
|
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
|
||||||
|
disabled={!adjacent.next}>
|
||||||
|
{#if isVertical}
|
||||||
|
<CaretDown size={14} weight="light" />
|
||||||
|
{:else}
|
||||||
|
<CaretRight size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<span class="bar-sep" data-tauri-drag-region></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isVertical && progressBar}
|
||||||
|
<div class="bar-middle">
|
||||||
|
{@render progressBar()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<div class="bar-drag-gap" data-tauri-drag-region></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="bar-end">
|
||||||
|
<div class="zoom-wrap">
|
||||||
|
<div class="zoom-inline">
|
||||||
|
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||||
|
<MagnifyingGlassMinus size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
<div class="zoom-divider"></div>
|
||||||
|
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
|
||||||
|
{zoomPct}%
|
||||||
|
</button>
|
||||||
|
<div class="zoom-divider"></div>
|
||||||
|
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||||
|
<MagnifyingGlassPlus size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if readerState.zoomOpen}
|
||||||
|
<div class="popover zoom-popover popover-{popoverSide}">
|
||||||
|
<div class="zoom-slider-row">
|
||||||
|
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
||||||
|
oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
||||||
|
</div>
|
||||||
|
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="marker-wrap">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={hasMarkerOnPage}
|
||||||
|
class:marker-btn-has={hasMarkerOnPage}
|
||||||
|
onclick={openMarkerPopover}
|
||||||
|
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
|
||||||
|
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
|
||||||
|
>
|
||||||
|
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if readerState.markerOpen}
|
||||||
|
<div class="popover marker-popover popover-{popoverSide}" role="presentation"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="marker-pop-header">
|
||||||
|
<span class="marker-pop-title">
|
||||||
|
{readerState.markerEditId ? "Edit marker" : "New marker"} · p.{readerState.pageNumber}
|
||||||
|
</span>
|
||||||
|
{#if readerState.markerEditId}
|
||||||
|
<button class="marker-delete-btn" onclick={onDeleteMarker} title="Delete marker">
|
||||||
|
<X size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="marker-color-row">
|
||||||
|
{#each MARKER_COLORS as c}
|
||||||
|
<button
|
||||||
|
class="marker-swatch"
|
||||||
|
class:marker-swatch-active={readerState.markerColor === c}
|
||||||
|
style="--swatch:{MARKER_COLOR_HEX[c]}"
|
||||||
|
onclick={() => readerState.markerColor = c}
|
||||||
|
title={c}
|
||||||
|
>
|
||||||
|
<span class="swatch-dot"></span>
|
||||||
|
<span class="swatch-label">{c}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="marker-textarea"
|
||||||
|
style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Note (optional)…"
|
||||||
|
bind:value={readerState.markerNote}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onCommitMarker(); }
|
||||||
|
if (e.key === "Escape") readerState.markerOpen = false;
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<div class="marker-pop-actions">
|
||||||
|
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}" onclick={onCommitMarker}>
|
||||||
|
<Check size={12} weight="bold" />
|
||||||
|
{readerState.markerEditId ? "Update" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button class="marker-cancel-btn" onclick={() => readerState.markerOpen = false}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="icon-btn" class:active={isBookmarked} onclick={onToggleBookmark}
|
||||||
|
title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||||
|
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" onclick={onDlOpen}>
|
||||||
|
<Download size={14} weight="light" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" class:active={perMangaEnabled}
|
||||||
|
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
|
||||||
|
title="Reader settings">
|
||||||
|
<Sliders size={13} weight="regular" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
|
||||||
|
<GearSix size={13} weight="regular" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="wc-wrap">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={readerState.winOpen}
|
||||||
|
onclick={() => { readerState.winOpen = !readerState.winOpen; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
|
||||||
|
title="Window controls"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<circle cx="6" cy="1.5" r="1.2" fill="currentColor"/>
|
||||||
|
<circle cx="6" cy="6" r="1.2" fill="currentColor"/>
|
||||||
|
<circle cx="6" cy="10.5" r="1.2" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if readerState.winOpen}
|
||||||
|
<div
|
||||||
|
class="wc-clip wc-clip-{popoverSide}"
|
||||||
|
role="presentation"
|
||||||
|
onmouseenter={wcResetTimer}
|
||||||
|
onmousemove={wcResetTimer}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="wc-bar"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
in:fly={isVertical
|
||||||
|
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
|
||||||
|
: { y: '-100%', duration: 200, easing: cubicOut }}
|
||||||
|
out:fly={isVertical
|
||||||
|
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
|
||||||
|
: { y: '-100%', duration: 150, easing: cubicIn }}
|
||||||
|
>
|
||||||
|
<button class="wc-icon-btn" onclick={async () => { readerState.winOpen = false; await toggleFullscreen(); }} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
|
||||||
|
{#if isFullscreen}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 11 11">
|
||||||
|
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
background: var(--bg-void);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
overflow: visible;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.bar.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.bar-top {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
height: 40px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-left, .bar-right {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
width: 40px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
|
||||||
|
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
|
||||||
|
|
||||||
|
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
|
||||||
|
.bar-drag-gap:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.bar-start, .bar-end { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
.bar-top .bar-start { overflow: hidden; }
|
||||||
|
.bar-left .bar-start,
|
||||||
|
.bar-left .bar-end,
|
||||||
|
.bar-right .bar-start,
|
||||||
|
.bar-right .bar-end { flex-direction: column; }
|
||||||
|
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||||
|
.icon-btn.active { color: var(--accent-fg); }
|
||||||
|
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||||
|
|
||||||
|
.ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.ch-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
cursor: default;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.bar-left .ch-pill, .bar-right .ch-pill { width: 28px; height: 28px; justify-content: center; padding: 0; }
|
||||||
|
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
|
||||||
|
.ch-marquee-track::-webkit-scrollbar { display: none; }
|
||||||
|
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
|
||||||
|
|
||||||
|
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.ch-name { color: var(--text-muted); }
|
||||||
|
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.ch-popover {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||||
|
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||||
|
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.ch-pop-sep { color: var(--text-faint); }
|
||||||
|
.ch-pop-name { color: var(--text-muted); }
|
||||||
|
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||||
|
|
||||||
|
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.zoom-inline { display: flex; align-items: center; }
|
||||||
|
.bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
|
||||||
|
|
||||||
|
.zoom-icon-btn { width: 28px; height: 28px; }
|
||||||
|
.zoom-divider { background: var(--border-dim); flex-shrink: 0; }
|
||||||
|
.bar-top .zoom-divider { width: 1px; height: 16px; }
|
||||||
|
.bar-left .zoom-divider,
|
||||||
|
.bar-right .zoom-divider { height: 1px; width: 16px; }
|
||||||
|
|
||||||
|
.zoom-pct-btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
height: 28px;
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.bar-left .zoom-pct-btn,
|
||||||
|
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
|
||||||
|
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 100;
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
|
||||||
|
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
|
||||||
|
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
|
||||||
|
|
||||||
|
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
|
||||||
|
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||||
|
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||||
|
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.marker-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.marker-popover { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||||
|
.marker-delete-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-sm); background: none; border: none; cursor: pointer; flex: 1; transition: background var(--t-fast); }
|
||||||
|
.marker-swatch:hover { background: var(--bg-overlay); }
|
||||||
|
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||||
|
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||||||
|
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||||||
|
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
|
||||||
|
.marker-swatch-active .swatch-label { color: var(--text-muted); }
|
||||||
|
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||||||
|
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
|
||||||
|
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||||
|
.marker-save-btn:hover { filter: brightness(1.2); }
|
||||||
|
.marker-cancel-btn { flex: 1; padding: 6px 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-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||||
|
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
|
||||||
|
.wc-wrap { position: static; flex-shrink: 0; }
|
||||||
|
.wc-clip { position: absolute; z-index: 100; }
|
||||||
|
.wc-clip-bottom { top: 100%; right: var(--sp-3); clip-path: inset(0 -20px -20px -20px); }
|
||||||
|
.wc-clip-right { left: calc(100% + 1px); top: auto; bottom: var(--sp-3); clip-path: inset(-20px -20px -20px 0); }
|
||||||
|
.wc-clip-left { right: calc(100% + 1px); top: auto; bottom: var(--sp-3); clip-path: inset(-20px 0 -20px -20px); }
|
||||||
|
.wc-bar { display: flex; align-items: center; gap: 1px; padding: 3px 10px 4px; background: var(--bg-raised); border: 1px solid var(--border-base); box-shadow: 0 6px 16px rgba(0,0,0,0.45); }
|
||||||
|
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
|
||||||
|
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
|
||||||
|
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
|
||||||
|
|
||||||
|
.wc-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 24px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
|
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.bar-middle { flex: 1; display: flex; flex-direction: column; align-items: center; width: 100%; min-height: 0; padding: var(--sp-1) 0; overflow: hidden; }
|
||||||
|
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showResumeBanner: boolean;
|
||||||
|
resumePage: number;
|
||||||
|
resumeFading: boolean;
|
||||||
|
adjacent: { remaining: Chapter[] };
|
||||||
|
onDismissResume: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume }: Props = $props();
|
||||||
|
|
||||||
|
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
||||||
|
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
||||||
|
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
||||||
|
|
||||||
|
async function runDl(fn: () => Promise<void>) {
|
||||||
|
readerState.dlBusy = true;
|
||||||
|
try { await fn(); } catch (e) { console.error(e); }
|
||||||
|
readerState.dlBusy = false;
|
||||||
|
readerState.dlOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showResumeBanner}
|
||||||
|
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
|
||||||
|
Bookmark at page {resumePage}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if readerState.dlOpen && readerState.activeChapter}
|
||||||
|
{@const chapter = readerState.activeChapter}
|
||||||
|
<div class="dl-backdrop" role="presentation" onclick={() => readerState.dlOpen = false}>
|
||||||
|
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<p class="dl-title">Download</p>
|
||||||
|
|
||||||
|
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||||
|
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
||||||
|
This chapter
|
||||||
|
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dl-row">
|
||||||
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
|
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
||||||
|
Next chapters
|
||||||
|
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||||
|
</button>
|
||||||
|
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.max(1, readerState.nextN - 1)} disabled={readerState.nextN <= 1}>−</button>
|
||||||
|
<span class="dl-step-val">{readerState.nextN}</span>
|
||||||
|
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.min(queueable.length || 1, readerState.nextN + 1)} disabled={readerState.nextN >= queueable.length}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
|
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
||||||
|
All remaining
|
||||||
|
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.resume-banner { position: fixed; top: 48px; left: 50%; translate: -50% 0; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; }
|
||||||
|
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
|
||||||
|
@keyframes bannerIn { from { opacity: 0; translate: -50% -6px; scale: 0.97; } to { opacity: 1; translate: -50% 0; scale: 1; } }
|
||||||
|
@keyframes bannerOut { from { opacity: 1; translate: -50% 0; scale: 1; } to { opacity: 0; translate: -50% -4px; scale: 0.97; } }
|
||||||
|
|
||||||
|
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
||||||
|
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
||||||
|
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
||||||
|
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||||
|
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
||||||
|
.dl-step-btn { 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); }
|
||||||
|
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.dl-step-val { 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); }
|
||||||
|
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
X, Check, Trash, FloppyDisk,
|
||||||
|
Square, Rows, BookOpen, MonitorPlay,
|
||||||
|
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||||
|
ArrowsHorizontal,
|
||||||
|
SidebarSimple,
|
||||||
|
} from "phosphor-svelte";
|
||||||
|
import type { ReaderSettings, ReaderPreset } from "$lib/state/reader.svelte";
|
||||||
|
import type { FitMode } from "$lib/types/settings";
|
||||||
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
|
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fit: FitMode;
|
||||||
|
style: string;
|
||||||
|
rtl: boolean;
|
||||||
|
zoom: number;
|
||||||
|
zoomPct: number;
|
||||||
|
perMangaEnabled: boolean;
|
||||||
|
onTogglePerManga: () => void;
|
||||||
|
onSavePreset: (name: string) => void;
|
||||||
|
onApplyPreset: (settings: ReaderSettings) => void;
|
||||||
|
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
|
||||||
|
onDeletePreset: (id: string) => void;
|
||||||
|
onApplySettings: (patch: Partial<ReaderSettings>) => void;
|
||||||
|
onCaptureZoomAnchor: () => void;
|
||||||
|
onRestoreZoomAnchor: () => void;
|
||||||
|
onClampZoom: (z: number) => number;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
onBarPositionChange: (pos: "top" | "left" | "right") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
fit, style, rtl, zoom, zoomPct,
|
||||||
|
perMangaEnabled, onTogglePerManga,
|
||||||
|
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
|
||||||
|
onApplySettings,
|
||||||
|
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
|
||||||
|
barPosition, onBarPositionChange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const presets = $derived(settingsState.settings.readerPresets ?? []);
|
||||||
|
const effectiveSettings = $derived.by(() => {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
const override = mangaId != null ? (settingsState.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
||||||
|
return override ? { ...settingsState.settings, ...override } : settingsState.settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
let presetSaving = $state(false);
|
||||||
|
let presetNameInput = $state("");
|
||||||
|
let presetEditId = $state<string | null>(null);
|
||||||
|
let presetEditName = $state("");
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
readerState.presetOpen = false;
|
||||||
|
presetSaving = false;
|
||||||
|
presetNameInput = "";
|
||||||
|
presetEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitSavePreset() {
|
||||||
|
if (!presetNameInput.trim()) return;
|
||||||
|
onSavePreset(presetNameInput.trim());
|
||||||
|
presetSaving = false;
|
||||||
|
presetNameInput = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRenamePreset() {
|
||||||
|
if (!presetEditId || !presetEditName.trim()) return;
|
||||||
|
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
|
||||||
|
presetEditId = null;
|
||||||
|
presetEditName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSettings(s: ReaderSettings): string {
|
||||||
|
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
|
||||||
|
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
|
||||||
|
if (!s.pageGap) parts.push("no gap");
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZoom(v: number) {
|
||||||
|
onCaptureZoomAnchor();
|
||||||
|
onApplySettings({ readerZoom: onClampZoom(v) });
|
||||||
|
onRestoreZoomAnchor();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
|
||||||
|
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
|
||||||
|
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
|
||||||
|
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
|
||||||
|
{ value: "original", label: "Original", icon: ArrowsOut },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styleOptions: { value: string; label: string; icon: any }[] = [
|
||||||
|
{ value: "single", label: "Single", icon: Square },
|
||||||
|
{ value: "double", label: "Double", icon: BookOpen },
|
||||||
|
{ value: "fade", label: "Fade", icon: MonitorPlay },
|
||||||
|
{ value: "longstrip", label: "Long Strip", icon: Rows },
|
||||||
|
];
|
||||||
|
|
||||||
|
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="backdrop" role="button" tabindex="-1" aria-label="Close settings" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()} transition:fade={{ duration: 150 }}></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Reader settings & presets"
|
||||||
|
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Reader Settings</span>
|
||||||
|
{#if readerState.activeManga}
|
||||||
|
<span class="panel-manga">{readerState.activeManga.title}</span>
|
||||||
|
{/if}
|
||||||
|
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Page Style</p>
|
||||||
|
<div class="option-grid">
|
||||||
|
{#each styleOptions as o}
|
||||||
|
{@const Icon = o.icon}
|
||||||
|
<button
|
||||||
|
class="option-tile"
|
||||||
|
class:active={style === o.value}
|
||||||
|
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
|
||||||
|
>
|
||||||
|
<div class="tile-icon"><Icon size={18} weight={style === o.value ? "fill" : "light"} /></div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if style === "double"}
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Offset double spreads</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.offsetDoubleSpreads}
|
||||||
|
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Offset double spreads"
|
||||||
|
aria-checked={effectiveSettings.offsetDoubleSpreads}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if style === "longstrip"}
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Gap between pages</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.pageGap ?? true}
|
||||||
|
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Gap between pages"
|
||||||
|
aria-checked={effectiveSettings.pageGap ?? true}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Auto next chapter</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={settingsState.settings.autoNextChapter ?? false}
|
||||||
|
onclick={() => updateSettings({ autoNextChapter: !(settingsState.settings.autoNextChapter ?? false) })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Auto next chapter"
|
||||||
|
aria-checked={settingsState.settings.autoNextChapter ?? false}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Auto scroll</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={settingsState.settings.autoScroll ?? false}
|
||||||
|
onclick={() => updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Auto scroll"
|
||||||
|
aria-checked={settingsState.settings.autoScroll ?? false}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
{#if settingsState.settings.autoScroll}
|
||||||
|
<div class="speed-row">
|
||||||
|
<span class="speed-label">Speed</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="zoom-slider"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
value={settingsState.settings.autoScrollSpeed ?? 5}
|
||||||
|
oninput={(e) => updateSettings({ autoScrollSpeed: Number(e.currentTarget.value) })}
|
||||||
|
/>
|
||||||
|
<span class="speed-val">{settingsState.settings.autoScrollSpeed ?? 5}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Fit Mode</p>
|
||||||
|
<div class="option-grid">
|
||||||
|
{#each fitOptions as o}
|
||||||
|
{@const Icon = o.icon}
|
||||||
|
<button
|
||||||
|
class="option-tile"
|
||||||
|
class:active={fit === o.value}
|
||||||
|
onclick={() => onApplySettings({ fitMode: o.value })}
|
||||||
|
>
|
||||||
|
<div class="tile-icon"><Icon size={18} weight={fit === o.value ? "fill" : "light"} /></div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Reading Direction</p>
|
||||||
|
<div class="dir-row">
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={!rtl}
|
||||||
|
onclick={() => onApplySettings({ readingDirection: "ltr" })}
|
||||||
|
>
|
||||||
|
<ArrowsHorizontal size={14} weight="light" />
|
||||||
|
<span>Left to Right</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={rtl}
|
||||||
|
onclick={() => onApplySettings({ readingDirection: "rtl" })}
|
||||||
|
>
|
||||||
|
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
|
||||||
|
<span>Right to Left</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Bar Position</p>
|
||||||
|
<div class="bar-grid">
|
||||||
|
{#each barOptions as o}
|
||||||
|
<button
|
||||||
|
class="bar-tile"
|
||||||
|
class:active={barPosition === o.value}
|
||||||
|
onclick={() => onBarPositionChange(o.value)}
|
||||||
|
>
|
||||||
|
<div class="bar-tile-preview bar-preview-{o.value}">
|
||||||
|
<div class="bar-preview-strip"></div>
|
||||||
|
<div class="bar-preview-content"></div>
|
||||||
|
</div>
|
||||||
|
<span class="tile-label">{o.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<p class="section-label" style="margin:0">Zoom</p>
|
||||||
|
<span class="zoom-readout">{zoomPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-row">
|
||||||
|
<button class="zoom-step" aria-label="Zoom out" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}>−</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="zoom-slider"
|
||||||
|
min={Math.round(ZOOM_MIN * 100)}
|
||||||
|
max={Math.round(ZOOM_MAX * 100)}
|
||||||
|
step={5}
|
||||||
|
value={zoomPct}
|
||||||
|
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
|
||||||
|
/>
|
||||||
|
<button class="zoom-step" aria-label="Zoom in" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="section-label">Image</p>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Optimize contrast</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={effectiveSettings.optimizeContrast}
|
||||||
|
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Optimize contrast"
|
||||||
|
aria-checked={effectiveSettings.optimizeContrast}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Pinch to zoom <span class="toggle-badge">experimental</span></span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={settingsState.settings.pinchZoom ?? false}
|
||||||
|
onclick={() => updateSettings({ pinchZoom: !(settingsState.settings.pinchZoom ?? false) })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Pinch to zoom"
|
||||||
|
aria-checked={settingsState.settings.pinchZoom ?? false}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Mark read on chapter advance</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={settingsState.settings.markReadOnNext ?? true}
|
||||||
|
onclick={() => updateSettings({ markReadOnNext: !(settingsState.settings.markReadOnNext ?? true) })}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Mark read on chapter advance"
|
||||||
|
aria-checked={settingsState.settings.markReadOnNext ?? true}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if readerState.activeManga}
|
||||||
|
<section class="section">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span class="toggle-label">Per-manga settings</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={perMangaEnabled}
|
||||||
|
onclick={onTogglePerManga}
|
||||||
|
role="switch"
|
||||||
|
aria-label="Per-manga settings"
|
||||||
|
aria-checked={perMangaEnabled}
|
||||||
|
><span class="toggle-knob"></span></button>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<p class="section-label" style="margin:0">Saved Presets</p>
|
||||||
|
{#if !presetSaving}
|
||||||
|
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if presetSaving}
|
||||||
|
<div class="preset-name-row">
|
||||||
|
<input
|
||||||
|
class="preset-name-input"
|
||||||
|
placeholder="Preset name…"
|
||||||
|
bind:value={presetNameInput}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
|
||||||
|
/>
|
||||||
|
<button class="small-btn" aria-label="Confirm" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
|
||||||
|
<button class="small-btn" aria-label="Cancel" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if presets.length === 0 && !presetSaving}
|
||||||
|
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="preset-list">
|
||||||
|
{#each presets as p (p.id)}
|
||||||
|
{#if presetEditId === p.id}
|
||||||
|
<div class="preset-name-row">
|
||||||
|
<input
|
||||||
|
class="preset-name-input"
|
||||||
|
bind:value={presetEditName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
|
||||||
|
/>
|
||||||
|
<button class="small-btn" aria-label="Confirm" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
|
||||||
|
<button class="small-btn" aria-label="Cancel" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="preset-row">
|
||||||
|
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
|
||||||
|
<span class="preset-name">{p.name}</span>
|
||||||
|
<span class="preset-desc">{describeSettings(p.settings)}</span>
|
||||||
|
</button>
|
||||||
|
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
|
||||||
|
<FloppyDisk size={12} weight="regular" />
|
||||||
|
</button>
|
||||||
|
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
|
||||||
|
<Trash size={12} weight="regular" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: calc(var(--z-reader) + 20);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 320px;
|
||||||
|
z-index: calc(var(--z-reader) + 21);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-left: 1px solid var(--border-base);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 0 var(--sp-4);
|
||||||
|
height: 48px;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-manga {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-dim) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-2) var(--sp-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.tile-icon { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
|
||||||
|
|
||||||
|
.bar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: var(--sp-2) var(--sp-1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.bar-tile-preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.bar-tile.active .bar-tile-preview { opacity: 1; }
|
||||||
|
|
||||||
|
.bar-preview-strip {
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bar-preview-content {
|
||||||
|
flex: 1;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-preview-top { flex-direction: column; }
|
||||||
|
.bar-preview-left { flex-direction: row; }
|
||||||
|
.bar-preview-right { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
|
||||||
|
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
|
||||||
|
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-badge {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-left: var(--sp-1);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
.toggle.on { background: var(--accent-fg); }
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: left var(--t-base);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toggle.on .toggle-knob { left: 16px; }
|
||||||
|
|
||||||
|
.dir-row { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.dir-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||||||
|
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.zoom-readout {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||||
|
.zoom-step:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
|
||||||
|
.zoom-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zoom-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-preset-btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px var(--sp-1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.new-preset-btn:hover { background: var(--accent-muted); }
|
||||||
|
|
||||||
|
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.preset-name-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.preset-name-input:focus { border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.preset-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||||
|
|
||||||
|
.preset-apply {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 7px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.preset-apply:hover { background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-desc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
|
||||||
|
.small-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-val {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
min-width: 1.5ch;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, ArrowRight } from "phosphor-svelte";
|
||||||
|
import { readerState, MARKER_COLOR_HEX } from "$lib/state/reader.svelte";
|
||||||
|
import type { BookmarkEntry, MarkerEntry } from "$lib/types/history";
|
||||||
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
style: string;
|
||||||
|
loading: boolean;
|
||||||
|
rtl: boolean;
|
||||||
|
sliderPage: number;
|
||||||
|
sliderMax: number;
|
||||||
|
sliderPct: number;
|
||||||
|
lastPage: number;
|
||||||
|
displayChapter: Chapter | null;
|
||||||
|
currentBookmark: BookmarkEntry | undefined;
|
||||||
|
activeChapterMarkers: MarkerEntry[];
|
||||||
|
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||||
|
uiVisible: boolean;
|
||||||
|
barPosition: "top" | "left" | "right";
|
||||||
|
onGoPrev: () => void;
|
||||||
|
onGoNext: () => void;
|
||||||
|
onJumpToPage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
|
||||||
|
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
|
||||||
|
barPosition,
|
||||||
|
onGoPrev, onGoNext, onJumpToPage,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isVertical = $derived(barPosition === "left" || barPosition === "right");
|
||||||
|
|
||||||
|
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
|
||||||
|
const hPct = $derived(`--pct:${sliderPct}%`);
|
||||||
|
const vPct = $derived(`--pct:${sliderPct}%`);
|
||||||
|
|
||||||
|
function handleH(e: Event) {
|
||||||
|
const raw = Number((e.target as HTMLInputElement).value);
|
||||||
|
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleV(e: Event) {
|
||||||
|
onJumpToPage(Number((e.target as HTMLInputElement).value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerPct(pageNumber: number, forRtl = false): number {
|
||||||
|
if (sliderMax <= 1) return 0;
|
||||||
|
const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber;
|
||||||
|
return ((ord - 1) / (sliderMax - 1)) * 100;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isVertical}
|
||||||
|
<div class="bottombar" class:hidden={!uiVisible}>
|
||||||
|
<button class="nav-btn" onclick={onGoPrev}
|
||||||
|
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
||||||
|
<ArrowLeft size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if sliderMax > 1}
|
||||||
|
<div
|
||||||
|
class="slider-wrap"
|
||||||
|
onmouseenter={() => readerState.sliderHover = true}
|
||||||
|
onmouseleave={() => readerState.sliderHover = false}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="h-range"
|
||||||
|
style={hPct}
|
||||||
|
min={1}
|
||||||
|
max={sliderMax}
|
||||||
|
value={hValue}
|
||||||
|
oninput={handleH}
|
||||||
|
onmousedown={() => readerState.sliderDragging = true}
|
||||||
|
onmouseup={() => readerState.sliderDragging = false}
|
||||||
|
/>
|
||||||
|
<div class="slider-markers" aria-hidden="true">
|
||||||
|
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||||
|
<div class="slider-checkpoint bookmark-checkpoint"
|
||||||
|
style="left:{markerPct(currentBookmark.pageNumber, rtl)}%"
|
||||||
|
title="Bookmark: Page {currentBookmark.pageNumber}">
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each activeChapterMarkers as m (m.id)}
|
||||||
|
<div class="slider-checkpoint marker-checkpoint"
|
||||||
|
style="left:{markerPct(m.pageNumber, rtl)}%;background:{MARKER_COLOR_HEX[m.color]}"
|
||||||
|
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||||
|
<div class="slider-tooltip" style="left:{sliderPct}%">
|
||||||
|
{sliderPage} / {sliderMax}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="nav-btn" onclick={onGoNext}
|
||||||
|
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
||||||
|
<ArrowRight size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
|
||||||
|
{#if sliderMax > 1}
|
||||||
|
<div
|
||||||
|
class="vslider-wrap"
|
||||||
|
onmouseenter={() => readerState.sliderHover = true}
|
||||||
|
onmouseleave={() => readerState.sliderHover = false}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="v-range"
|
||||||
|
style={vPct}
|
||||||
|
min={1}
|
||||||
|
max={sliderMax}
|
||||||
|
value={sliderPage}
|
||||||
|
oninput={handleV}
|
||||||
|
onmousedown={() => readerState.sliderDragging = true}
|
||||||
|
onmouseup={() => readerState.sliderDragging = false}
|
||||||
|
/>
|
||||||
|
<div class="vslider-markers" aria-hidden="true">
|
||||||
|
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||||
|
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
|
<div class="vslider-checkpoint bookmark-checkpoint"
|
||||||
|
style="top:{bPct}%"
|
||||||
|
title="Bookmark: Page {currentBookmark.pageNumber}">
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each activeChapterMarkers as m (m.id)}
|
||||||
|
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
|
<div class="vslider-checkpoint marker-checkpoint"
|
||||||
|
style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}"
|
||||||
|
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||||
|
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
|
||||||
|
{sliderPage} / {sliderMax}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||||||
|
.bottombar.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
||||||
|
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||||
|
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
|
||||||
|
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; }
|
||||||
|
|
||||||
|
.h-range { -webkit-appearance: none; appearance: none; width: 100%; height: 34px; background: transparent; cursor: pointer; position: relative; z-index: 2; margin: 0; padding: 0; }
|
||||||
|
.h-range::-webkit-slider-runnable-track { height: 3px; background: linear-gradient(to right, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 2px; transition: height 0.15s ease, background 0.05s linear; }
|
||||||
|
.h-range:hover::-webkit-slider-runnable-track,
|
||||||
|
.h-range:active::-webkit-slider-runnable-track { height: 5px; }
|
||||||
|
.h-range::-moz-range-track { height: 3px; background: var(--border-strong); border-radius: 2px; transition: height 0.15s ease; }
|
||||||
|
.h-range::-moz-range-progress { height: 3px; background: var(--accent-fg); border-radius: 2px; transition: height 0.15s ease; }
|
||||||
|
.h-range:hover::-moz-range-track, .h-range:active::-moz-range-track { height: 5px; }
|
||||||
|
.h-range:hover::-moz-range-progress, .h-range:active::-moz-range-progress { height: 5px; }
|
||||||
|
.h-range::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); box-shadow: 0 0 0 2px rgba(0,0,0,0.5); margin-top: -4.5px; transition: transform var(--t-fast); }
|
||||||
|
.h-range:hover::-webkit-slider-thumb,
|
||||||
|
.h-range:active::-webkit-slider-thumb { transform: scale(1.3); }
|
||||||
|
.h-range::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); box-shadow: 0 0 0 2px rgba(0,0,0,0.5); border: none; transition: transform var(--t-fast); }
|
||||||
|
.h-range:hover::-moz-range-thumb,
|
||||||
|
.h-range:active::-moz-range-thumb { transform: scale(1.3); }
|
||||||
|
|
||||||
|
.slider-markers { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
|
||||||
|
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); }
|
||||||
|
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
|
||||||
|
.marker-checkpoint { opacity: 0.85; }
|
||||||
|
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; }
|
||||||
|
.vbar-progress.hidden { opacity: 0; }
|
||||||
|
|
||||||
|
.vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
|
.v-range { -webkit-appearance: slider-vertical; appearance: slider-vertical; writing-mode: vertical-lr; direction: rtl; width: 34px; height: 100%; background: transparent; cursor: pointer; position: relative; z-index: 2; margin: 0; padding: 0; }
|
||||||
|
.v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; }
|
||||||
|
.v-range:hover::-webkit-slider-runnable-track,
|
||||||
|
.v-range:active::-webkit-slider-runnable-track { width: 7px; }
|
||||||
|
.v-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent-fg); box-shadow: 0 0 0 2px rgba(0,0,0,0.5); margin-left: -4.5px; transition: transform var(--t-fast); }
|
||||||
|
.v-range:hover::-webkit-slider-thumb,
|
||||||
|
.v-range:active::-webkit-slider-thumb { transform: scale(1.3); }
|
||||||
|
|
||||||
|
.vslider-markers { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; }
|
||||||
|
.vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||||
|
.vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { readerState, DEFAULT_MANGA_PREFS } from "$lib/state/reader.svelte";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import type { MangaPrefs } from "$lib/types/settings";
|
||||||
|
import type { MarkerColor } from "$lib/types/history";
|
||||||
|
|
||||||
|
export function getMangaPrefs(mangaId?: number): MangaPrefs {
|
||||||
|
const id = mangaId ?? readerState.activeManga?.id;
|
||||||
|
if (!id) return { ...DEFAULT_MANGA_PREFS };
|
||||||
|
return { ...DEFAULT_MANGA_PREFS, ...(settingsState.settings.mangaPrefs?.[id] ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||||
|
if (markedRead.has(id)) return;
|
||||||
|
markedRead.add(id);
|
||||||
|
|
||||||
|
const chapter = readerState.activeChapterList.find(c => c.id === id) ?? readerState.activeChapter;
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
|
||||||
|
if (manga && chapter) {
|
||||||
|
readerState.addBookmark({
|
||||||
|
mangaId: manga.id,
|
||||||
|
mangaTitle: manga.title,
|
||||||
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: id,
|
||||||
|
chapterName: chapter.name,
|
||||||
|
pageNumber: readerState.pageUrls.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getAdapter();
|
||||||
|
|
||||||
|
adapter.markChapterRead(String(id), true)
|
||||||
|
.then(() => {
|
||||||
|
const mangaId = readerState.activeManga?.id;
|
||||||
|
if (!mangaId) return;
|
||||||
|
|
||||||
|
readerState.activeChapterList = readerState.activeChapterList.map(c =>
|
||||||
|
c.id === id ? { ...c, read: true } : c
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefs = getMangaPrefs(mangaId);
|
||||||
|
|
||||||
|
if (prefs.deleteOnRead) {
|
||||||
|
const ch = readerState.activeChapterList.find(c => c.id === id);
|
||||||
|
if (ch?.downloaded) {
|
||||||
|
const delayMs = (prefs.deleteDelayHours ?? 0) * 3_600_000;
|
||||||
|
const doDelete = () => adapter.deleteDownloadedChapters([String(id)]).catch(console.error);
|
||||||
|
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.downloadAhead > 0) {
|
||||||
|
const list = readerState.activeChapterList;
|
||||||
|
const idx = list.findIndex(c => c.id === id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const toQueue = list
|
||||||
|
.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
||||||
|
.filter(c => !c.downloaded && !c.read)
|
||||||
|
.map(c => String(c.id));
|
||||||
|
if (toQueue.length) adapter.enqueueDownloads(toQueue).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.maxKeepChapters > 0) {
|
||||||
|
const downloaded = readerState.activeChapterList
|
||||||
|
.filter(c => c.downloaded)
|
||||||
|
.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
|
||||||
|
if (excess.length) {
|
||||||
|
adapter.deleteDownloadedChapters(excess.map(c => String(c.id))).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => { markedRead.delete(id); console.error(e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNumber: number) {
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (!chapter || !manga) return;
|
||||||
|
|
||||||
|
const existing = readerState.bookmarks.find(
|
||||||
|
b => b.mangaId === manga.id && b.chapterId === chapter.id && b.pageNumber === pageNumber,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
readerState.removeBookmark(chapter.id);
|
||||||
|
} else {
|
||||||
|
const other = readerState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
||||||
|
if (other) readerState.removeBookmark(other.chapterId);
|
||||||
|
readerState.addBookmark({
|
||||||
|
mangaId: manga.id,
|
||||||
|
mangaTitle: manga.title,
|
||||||
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
chapterName: chapter.name,
|
||||||
|
pageNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitMarker(color: MarkerColor, note: string, editId: string) {
|
||||||
|
const chapter = readerState.activeChapter;
|
||||||
|
const manga = readerState.activeManga;
|
||||||
|
if (!chapter || !manga) return;
|
||||||
|
if (editId) {
|
||||||
|
readerState.updateMarker(editId, { note: note.trim(), color });
|
||||||
|
} else {
|
||||||
|
readerState.addMarker({
|
||||||
|
mangaId: manga.id,
|
||||||
|
mangaTitle: manga.title,
|
||||||
|
thumbnailUrl: manga.thumbnailUrl,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
chapterName: chapter.name,
|
||||||
|
pageNumber: readerState.pageNumber,
|
||||||
|
note: note.trim(),
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { fetchPages } from "./pageLoader";
|
||||||
|
import { cancelQueuedFetches } from "$lib/core/cache/imageCache";
|
||||||
|
import { clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
||||||
|
|
||||||
|
export function scheduleResumeDismiss() {
|
||||||
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
|
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadChapter(
|
||||||
|
id: number,
|
||||||
|
useBlob: boolean,
|
||||||
|
abortCtrl: { current: AbortController | null },
|
||||||
|
startAtLastPage: { current: boolean },
|
||||||
|
markedRead: Set<number>,
|
||||||
|
adjacent: { next: { id: number } | null },
|
||||||
|
) {
|
||||||
|
abortCtrl.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortCtrl.current = ctrl;
|
||||||
|
|
||||||
|
cancelQueuedFetches();
|
||||||
|
if (useBlob) clearResolvedUrlCache();
|
||||||
|
|
||||||
|
startAtLastPage.current = false;
|
||||||
|
markedRead.clear();
|
||||||
|
readerState.resetForChapter();
|
||||||
|
readerState.pageUrls = [];
|
||||||
|
|
||||||
|
const bookmark = readerState.bookmarks.find(b => b.chapterId === id);
|
||||||
|
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||||
|
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||||
|
readerState.resumeDismissed = false;
|
||||||
|
readerState.resumeVisible = resumeTo > 1;
|
||||||
|
if (resumeTo > 1) scheduleResumeDismiss();
|
||||||
|
|
||||||
|
readerState.pageNumber = 1;
|
||||||
|
try {
|
||||||
|
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
readerState.pageUrls = urls;
|
||||||
|
if (startAtLastPage.current) readerState.pageNumber = urls.length;
|
||||||
|
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||||
|
readerState.pageReady = true;
|
||||||
|
readerState.loading = false;
|
||||||
|
if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
readerState.error = e instanceof Error ? e.message : String(e);
|
||||||
|
readerState.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readerState, openReader, closeReader } from "$lib/state/reader.svelte";
|
||||||
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
|
interface Adjacent {
|
||||||
|
prev: Chapter | null;
|
||||||
|
next: Chapter | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||||
|
if (!readerState.pageGroups.length) return;
|
||||||
|
const gi = readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber));
|
||||||
|
if (forward) {
|
||||||
|
if (gi < readerState.pageGroups.length - 1) readerState.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||||
|
else if (adjacent.next) { readerState.pageNumber = 1; openReader(adjacent.next, readerState.activeChapterList); }
|
||||||
|
else closeReader();
|
||||||
|
} else {
|
||||||
|
if (gi > 0) readerState.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||||
|
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function animateFade(fn: () => void) {
|
||||||
|
readerState.fadingOut = true;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
fn();
|
||||||
|
readerState.fadingOut = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goForward(
|
||||||
|
style: string,
|
||||||
|
adjacent: Adjacent,
|
||||||
|
lastPage: number,
|
||||||
|
onMaybeMarkRead: () => void,
|
||||||
|
startAtLastPage: () => void,
|
||||||
|
) {
|
||||||
|
if (readerState.loading) return;
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, readerState.activeChapterList); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||||
|
if (!readerState.pageUrls.length) return;
|
||||||
|
if (readerState.pageNumber < lastPage) {
|
||||||
|
if (style === "fade") animateFade(() => { readerState.pageNumber++; });
|
||||||
|
else readerState.pageNumber++;
|
||||||
|
} else if (adjacent.next) {
|
||||||
|
onMaybeMarkRead();
|
||||||
|
readerState.pageNumber = 1;
|
||||||
|
openReader(adjacent.next, readerState.activeChapterList);
|
||||||
|
} else closeReader();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||||
|
if (readerState.loading) return;
|
||||||
|
if (style === "longstrip") {
|
||||||
|
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||||
|
if (!readerState.pageUrls.length) return;
|
||||||
|
if (readerState.pageNumber > 1) {
|
||||||
|
if (style === "fade") animateFade(() => { readerState.pageNumber--; });
|
||||||
|
else readerState.pageNumber--;
|
||||||
|
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
||||||
|
if (style === "longstrip") {
|
||||||
|
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
||||||
|
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (style === "double" && readerState.pageGroups.length) {
|
||||||
|
const group = readerState.pageGroups[page - 1];
|
||||||
|
if (group) readerState.pageNumber = group[0];
|
||||||
|
} else {
|
||||||
|
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache, clearResolvedUrlCache } from "$lib/core/cache/pageCache";
|
||||||
|
|
||||||
|
export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] {
|
||||||
|
const groups: number[][] = [[1]];
|
||||||
|
if (offsetSpreads) groups.push([2]);
|
||||||
|
let i = offsetSpreads ? 3 : 2;
|
||||||
|
while (i <= urls.length) {
|
||||||
|
const a = aspects[i - 1];
|
||||||
|
if (a > 1.2 || i === urls.length) { groups.push([i++]); }
|
||||||
|
else { groups.push([i, i + 1]); i += 2; }
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createPinchGesture } from "$lib/core/ui/touchscreen";
|
||||||
|
import { clampZoom } from "./zoomHelpers";
|
||||||
|
|
||||||
|
export type { PinchGesture as PinchTracker } from "$lib/core/ui/touchscreen";
|
||||||
|
|
||||||
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
|
export interface PinchTrackerOptions {
|
||||||
|
getZoom: () => number;
|
||||||
|
setZoom: (z: number) => void;
|
||||||
|
getInspectScale: () => number;
|
||||||
|
setInspectScale: (s: number) => void;
|
||||||
|
resetInspectPan: () => void;
|
||||||
|
isLongstrip: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPinchTracker(opts: PinchTrackerOptions) {
|
||||||
|
let startZoom = 0;
|
||||||
|
let startInspect = 0;
|
||||||
|
|
||||||
|
return createPinchGesture({
|
||||||
|
onPinch(scale) {
|
||||||
|
if (startZoom === 0) {
|
||||||
|
startZoom = opts.getZoom();
|
||||||
|
startInspect = opts.getInspectScale();
|
||||||
|
}
|
||||||
|
if (opts.isLongstrip()) {
|
||||||
|
opts.setZoom(clampZoom(startZoom * scale));
|
||||||
|
} else {
|
||||||
|
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
|
||||||
|
if (next !== opts.getInspectScale()) {
|
||||||
|
if (next === 1) opts.resetInspectPan();
|
||||||
|
opts.setInspectScale(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPinchEnd() {
|
||||||
|
startZoom = 0;
|
||||||
|
startInspect = 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { matchesKeybind } from "$lib/core/keybinds/keybindEngine";
|
||||||
|
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||||
|
import type { Keybinds } from "$lib/core/keybinds/defaultBinds";
|
||||||
|
|
||||||
|
export interface ReaderKeyActions {
|
||||||
|
goNext: () => void;
|
||||||
|
goPrev: () => void;
|
||||||
|
closeReader: () => void;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
lastPage: () => number;
|
||||||
|
adjustZoom: (delta: number) => void;
|
||||||
|
resetZoom: () => void;
|
||||||
|
cycleStyle: () => void;
|
||||||
|
toggleDirection: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
|
toggleBookmark: () => void;
|
||||||
|
toggleMarker: () => void;
|
||||||
|
toggleAutoScroll: () => void;
|
||||||
|
chapterNext: () => void;
|
||||||
|
chapterPrev: () => void;
|
||||||
|
closePopovers: () => boolean;
|
||||||
|
getKeybinds: () => Keybinds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZOOM_STEP = 0.10;
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
|
||||||
|
else await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardEvent) => void {
|
||||||
|
return function onKey(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (actions.closePopovers()) return;
|
||||||
|
actions.closeReader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
if (e.key === "=" || e.key === "+") { e.preventDefault(); actions.adjustZoom(ZOOM_STEP); return; }
|
||||||
|
if (e.key === "-") { e.preventDefault(); actions.adjustZoom(-ZOOM_STEP); return; }
|
||||||
|
if (e.key === "0") { e.preventDefault(); actions.resetZoom(); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const kb = actions.getKeybinds();
|
||||||
|
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); actions.closeReader(); }
|
||||||
|
else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); actions.goNext(); }
|
||||||
|
else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); actions.goPrev(); }
|
||||||
|
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); actions.goToPage(1); }
|
||||||
|
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); actions.goToPage(actions.lastPage()); }
|
||||||
|
else if (matchesKeybind(e, kb.turnChapterRight)) { e.preventDefault(); actions.chapterNext(); }
|
||||||
|
else if (matchesKeybind(e, kb.turnChapterLeft)) { e.preventDefault(); actions.chapterPrev(); }
|
||||||
|
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); actions.cycleStyle(); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); actions.toggleDirection(); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||||
|
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
|
||||||
|
else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export const READ_LINE_PCT = 0.50;
|
||||||
|
|
||||||
|
export interface StripChapter {
|
||||||
|
chapterId: number;
|
||||||
|
chapterName: string;
|
||||||
|
urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollHandlerCallbacks {
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onChapterChange: (chapterId: number) => void;
|
||||||
|
onMarkRead: (chapterId: number) => void;
|
||||||
|
onAppend: () => void;
|
||||||
|
getStripChapters: () => StripChapter[];
|
||||||
|
getPageUrls: () => string[];
|
||||||
|
shouldAutoMark: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupScrollTracking(
|
||||||
|
containerEl: HTMLElement,
|
||||||
|
callbacks: ScrollHandlerCallbacks,
|
||||||
|
): () => void {
|
||||||
|
const { onPageChange, onChapterChange, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
rafId = null;
|
||||||
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
if (!imgs.length) return;
|
||||||
|
|
||||||
|
const containerTop = containerEl.getBoundingClientRect().top;
|
||||||
|
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||||
|
|
||||||
|
let lo = 0, hi = imgs.length - 1, best = 0;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = imgs[best];
|
||||||
|
const activePage = Number(active.dataset.localPage);
|
||||||
|
const activeChId = Number(active.dataset.chapter);
|
||||||
|
|
||||||
|
onPageChange(activePage);
|
||||||
|
if (activeChId) onChapterChange(activeChId);
|
||||||
|
|
||||||
|
if (shouldAutoMark() && activeChId) {
|
||||||
|
const chunks = getStripChapters();
|
||||||
|
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||||
|
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||||
|
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||||
|
|
||||||
|
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
||||||
|
if (atBottom) {
|
||||||
|
const last = chunks[chunks.length - 1];
|
||||||
|
if (last) onMarkRead(last.chapterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
|
if (pct >= 0.80) onAppend();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
containerEl.removeEventListener("scroll", onScroll);
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendNextChapter(
|
||||||
|
stripChapters: StripChapter[],
|
||||||
|
chapterList: { id: number; name: string }[],
|
||||||
|
fetchPages: (chapterId: number) => Promise<string[]>,
|
||||||
|
preloadImage: (url: string) => void,
|
||||||
|
onAppended: (next: StripChapter) => void,
|
||||||
|
onDone: () => void,
|
||||||
|
): void {
|
||||||
|
if (!stripChapters.length) return;
|
||||||
|
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||||
|
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
||||||
|
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
||||||
|
const next = chapterList[lastIdx + 1];
|
||||||
|
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
||||||
|
|
||||||
|
fetchPages(next.id)
|
||||||
|
.then(urls => { urls.slice(0, 6).forEach(preloadImage); return urls; })
|
||||||
|
.then(urls => {
|
||||||
|
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
||||||
|
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
||||||
|
onDone();
|
||||||
|
})
|
||||||
|
.catch(() => onDone());
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
|
||||||
|
|
||||||
|
export function clampZoom(z: number): number {
|
||||||
|
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
style: string,
|
||||||
|
anchor: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!containerEl || style !== "longstrip") return;
|
||||||
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
|
let best: HTMLElement | null = null;
|
||||||
|
let bestTop = -Infinity;
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
for (const img of imgs) {
|
||||||
|
const top = img.getBoundingClientRect().top;
|
||||||
|
if (top <= readY && top > bestTop) { bestTop = top; best = img; }
|
||||||
|
}
|
||||||
|
anchor.el = best;
|
||||||
|
anchor.offset = best ? readY - best.getBoundingClientRect().top : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreZoomAnchor(
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
anchor: { el: HTMLElement | null; offset: number },
|
||||||
|
) {
|
||||||
|
if (!containerEl || !anchor.el) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!anchor.el) return;
|
||||||
|
const rect = anchor.el.getBoundingClientRect();
|
||||||
|
const readY = containerEl.getBoundingClientRect().top + containerEl.clientHeight * 0.5;
|
||||||
|
const delta = (readY - rect.top) - anchor.offset;
|
||||||
|
containerEl.scrollTop -= delta;
|
||||||
|
anchor.el = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Books, ClockCounterClockwise, Clock, BookOpen, Fire, TrendUp } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import { timeAgo, formatReadTime } from '$lib/core/util'
|
||||||
|
import type { HistorySession, HistoryGroup } from './lib/recentHistory'
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
currentStreakDays: number
|
||||||
|
totalChaptersRead: number
|
||||||
|
totalMinutesRead: number
|
||||||
|
totalMangaRead: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: HistoryGroup[]
|
||||||
|
hasHistory: boolean
|
||||||
|
historySearch: string
|
||||||
|
stats: Stats
|
||||||
|
thumbFor: (mangaId: number, fallback: string) => string
|
||||||
|
onOpenSeries: (session: HistorySession) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { groups, hasHistory, historySearch, stats, thumbFor, onOpenSeries }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
{#if !hasHistory}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon-wrap"><ClockCounterClockwise size={24} weight="light" /></div>
|
||||||
|
<p class="empty-text">No reading history yet</p>
|
||||||
|
<p class="empty-hint">Chapters you read will appear here</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon-wrap"><Books size={20} weight="light" /></div>
|
||||||
|
<p class="empty-text">No results for "{historySearch}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="timeline">
|
||||||
|
{#if stats.totalChaptersRead > 0}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon-wrap fire"><Fire size={14} weight="fill" /></div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-val">{stats.currentStreakDays}</span>
|
||||||
|
<span class="stat-label">Day streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon-wrap accent"><BookOpen size={14} weight="light" /></div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-val">{stats.totalChaptersRead}</span>
|
||||||
|
<span class="stat-label">Chapters read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon-wrap neutral"><Clock size={14} weight="light" /></div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span>
|
||||||
|
<span class="stat-label">Read time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon-wrap neutral"><TrendUp size={14} weight="light" /></div>
|
||||||
|
<div class="stat-body">
|
||||||
|
<span class="stat-val">{stats.totalMangaRead}</span>
|
||||||
|
<span class="stat-label">Series read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each groups as { label, items } (label)}
|
||||||
|
<div class="day-group">
|
||||||
|
<div class="day-header">
|
||||||
|
<span class="day-label">{label}</span>
|
||||||
|
<div class="day-rule"></div>
|
||||||
|
</div>
|
||||||
|
<div class="session-list">
|
||||||
|
{#each items as session (session.latestChapterId)}
|
||||||
|
<button class="session-row" onclick={() => onOpenSeries(session)}>
|
||||||
|
<div class="thumb-wrap">
|
||||||
|
<Thumbnail
|
||||||
|
src={thumbFor(session.mangaId, session.thumbnailUrl)}
|
||||||
|
alt={session.mangaTitle}
|
||||||
|
class="thumb"
|
||||||
|
/>
|
||||||
|
{#if session.chapterCount > 1}
|
||||||
|
<span class="session-count">{session.chapterCount}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-title">{session.mangaTitle}</span>
|
||||||
|
<span class="session-chapter">
|
||||||
|
{#if session.chapterCount > 1}
|
||||||
|
{session.firstChapterName}<span class="ch-arrow">→</span>{session.latestChapterName}
|
||||||
|
{:else}
|
||||||
|
{session.latestChapterName}
|
||||||
|
{#if session.latestPageNumber > 1}
|
||||||
|
<span class="ch-page">· p.{session.latestPageNumber}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="session-time">{timeAgo(session.readAt)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
flex: 1; overflow-y: auto; scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-dim) transparent;
|
||||||
|
padding: var(--sp-4) var(--sp-6) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: var(--sp-3);
|
||||||
|
transition: border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.stat-card:hover { border-color: var(--border-base); }
|
||||||
|
.stat-icon-wrap {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 32px; height: 32px; border-radius: var(--radius-sm); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
|
||||||
|
.accent { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
|
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
|
.stat-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.stat-val { font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
||||||
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
|
|
||||||
|
.day-group { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.day-header { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
|
.day-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
|
.day-rule { flex: 1; height: 1px; background: var(--border-dim); }
|
||||||
|
.session-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
width: 100%; padding: var(--sp-3); border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
|
text-align: left; cursor: pointer;
|
||||||
|
transition: border-color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.session-row:hover { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
:global(.thumb) { width: 38px; height: 54px; object-fit: cover; display: block; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||||
|
.session-count {
|
||||||
|
position: absolute; bottom: -4px; right: -6px;
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui); font-size: 8px; font-weight: 700;
|
||||||
|
padding: 1px 3px; border-radius: var(--radius-sm); line-height: 1.3;
|
||||||
|
pointer-events: none; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
|
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
|
||||||
|
.session-chapter {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||||||
|
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||||
|
}
|
||||||
|
.ch-arrow { color: var(--text-faint); opacity: 0.35; flex-shrink: 0; }
|
||||||
|
.ch-page { color: var(--text-faint); opacity: 0.5; flex-shrink: 0; }
|
||||||
|
.session-time {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||||
|
.empty-icon-wrap {
|
||||||
|
width: 44px; height: 44px; border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-faint); opacity: 0.5; margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
.empty-text { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-muted); }
|
||||||
|
.empty-hint { font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
|
||||||
|
import { homeState, clearHistory } from '$lib/state/home.svelte'
|
||||||
|
import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte'
|
||||||
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
|
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
||||||
|
import { buildSessions, groupByDay } from './lib/recentHistory'
|
||||||
|
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
||||||
|
import RecentToolbar from './RecentToolbar.svelte'
|
||||||
|
import UpdatesTab from './UpdatesTab.svelte'
|
||||||
|
import HistoryTab from './HistoryTab.svelte'
|
||||||
|
import type { Manga } from '$lib/types'
|
||||||
|
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||||
|
import type { HistoryGroup } from './lib/recentHistory'
|
||||||
|
|
||||||
|
const RECENT_UPDATES_TTL_MS = 60 * 1_000
|
||||||
|
const UPDATE_STATUS_POLL_MS = 2_000
|
||||||
|
|
||||||
|
let tab: 'updates' | 'history' = $state('updates')
|
||||||
|
let historySearch: string = $state('')
|
||||||
|
let updatesSearch: string = $state('')
|
||||||
|
let historyConfirmClear: boolean = $state(false)
|
||||||
|
|
||||||
|
let updates: RecentUpdate[] = $state([])
|
||||||
|
let updatesLoading: boolean = $state(true)
|
||||||
|
let updatesError: string | null = $state(null)
|
||||||
|
let openingId: number | null = $state(null)
|
||||||
|
let updaterRunning: boolean = $state(false)
|
||||||
|
let lastUpdatedTs: number | null = $state(null)
|
||||||
|
let updaterFinishedJobs: number | null = $state(null)
|
||||||
|
let updaterTotalJobs: number | null = $state(null)
|
||||||
|
|
||||||
|
let libraryManga: Manga[] = $state([])
|
||||||
|
|
||||||
|
let ctrl: AbortController | null = null
|
||||||
|
let statusPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadUpdates()
|
||||||
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
|
getAdapter().getMangaList({ inLibrary: true }).then(r => r.items)
|
||||||
|
).then(m => { libraryManga = m }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
ctrl?.abort()
|
||||||
|
stopStatusPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateGroups = $derived(groupUpdatesByDay(updates))
|
||||||
|
|
||||||
|
const lastUpdatedLabel = $derived(
|
||||||
|
lastUpdatedTs
|
||||||
|
? new Date(lastUpdatedTs).toLocaleString('en-US', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
|
||||||
|
const updaterProgressLabel = $derived(
|
||||||
|
typeof updaterFinishedJobs === 'number' &&
|
||||||
|
typeof updaterTotalJobs === 'number' &&
|
||||||
|
updaterTotalJobs > 0
|
||||||
|
? `${updaterFinishedJobs}/${updaterTotalJobs}`
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredHistory = $derived(historySearch.trim()
|
||||||
|
? homeState.history.filter(e =>
|
||||||
|
e.mangaTitle.toLowerCase().includes(historySearch.toLowerCase()) ||
|
||||||
|
e.chapterName.toLowerCase().includes(historySearch.toLowerCase())
|
||||||
|
)
|
||||||
|
: homeState.history)
|
||||||
|
|
||||||
|
const historyGroups = $derived(groupByDay(buildSessions(filteredHistory)))
|
||||||
|
|
||||||
|
function applyUpdateStatus(statusRes: { isRunning?: boolean; finishedJobs?: number; totalJobs?: number; lastUpdated?: unknown } | null) {
|
||||||
|
if (!statusRes) return
|
||||||
|
updaterRunning = statusRes.isRunning ?? false
|
||||||
|
updaterFinishedJobs = statusRes.finishedJobs ?? null
|
||||||
|
updaterTotalJobs = statusRes.totalJobs ?? null
|
||||||
|
lastUpdatedTs = parseServerTimestamp(statusRes.lastUpdated ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatusPolling() {
|
||||||
|
if (!statusPollTimer) return
|
||||||
|
clearTimeout(statusPollTimer)
|
||||||
|
statusPollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStatusPoll() {
|
||||||
|
if (statusPollTimer) return
|
||||||
|
const tick = async () => {
|
||||||
|
statusPollTimer = null
|
||||||
|
try {
|
||||||
|
const statusRes = await getAdapter().getLibraryUpdateStatus()
|
||||||
|
const wasRunning = updaterRunning
|
||||||
|
applyUpdateStatus(statusRes)
|
||||||
|
if (updaterRunning) statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
|
else if (wasRunning) void loadUpdates(true)
|
||||||
|
} catch {
|
||||||
|
if (updaterRunning) statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUpdates(force = false) {
|
||||||
|
ctrl?.abort()
|
||||||
|
const nextCtrl = new AbortController()
|
||||||
|
ctrl = nextCtrl
|
||||||
|
updatesLoading = true
|
||||||
|
updatesError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = CACHE_KEYS.RECENT_UPDATES
|
||||||
|
if (force) cache.clear(key)
|
||||||
|
|
||||||
|
const [updatesRes, statusRes] = await Promise.all([
|
||||||
|
cache.get<RecentUpdate[]>(
|
||||||
|
key,
|
||||||
|
() => getAdapter().getRecentlyUpdated(nextCtrl.signal),
|
||||||
|
RECENT_UPDATES_TTL_MS,
|
||||||
|
CACHE_GROUPS.LIBRARY,
|
||||||
|
),
|
||||||
|
getAdapter().getLibraryUpdateStatus().catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
applyUpdateStatus(statusRes)
|
||||||
|
if (updaterRunning) scheduleStatusPoll()
|
||||||
|
else stopStatusPolling()
|
||||||
|
|
||||||
|
if (nextCtrl.signal.aborted) return
|
||||||
|
|
||||||
|
updates = (updatesRes ?? [])
|
||||||
|
.filter(item => item.manga?.inLibrary)
|
||||||
|
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (nextCtrl.signal.aborted) return
|
||||||
|
updatesError = e?.message ?? 'Failed to load updates'
|
||||||
|
updates = []
|
||||||
|
updaterRunning = false
|
||||||
|
lastUpdatedTs = null
|
||||||
|
updaterFinishedJobs = null
|
||||||
|
updaterTotalJobs = null
|
||||||
|
stopStatusPolling()
|
||||||
|
} finally {
|
||||||
|
if (!nextCtrl.signal.aborted) updatesLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mangaStub(item: RecentUpdate): Manga {
|
||||||
|
return {
|
||||||
|
id: item.manga?.id ?? item.mangaId,
|
||||||
|
title: item.manga?.title ?? 'Unknown series',
|
||||||
|
thumbnailUrl: item.manga?.thumbnailUrl ?? '',
|
||||||
|
inLibrary: item.manga?.inLibrary ?? true,
|
||||||
|
} as Manga
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUpdate(item: RecentUpdate) {
|
||||||
|
if (openingId !== null) return
|
||||||
|
openingId = item.id
|
||||||
|
const manga = mangaStub(item)
|
||||||
|
try {
|
||||||
|
const chapters = await getAdapter().getChapters(String(item.mangaId))
|
||||||
|
const sorted = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
||||||
|
const list = buildChapterList(sorted, {})
|
||||||
|
const target = list.find(ch => ch.id === item.id)
|
||||||
|
if (target) { setActiveManga(manga); openReader(target, list) }
|
||||||
|
else setActiveManga(manga)
|
||||||
|
} catch {
|
||||||
|
setActiveManga(manga)
|
||||||
|
addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' })
|
||||||
|
} finally {
|
||||||
|
openingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbFor(mangaId: number, fallback: string): string {
|
||||||
|
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryClear() {
|
||||||
|
if (!historyConfirmClear) {
|
||||||
|
historyConfirmClear = true
|
||||||
|
setTimeout(() => { historyConfirmClear = false }, 3_000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearHistory()
|
||||||
|
historyConfirmClear = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root anim-fade-in">
|
||||||
|
<RecentToolbar
|
||||||
|
{tab}
|
||||||
|
{historySearch}
|
||||||
|
{updatesSearch}
|
||||||
|
{historyConfirmClear}
|
||||||
|
hasHistory={homeState.history.length > 0}
|
||||||
|
{updatesLoading}
|
||||||
|
onTabChange={(t) => tab = t}
|
||||||
|
onHistorySearchChange={(v) => historySearch = v}
|
||||||
|
onUpdatesSearchChange={(v) => updatesSearch = v}
|
||||||
|
onHistoryClear={handleHistoryClear}
|
||||||
|
onRefreshUpdates={() => loadUpdates(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{#if tab === 'updates'}
|
||||||
|
<UpdatesTab
|
||||||
|
loading={updatesLoading}
|
||||||
|
error={updatesError}
|
||||||
|
groups={updateGroups}
|
||||||
|
{updatesSearch}
|
||||||
|
totalCount={updates.length}
|
||||||
|
{openingId}
|
||||||
|
{updaterRunning}
|
||||||
|
{lastUpdatedLabel}
|
||||||
|
{updaterProgressLabel}
|
||||||
|
onOpenUpdate={openUpdate}
|
||||||
|
onOpenSeries={(item) => setActiveManga(mangaStub(item))}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<HistoryTab
|
||||||
|
groups={historyGroups}
|
||||||
|
hasHistory={homeState.history.length > 0}
|
||||||
|
{historySearch}
|
||||||
|
stats={homeState.stats}
|
||||||
|
{thumbFor}
|
||||||
|
onOpenSeries={(session) => setPreviewManga({
|
||||||
|
id: session.mangaId,
|
||||||
|
title: session.mangaTitle,
|
||||||
|
thumbnailUrl: thumbFor(session.mangaId, session.thumbnailUrl),
|
||||||
|
} as any)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
.content { flex: 1; min-height: 0; overflow: hidden; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
ArrowsClockwise, BookOpen, CircleNotch,
|
||||||
|
MagnifyingGlass, NewspaperClipping, Trash,
|
||||||
|
} from 'phosphor-svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tab: 'updates' | 'history'
|
||||||
|
historySearch: string
|
||||||
|
updatesSearch: string
|
||||||
|
historyConfirmClear: boolean
|
||||||
|
hasHistory: boolean
|
||||||
|
updatesLoading: boolean
|
||||||
|
onTabChange: (tab: 'updates' | 'history') => void
|
||||||
|
onHistorySearchChange: (v: string) => void
|
||||||
|
onUpdatesSearchChange: (v: string) => void
|
||||||
|
onHistoryClear: () => void
|
||||||
|
onRefreshUpdates: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory, updatesLoading,
|
||||||
|
onTabChange, onHistorySearchChange, onUpdatesSearchChange, onHistoryClear, onRefreshUpdates,
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<span class="heading">Recent</span>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab" class:active={tab === 'updates'} onclick={() => onTabChange('updates')}>
|
||||||
|
<NewspaperClipping size={11} weight="bold" />
|
||||||
|
Updates
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:active={tab === 'history'} onclick={() => onTabChange('history')}>
|
||||||
|
<BookOpen size={11} weight="bold" />
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
{#if tab === 'updates'}
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={11} class="search-icon" weight="light" />
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={updatesSearch}
|
||||||
|
oninput={(e) => onUpdatesSearchChange((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{#if updatesSearch}
|
||||||
|
<button class="search-clear" onclick={() => onUpdatesSearchChange('')}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={onRefreshUpdates}
|
||||||
|
disabled={updatesLoading}
|
||||||
|
title="Refresh updates"
|
||||||
|
>
|
||||||
|
{#if updatesLoading}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<ArrowsClockwise size={14} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={11} class="search-icon" weight="light" />
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={historySearch}
|
||||||
|
oninput={(e) => onHistorySearchChange((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{#if historySearch}
|
||||||
|
<button class="search-clear" onclick={() => onHistorySearchChange('')}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={onRefreshUpdates}
|
||||||
|
disabled={updatesLoading}
|
||||||
|
title="Refresh library"
|
||||||
|
>
|
||||||
|
{#if updatesLoading}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<ArrowsClockwise size={14} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if hasHistory}
|
||||||
|
<button
|
||||||
|
class="clear-btn"
|
||||||
|
class:confirm={historyConfirmClear}
|
||||||
|
onclick={onHistoryClear}
|
||||||
|
title={historyConfirmClear ? 'Click again to confirm' : 'Clear history'}
|
||||||
|
>
|
||||||
|
<Trash size={12} weight="light" />
|
||||||
|
{#if historyConfirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
position: relative; z-index: 100;
|
||||||
|
display: flex; align-items: center; gap: var(--sp-4);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 4px 10px; border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint); white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
margin-left: auto; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
|
color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|
||||||
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.search-wrap :global(.search-icon) { position: absolute; left: 8px; color: var(--text-faint); pointer-events: none; }
|
||||||
|
|
||||||
|
.search {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 4px 26px;
|
||||||
|
color: var(--text-primary); font-size: var(--text-xs);
|
||||||
|
width: 148px; outline: none;
|
||||||
|
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.search::placeholder { color: var(--text-faint); }
|
||||||
|
.search:focus { border-color: var(--border-strong); background: var(--bg-elevated); width: 200px; }
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 8px; color: var(--text-faint);
|
||||||
|
font-size: 13px; line-height: 1; background: none; border: none;
|
||||||
|
cursor: pointer; padding: 2px; transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
height: 28px; padding: 0 var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||||
|
color: var(--text-faint); cursor: pointer;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.clear-btn:hover {
|
||||||
|
color: var(--color-error);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||||
|
}
|
||||||
|
.clear-btn.confirm {
|
||||||
|
color: var(--color-error);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
.clear-label { font-size: var(--text-2xs); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, CircleNotch } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
groups: UpdateGroup[]
|
||||||
|
updatesSearch: string
|
||||||
|
totalCount: number
|
||||||
|
openingId: number | null
|
||||||
|
updaterRunning: boolean
|
||||||
|
lastUpdatedLabel: string | null
|
||||||
|
updaterProgressLabel: string | null
|
||||||
|
onOpenUpdate: (item: RecentUpdate) => void
|
||||||
|
onOpenSeries: (item: RecentUpdate) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
loading, error, groups, updatesSearch, totalCount, openingId,
|
||||||
|
updaterRunning, lastUpdatedLabel, updaterProgressLabel,
|
||||||
|
onOpenUpdate, onOpenSeries,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const filteredGroups = $derived(updatesSearch.trim()
|
||||||
|
? groups
|
||||||
|
.map(g => ({
|
||||||
|
...g,
|
||||||
|
items: g.items.filter(item =>
|
||||||
|
(item.manga?.title ?? '').toLowerCase().includes(updatesSearch.toLowerCase()) ||
|
||||||
|
(item.name ?? '').toLowerCase().includes(updatesSearch.toLowerCase())
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter(g => g.items.length > 0)
|
||||||
|
: groups)
|
||||||
|
|
||||||
|
function chapterLabel(item: RecentUpdate): string {
|
||||||
|
if (item.name?.trim()) return item.name
|
||||||
|
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`
|
||||||
|
return 'Chapter'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="bar-wrap">
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-dot" class:active={loading || updaterRunning}></div>
|
||||||
|
<span class="status-text">
|
||||||
|
{#if loading}
|
||||||
|
Checking for updates…
|
||||||
|
{:else if error}
|
||||||
|
Update check failed
|
||||||
|
{:else if updaterRunning}
|
||||||
|
Library update in progress{#if updaterProgressLabel} ({updaterProgressLabel}){/if}
|
||||||
|
{:else}
|
||||||
|
Up to date
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="status-right">
|
||||||
|
{#if !loading && lastUpdatedLabel}
|
||||||
|
<span class="status-detail">Last updated: {lastUpdatedLabel}</span>
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
{/if}
|
||||||
|
{#if !loading && totalCount > 0}
|
||||||
|
<span class="status-count">{totalCount} chapter{totalCount === 1 ? '' : 's'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading && groups.length === 0}
|
||||||
|
<div class="timeline" aria-hidden="true">
|
||||||
|
<section class="day-group">
|
||||||
|
<div class="day-header">
|
||||||
|
<span class="day-label skeleton sk-day-label"></span>
|
||||||
|
<div class="day-rule skeleton sk-day-rule"></div>
|
||||||
|
</div>
|
||||||
|
<div class="updates-list">
|
||||||
|
{#each Array(8) as _, i (i)}
|
||||||
|
<div class="update-row skeleton-row">
|
||||||
|
<div class="thumb-skeleton skeleton"></div>
|
||||||
|
<div class="info-skeleton">
|
||||||
|
<div class="skeleton sk-title"></div>
|
||||||
|
<div class="skeleton sk-chapter"></div>
|
||||||
|
<div class="skeleton sk-meta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="end-skeleton skeleton"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if error}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon-wrap"><BookOpen size={22} weight="light" /></div>
|
||||||
|
<p class="empty-text">Couldn't load updates</p>
|
||||||
|
<p class="empty-hint">{error}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if groups.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon-wrap"><BookOpen size={22} weight="light" /></div>
|
||||||
|
<p class="empty-text">No recent library updates</p>
|
||||||
|
<p class="empty-hint">Run a library update to populate this page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if filteredGroups.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div class="empty-icon-wrap"><BookOpen size={22} weight="light" /></div>
|
||||||
|
<p class="empty-text">No results for "{updatesSearch}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="timeline">
|
||||||
|
{#each filteredGroups as { label, items } (label)}
|
||||||
|
<section class="day-group">
|
||||||
|
<div class="day-header">
|
||||||
|
<span class="day-label">{label}</span>
|
||||||
|
<div class="day-rule"></div>
|
||||||
|
</div>
|
||||||
|
<div class="updates-list">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<div class="update-row" class:read={item.isRead}>
|
||||||
|
<button class="thumb-btn" onclick={() => onOpenSeries(item)} title="View series">
|
||||||
|
<Thumbnail
|
||||||
|
src={item.manga?.thumbnailUrl ?? ''}
|
||||||
|
alt={item.manga?.title ?? 'Series cover'}
|
||||||
|
class="thumb"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="info-btn"
|
||||||
|
onclick={() => onOpenUpdate(item)}
|
||||||
|
disabled={openingId === item.id}
|
||||||
|
>
|
||||||
|
<div class="update-info">
|
||||||
|
<div class="title-row">
|
||||||
|
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
||||||
|
{#if !item.isRead}<span class="pill">Unread</span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||||
|
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||||
|
<div class="meta-row"><span>Resume p.{item.lastPageRead}</span></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="row-end">
|
||||||
|
{#if openingId === item.id}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<BookOpen size={14} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
.bar-wrap { padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
|
||||||
|
.status-bar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
background: var(--bg-surface, var(--bg-raised));
|
||||||
|
border: 1px solid var(--border-strong, var(--border-dim));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--text-faint); flex-shrink: 0;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||||
|
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||||
|
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
|
.status-detail,
|
||||||
|
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
flex: 1; overflow-y: auto; scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-dim) transparent;
|
||||||
|
padding: var(--sp-4) var(--sp-6) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||||
|
}
|
||||||
|
.day-group { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.day-header { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
|
.day-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.day-rule { height: 1px; flex: 1; background: var(--border-dim); }
|
||||||
|
.updates-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from { background-position: -200% 0 }
|
||||||
|
to { background-position: 200% 0 }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||||
|
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||||
|
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.skeleton-row { min-height: 74px; pointer-events: none; }
|
||||||
|
.thumb-skeleton { width: 34px; aspect-ratio: 2/3; margin: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3); border-radius: var(--radius-sm); flex-shrink: 0; align-self: center; }
|
||||||
|
.info-skeleton { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; }
|
||||||
|
.sk-title { height: 12px; width: clamp(140px, 42%, 340px); }
|
||||||
|
.sk-chapter { height: 10px; width: clamp(100px, 30%, 260px); }
|
||||||
|
.sk-meta { height: 8px; width: clamp(70px, 18%, 180px); }
|
||||||
|
.end-skeleton { width: 14px; height: 14px; border-radius: 50%; margin: auto var(--sp-4) auto 0; flex-shrink: 0; }
|
||||||
|
.sk-day-label { display: block; width: 74px; height: 10px; border-radius: var(--radius-sm); }
|
||||||
|
.sk-day-rule { opacity: 0.7; }
|
||||||
|
|
||||||
|
.update-row {
|
||||||
|
display: flex; align-items: stretch;
|
||||||
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); overflow: hidden;
|
||||||
|
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.update-row:has(.info-btn:hover:not(:disabled)),
|
||||||
|
.update-row:has(.thumb-btn:hover) { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
|
.update-row.read { opacity: 0.5; }
|
||||||
|
|
||||||
|
.thumb-btn {
|
||||||
|
width: 52px; flex-shrink: 0; padding: var(--sp-2);
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
:global(.thumb) { width: 100%; aspect-ratio: 2/3; display: block; object-fit: cover; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
.info-btn {
|
||||||
|
flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-3); background: none; border: none;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.info-btn:disabled { cursor: default; opacity: 0.8; }
|
||||||
|
|
||||||
|
.update-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.title-row { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||||
|
.series-title,
|
||||||
|
.chapter-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.series-title { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.meta-row { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.pill {
|
||||||
|
padding: 2px 6px; border-radius: var(--radius-full);
|
||||||
|
background: var(--accent-muted); color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row-end { color: var(--text-faint); display: flex; align-items: center; justify-content: center; width: 24px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; gap: var(--sp-2); color: var(--text-faint);
|
||||||
|
padding: var(--sp-6); text-align: center;
|
||||||
|
}
|
||||||
|
.empty-icon-wrap {
|
||||||
|
width: 44px; height: 44px; border-radius: var(--radius-lg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
color: var(--text-faint); opacity: 0.5; margin-bottom: var(--sp-1);
|
||||||
|
}
|
||||||
|
.empty-text { margin: 0; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.empty-hint { margin: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { dayLabel } from '$lib/core/util'
|
||||||
|
|
||||||
|
export interface HistorySession {
|
||||||
|
mangaId: number
|
||||||
|
mangaTitle: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
latestChapterId: number
|
||||||
|
latestChapterName: string
|
||||||
|
latestPageNumber: number
|
||||||
|
firstChapterName: string
|
||||||
|
chapterCount: number
|
||||||
|
readAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryGroup {
|
||||||
|
label: string
|
||||||
|
items: HistorySession[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_GAP_MS = 30 * 60 * 1_000
|
||||||
|
|
||||||
|
export function buildSessions(entries: {
|
||||||
|
mangaId: number
|
||||||
|
mangaTitle: string
|
||||||
|
thumbnailUrl: string
|
||||||
|
chapterId: number
|
||||||
|
chapterName: string
|
||||||
|
pageNumber: number
|
||||||
|
readAt: number
|
||||||
|
}[]): HistorySession[] {
|
||||||
|
if (!entries.length) return []
|
||||||
|
const sessions: HistorySession[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < entries.length) {
|
||||||
|
const anchor = entries[i]
|
||||||
|
const group = [anchor]
|
||||||
|
let j = i + 1
|
||||||
|
while (j < entries.length) {
|
||||||
|
const next = entries[j]
|
||||||
|
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||||
|
group.push(next); j++
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
const latest = group[0], oldest = group[group.length - 1]
|
||||||
|
sessions.push({
|
||||||
|
mangaId: latest.mangaId,
|
||||||
|
mangaTitle: latest.mangaTitle,
|
||||||
|
thumbnailUrl: latest.thumbnailUrl,
|
||||||
|
latestChapterId: latest.chapterId,
|
||||||
|
latestChapterName: latest.chapterName,
|
||||||
|
latestPageNumber: latest.pageNumber,
|
||||||
|
firstChapterName: oldest.chapterName,
|
||||||
|
chapterCount: group.length,
|
||||||
|
readAt: latest.readAt,
|
||||||
|
})
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDay(sessions: HistorySession[]): HistoryGroup[] {
|
||||||
|
const map = new Map<string, HistorySession[]>()
|
||||||
|
for (const s of sessions) {
|
||||||
|
const l = dayLabel(s.readAt)
|
||||||
|
if (!map.has(l)) map.set(l, [])
|
||||||
|
map.get(l)!.push(s)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { dayLabel } from '$lib/core/util'
|
||||||
|
|
||||||
|
export interface RecentUpdate {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
chapterNumber: number
|
||||||
|
sourceOrder: number
|
||||||
|
isRead: boolean
|
||||||
|
lastPageRead: number
|
||||||
|
mangaId: number
|
||||||
|
fetchedAt: string
|
||||||
|
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGroup {
|
||||||
|
label: string
|
||||||
|
items: RecentUpdate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
isRunning: boolean
|
||||||
|
finishedJobs: number | null
|
||||||
|
totalJobs: number | null
|
||||||
|
lastUpdated?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchedAtMs(item: Pick<RecentUpdate, 'fetchedAt'>): number {
|
||||||
|
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now()
|
||||||
|
return Number.isFinite(ts) ? ts : Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServerTimestamp(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (Number.isFinite(numeric)) return numeric
|
||||||
|
const parsed = new Date(value).getTime()
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupUpdatesByDay(updates: RecentUpdate[]): UpdateGroup[] {
|
||||||
|
const grouped: Record<string, RecentUpdate[]> = {}
|
||||||
|
for (const item of updates) {
|
||||||
|
const label = dayLabel(fetchedAtMs(item))
|
||||||
|
if (!grouped[label]) grouped[label] = []
|
||||||
|
grouped[label].push(item)
|
||||||
|
}
|
||||||
|
return Object.entries(grouped).map(([label, items]) => ({ label, items }))
|
||||||
|
}
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openReader(ch, ascList)
|
openReader(ch, ascList, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContinue(cc: typeof continueChapter) {
|
function handleContinue(cc: typeof continueChapter) {
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openReader(cc.chapter, ascList)
|
openReader(cc.chapter, ascList, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars</span></div>
|
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars</span></div>
|
||||||
<button role="switch" aria-checked={settingsState.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={settingsState.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(settingsState.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
|
<button role="switch" aria-checked={settingsState.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={settingsState.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(settingsState.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Containerized view</span><span class="s-desc">Shows the reader inside the app shell with the sidebar instead of filling the whole screen</span></div>
|
||||||
|
<button role="switch" aria-checked={settingsState.settings.readerContainerized ?? false} aria-label="Containerized reader view" class="s-toggle" class:on={settingsState.settings.readerContainerized ?? false} onclick={() => updateSettings({ readerContainerized: !(settingsState.settings.readerContainerized ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte';
|
||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
import { toast } from "$lib/state/notifications.svelte";
|
import { toast } from "$lib/state/notifications.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
|
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
||||||
|
import { trackingState } from "$lib/state/tracking.svelte";
|
||||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||||
|
import type { ChapterDisplayPrefs } from "$lib/components/series/lib/chapterList";
|
||||||
|
|
||||||
let trackers = $state<Tracker[]>([]);
|
let trackers = $state<Tracker[]>([]);
|
||||||
let trackersLoading = $state(false);
|
let trackersLoading = $state(false);
|
||||||
@@ -20,6 +23,8 @@
|
|||||||
let loggingOut = $state<number | null>(null);
|
let loggingOut = $state<number | null>(null);
|
||||||
let syncing = $state(false);
|
let syncing = $state(false);
|
||||||
|
|
||||||
|
const settings = $derived(settingsState.settings);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (trackers.length === 0 && !trackersLoading) loadTrackers();
|
if (trackers.length === 0 && !trackersLoading) loadTrackers();
|
||||||
});
|
});
|
||||||
@@ -41,7 +46,7 @@
|
|||||||
|
|
||||||
async function submitOAuth() {
|
async function submitOAuth() {
|
||||||
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
|
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
|
||||||
oauthSubmitting = true;
|
oauthSubmitting = true; oauthError = null;
|
||||||
try {
|
try {
|
||||||
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
|
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
|
||||||
await loadTrackers();
|
await loadTrackers();
|
||||||
@@ -57,7 +62,7 @@
|
|||||||
|
|
||||||
async function submitCredentials() {
|
async function submitCredentials() {
|
||||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
|
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
|
||||||
credsSubmitting = true;
|
credsSubmitting = true; credsError = null;
|
||||||
try {
|
try {
|
||||||
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
|
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
|
||||||
await loadTrackers();
|
await loadTrackers();
|
||||||
@@ -84,20 +89,21 @@
|
|||||||
async function runSyncAll() {
|
async function runSyncAll() {
|
||||||
syncing = true;
|
syncing = true;
|
||||||
try {
|
try {
|
||||||
const adapter = getAdapter();
|
const adapter = getAdapter();
|
||||||
const allTrackers = await adapter.getTrackersWithRecords();
|
|
||||||
const loggedIn = allTrackers.filter((t: any) => t.isLoggedIn);
|
if (trackingState.allTrackers.length === 0) await trackingState.loadAll();
|
||||||
const settings = settingsState.settings;
|
const loggedIn = trackingState.allTrackers.filter((t) => t.isLoggedIn);
|
||||||
let totalMarked = 0;
|
|
||||||
|
let totalMarked = 0;
|
||||||
|
|
||||||
for (const tracker of loggedIn) {
|
for (const tracker of loggedIn) {
|
||||||
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
||||||
if (!record.manga?.id) continue;
|
if (!record.manga?.id) continue;
|
||||||
const mangaId = record.manga.id;
|
const mangaId = record.manga.id;
|
||||||
const chapters = await adapter.getChapters(mangaId);
|
const chapters = await adapter.getChapters(String(mangaId));
|
||||||
const prefs = settings.mangaPrefs?.[mangaId] ?? {};
|
const prefs = (settings.mangaPrefs?.[mangaId] ?? {}) as ChapterDisplayPrefs;
|
||||||
|
|
||||||
const marked = await syncBackFromTracker(
|
const markedIds = await syncBackFromTracker(
|
||||||
[record],
|
[record],
|
||||||
chapters,
|
chapters,
|
||||||
{
|
{
|
||||||
@@ -107,7 +113,7 @@
|
|||||||
},
|
},
|
||||||
adapter.markChaptersRead.bind(adapter),
|
adapter.markChaptersRead.bind(adapter),
|
||||||
);
|
);
|
||||||
totalMarked += marked.length;
|
totalMarked += markedIds.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +138,7 @@
|
|||||||
{#each trackers as tracker}
|
{#each trackers as tracker}
|
||||||
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
|
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
|
||||||
<div class="s-tracker-identity">
|
<div class="s-tracker-identity">
|
||||||
<img src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
|
<Thumbnail src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">{tracker.name}</span>
|
<span class="s-label">{tracker.name}</span>
|
||||||
<div class="s-tracker-status-row">
|
<div class="s-tracker-status-row">
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleNotch } from 'phosphor-svelte'
|
||||||
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
|
import {
|
||||||
|
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
|
||||||
|
type FlatRecord, type SortKey,
|
||||||
|
} from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
import TrackingToolbar from './TrackingToolbar.svelte'
|
||||||
|
import TrackingCard from './TrackingCard.svelte'
|
||||||
|
import TrackingPreview from './TrackingPreview.svelte'
|
||||||
|
|
||||||
|
let activeTrackerId = $state<number | 'all'>('all')
|
||||||
|
let statusFilter = $state<number | 'all'>('all')
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let sortBy = $state<SortKey>('title')
|
||||||
|
let selectedRecord = $state<FlatRecord | null>(null)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
|
||||||
|
trackingState.loadAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loggedIn = $derived(trackingState.allTrackers.filter((t) => t.isLoggedIn))
|
||||||
|
const allRecords = $derived(flattenRecords(trackingState.allTrackers))
|
||||||
|
const totalCount = $derived(allRecords.length)
|
||||||
|
const statusOptions = $derived(
|
||||||
|
activeTrackerId === 'all'
|
||||||
|
? dedupeStatuses(trackingState.allTrackers)
|
||||||
|
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
|
||||||
|
)
|
||||||
|
const filtered = $derived(
|
||||||
|
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<TrackingToolbar
|
||||||
|
{loggedIn}
|
||||||
|
{totalCount}
|
||||||
|
{activeTrackerId}
|
||||||
|
{statusFilter}
|
||||||
|
{statusOptions}
|
||||||
|
{searchQuery}
|
||||||
|
{sortBy}
|
||||||
|
loading={trackingState.loadingAll}
|
||||||
|
onRefresh={() => trackingState.loadAll()}
|
||||||
|
onTrackerChange={(id) => { activeTrackerId = id; statusFilter = 'all' }}
|
||||||
|
onStatusChange={(v) => statusFilter = v}
|
||||||
|
onSearchChange={(v) => searchQuery = v}
|
||||||
|
onSortChange={(v) => sortBy = v}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
{#if trackingState.loadingAll}
|
||||||
|
<div class="state">
|
||||||
|
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if trackingState.error}
|
||||||
|
<div class="state">
|
||||||
|
<span class="state-error">{trackingState.error}</span>
|
||||||
|
<button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loggedIn.length === 0}
|
||||||
|
<div class="state">
|
||||||
|
<span class="state-text">No trackers connected.</span>
|
||||||
|
<span class="state-hint">Settings → Tracking to connect AniList, MAL, or others.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="state">
|
||||||
|
<span class="state-text">{searchQuery || statusFilter !== 'all' ? 'No results.' : 'Nothing tracked yet.'}</span>
|
||||||
|
{#if searchQuery || statusFilter !== 'all'}
|
||||||
|
<button class="ghost-btn" onclick={() => { searchQuery = ''; statusFilter = 'all' }}>Clear filters</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each filtered as record (record.tracker.id + ':' + record.id)}
|
||||||
|
<TrackingCard
|
||||||
|
{record}
|
||||||
|
active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
|
||||||
|
onSelect={(r) => selectedRecord = r}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedRecord}
|
||||||
|
<TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||||
|
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: var(--sp-3); height: 100%; text-align: center;
|
||||||
|
}
|
||||||
|
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; }
|
||||||
|
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 14px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.ghost-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
|
||||||
|
gap: var(--sp-4); align-content: start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import type { FlatRecord } from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
import { calcProgress } from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: FlatRecord
|
||||||
|
active: boolean
|
||||||
|
onSelect: (r: FlatRecord) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { record, active, onSelect }: Props = $props()
|
||||||
|
|
||||||
|
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="card" class:active onclick={() => onSelect(record)}>
|
||||||
|
<div class="cover-wrap">
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="tracker-badge">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
|
||||||
|
</div>
|
||||||
|
{#if progress !== null}
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width:{progress}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="title">{record.title}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: none; border: none; padding: 0;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover .cover-wrap { border-color: var(--border-strong); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||||
|
.card:hover .title { color: var(--text-primary); }
|
||||||
|
.card.active .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent-dim); }
|
||||||
|
.card.active .title { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
||||||
|
border-radius: var(--radius-md); background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
|
||||||
|
}
|
||||||
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||||
|
|
||||||
|
.tracker-badge {
|
||||||
|
position: absolute; bottom: 6px; left: 6px; z-index: 2;
|
||||||
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.4); overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 2px; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
height: 2lh;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import { addToast } from "$lib/state/notifications.svelte";
|
import { addToast } from "$lib/state/notifications.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { seriesState } from "$lib/state/series.svelte";
|
import { seriesState } from "$lib/state/series.svelte";
|
||||||
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
|
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
||||||
import { getChapters } from "$lib/request-manager/chapters";
|
import { getChapters } from "$lib/request-manager/chapters";
|
||||||
import { markManyRead } from "$lib/request-manager/chapters";
|
import { markManyRead } from "$lib/request-manager/chapters";
|
||||||
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
|
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
|
||||||
|
|||||||
@@ -0,0 +1,615 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { X, ArrowSquareOut, ArrowsClockwise, Lock, CircleNotch, Books } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
|
import { setNavPage } from '$lib/state/app.svelte'
|
||||||
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { calcProgress, type FlatRecord } from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
import type { Chapter } from '$lib/types'
|
||||||
|
import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: FlatRecord
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { record, onClose }: Props = $props()
|
||||||
|
|
||||||
|
let updatingId = $state<number | null>(null)
|
||||||
|
let syncingId = $state<number | null>(null)
|
||||||
|
let editingChapter = $state(false)
|
||||||
|
let chapterDraft = $state(0)
|
||||||
|
let scoreDraft = $state('')
|
||||||
|
let confirmUnbind = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
chapterDraft = record.lastChapterRead
|
||||||
|
scoreDraft = record.displayScore ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBusy = $derived(updatingId === record.id)
|
||||||
|
const isSyncing = $derived(syncingId === record.id)
|
||||||
|
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters))
|
||||||
|
const statusName = $derived(record.tracker.statuses?.find((s) => s.value === record.status)?.name)
|
||||||
|
|
||||||
|
function prefsForManga(mangaId: number): ChapterDisplayPrefs {
|
||||||
|
return (settingsState.settings.mangaPrefs?.[mangaId] ?? {}) as ChapterDisplayPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(status: number) {
|
||||||
|
const mangaId = record.manga?.id ?? null
|
||||||
|
if (mangaId === null) return
|
||||||
|
updatingId = record.id
|
||||||
|
try {
|
||||||
|
await trackingState.updateStatus(mangaId, record, status)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
|
||||||
|
} finally { updatingId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScore() {
|
||||||
|
const val = String(scoreDraft).trim()
|
||||||
|
if (val === String(record.displayScore ?? '')) return
|
||||||
|
const mangaId = record.manga?.id ?? null
|
||||||
|
if (mangaId === null) return
|
||||||
|
updatingId = record.id
|
||||||
|
try {
|
||||||
|
await trackingState.updateScore(mangaId, record, val)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
|
||||||
|
} finally { updatingId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChapter() {
|
||||||
|
const val = Math.max(0, chapterDraft)
|
||||||
|
editingChapter = false
|
||||||
|
if (val === record.lastChapterRead) return
|
||||||
|
const mangaId = record.manga?.id ?? null
|
||||||
|
if (mangaId === null) return
|
||||||
|
updatingId = record.id
|
||||||
|
try {
|
||||||
|
await trackingState.updateChapterProgress(mangaId, record, val)
|
||||||
|
if (settingsState.settings.trackerSyncBack && record.manga?.id) {
|
||||||
|
const chapters = await getAdapter().getChapters(String(record.manga.id)) as Chapter[]
|
||||||
|
await trackingState.syncFromRemote(mangaId, { ...record, lastChapterRead: val }, chapters, prefsForManga(mangaId))
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
|
||||||
|
} finally { updatingId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRecord() {
|
||||||
|
const mangaId = record.manga?.id ?? null
|
||||||
|
if (mangaId === null) return
|
||||||
|
syncingId = record.id
|
||||||
|
try {
|
||||||
|
let chapters: Chapter[] = []
|
||||||
|
if (settingsState.settings.trackerSyncBack && record.manga?.id) {
|
||||||
|
chapters = await getAdapter().getChapters(String(record.manga.id)) as Chapter[]
|
||||||
|
}
|
||||||
|
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chapters, prefsForManga(mangaId))
|
||||||
|
const body = markedIds.length > 0
|
||||||
|
? `${markedIds.length} chapter${markedIds.length !== 1 ? 's' : ''} marked read`
|
||||||
|
: undefined
|
||||||
|
addToast({ kind: 'success', title: 'Synced from tracker', body })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
addToast({ kind: 'error', title: 'Sync failed', body: e instanceof Error ? e.message : undefined })
|
||||||
|
} finally { syncingId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind() {
|
||||||
|
const mangaId = record.manga?.id ?? null
|
||||||
|
if (mangaId === null) return
|
||||||
|
updatingId = record.id
|
||||||
|
confirmUnbind = false
|
||||||
|
try {
|
||||||
|
await trackingState.unbind(mangaId, record)
|
||||||
|
addToast({ kind: 'info', title: `Unlinked from ${record.tracker.name}` })
|
||||||
|
onClose()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
addToast({ kind: 'error', title: 'Unbind failed', body: e instanceof Error ? e.message : undefined })
|
||||||
|
} finally { updatingId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManga() {
|
||||||
|
if (!record.manga) return
|
||||||
|
// Navigate to series page — set the series and switch nav
|
||||||
|
seriesState.current = record.manga as any
|
||||||
|
setNavPage('series')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0) }
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||||
|
onMount(() => window.addEventListener('keydown', onKey))
|
||||||
|
onDestroy(() => window.removeEventListener('keydown', onKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Close tracking detail"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') onClose() }}
|
||||||
|
>
|
||||||
|
<div class="modal" role="dialog" aria-label="Tracking detail">
|
||||||
|
|
||||||
|
<div class="cover-col">
|
||||||
|
<div class="cover-wrap">
|
||||||
|
{#if record.manga?.thumbnailUrl}
|
||||||
|
<div class="cover-glow" style="background-image:url({record.manga.thumbnailUrl})"></div>
|
||||||
|
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="cover-empty"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="tracker-badge">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-badge-img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-actions">
|
||||||
|
{#if isSyncing}
|
||||||
|
<div class="action-btn action-btn-inert">
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
|
<span class="action-label">Syncing…</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="action-btn" onclick={syncRecord} disabled={isBusy}>
|
||||||
|
<ArrowsClockwise size={13} weight="light" />
|
||||||
|
<span class="action-label">Sync from tracker</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if record.manga}
|
||||||
|
<button class="action-btn" onclick={openManga}>
|
||||||
|
<Books size={13} weight="light" />
|
||||||
|
<span class="action-label">Go to series</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if record.remoteUrl}
|
||||||
|
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="action-btn">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-icon" />
|
||||||
|
<span class="action-label">Open on {record.tracker.name}</span>
|
||||||
|
<ArrowSquareOut size={11} weight="light" style="flex-shrink:0;opacity:0.5" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="action-btn action-danger" onclick={() => confirmUnbind = true} disabled={isBusy}>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
<span class="action-label">Unlink</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="content-header">
|
||||||
|
<div class="title-block">
|
||||||
|
<h2 class="title">{record.title}</h2>
|
||||||
|
{#if record.manga?.title && record.manga.title !== record.title}
|
||||||
|
<p class="byline">{record.manga.title}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge badge-tracker">
|
||||||
|
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-icon" />
|
||||||
|
{record.tracker.name}
|
||||||
|
</span>
|
||||||
|
{#if statusName}
|
||||||
|
<span class="badge badge-accent">{statusName}</span>
|
||||||
|
{/if}
|
||||||
|
{#if record.private}
|
||||||
|
<span class="badge badge-private"><Lock size={10} weight="fill" /> Private</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-box">
|
||||||
|
<div class="progress-box-top">
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{record.lastChapterRead > 0 ? record.lastChapterRead : '—'}</span>
|
||||||
|
<span class="progress-stat-label">read</span>
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<div class="progress-divider"></div>
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{record.totalChapters}</span>
|
||||||
|
<span class="progress-stat-label">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-divider"></div>
|
||||||
|
<div class="progress-stat">
|
||||||
|
<span class="progress-stat-value">{Math.max(0, record.totalChapters - record.lastChapterRead)}</span>
|
||||||
|
<span class="progress-stat-label">left</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !editingChapter}
|
||||||
|
<button class="edit-btn" onclick={() => { editingChapter = true; chapterDraft = record.lastChapterRead }} disabled={isBusy}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if progress !== null}
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width:{progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-pct">{Math.round(progress)}% complete</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingChapter}
|
||||||
|
<div class="chapter-editor">
|
||||||
|
<div class="chapter-input-row">
|
||||||
|
<input
|
||||||
|
type="number" class="chapter-input"
|
||||||
|
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||||
|
step="0.5"
|
||||||
|
bind:value={chapterDraft}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') submitChapter(); if (e.key === 'Escape') editingChapter = false }}
|
||||||
|
use:focusEl
|
||||||
|
/>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if record.totalChapters > 0}
|
||||||
|
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||||
|
{/if}
|
||||||
|
<div class="chapter-actions">
|
||||||
|
<button class="chapter-cancel" onclick={() => editingChapter = false}>Cancel</button>
|
||||||
|
<button class="chapter-save" onclick={submitChapter}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-row">
|
||||||
|
<div class="control-group">
|
||||||
|
<span class="control-label">Status</span>
|
||||||
|
<select
|
||||||
|
class="field-select"
|
||||||
|
value={record.status}
|
||||||
|
disabled={isBusy}
|
||||||
|
onchange={(e) => updateStatus(parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
{#each (record.tracker.statuses ?? []) as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<span class="control-label">Score</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="field-input"
|
||||||
|
bind:value={scoreDraft}
|
||||||
|
disabled={isBusy}
|
||||||
|
min={record.tracker.scores?.[0] ?? 0}
|
||||||
|
max={record.tracker.scores?.[record.tracker.scores.length - 1] ?? 10}
|
||||||
|
step="0.1"
|
||||||
|
onblur={submitScore}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-section">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Tracker</span>
|
||||||
|
<span class="meta-val">{record.tracker.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if record.manga?.title}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Local title</span>
|
||||||
|
<span class="meta-val">{record.manga.title}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if record.startDate}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Started</span>
|
||||||
|
<span class="meta-val">{record.startDate}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if record.finishDate}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-key">Finished</span>
|
||||||
|
<span class="meta-val">{record.finishDate}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmUnbind}
|
||||||
|
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel" onclick={() => confirmUnbind = false} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbind = false }}>
|
||||||
|
<div class="confirm-modal" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
|
<div class="confirm-icon"><X size={16} weight="bold" /></div>
|
||||||
|
<p class="confirm-title">Unlink from {record.tracker.name}?</p>
|
||||||
|
<p class="confirm-body"><strong>{record.title}</strong> will be removed from your list. Your progress on {record.tracker.name} is unaffected.</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="confirm-cancel" onclick={() => confirmUnbind = false}>Cancel</button>
|
||||||
|
<button class="confirm-confirm" onclick={unbind}>Unlink</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: var(--z-settings);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(720px, calc(100vw - 48px));
|
||||||
|
height: min(520px, calc(100vh - 80px));
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||||
|
animation: scaleIn 0.16s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-col {
|
||||||
|
width: 190px; flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
gap: var(--sp-3); overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-wrap { position: relative; width: 100%; }
|
||||||
|
.cover-glow {
|
||||||
|
position: absolute; inset: -20px; z-index: 0;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
filter: blur(24px) saturate(1.4);
|
||||||
|
opacity: 0.18;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
:global(.cover) {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 100%; aspect-ratio: 2/3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
display: block;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.cover-empty {
|
||||||
|
width: 100%; aspect-ratio: 2/3;
|
||||||
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
.tracker-badge {
|
||||||
|
position: absolute; bottom: 7px; right: 7px; z-index: 2;
|
||||||
|
width: 22px; height: 22px; border-radius: 5px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
:global(.tracker-badge-img) { width: 16px; height: 16px; object-fit: contain; display: block; }
|
||||||
|
|
||||||
|
.col-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.action-btn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
width: 100%; padding: 7px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; text-align: left; text-decoration: none;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.action-btn-inert { cursor: default; pointer-events: none; }
|
||||||
|
.action-btn:hover:not(:disabled):not(.action-btn-inert) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
|
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.action-danger:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||||
|
}
|
||||||
|
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
:global(.tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.content-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
|
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); margin: 0; }
|
||||||
|
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); margin: 0; }
|
||||||
|
.close-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm); color: var(--text-faint);
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
flex: 1; min-height: 0; overflow-y: auto;
|
||||||
|
padding: var(--sp-5) var(--sp-6);
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.content-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.badge-tracker { background: var(--bg-overlay); border-color: var(--border-dim); color: var(--text-muted); }
|
||||||
|
.badge-private { background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.25); color: #f59e0b; }
|
||||||
|
:global(.badge-icon) { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||||
|
|
||||||
|
.progress-box {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4); background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.progress-box-top { display: flex; align-items: center; gap: var(--sp-4); }
|
||||||
|
.progress-stat { display: flex; flex-direction: column; align-items: center; gap: 1px; }
|
||||||
|
.progress-stat-value { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: 1; }
|
||||||
|
.progress-stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); }
|
||||||
|
.progress-divider { width: 1px; height: 24px; background: var(--border-dim); }
|
||||||
|
.edit-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 4px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.edit-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
.edit-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||||
|
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); }
|
||||||
|
|
||||||
|
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-1); border-top: 1px solid var(--border-dim); }
|
||||||
|
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.chapter-input {
|
||||||
|
width: 70px; background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
padding: 5px 8px; font-family: var(--font-ui); font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary); outline: none; text-align: center;
|
||||||
|
appearance: none; -moz-appearance: textfield;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-input:focus { border-color: var(--accent); }
|
||||||
|
.chapter-input::-webkit-outer-spin-button,
|
||||||
|
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||||
|
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||||
|
.chapter-save {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 16px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||||
|
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-save:hover { filter: brightness(1.15); }
|
||||||
|
.chapter-cancel {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||||
|
border: none; background: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.chapter-cancel:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.controls-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
|
||||||
|
.control-group { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.control-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.field-select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 28px 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary); outline: none; cursor: pointer; appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat; background-position: right 8px center;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.field-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.field-select:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.field-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||||
|
color: var(--text-secondary); outline: none;
|
||||||
|
appearance: none; -moz-appearance: textfield;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.field-input:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.field-input:focus { border-color: var(--accent); color: var(--text-primary); }
|
||||||
|
.field-input:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.field-input::-webkit-outer-spin-button,
|
||||||
|
.field-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
|
||||||
|
.meta-section { display: flex; flex-direction: column; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||||
|
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||||
|
.meta-key {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||||
|
min-width: 72px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1);
|
||||||
|
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: fadeIn 0.12s ease both;
|
||||||
|
}
|
||||||
|
.confirm-modal {
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-xl); padding: var(--sp-6);
|
||||||
|
width: 300px; max-width: calc(100vw - 32px);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||||
|
animation: scaleIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||||
|
}
|
||||||
|
.confirm-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
|
||||||
|
color: var(--color-error); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); text-align: center; margin: 0; }
|
||||||
|
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0; }
|
||||||
|
.confirm-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||||
|
.confirm-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
|
||||||
|
.confirm-cancel {
|
||||||
|
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 8px 0; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim); background: none;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||||
|
.confirm-confirm {
|
||||||
|
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
padding: 8px 0; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
|
||||||
|
color: var(--color-error); cursor: pointer;
|
||||||
|
transition: filter var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
|
||||||
|
|
||||||
|
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowsClockwise, MagnifyingGlass } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
|
import type { SortKey } from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
|
||||||
|
interface Tracker { id: number; name: string; icon: string; trackRecords: { nodes: unknown[] }; isLoggedIn: boolean }
|
||||||
|
interface StatusOption { value: number; name: string }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loggedIn: Tracker[]
|
||||||
|
totalCount: number
|
||||||
|
activeTrackerId: number | 'all'
|
||||||
|
statusFilter: number | 'all'
|
||||||
|
statusOptions: StatusOption[]
|
||||||
|
searchQuery: string
|
||||||
|
sortBy: SortKey
|
||||||
|
loading: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
onTrackerChange: (id: number | 'all') => void
|
||||||
|
onStatusChange: (v: number | 'all') => void
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
onSortChange: (v: SortKey) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
loggedIn, totalCount, activeTrackerId, statusFilter, statusOptions,
|
||||||
|
searchQuery, sortBy, loading,
|
||||||
|
onRefresh, onTrackerChange, onStatusChange, onSearchChange, onSortChange,
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-top">
|
||||||
|
<span class="heading">Tracking</span>
|
||||||
|
|
||||||
|
{#if !loading && loggedIn.length > 0}
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab" class:active={activeTrackerId === 'all'}
|
||||||
|
onclick={() => onTrackerChange('all')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span class="tab-count">{totalCount}</span>
|
||||||
|
</button>
|
||||||
|
{#each loggedIn as t}
|
||||||
|
<button
|
||||||
|
class="tab" class:active={activeTrackerId === t.id}
|
||||||
|
onclick={() => onTrackerChange(t.id)}
|
||||||
|
>
|
||||||
|
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||||
|
{t.name}
|
||||||
|
<span class="tab-count">{t.trackRecords.nodes.length}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
|
||||||
|
<ArrowsClockwise size={14} weight="bold" class={loading ? 'anim-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !loading && loggedIn.length > 0}
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={13} weight="light" class="search-ico" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="pill-select"
|
||||||
|
value={statusFilter}
|
||||||
|
onchange={(e) => {
|
||||||
|
const v = (e.target as HTMLSelectElement).value
|
||||||
|
onStatusChange(v === 'all' ? 'all' : parseInt(v))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{#each statusOptions as s}
|
||||||
|
<option value={s.value}>{s.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="pill-select" value={sortBy} onchange={(e) => onSortChange((e.target as HTMLSelectElement).value as SortKey)}>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
<option value="score">Score</option>
|
||||||
|
<option value="progress">Progress</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.toolbar-top {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-4);
|
||||||
|
padding: var(--sp-4) var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||||
|
.tab:hover { color: var(--text-muted); }
|
||||||
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
|
||||||
|
|
||||||
|
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||||
|
.tab.active .tab-count { opacity: 1; }
|
||||||
|
|
||||||
|
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
|
.filter-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.search-wrap { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; transition: border-color var(--t-base); }
|
||||||
|
.search-wrap:focus-within { border-color: var(--border-strong); }
|
||||||
|
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.search-input { flex: 1; background: none; border: none; outline: none; min-width: 0; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
|
.pill-select { flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 22px 5px 9px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); outline: none; cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), color var(--t-base); }
|
||||||
|
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
|
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { Tracker, TrackRecord } from '$lib/types'
|
||||||
|
import { buildChapterList, type ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||||
|
import type { Chapter } from '$lib/types'
|
||||||
|
|
||||||
|
export interface TrackerWithRecords extends Tracker {
|
||||||
|
trackRecords: { nodes: TrackRecord[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlatRecord extends TrackRecord {
|
||||||
|
tracker: Tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortKey = 'title' | 'status' | 'score' | 'progress'
|
||||||
|
|
||||||
|
export function flattenRecords(trackers: TrackerWithRecords[]): FlatRecord[] {
|
||||||
|
return trackers
|
||||||
|
.filter((t) => t.isLoggedIn)
|
||||||
|
.flatMap((t) =>
|
||||||
|
t.trackRecords.nodes.map((r) => ({
|
||||||
|
...r,
|
||||||
|
trackerId: r.trackerId ?? t.id,
|
||||||
|
tracker: t as Tracker,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeStatuses(trackers: TrackerWithRecords[]): { value: number; name: string }[] {
|
||||||
|
const seen = new Map<string, { value: number; name: string }>()
|
||||||
|
for (const t of trackers.filter((t) => t.isLoggedIn))
|
||||||
|
for (const s of t.statuses ?? [])
|
||||||
|
seen.set(`${s.value}:${s.name}`, s)
|
||||||
|
return [...seen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRecords(
|
||||||
|
records: FlatRecord[],
|
||||||
|
trackerId: number | 'all',
|
||||||
|
statusFilter: number | 'all',
|
||||||
|
query: string,
|
||||||
|
): FlatRecord[] {
|
||||||
|
let list = trackerId === 'all'
|
||||||
|
? records
|
||||||
|
: records.filter((r) => Number(r.trackerId) === Number(trackerId))
|
||||||
|
|
||||||
|
if (statusFilter !== 'all')
|
||||||
|
list = list.filter((r) => Number(r.status) === Number(statusFilter))
|
||||||
|
|
||||||
|
if (query.trim()) {
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
list = list.filter((r) =>
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
r.manga?.title?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortRecords(records: FlatRecord[], sortBy: SortKey): FlatRecord[] {
|
||||||
|
return [...records].sort((a, b) => {
|
||||||
|
if (sortBy === 'title') return a.title.localeCompare(b.title)
|
||||||
|
if (sortBy === 'status') return a.status - b.status
|
||||||
|
if (sortBy === 'score') return parseFloat(b.displayScore ?? '0') - parseFloat(a.displayScore ?? '0')
|
||||||
|
if (sortBy === 'progress') {
|
||||||
|
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0
|
||||||
|
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0
|
||||||
|
return bp - ap
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcProgress(lastChapterRead: number, totalChapters: number): number | null {
|
||||||
|
if (totalChapters <= 0) return null
|
||||||
|
return Math.min(100, (lastChapterRead / totalChapters) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncBackOptions {
|
||||||
|
threshold: number | null
|
||||||
|
respectScanlatorFilter: boolean
|
||||||
|
chapterPrefs: ChapterDisplayPrefs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncBackFromTracker(
|
||||||
|
records: TrackRecord[],
|
||||||
|
chapters: Chapter[],
|
||||||
|
opts: SyncBackOptions,
|
||||||
|
markRead: (ids: string[], read: boolean) => Promise<void>,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const eligible = buildChapterList(chapters, {
|
||||||
|
...opts.chapterPrefs,
|
||||||
|
sortDir: 'asc',
|
||||||
|
...(opts.respectScanlatorFilter ? {} : {
|
||||||
|
scanlatorFilter: [],
|
||||||
|
scanlatorBlacklist: [],
|
||||||
|
scanlatorForce: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dedupe to one chapter per integer floor (prefer exact integer)
|
||||||
|
const seenInt = new Map<number, Chapter>()
|
||||||
|
for (const ch of eligible) {
|
||||||
|
if (!Number.isInteger(ch.chapterNumber)) continue
|
||||||
|
const key = Math.floor(ch.chapterNumber)
|
||||||
|
if (!seenInt.has(key)) seenInt.set(key, ch)
|
||||||
|
}
|
||||||
|
const dedupedEligible = [...seenInt.values()]
|
||||||
|
|
||||||
|
// Also track decimal sub-chapters grouped by their floor
|
||||||
|
const decimalsByFloor = new Map<number, Chapter[]>()
|
||||||
|
for (const ch of eligible) {
|
||||||
|
if (Number.isInteger(ch.chapterNumber)) continue
|
||||||
|
const key = Math.floor(ch.chapterNumber)
|
||||||
|
const arr = decimalsByFloor.get(key) ?? []
|
||||||
|
arr.push(ch)
|
||||||
|
decimalsByFloor.set(key, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMarkRead: number[] = []
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const remote = record.lastChapterRead
|
||||||
|
if (!remote || remote <= 0) continue
|
||||||
|
|
||||||
|
for (const chapter of dedupedEligible) {
|
||||||
|
if (chapter.read) continue
|
||||||
|
if (chapter.chapterNumber > remote) continue
|
||||||
|
if (opts.threshold !== null && remote - chapter.chapterNumber > opts.threshold) continue
|
||||||
|
toMarkRead.push(chapter.id)
|
||||||
|
for (const dec of decimalsByFloor.get(chapter.chapterNumber) ?? []) {
|
||||||
|
if (!dec.read) toMarkRead.push(dec.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...new Set(toMarkRead)]
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await markRead(ids.map(String), true)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
Vendored
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
|
||||||
|
const pageCache = new Map<number, string[]>();
|
||||||
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
|
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||||
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
|
function getServerUrl(): string {
|
||||||
|
return settingsState.settings.serverUrl ?? "http://localhost:4567";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChapterPagesFromServer(chapterId: number): Promise<string[]> {
|
||||||
|
const base = getServerUrl();
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
||||||
|
if (mode === "BASIC_AUTH") {
|
||||||
|
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
||||||
|
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
||||||
|
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
||||||
|
}
|
||||||
|
const query = `mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } }`;
|
||||||
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query, variables: { chapterId } }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||||
|
return (json.data.fetchChapterPages.pages as string[]).map(p =>
|
||||||
|
p.startsWith("http") ? p : `${base}${p}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||||
|
if (!useBlob) return Promise.resolve(url);
|
||||||
|
const cached = resolvedUrlCache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
const p = getBlobUrl(url, priority).catch(err => {
|
||||||
|
resolvedUrlCache.delete(url);
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
resolvedUrlCache.set(url, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPages(
|
||||||
|
chapterId: number,
|
||||||
|
useBlob: boolean,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
priorityPage = 0,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cached = pageCache.get(chapterId);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
|
||||||
|
if (!inflight.has(chapterId)) {
|
||||||
|
const p = fetchChapterPagesFromServer(chapterId)
|
||||||
|
.then(urls => {
|
||||||
|
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
|
||||||
|
pageCache.set(chapterId, urls);
|
||||||
|
return urls;
|
||||||
|
})
|
||||||
|
.finally(() => inflight.delete(chapterId));
|
||||||
|
inflight.set(chapterId, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = inflight.get(chapterId)!;
|
||||||
|
if (!signal) return base;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
|
base.then(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||||
|
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||||
|
return resolveUrl(url, useBlob).then(src => new Promise(res => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
|
||||||
|
img.onerror = () => res(0.67);
|
||||||
|
img.src = src;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
|
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||||
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearResolvedUrlCache(): void {
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPageCache(chapterId?: number): void {
|
||||||
|
if (chapterId !== undefined) {
|
||||||
|
pageCache.delete(chapterId);
|
||||||
|
inflight.delete(chapterId);
|
||||||
|
} else {
|
||||||
|
pageCache.clear();
|
||||||
|
inflight.clear();
|
||||||
|
resolvedUrlCache.clear();
|
||||||
|
aspectCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ import type {
|
|||||||
SetServerAuthInput,
|
SetServerAuthInput,
|
||||||
SetSocksProxyInput,
|
SetSocksProxyInput,
|
||||||
SetFlareSolverrInput,
|
SetFlareSolverrInput,
|
||||||
|
TrackRecordPatch,
|
||||||
} from '$lib/server-adapters/types'
|
} from '$lib/server-adapters/types'
|
||||||
import type { DownloadStatus } from '$lib/types/api'
|
import type { DownloadStatus } from '$lib/types/api'
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
|
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||||
import {
|
import {
|
||||||
GET_LIBRARY,
|
GET_LIBRARY,
|
||||||
GET_MANGA,
|
GET_MANGA,
|
||||||
@@ -74,8 +75,10 @@ import {
|
|||||||
} from './extensions'
|
} from './extensions'
|
||||||
import {
|
import {
|
||||||
GET_TRACKERS,
|
GET_TRACKERS,
|
||||||
|
GET_ALL_TRACKER_RECORDS,
|
||||||
GET_MANGA_TRACK_RECORDS,
|
GET_MANGA_TRACK_RECORDS,
|
||||||
SEARCH_TRACKER,
|
SEARCH_TRACKER,
|
||||||
|
FETCH_TRACK,
|
||||||
BIND_TRACK,
|
BIND_TRACK,
|
||||||
UNLINK_TRACK,
|
UNLINK_TRACK,
|
||||||
TRACK_PROGRESS,
|
TRACK_PROGRESS,
|
||||||
@@ -317,9 +320,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
|
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Downloads ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @deprecated Use getDownloadStatus() — kept for any legacy callers. */
|
|
||||||
async getDownloads(): Promise<DownloadItem[]> {
|
async getDownloads(): Promise<DownloadItem[]> {
|
||||||
const status = await this.getDownloadStatus()
|
const status = await this.getDownloadStatus()
|
||||||
return status.queue.map(item => ({
|
return status.queue.map(item => ({
|
||||||
@@ -391,8 +391,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
} catch { return null }
|
} catch { return null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extensions & Sources ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getExtensions(): Promise<Extension[]> {
|
async getExtensions(): Promise<Extension[]> {
|
||||||
await this.gql(FETCH_EXTENSIONS)
|
await this.gql(FETCH_EXTENSIONS)
|
||||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
|
||||||
@@ -429,13 +427,11 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
||||||
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
|
||||||
return {
|
return {
|
||||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Categories ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getCategories(): Promise<Category[]> {
|
async getCategories(): Promise<Category[]> {
|
||||||
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
||||||
return data.categories.nodes.map(mapCategory)
|
return data.categories.nodes.map(mapCategory)
|
||||||
@@ -471,13 +467,16 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
|
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tracking ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getTrackers(): Promise<Tracker[]> {
|
async getTrackers(): Promise<Tracker[]> {
|
||||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||||
return data.trackers.nodes
|
return data.trackers.nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllTrackerRecords(): Promise<unknown[]> {
|
||||||
|
const data = await this.gql<{ trackers: { nodes: unknown[] } }>(GET_ALL_TRACKER_RECORDS)
|
||||||
|
return data.trackers.nodes
|
||||||
|
}
|
||||||
|
|
||||||
async getMangaTrackRecords(mangaId: string): Promise<unknown[]> {
|
async getMangaTrackRecords(mangaId: string): Promise<unknown[]> {
|
||||||
const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>(
|
const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>(
|
||||||
GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }
|
GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }
|
||||||
@@ -493,27 +492,31 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void> {
|
async linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void> {
|
||||||
await this.gql(BIND_TRACK, {
|
await this.gql(BIND_TRACK, { mangaId: Number(mangaId), trackerId: Number(trackerId), remoteId })
|
||||||
mangaId: Number(mangaId),
|
|
||||||
trackerId: Number(trackerId),
|
|
||||||
remoteId,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlinkTracker(recordId: string): Promise<void> {
|
async unlinkTracker(recordId: string): Promise<void> {
|
||||||
await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) })
|
await this.gql(UNLINK_TRACK, { trackRecordId: Number(recordId) })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchTrackRecord(recordId: string): Promise<void> {
|
async updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord> {
|
||||||
await this.gql(UPDATE_TRACK, { recordId: Number(recordId) })
|
const data = await this.gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: Number(recordId), ...patch }
|
||||||
|
)
|
||||||
|
return data.updateTrack.trackRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchTrackRecord(recordId: string): Promise<TrackRecord> {
|
||||||
|
const data = await this.gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
FETCH_TRACK, { recordId: Number(recordId) }
|
||||||
|
)
|
||||||
|
return data.fetchTrack.trackRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncTracking(mangaId: string): Promise<void> {
|
async syncTracking(mangaId: string): Promise<void> {
|
||||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Security ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async getServerSecurity(): Promise<ServerSecurity> {
|
async getServerSecurity(): Promise<ServerSecurity> {
|
||||||
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
|
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
|
||||||
return data.settings
|
return data.settings
|
||||||
@@ -521,7 +524,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
|
|
||||||
async setServerAuth(input: SetServerAuthInput): Promise<void> {
|
async setServerAuth(input: SetServerAuthInput): Promise<void> {
|
||||||
await this.gql(SET_SERVER_AUTH, {
|
await this.gql(SET_SERVER_AUTH, {
|
||||||
authMode: input.authMode,
|
authMode: input.authMode,
|
||||||
authUsername: input.authUsername,
|
authUsername: input.authUsername,
|
||||||
authPassword: input.authPassword,
|
authPassword: input.authPassword,
|
||||||
})
|
})
|
||||||
@@ -535,8 +538,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(SET_FLARE_SOLVERR, input)
|
await this.gql(SET_FLARE_SOLVERR, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Browse / Search ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async searchSource(
|
async searchSource(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -560,20 +561,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
||||||
const data = await this.gql<{
|
const data = await this.gql<{
|
||||||
mangas: {
|
mangas: {
|
||||||
nodes: Record<string, unknown>[];
|
nodes: Record<string, unknown>[]
|
||||||
pageInfo: { hasNextPage: boolean };
|
pageInfo: { hasNextPage: boolean }
|
||||||
totalCount: number;
|
totalCount: number
|
||||||
}
|
}
|
||||||
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
||||||
return {
|
return {
|
||||||
items: data.mangas.nodes.map(mapManga),
|
items: data.mangas.nodes.map(mapManga),
|
||||||
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
||||||
totalCount: data.mangas.totalCount,
|
totalCount: data.mangas.totalCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Library updates ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||||
if (mangaIds?.length) {
|
if (mangaIds?.length) {
|
||||||
const results: UpdateResult[] = []
|
const results: UpdateResult[] = []
|
||||||
@@ -607,4 +606,3 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
_clearPageCache(chapterId)
|
_clearPageCache(chapterId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,26 @@ export const GET_TRACKERS = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_ALL_TRACKER_RECORDS = `
|
||||||
|
query GetAllTrackerRecords {
|
||||||
|
trackers {
|
||||||
|
nodes {
|
||||||
|
id name icon isLoggedIn isTokenExpired authUrl
|
||||||
|
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||||
|
scores
|
||||||
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
manga { id title thumbnailUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const GET_MANGA_TRACK_RECORDS = `
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
query GetMangaTrackRecords($mangaId: Int!) {
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
manga(id: $mangaId) {
|
manga(id: $mangaId) {
|
||||||
@@ -35,6 +55,17 @@ export const SEARCH_TRACKER = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const FETCH_TRACK = `
|
||||||
|
mutation FetchTrack($recordId: Int!) {
|
||||||
|
fetchTrack(input: { recordId: $recordId }) {
|
||||||
|
trackRecord {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const BIND_TRACK = `
|
export const BIND_TRACK = `
|
||||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||||
@@ -62,7 +93,7 @@ export const UPDATE_TRACK = `
|
|||||||
finishDate: $finishDate
|
finishDate: $finishDate
|
||||||
private: $private
|
private: $private
|
||||||
}) {
|
}) {
|
||||||
trackRecord { id status score lastChapterRead }
|
trackRecord { id trackerId status score displayScore lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { DownloadStatus } from '$lib/types/api'
|
import type { DownloadStatus } from '$lib/types/api'
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
|
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -104,12 +104,21 @@ export interface SetFlareSolverrInput {
|
|||||||
flareSolverrAsResponseFallback: boolean
|
flareSolverrAsResponseFallback: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrackRecordPatch {
|
||||||
|
status?: number
|
||||||
|
score?: number
|
||||||
|
lastChapterRead?: number
|
||||||
|
startDate?: string
|
||||||
|
finishDate?: string
|
||||||
|
private?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerAdapter {
|
export interface ServerAdapter {
|
||||||
connect(config: ServerConfig): Promise<void>
|
connect(config: ServerConfig): Promise<void>
|
||||||
getStatus(): Promise<ServerStatus>
|
getStatus(): Promise<ServerStatus>
|
||||||
getServerUrl(): string
|
getServerUrl(): string
|
||||||
|
|
||||||
getManga(id: string): Promise<Manga>
|
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
||||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||||
fetchManga(id: string): Promise<Manga>
|
fetchManga(id: string): Promise<Manga>
|
||||||
@@ -161,11 +170,13 @@ export interface ServerAdapter {
|
|||||||
updateCategoryManga(categoryId: number): Promise<void>
|
updateCategoryManga(categoryId: number): Promise<void>
|
||||||
|
|
||||||
getTrackers(): Promise<Tracker[]>
|
getTrackers(): Promise<Tracker[]>
|
||||||
|
getAllTrackerRecords(): Promise<unknown[]>
|
||||||
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
|
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
|
||||||
searchTracker(trackerId: string, query: string): Promise<unknown[]>
|
searchTracker(trackerId: string, query: string): Promise<unknown[]>
|
||||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||||
unlinkTracker(recordId: string): Promise<void>
|
unlinkTracker(recordId: string): Promise<void>
|
||||||
fetchTrackRecord(recordId: string): Promise<void>
|
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
|
||||||
|
fetchTrackRecord(recordId: string): Promise<TrackRecord>
|
||||||
syncTracking(mangaId: string): Promise<void>
|
syncTracking(mangaId: string): Promise<void>
|
||||||
|
|
||||||
getServerSecurity(): Promise<ServerSecurity>
|
getServerSecurity(): Promise<ServerSecurity>
|
||||||
|
|||||||
@@ -40,3 +40,7 @@ export function recordRead(entry: HistoryEntry) {
|
|||||||
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1;
|
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1;
|
||||||
homeState.stats.totalChaptersRead++;
|
homeState.stats.totalChaptersRead++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearHistory() {
|
||||||
|
homeState.history = [];
|
||||||
|
}
|
||||||
+215
-34
@@ -1,44 +1,225 @@
|
|||||||
import type { Manga, Chapter } from "$lib/types";
|
import type { Manga, Chapter } from "$lib/types";
|
||||||
import type { Page } from "$lib/server-adapters/types";
|
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||||
|
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||||
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
export type ReadMode = "single" | "strip";
|
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||||||
export type FitMode = "width" | "height" | "original";
|
export type PageStyle = typeof PAGE_STYLES[number];
|
||||||
export type ReadDirection = "ltr" | "rtl";
|
|
||||||
|
|
||||||
export const readerState = $state({
|
export const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
|
||||||
manga: null as Manga | null,
|
export const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
|
||||||
chapter: null as Chapter | null,
|
yellow: "#c4a94a",
|
||||||
chapters: [] as Chapter[],
|
red: "#c47a7a",
|
||||||
|
blue: "#7a9ec4",
|
||||||
|
green: "#7aab7a",
|
||||||
|
purple: "#a07ac4",
|
||||||
|
};
|
||||||
|
|
||||||
pages: [] as Page[],
|
export const ZOOM_STEP = 0.05;
|
||||||
pagesLoading: false,
|
export const ZOOM_MIN = 0.1;
|
||||||
pagesError: null as string | null,
|
export const ZOOM_MAX = 1.0;
|
||||||
|
|
||||||
currentPage: 0,
|
export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||||
mode: "single" as ReadMode,
|
export type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||||
fit: "width" as FitMode,
|
|
||||||
direction: "ltr" as ReadDirection,
|
|
||||||
zoom: 1,
|
|
||||||
|
|
||||||
showControls: false,
|
export interface StripChapter {
|
||||||
showSettings: false,
|
chapterId: number;
|
||||||
fullscreen: false,
|
chapterName: string;
|
||||||
});
|
urls: string[];
|
||||||
|
|
||||||
export function currentPageData() {
|
|
||||||
return readerState.pages[readerState.currentPage] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function progress() {
|
class ReaderState {
|
||||||
return readerState.pages.length > 0
|
activeManga = $state<Manga | null>(null);
|
||||||
? (readerState.currentPage + 1) / readerState.pages.length
|
activeChapter = $state<Chapter | null>(null);
|
||||||
: 0;
|
activeChapterList = $state<Chapter[]>([]);
|
||||||
|
pageUrls = $state<string[]>([]);
|
||||||
|
pageNumber = $state(1);
|
||||||
|
bookmarks = $state<BookmarkEntry[]>([]);
|
||||||
|
markers = $state<MarkerEntry[]>([]);
|
||||||
|
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
pageReady = $state(false);
|
||||||
|
pageGroups = $state<number[][]>([]);
|
||||||
|
stripChapters = $state<StripChapter[]>([]);
|
||||||
|
visibleChapterId = $state<number | null>(null);
|
||||||
|
|
||||||
|
uiVisible = $state(true);
|
||||||
|
isFullscreen = $state(false);
|
||||||
|
|
||||||
|
dlOpen = $state(false);
|
||||||
|
zoomOpen = $state(false);
|
||||||
|
winOpen = $state(false);
|
||||||
|
presetOpen = $state(false);
|
||||||
|
nextN = $state(5);
|
||||||
|
dlBusy = $state(false);
|
||||||
|
|
||||||
|
fadingOut = $state(false);
|
||||||
|
sliderDragging = $state(false);
|
||||||
|
sliderHover = $state(false);
|
||||||
|
|
||||||
|
resumePage = $state(0);
|
||||||
|
resumeDismissed = $state(false);
|
||||||
|
resumeFading = $state(false);
|
||||||
|
resumeVisible = $state(false);
|
||||||
|
stripResumeReady = $state(false);
|
||||||
|
|
||||||
|
markerOpen = $state(false);
|
||||||
|
markerNote = $state("");
|
||||||
|
markerColor = $state<MarkerColor>("yellow");
|
||||||
|
markerEditId = $state("");
|
||||||
|
|
||||||
|
inspectScale = $state(1);
|
||||||
|
inspectPanX = $state(0);
|
||||||
|
inspectPanY = $state(0);
|
||||||
|
|
||||||
|
containerWidth = $state(0);
|
||||||
|
|
||||||
|
get settings() { return settingsState.settings; }
|
||||||
|
|
||||||
|
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||||
|
this.activeChapter = chapter;
|
||||||
|
this.activeChapterList = chapterList;
|
||||||
|
if (manga !== undefined) this.activeManga = manga;
|
||||||
|
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReader() {
|
||||||
|
this.activeChapter = null;
|
||||||
|
this.activeChapterList = [];
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForChapter() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.pageReady = false;
|
||||||
|
this.pageGroups = [];
|
||||||
|
this.stripChapters = [];
|
||||||
|
this.visibleChapterId = null;
|
||||||
|
this.fadingOut = false;
|
||||||
|
this.markerOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetResume() {
|
||||||
|
this.resumePage = 0;
|
||||||
|
this.resumeDismissed = false;
|
||||||
|
this.resumeVisible = false;
|
||||||
|
this.stripResumeReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetInspect() {
|
||||||
|
this.inspectScale = 1;
|
||||||
|
this.inspectPanX = 0;
|
||||||
|
this.inspectPanY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAllPopovers(): boolean {
|
||||||
|
if (this.markerOpen) { this.markerOpen = false; return true; }
|
||||||
|
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||||
|
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||||
|
if (this.winOpen) { this.winOpen = false; return true; }
|
||||||
|
if (this.presetOpen) { this.presetOpen = false; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
openMarker(editId: string, note: string, color: MarkerColor) {
|
||||||
|
this.markerEditId = editId;
|
||||||
|
this.markerNote = note;
|
||||||
|
this.markerColor = color;
|
||||||
|
this.markerOpen = true;
|
||||||
|
this.zoomOpen = false;
|
||||||
|
this.dlOpen = false;
|
||||||
|
this.winOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMarkerPopover() {
|
||||||
|
this.markerOpen = false;
|
||||||
|
this.markerNote = "";
|
||||||
|
this.markerEditId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
addBookmark(entry: Omit<BookmarkEntry, "savedAt">) {
|
||||||
|
this.bookmarks = [
|
||||||
|
{ ...entry, savedAt: Date.now() },
|
||||||
|
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||||
|
].slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(chapterId: number) {
|
||||||
|
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||||
|
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch } : m);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMarker(id: string) {
|
||||||
|
this.markers = this.markers.filter(m => m.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForPage(chapterId: number, page: number) {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkersForChapter(chapterId: number) {
|
||||||
|
return this.markers.filter(m => m.chapterId === chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMangaPrefs(mangaId: number): MangaPrefs {
|
||||||
|
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
return { ...DEFAULT_MANGA_PREFS, ...prefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
setMangaReaderSettings(mangaId: number, patch: Partial<ReaderSettings>) {
|
||||||
|
updateSettings({
|
||||||
|
mangaReaderSettings: {
|
||||||
|
...settingsState.settings.mangaReaderSettings,
|
||||||
|
[mangaId]: { ...(settingsState.settings.mangaReaderSettings?.[mangaId] ?? {}), ...patch } as ReaderSettings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMangaReaderSettings(mangaId: number) {
|
||||||
|
const next = { ...settingsState.settings.mangaReaderSettings };
|
||||||
|
delete next[mangaId];
|
||||||
|
updateSettings({ mangaReaderSettings: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
saveReaderPreset(name: string, settings: ReaderSettings) {
|
||||||
|
const preset: ReaderPreset = { id: Math.random().toString(36).slice(2), name, settings };
|
||||||
|
updateSettings({ readerPresets: [...(settingsState.settings.readerPresets ?? []), preset] });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
|
||||||
|
updateSettings({
|
||||||
|
readerPresets: (settingsState.settings.readerPresets ?? []).map(p =>
|
||||||
|
p.id === id ? { ...p, ...patch } : p
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReaderPreset(id: string) {
|
||||||
|
updateSettings({ readerPresets: (settingsState.settings.readerPresets ?? []).filter(p => p.id !== id) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPrev() {
|
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||||
return readerState.currentPage > 0;
|
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
||||||
}
|
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
||||||
|
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
|
||||||
|
scanlatorBlacklist: [], scanlatorForce: false, autoDownloadScanlators: [],
|
||||||
|
sortMode: "source", sortDir: "asc", coverUrl: "",
|
||||||
|
};
|
||||||
|
|
||||||
export function hasNext() {
|
export const readerState = new ReaderState();
|
||||||
return readerState.currentPage < readerState.pages.length - 1;
|
|
||||||
}
|
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { readerState.openReader(ch, list, manga); }
|
||||||
|
export function closeReader() { readerState.closeReader(); }
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import { getAdapter } from '$lib/request-manager'
|
||||||
import { settingsState } from '$lib/state/settings.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
import { buildChapterList, type ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||||
import type { Tracker, TrackRecord } from '$lib/types'
|
import { syncBackFromTracker } from '$lib/components/tracking/lib/trackingSync'
|
||||||
import type { Chapter } from '$lib/types'
|
import type { Tracker, TrackRecord } from '$lib/types'
|
||||||
import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
import type { Chapter } from '$lib/types'
|
||||||
|
import type { TrackerWithRecords } from '$lib/components/tracking/lib/trackingSync'
|
||||||
|
|
||||||
type RecordMap = Map<number, TrackRecord[]>
|
const BOOT_SYNC_RATE_MS = 400
|
||||||
|
|
||||||
class TrackingStore {
|
type RecordMap = Map<number, TrackRecord[]>
|
||||||
|
type MangaBucket = { mangaId: number; records: TrackRecord[] }
|
||||||
|
|
||||||
|
class TrackingState {
|
||||||
private byManga: RecordMap = $state(new Map())
|
private byManga: RecordMap = $state(new Map())
|
||||||
|
|
||||||
|
allTrackers: TrackerWithRecords[] = $state([])
|
||||||
|
loadingAll: boolean = $state(false)
|
||||||
|
loadingFor: Set<number> = $state(new Set())
|
||||||
|
error: string | null = $state(null)
|
||||||
|
|
||||||
|
// Legacy flat fields kept for request-manager/tracking.ts compatibility
|
||||||
trackers: Tracker[] = $state([])
|
trackers: Tracker[] = $state([])
|
||||||
loading: boolean = $state(false)
|
loading: boolean = $state(false)
|
||||||
error: string | null = $state(null)
|
|
||||||
syncing: boolean = $state(false)
|
syncing: boolean = $state(false)
|
||||||
|
|
||||||
recordsLoading: boolean = $state(false)
|
recordsLoading: boolean = $state(false)
|
||||||
recordsError: string | null = $state(null)
|
recordsError: string | null = $state(null)
|
||||||
searchResults: unknown[] = $state([])
|
searchResults: unknown[] = $state([])
|
||||||
searchLoading: boolean = $state(false)
|
searchLoading: boolean = $state(false)
|
||||||
searchError: string | null = $state(null)
|
searchError: string | null = $state(null)
|
||||||
|
|
||||||
private loadingFor = new Set<number>()
|
|
||||||
|
|
||||||
recordsFor(mangaId: number): TrackRecord[] {
|
recordsFor(mangaId: number): TrackRecord[] {
|
||||||
return this.byManga.get(mangaId) ?? []
|
return this.byManga.get(mangaId) ?? []
|
||||||
}
|
}
|
||||||
@@ -33,84 +39,167 @@ class TrackingStore {
|
|||||||
this.byManga = next
|
this.byManga = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private patchFor(mangaId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
const records = this.recordsFor(mangaId).map((r) =>
|
||||||
|
r.id === updated.id ? { ...r, ...updated } : r
|
||||||
|
)
|
||||||
|
this.setFor(mangaId, records)
|
||||||
|
|
||||||
|
this.allTrackers = this.allTrackers.map((t) => ({
|
||||||
|
...t,
|
||||||
|
trackRecords: {
|
||||||
|
nodes: t.trackRecords.nodes.map((r) =>
|
||||||
|
r.id === updated.id ? { ...r, ...updated } : r
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-manga load ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async loadForManga(mangaId: number) {
|
async loadForManga(mangaId: number) {
|
||||||
if (this.loadingFor.has(mangaId)) return
|
if (this.loadingFor.has(mangaId)) return
|
||||||
const existing = this.byManga.get(mangaId)
|
const existing = this.byManga.get(mangaId)
|
||||||
if (existing && existing.length > 0) return
|
if (existing && existing.length > 0) return
|
||||||
|
|
||||||
this.loadingFor.add(mangaId)
|
const next = new Set(this.loadingFor)
|
||||||
|
next.add(mangaId)
|
||||||
|
this.loadingFor = next
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const records = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
const records = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||||
this.setFor(mangaId, records)
|
this.setFor(mangaId, records)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
// silently ignore — tracking is non-critical
|
this.error = e instanceof Error ? e.message : 'Failed to load tracking'
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingFor.delete(mangaId)
|
const s = new Set(this.loadingFor)
|
||||||
|
s.delete(mangaId)
|
||||||
|
this.loadingFor = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global load (tracking page) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async loadAll() {
|
||||||
|
this.loadingAll = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const trackers = await getAdapter().getAllTrackerRecords() as TrackerWithRecords[]
|
||||||
|
this.allTrackers = trackers
|
||||||
|
this.trackers = trackers // keep flat field in sync
|
||||||
|
|
||||||
|
for (const tracker of trackers.filter((t) => t.isLoggedIn)) {
|
||||||
|
for (const record of tracker.trackRecords.nodes) {
|
||||||
|
if (!record.manga?.id) continue
|
||||||
|
const mangaId = record.manga.id
|
||||||
|
const existing = this.byManga.get(mangaId) ?? []
|
||||||
|
const merged = [...existing.filter((r) => r.id !== record.id), record]
|
||||||
|
this.setFor(mangaId, merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.error = e instanceof Error ? e.message : 'Failed to load tracking'
|
||||||
|
} finally {
|
||||||
|
this.loadingAll = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field updates ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
|
||||||
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), { status })
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise<TrackRecord> {
|
||||||
|
const score = parseFloat(scoreString)
|
||||||
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), { score: isNaN(score) ? undefined : score })
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise<TrackRecord> {
|
||||||
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), { lastChapterRead })
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
async unbind(mangaId: number, record: TrackRecord) {
|
||||||
|
await getAdapter().unlinkTracker(String(record.id))
|
||||||
|
this.setFor(mangaId, this.recordsFor(mangaId).filter((r) => r.id !== record.id))
|
||||||
|
this.allTrackers = this.allTrackers.map((t) => ({
|
||||||
|
...t,
|
||||||
|
trackRecords: { nodes: t.trackRecords.nodes.filter((r) => r.id !== record.id) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remote sync ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async syncFromRemote(
|
async syncFromRemote(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
record: TrackRecord,
|
record: TrackRecord,
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
prefs: ChapterDisplayPrefs,
|
prefs: ChapterDisplayPrefs,
|
||||||
): Promise<{ markedIds: number[] }> {
|
): Promise<{ fresh: TrackRecord; markedIds: number[] }> {
|
||||||
if (!settingsState.settings.trackerSyncBack) return { markedIds: [] }
|
const fresh = await getAdapter().fetchTrackRecord(String(record.id)) as TrackRecord
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
|
||||||
try {
|
const markedIds = await this._applyRemoteProgress(fresh, chapters, prefs)
|
||||||
await getAdapter().syncTracking(String(mangaId))
|
return { fresh, markedIds }
|
||||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
|
||||||
this.setFor(mangaId, fresh)
|
|
||||||
|
|
||||||
const freshRecord = fresh.find(r => r.id === record.id)
|
|
||||||
if (!freshRecord) return { markedIds: [] }
|
|
||||||
|
|
||||||
const markedIds = this._applyRemoteProgress(freshRecord, chapters, prefs)
|
|
||||||
return { markedIds }
|
|
||||||
} catch {
|
|
||||||
return { markedIds: [] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyRemoteProgress(
|
private async _applyRemoteProgress(
|
||||||
record: TrackRecord,
|
record: TrackRecord,
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
prefs: ChapterDisplayPrefs,
|
prefs: ChapterDisplayPrefs,
|
||||||
): number[] {
|
): Promise<number[]> {
|
||||||
const lastRead = record.lastChapterRead ?? 0
|
if (!settingsState.settings.trackerSyncBack) return []
|
||||||
if (lastRead <= 0) return []
|
|
||||||
|
|
||||||
const threshold = settingsState.settings.trackerSyncBackThreshold ?? null
|
return syncBackFromTracker(
|
||||||
const respectScanlator = settingsState.settings.trackerRespectScanlatorFilter ?? true
|
[record],
|
||||||
const activeScanlators: string[] | null =
|
chapters,
|
||||||
respectScanlator && (prefs as any).scanlatorFilter?.length
|
{
|
||||||
? (prefs as any).scanlatorFilter
|
threshold: settingsState.settings.trackerSyncBackThreshold ?? null,
|
||||||
: null
|
respectScanlatorFilter: settingsState.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
return chapters
|
},
|
||||||
.filter(ch => {
|
(ids, read) => getAdapter().markChaptersRead(ids, read),
|
||||||
if (ch.read) return false
|
)
|
||||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
|
||||||
return threshold !== null
|
|
||||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - threshold
|
|
||||||
: ch.chapterNumber <= lastRead
|
|
||||||
})
|
|
||||||
.map(ch => ch.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Read/unread sync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async updateFromRead(
|
async updateFromRead(
|
||||||
mangaId: number,
|
mangaId: number,
|
||||||
chapter: Chapter,
|
chapter: Chapter,
|
||||||
chapterList: Chapter[],
|
chapterList: Chapter[],
|
||||||
prefs: ChapterDisplayPrefs,
|
prefs: ChapterDisplayPrefs,
|
||||||
) {
|
) {
|
||||||
|
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: 'asc' })
|
||||||
|
const idx = filtered.findIndex((c) => c.id === chapter.id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const position = idx + 1
|
||||||
|
|
||||||
const records = this.recordsFor(mangaId)
|
const records = this.recordsFor(mangaId)
|
||||||
if (!records.length) return
|
for (const record of records) {
|
||||||
try {
|
try {
|
||||||
await getAdapter().syncTracking(String(mangaId))
|
const completedValue = this._completedStatusFor(record.trackerId)
|
||||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
const isCompleted = completedValue !== null && record.status === completedValue
|
||||||
this.setFor(mangaId, fresh)
|
const readingValue = this._readingStatusFor(record.trackerId)
|
||||||
} catch {}
|
const belowMax = record.totalChapters > 0 && (record.lastChapterRead ?? 0) < record.totalChapters
|
||||||
|
|
||||||
|
if ((isCompleted || belowMax) && readingValue !== null && position > (record.lastChapterRead ?? 0)) {
|
||||||
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), {
|
||||||
|
lastChapterRead: position,
|
||||||
|
status: readingValue,
|
||||||
|
})
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
} else if (!isCompleted && position > (record.lastChapterRead ?? 0)) {
|
||||||
|
await this.updateChapterProgress(mangaId, record, position)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFromUnread(
|
async updateFromUnread(
|
||||||
@@ -118,13 +207,88 @@ class TrackingStore {
|
|||||||
chapterList: Chapter[],
|
chapterList: Chapter[],
|
||||||
prefs: ChapterDisplayPrefs,
|
prefs: ChapterDisplayPrefs,
|
||||||
) {
|
) {
|
||||||
|
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: 'asc' })
|
||||||
|
const lastRead = [...filtered].reverse().find((c) => c.read)
|
||||||
|
const position = lastRead ? filtered.findIndex((c) => c.id === lastRead.id) + 1 : 0
|
||||||
|
|
||||||
const records = this.recordsFor(mangaId)
|
const records = this.recordsFor(mangaId)
|
||||||
if (!records.length) return
|
for (const record of records.filter((r) => (r.lastChapterRead ?? 0) > position)) {
|
||||||
try {
|
try {
|
||||||
await getAdapter().syncTracking(String(mangaId))
|
const completedValue = this._completedStatusFor(record.trackerId)
|
||||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
const isCompleted = completedValue !== null && record.status === completedValue
|
||||||
this.setFor(mangaId, fresh)
|
const belowMax = record.totalChapters > 0 && position < record.totalChapters
|
||||||
} catch {}
|
const readingValue = this._readingStatusFor(record.trackerId)
|
||||||
|
|
||||||
|
if ((isCompleted || belowMax) && readingValue !== null) {
|
||||||
|
const fresh = await getAdapter().updateTrackRecord(String(record.id), {
|
||||||
|
lastChapterRead: position,
|
||||||
|
status: readingValue,
|
||||||
|
})
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
} else {
|
||||||
|
await this.updateChapterProgress(mangaId, record, position)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot sync ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async bootSync() {
|
||||||
|
if (!settingsState.settings.trackerSyncBack) return
|
||||||
|
if (this.allTrackers.length === 0) await this.loadAll()
|
||||||
|
|
||||||
|
const buckets = new Map<number, MangaBucket>()
|
||||||
|
|
||||||
|
for (const tracker of this.allTrackers.filter((t) => t.isLoggedIn)) {
|
||||||
|
const completedValue = this._completedStatusFor(tracker.id)
|
||||||
|
for (const record of tracker.trackRecords.nodes) {
|
||||||
|
const mangaId = record.manga?.id
|
||||||
|
if (!mangaId) continue
|
||||||
|
if (completedValue !== null && record.status === completedValue) continue
|
||||||
|
const bucket = buckets.get(mangaId) ?? { mangaId, records: [] }
|
||||||
|
bucket.records.push(record)
|
||||||
|
buckets.set(mangaId, bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
for (const { mangaId, records } of buckets.values()) {
|
||||||
|
const prefs = { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs
|
||||||
|
|
||||||
|
let chapters: Chapter[]
|
||||||
|
try {
|
||||||
|
chapters = await getAdapter().getChapters(String(mangaId))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshRecords: TrackRecord[] = []
|
||||||
|
for (const record of records) {
|
||||||
|
await delay(BOOT_SYNC_RATE_MS)
|
||||||
|
try {
|
||||||
|
const fresh = await getAdapter().fetchTrackRecord(String(record.id)) as TrackRecord
|
||||||
|
this.patchFor(mangaId, fresh)
|
||||||
|
freshRecords.push(fresh)
|
||||||
|
} catch {
|
||||||
|
freshRecords.push(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncBackFromTracker(
|
||||||
|
freshRecords,
|
||||||
|
chapters,
|
||||||
|
{
|
||||||
|
threshold: settingsState.settings.trackerSyncBackThreshold ?? null,
|
||||||
|
respectScanlatorFilter: settingsState.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(ids, read) => getAdapter().markChaptersRead(ids, read),
|
||||||
|
)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(mangaId: number) {
|
clear(mangaId: number) {
|
||||||
@@ -132,44 +296,22 @@ class TrackingStore {
|
|||||||
next.delete(mangaId)
|
next.delete(mangaId)
|
||||||
this.byManga = next
|
this.byManga = next
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export const trackingState = new TrackingStore()
|
// ── Status helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Standalone export for components that run their own sync loop (e.g. TrackingSettings)
|
private _statusesFor(trackerId: number): { value: number; name: string }[] {
|
||||||
export async function syncBackFromTracker(
|
return this.allTrackers.find((t) => t.id === trackerId)?.statuses ?? []
|
||||||
records: TrackRecord[],
|
|
||||||
chapters: Chapter[],
|
|
||||||
opts: {
|
|
||||||
threshold: number | null
|
|
||||||
respectScanlatorFilter: boolean
|
|
||||||
chapterPrefs: Partial<any>
|
|
||||||
},
|
|
||||||
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
|
|
||||||
): Promise<Chapter[]> {
|
|
||||||
const marked: Chapter[] = []
|
|
||||||
|
|
||||||
const activeScanlators: string[] | null =
|
|
||||||
opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length
|
|
||||||
? opts.chapterPrefs.scanlatorFilter
|
|
||||||
: null
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
const lastRead = record.lastChapterRead ?? 0
|
|
||||||
if (lastRead <= 0) continue
|
|
||||||
|
|
||||||
const toMark = chapters.filter(ch => {
|
|
||||||
if (ch.read) return false
|
|
||||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
|
||||||
return opts.threshold !== null
|
|
||||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold
|
|
||||||
: ch.chapterNumber <= lastRead
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!toMark.length) continue
|
|
||||||
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
|
|
||||||
marked.push(...toMark)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return marked
|
private _completedStatusFor(trackerId: number): number | null {
|
||||||
|
const s = this._statusesFor(trackerId).find((s) => s.name.toLowerCase() === 'completed')
|
||||||
|
return s?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private _readingStatusFor(trackerId: number): number | null {
|
||||||
|
const s = this._statusesFor(trackerId).find((s) => s.name.toLowerCase() === 'reading')
|
||||||
|
return s?.value ?? null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const trackingState = new TrackingState()
|
||||||
@@ -125,6 +125,7 @@ export interface Settings {
|
|||||||
automationEnabled?: boolean; automationEnforceGlobal?: boolean
|
automationEnabled?: boolean; automationEnforceGlobal?: boolean
|
||||||
automationDefaults?: Partial<MangaPrefs>
|
automationDefaults?: Partial<MangaPrefs>
|
||||||
libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean
|
libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean
|
||||||
|
readerContainerized?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
|||||||
+20
-13
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { page } from '$app/stores'
|
||||||
import { appState, app } from '$lib/state/app.svelte'
|
import { appState, app } from '$lib/state/app.svelte'
|
||||||
import { notifications } from '$lib/state/notifications.svelte'
|
import { notifications } from '$lib/state/notifications.svelte'
|
||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
@@ -42,7 +43,10 @@
|
|||||||
bypassed
|
bypassed
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply theme immediately on mount (before first paint if possible)
|
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
|
||||||
|
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
||||||
|
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
polling = true
|
polling = true
|
||||||
pollLoop()
|
pollLoop()
|
||||||
@@ -56,7 +60,6 @@
|
|||||||
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reactive theme application — explicitly pass values so Svelte tracks them
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const theme = settingsState.settings.theme ?? 'dark'
|
const theme = settingsState.settings.theme ?? 'dark'
|
||||||
const customThemes = settingsState.settings.customThemes ?? []
|
const customThemes = settingsState.settings.customThemes ?? []
|
||||||
@@ -96,19 +99,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showApp}
|
{#if showApp}
|
||||||
<div class="frame">
|
{#if strippedLayout}
|
||||||
<div class="shell">
|
{@render children()}
|
||||||
{#if isTauri}
|
{:else}
|
||||||
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
<div class="frame">
|
||||||
{/if}
|
<div class="shell">
|
||||||
<div class="body">
|
{#if isTauri}
|
||||||
<Sidebar />
|
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
||||||
<main class="main">
|
{/if}
|
||||||
{@render children()}
|
<div class="body">
|
||||||
</main>
|
<Sidebar />
|
||||||
|
<main class="main">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if app.settingsOpen}
|
{#if app.settingsOpen}
|
||||||
|
|||||||
@@ -1,19 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
import { page } from "$app/stores";
|
||||||
import { loadChapterPages } from '$lib/request-manager/chapters'
|
import { goto } from "$app/navigation";
|
||||||
import { readerState } from '$lib/state/reader.svelte'
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import Reader from "$lib/components/reader/Reader.svelte";
|
||||||
|
|
||||||
const mangaId = $derived($page.params.mangaId)
|
const mangaId = $derived(Number($page.params.mangaId));
|
||||||
const chapterId = $derived($page.params.chapterId)
|
const chapterId = $derived(Number($page.params.chapterId));
|
||||||
|
|
||||||
let controller = $state<AbortController | null>(null)
|
let booted = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
controller?.abort()
|
const mId = mangaId;
|
||||||
controller = new AbortController()
|
const cId = chapterId;
|
||||||
loadChapterPages(chapterId, controller.signal)
|
if (!mId || !cId) { error = "Invalid route params"; return; }
|
||||||
return () => controller?.abort()
|
|
||||||
})
|
const alreadyLoaded =
|
||||||
|
readerState.activeChapter?.id === cId &&
|
||||||
|
readerState.activeManga?.id === mId &&
|
||||||
|
readerState.activeChapterList.length > 0;
|
||||||
|
|
||||||
|
if (alreadyLoaded) { booted = true; return; }
|
||||||
|
|
||||||
|
const adapter = getAdapter();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [manga, chapterList] = await Promise.all([
|
||||||
|
adapter.getManga(String(mId)),
|
||||||
|
adapter.getChapters(String(mId)),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const chapter = chapterList.find(c => c.id === cId);
|
||||||
|
if (!chapter) throw new Error(`Chapter ${cId} not found in chapter list`);
|
||||||
|
|
||||||
|
readerState.activeManga = manga;
|
||||||
|
readerState.activeChapter = chapter;
|
||||||
|
readerState.activeChapterList = chapterList;
|
||||||
|
booted = true;
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p>Reader {$page.params.mangaId} / {$page.params.chapterId} — stub</p>
|
{#if error}
|
||||||
|
<div class="error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onclick={() => goto(-1 as any)}>Go back</button>
|
||||||
|
</div>
|
||||||
|
{:else if booted}
|
||||||
|
<Reader />
|
||||||
|
{:else}
|
||||||
|
<div class="spinner" aria-label="Loading…" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error button {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Recent from '$lib/components/recent/Recent.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Recent />
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import Tracking from '$lib/components/tracking/Tracking.svelte'
|
||||||
import { loadTrackers } from '$lib/request-manager/tracking'
|
|
||||||
import { trackingState } from '$lib/state/tracking.svelte'
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p>Tracking — stub</p>
|
<Tracking />
|
||||||
Reference in New Issue
Block a user