Feat: TouchScreen Support for SeriesDetail & Modularity Revamp (#29

This commit is contained in:
Youwes09
2026-04-29 18:40:41 -05:00
parent 1bb7da3b22
commit 78573eacb1
8 changed files with 272 additions and 48 deletions
-8
View File
@@ -34,14 +34,6 @@ In-Progress:
- Integrate Tauri JSON for Settings - Integrate Tauri JSON for Settings
- Create Migration Logic for Local Storage - Create Migration Logic for Local Storage
- Integrate Tauri SQLITE for Caching
- Document Caching (What is being Loaded)
- Create Proper Cache-Clearing & Tie into Suwayomi Methods
- Fix Tap-Hold to Context-Menu Instead of Select
- Cap Context-Menu Folders (Sub-In Move-Custom for Additionals)
- Add Multi-Select for SeriesDetail GridView
Notes from last time: Notes from last time:
- Storage has been configured, now just need protocols - Storage has been configured, now just need protocols
+1
View File
@@ -1,2 +1,3 @@
export * from './idle'; export * from './idle';
export * from './zoom'; export * from './zoom';
export * from './touchscreen';
+222
View File
@@ -0,0 +1,222 @@
export interface LongPressOptions {
onLongPress: (e: PointerEvent) => void;
duration?: number;
moveThreshold?: number;
}
export function longPress(node: HTMLElement, opts: LongPressOptions) {
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
let timer: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
let fired = false;
function start(e: PointerEvent) {
if (e.button !== 0 && e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; fired = false;
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
}
function move(e: PointerEvent) {
if (!timer) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
}
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
node.addEventListener("pointerdown", start);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", cancel);
node.addEventListener("pointerleave", cancel);
node.addEventListener("pointercancel",cancel);
function suppressClick(e: MouseEvent) { if (fired) { fired = false; e.preventDefault(); e.stopPropagation(); } }
node.addEventListener("click", suppressClick, true);
return {
get fired() { return fired; },
destroy() {
cancel();
node.removeEventListener("pointerdown", start);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", cancel);
node.removeEventListener("pointerleave", cancel);
node.removeEventListener("pointercancel",cancel);
node.removeEventListener("click", suppressClick, true);
},
};
}
export interface TapOptions {
onTap: (e: PointerEvent) => void;
onDoubleTap?: (e: PointerEvent) => void;
doubleTapGap?: number;
}
export function tap(node: HTMLElement, opts: TapOptions) {
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
let lastTap = 0;
let pending: ReturnType<typeof setTimeout> | null = null;
let startX = 0, startY = 0;
const SLOP = 8;
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
function up(e: PointerEvent) {
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
const now = Date.now();
if (onDoubleTap && now - lastTap < doubleTapGap) {
if (pending) { clearTimeout(pending); pending = null; }
onDoubleTap(e);
lastTap = 0;
} else {
lastTap = now;
if (onDoubleTap) {
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
} else {
onTap(e);
}
}
}
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
}};
}
export interface SwipeOptions {
onSwipeLeft?: (e: PointerEvent) => void;
onSwipeRight?: (e: PointerEvent) => void;
onSwipeUp?: (e: PointerEvent) => void;
onSwipeDown?: (e: PointerEvent) => void;
threshold?: number;
lockAxis?: boolean;
}
export function swipe(node: HTMLElement, opts: SwipeOptions) {
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
let startX = 0, startY = 0, active = false;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
startX = e.clientX; startY = e.clientY; active = true;
node.setPointerCapture(e.pointerId);
}
function up(e: PointerEvent) {
if (!active) return; active = false;
const dx = e.clientX - startX, dy = e.clientY - startY;
const ax = Math.abs(dx), ay = Math.abs(dy);
if (Math.max(ax, ay) < threshold) return;
if (lockAxis && ax > ay) {
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
} else if (lockAxis && ay >= ax) {
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
} else {
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
}
}
function cancel() { active = false; }
node.addEventListener("pointerdown", down);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", cancel);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", cancel);
}};
}
export interface PinchOptions {
onPinch: (scale: number, origin: { x: number; y: number }) => void;
onPinchEnd?: (scale: number) => void;
}
export function pinch(node: HTMLElement, opts: PinchOptions) {
const { onPinch, onPinchEnd } = opts;
const pointers = new Map<number, PointerEvent>();
let initDist = 0, initMid = { x: 0, y: 0 };
function dist(a: PointerEvent, b: PointerEvent) {
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function mid(a: PointerEvent, b: PointerEvent) {
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
}
function down(e: PointerEvent) {
pointers.set(e.pointerId, e);
node.setPointerCapture(e.pointerId);
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
initDist = dist(a, b);
initMid = mid(a, b);
}
}
function move(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, e);
if (pointers.size !== 2 || initDist === 0) return;
const [a, b] = [...pointers.values()];
onPinch(dist(a, b) / initDist, mid(a, b));
}
function up(e: PointerEvent) {
if (pointers.size === 2 && onPinchEnd) {
const [a, b] = [...pointers.values()];
onPinchEnd(dist(a, b) / initDist);
}
pointers.delete(e.pointerId);
initDist = 0;
}
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", up);
}};
}
export interface DragScrollOptions {
direction?: "x" | "y" | "both";
onDragStart?: () => void;
onDragEnd?: () => void;
}
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
const { direction = "both", onDragStart, onDragEnd } = opts;
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
function down(e: PointerEvent) {
if (e.pointerType === "mouse") return;
active = true;
startX = e.clientX; startY = e.clientY;
scrollX = node.scrollLeft; scrollY = node.scrollTop;
node.setPointerCapture(e.pointerId);
onDragStart?.();
}
function move(e: PointerEvent) {
if (!active) return;
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
}
function up() { if (active) { active = false; onDragEnd?.(); } }
node.addEventListener("pointerdown", down);
node.addEventListener("pointermove", move);
node.addEventListener("pointerup", up);
node.addEventListener("pointercancel", up);
return { destroy() {
node.removeEventListener("pointerdown", down);
node.removeEventListener("pointermove", move);
node.removeEventListener("pointerup", up);
node.removeEventListener("pointercancel", up);
}};
}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte"; import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { DownloadQueueItem } from "@types/index"; import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue"; import { pageProgress } from "../lib/downloadQueue";
@@ -24,6 +25,12 @@
const prog = $derived(pageProgress(item.progress, pages)); const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR"); const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100)); const pct = $derived(Math.round(item.progress * 100));
function rowLongPress(node: HTMLElement) {
return longPress(node, {
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
});
}
</script> </script>
<div <div
@@ -32,6 +39,7 @@
class:row-error={isError} class:row-error={isError}
class:row-selected={isSelected} class:row-selected={isSelected}
class:row-removing={isRemoving} class:row-removing={isRemoving}
use:rowLongPress
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }} onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
> >
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
+20 -29
View File
@@ -13,6 +13,7 @@
import { sortLibrary } from "../lib/librarySort"; import { sortLibrary } from "../lib/librarySort";
import { startLibraryUpdate } from "../lib/libraryUpdater"; import { startLibraryUpdate } from "../lib/libraryUpdater";
import { createPaginator } from "@core/algorithms/paginate"; import { createPaginator } from "@core/algorithms/paginate";
import { longPress } from "@core/ui/touchscreen";
import { import {
store, setCategories, setLibraryUpdates, addToast, store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters, setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
@@ -197,34 +198,28 @@
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); } function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); } function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
let longPressTimer: ReturnType<typeof setTimeout> | null = null; let cardLongPressFired = false;
let longPressFired = false;
let emptyLongPressTimer: ReturnType<typeof setTimeout> | null = null;
function onRootPointerDown(e: PointerEvent) { function rootLongPressAction(node: HTMLElement) {
if (e.button !== 0) return; return longPress(node, {
if ((e.target as HTMLElement).closest("button, .card")) return; onLongPress(e) {
emptyLongPressTimer = setTimeout(() => { if ((e.target as HTMLElement).closest("button, .card")) return;
emptyLongPressTimer = null; emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }; },
}, 500); });
} }
function onRootPointerUp() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } }
function onRootPointerLeave() { if (emptyLongPressTimer) { clearTimeout(emptyLongPressTimer); emptyLongPressTimer = null; } } function cardLongPress(node: HTMLElement, m: Manga) {
function onCardPointerDown(e: PointerEvent, m: Manga) { return longPress(node, {
if (e.button !== 0) return; onLongPress(e) {
longPressFired = false; cardLongPressFired = true;
longPressTimer = setTimeout(() => { ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
longPressTimer = null; },
longPressFired = true; });
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m };
}, 500);
} }
function onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardClick(e: MouseEvent, m: Manga) { function onCardClick(e: MouseEvent, m: Manga) {
if (longPressFired) { longPressFired = false; return; } if (cardLongPressFired) { cardLongPressFired = false; return; }
if (selectMode) { toggleSelect(m.id); return; } if (selectMode) { toggleSelect(m.id); return; }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; } if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
store.activeManga = m; store.activeManga = m;
@@ -523,14 +518,12 @@
class="root" class="root"
role="presentation" role="presentation"
bind:this={scrollEl} bind:this={scrollEl}
use:rootLongPressAction
oncontextmenu={(e) => { oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("button")) return; if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); e.preventDefault();
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }; emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H };
}} }}
onpointerdown={onRootPointerDown}
onpointerup={onRootPointerUp}
onpointerleave={onRootPointerLeave}
> >
{#if store.settings.libraryBranches ?? true} {#if store.settings.libraryBranches ?? true}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true"> <svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
@@ -624,9 +617,7 @@
libraryFilter={tab} libraryFilter={tab}
onCardClick={onCardClick} onCardClick={onCardClick}
onCardContextMenu={openCtx} onCardContextMenu={openCtx}
onCardPointerDown={onCardPointerDown} onCardLongPress={cardLongPress}
onCardPointerUp={onCardPointerUp}
onCardPointerLeave={onCardPointerLeave}
onLoadMore={loadMore} onLoadMore={loadMore}
onRetry={() => retryCount++} onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode} onExitSelectMode={exitSelectMode}
@@ -2,6 +2,7 @@
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte"; import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver"; import { resolvedCover } from "@core/cover/coverResolver";
import { longPress } from "@core/ui/touchscreen";
import type { Manga, Category } from "@types"; import type { Manga, Category } from "@types";
interface Props { interface Props {
@@ -22,9 +23,7 @@
visibleCategories: Category[]; visibleCategories: Category[];
onCardClick: (e: MouseEvent, m: Manga) => void; onCardClick: (e: MouseEvent, m: Manga) => void;
onCardContextMenu: (e: MouseEvent, m: Manga) => void; onCardContextMenu: (e: MouseEvent, m: Manga) => void;
onCardPointerDown: (e: PointerEvent, m: Manga) => void; onCardLongPress: (node: HTMLElement, m: Manga) => ReturnType<typeof longPress>;
onCardPointerUp: () => void;
onCardPointerLeave: () => void;
onLoadMore: () => void; onLoadMore: () => void;
onRetry: () => void; onRetry: () => void;
onExitSelectMode: () => void; onExitSelectMode: () => void;
@@ -38,7 +37,7 @@
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds, visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter, hasMore, remainingCount, renderLimit, cropCovers, statsAlways, libraryFilter,
bulkWorking, visibleCategories, bulkWorking, visibleCategories,
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave, onCardClick, onCardContextMenu, onCardLongPress,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate, onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
}: Props = $props(); }: Props = $props();
@@ -122,11 +121,9 @@
class:card-selected={isSelected} class:card-selected={isSelected}
class:select-mode={selectMode} class:select-mode={selectMode}
class:anims={anims} class:anims={anims}
use:onCardLongPress={m}
onclick={(e) => onCardClick(e, m)} onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => onCardContextMenu(e, m)} oncontextmenu={(e) => onCardContextMenu(e, m)}
onpointerdown={(e) => onCardPointerDown(e, m)}
onpointerup={onCardPointerUp}
onpointerleave={onCardPointerLeave}
> >
<div class="cover-wrap" class:completed={isCompleted}> <div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" /> <Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
@@ -2,6 +2,7 @@
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte"; import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte"; import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte"; import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { Chapter } from "@types"; import type { Chapter } from "@types";
interface Props { interface Props {
@@ -32,6 +33,13 @@
const hasSelection = $derived(selectedIds.size > 0); const hasSelection = $derived(selectedIds.size > 0);
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
const [ch, idx] = param;
return longPress(node, {
onLongPress(e) { ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx }; },
});
}
function formatDate(ts: string | null | undefined): string { function formatDate(ts: string | null | undefined): string {
if (!ts) return ""; if (!ts) return "";
const n = Number(ts); const n = Number(ts);
@@ -58,9 +66,11 @@
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)} {@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)} onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}
>{#if isGridSelected}<span class="grid-cell-check"></span>{/if}
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if} {#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if} {#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
@@ -74,6 +84,7 @@
{@const isSelected = selectedIds.has(ch.id)} {@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected} <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
use:chapterLongPress={[ch, idxInSorted]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)} onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))} onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
@@ -164,6 +175,7 @@
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; } .grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); } .grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; } .grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-check { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--accent-fg); pointer-events: none; }
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; } .pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
@@ -4,7 +4,7 @@
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { import {
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown, CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga"; import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
import { GET_CHAPTERS } from "@api/queries/chapters"; import { GET_CHAPTERS } from "@api/queries/chapters";
@@ -452,6 +452,7 @@
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1; const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
return [ return [
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) }, { label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
{ label: "Select", icon: CheckSquare, onClick: () => { const next = new Set(selectedIds); next.add(ch.id); selectedIds = next; } },
{ separator: true }, { separator: true },
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 }, { label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 }, { label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },