mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
chore: migrated context + series-detail + migrate
This commit is contained in:
+7
-5
@@ -9,12 +9,13 @@
|
|||||||
activeDownloads, addToast,
|
activeDownloads, addToast,
|
||||||
} from "./store";
|
} from "./store";
|
||||||
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
|
||||||
import Layout from "./components/layout/Layout.svelte";
|
import Layout from "./components/layout/Layout.svelte";
|
||||||
import Reader from "./components/reader/Reader.svelte";
|
import Reader from "./components/reader/Reader.svelte";
|
||||||
import Settings from "./components/settings/Settings.svelte";
|
import Settings from "./components/settings/Settings.svelte";
|
||||||
import TitleBar from "./components/layout/TitleBar.svelte";
|
import TitleBar from "./components/layout/TitleBar.svelte";
|
||||||
import Toaster from "./components/layout/Toaster.svelte";
|
import Toaster from "./components/layout/Toaster.svelte";
|
||||||
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
import SplashScreen from "./components/layout/SplashScreen.svelte";
|
||||||
|
import MangaPreview from "./components/shared/MangaPreview.svelte";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 30;
|
const MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@
|
|||||||
{#if $activeChapter}<Reader />{:else}<Layout />{/if}
|
{#if $activeChapter}<Reader />{:else}<Layout />{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if $settingsOpen}<Settings />{/if}
|
{#if $settingsOpen}<Settings />{/if}
|
||||||
|
<MangaPreview />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1 +1,180 @@
|
|||||||
<div>Downloads.svelte</div>
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
|
import { activeDownloads } from "../../store";
|
||||||
|
import type { DownloadStatus } from "../../lib/types";
|
||||||
|
|
||||||
|
let status: DownloadStatus | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let togglingPlay = false;
|
||||||
|
let clearing = false;
|
||||||
|
let dequeueing = new Set<number>();
|
||||||
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
function applyStatus(ds: DownloadStatus) {
|
||||||
|
status = ds;
|
||||||
|
activeDownloads.set(ds.queue.map((item) => ({
|
||||||
|
chapterId: item.chapter.id,
|
||||||
|
mangaId: item.chapter.mangaId,
|
||||||
|
progress: item.progress,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
|
||||||
|
.then((d) => applyStatus(d.downloadStatus))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => { poll(); interval = setInterval(poll, 2000); });
|
||||||
|
onDestroy(() => clearInterval(interval));
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
if (togglingPlay) return;
|
||||||
|
togglingPlay = true;
|
||||||
|
const wasRunning = status?.state === "STARTED";
|
||||||
|
if (status) status = { ...status, state: wasRunning ? "STOPPED" : "STARTED" };
|
||||||
|
try {
|
||||||
|
if (wasRunning) {
|
||||||
|
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
|
||||||
|
applyStatus(d.stopDownloader.downloadStatus);
|
||||||
|
} else {
|
||||||
|
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
|
||||||
|
applyStatus(d.startDownloader.downloadStatus);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); poll(); }
|
||||||
|
finally { togglingPlay = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
if (clearing) return;
|
||||||
|
clearing = true;
|
||||||
|
if (status) status = { ...status, queue: [] };
|
||||||
|
activeDownloads.set([]);
|
||||||
|
try {
|
||||||
|
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
|
||||||
|
applyStatus(d.clearDownloader.downloadStatus);
|
||||||
|
} catch (e) { console.error(e); poll(); }
|
||||||
|
finally { clearing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dequeue(chapterId: number) {
|
||||||
|
if (dequeueing.has(chapterId)) return;
|
||||||
|
dequeueing = new Set(dequeueing).add(chapterId);
|
||||||
|
if (status) status = { ...status, queue: status.queue.filter((i) => i.chapter.id !== chapterId) };
|
||||||
|
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); poll(); }
|
||||||
|
catch (e) { console.error(e); poll(); }
|
||||||
|
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$: queue = status?.queue ?? [];
|
||||||
|
$: isRunning = status?.state === "STARTED";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="heading">Downloads</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn" class:loading={togglingPlay} on:click={togglePlay}
|
||||||
|
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||||
|
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if isRunning}<Pause size={14} weight="fill" />
|
||||||
|
{:else}<Play size={14} weight="fill" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" class:loading={clearing} on:click={clear}
|
||||||
|
disabled={clearing || queue.length === 0} title="Clear queue">
|
||||||
|
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}<Trash size={14} weight="regular" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-dot" class:active={isRunning}></div>
|
||||||
|
<span class="status-text">
|
||||||
|
{togglingPlay ? (isRunning ? "Pausing…" : "Starting…") : isRunning ? "Downloading" : "Paused"}
|
||||||
|
</span>
|
||||||
|
<span class="status-count">{queue.length} queued</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||||
|
{:else if queue.length === 0}
|
||||||
|
<div class="empty">Queue is empty.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="list">
|
||||||
|
{#each queue as item, i (item.chapter.id)}
|
||||||
|
{@const isActive = i === 0 && isRunning}
|
||||||
|
{@const pages = item.chapter.pageCount ?? 0}
|
||||||
|
{@const done = Math.round(item.progress * pages)}
|
||||||
|
{@const manga = item.chapter.manga}
|
||||||
|
{@const isRemoving = dequeueing.has(item.chapter.id)}
|
||||||
|
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
|
||||||
|
{#if manga?.thumbnailUrl}
|
||||||
|
<div class="thumb">
|
||||||
|
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="info">
|
||||||
|
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
|
||||||
|
<span class="chapter-name">{item.chapter.name}</span>
|
||||||
|
{#if pages > 0}
|
||||||
|
<span class="pages-label">{isActive ? `${done} / ${pages} pages` : `${pages} pages`}</span>
|
||||||
|
{/if}
|
||||||
|
{#if isActive}
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="row-right">
|
||||||
|
<span class="state-label">{item.state}</span>
|
||||||
|
{#if !isActive}
|
||||||
|
<button class="remove-btn" on:click={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
|
||||||
|
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
||||||
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
||||||
|
.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; }
|
||||||
|
.header-actions { display: flex; gap: var(--sp-2); }
|
||||||
|
.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); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||||
|
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); margin-bottom: var(--sp-4); }
|
||||||
|
.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; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
|
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||||
|
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); transition: border-color var(--t-fast), opacity var(--t-base); }
|
||||||
|
.row.row-active { border-color: var(--accent-dim); }
|
||||||
|
.row.row-removing { opacity: 0.4; pointer-events: none; }
|
||||||
|
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
|
.thumb-img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
|
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.progress-wrap { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; margin-top: 4px; }
|
||||||
|
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||||
|
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.remove-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||||
|
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
.remove-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="root"
|
class="root"
|
||||||
|
role="presentation"
|
||||||
bind:this={scrollEl}
|
bind:this={scrollEl}
|
||||||
on:contextmenu={(e) => {
|
on:contextmenu={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) return;
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||||
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
|
||||||
|
import type { Manga, Source, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
|
export let manga: Manga;
|
||||||
|
export let currentChapters: Chapter[];
|
||||||
|
export let onClose: () => void;
|
||||||
|
export let onMigrated: (newManga: Manga) => void;
|
||||||
|
|
||||||
|
type Step = "source" | "search" | "confirm";
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
manga: Manga;
|
||||||
|
chapters: Chapter[];
|
||||||
|
readCount: number;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleSimilarity(a: string, b: string): number {
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||||
|
const wordsA = new Set(norm(a));
|
||||||
|
const wordsB = new Set(norm(b));
|
||||||
|
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||||
|
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||||
|
const union = new Set([...wordsA, ...wordsB]).size;
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
let step: Step = "source";
|
||||||
|
let sources: Source[] = [];
|
||||||
|
let loadingSources = true;
|
||||||
|
let selectedSource: Source | null = null;
|
||||||
|
let query = manga.title;
|
||||||
|
let results: { manga: Manga; similarity: number }[] = [];
|
||||||
|
let searching = false;
|
||||||
|
let selectedMatch: Match | null = null;
|
||||||
|
let loadingMatchId: number | null = null;
|
||||||
|
let migrating = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
$: readCount = currentChapters.filter((c) => c.isRead).length;
|
||||||
|
$: totalCount = currentChapters.length;
|
||||||
|
$: chapterDiff = selectedMatch ? selectedMatch.chapters.length - totalCount : 0;
|
||||||
|
$: STEPS = (["source", "search", "confirm"] as Step[]);
|
||||||
|
$: stepIdx = STEPS.indexOf(step);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then((d) => sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => loadingSources = false);
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => window.removeEventListener("keydown", onKey));
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
|
||||||
|
async function searchSource(src: Source, q: string) {
|
||||||
|
if (!src || !q.trim()) return;
|
||||||
|
searching = true; results = []; error = null;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
|
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||||
|
});
|
||||||
|
const scored = d.fetchSourceManga.mangas.map((m) => ({
|
||||||
|
manga: m,
|
||||||
|
similarity: titleSimilarity(manga.title, m.title),
|
||||||
|
}));
|
||||||
|
scored.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
results = scored;
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSource(src: Source) {
|
||||||
|
selectedSource = src;
|
||||||
|
step = "search";
|
||||||
|
searchSource(src, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMatch(m: Manga, similarity: number) {
|
||||||
|
loadingMatchId = m.id; error = null;
|
||||||
|
try {
|
||||||
|
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
|
||||||
|
const chapters = d.fetchChapters.chapters;
|
||||||
|
const matchReadCount = chapters.filter((c) => {
|
||||||
|
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||||
|
return old?.isRead;
|
||||||
|
}).length;
|
||||||
|
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
|
||||||
|
step = "confirm";
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loadingMatchId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
if (!selectedMatch) return;
|
||||||
|
migrating = true; error = null;
|
||||||
|
try {
|
||||||
|
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||||
|
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||||
|
|
||||||
|
const toMarkRead: number[] = [];
|
||||||
|
const toMarkBookmarked: number[] = [];
|
||||||
|
const progressUpdates: { id: number; lastPageRead: number }[] = [];
|
||||||
|
|
||||||
|
for (const nc of newChapters) {
|
||||||
|
const key = Math.round(nc.chapterNumber * 100);
|
||||||
|
const old = oldByNum.get(key);
|
||||||
|
if (!old) continue;
|
||||||
|
if (old.isRead) toMarkRead.push(nc.id);
|
||||||
|
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||||
|
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||||
|
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toMarkRead.length)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||||
|
if (toMarkBookmarked.length)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||||
|
for (const { id, lastPageRead } of progressUpdates)
|
||||||
|
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||||
|
|
||||||
|
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||||
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||||
|
|
||||||
|
onMigrated({ ...newManga, inLibrary: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message;
|
||||||
|
migrating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div class="overlay" on:click={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div class="modal">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title">
|
||||||
|
<span class="modal-title-label">Migrate source</span>
|
||||||
|
<span class="modal-title-manga">{manga.title}</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" on:click={onClose}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step indicators -->
|
||||||
|
<div class="steps">
|
||||||
|
{#each STEPS as st, i}
|
||||||
|
<div class="step" class:step-active={step === st} class:step-done={i < stepIdx}>
|
||||||
|
<span class="step-dot">
|
||||||
|
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
|
||||||
|
</span>
|
||||||
|
<span class="step-label">
|
||||||
|
{st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="body">
|
||||||
|
|
||||||
|
<!-- Step 1: Pick source -->
|
||||||
|
{#if step === "source"}
|
||||||
|
<div class="source-list">
|
||||||
|
{#if loadingSources}
|
||||||
|
<div class="centered">
|
||||||
|
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||||
|
</div>
|
||||||
|
{:else if sources.length === 0}
|
||||||
|
<div class="centered"><span class="hint">No other sources installed.</span></div>
|
||||||
|
{:else}
|
||||||
|
{#each sources as src}
|
||||||
|
<button
|
||||||
|
class="source-row"
|
||||||
|
class:source-row-active={selectedSource?.id === src.id}
|
||||||
|
on:click={() => pickSource(src)}>
|
||||||
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
|
||||||
|
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<div class="source-info">
|
||||||
|
<span class="source-name">{src.displayName}</span>
|
||||||
|
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={13} weight="light" class="source-arrow" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Search & pick match -->
|
||||||
|
{:else if step === "search"}
|
||||||
|
<div class="search-step">
|
||||||
|
|
||||||
|
<!-- Source context pill -->
|
||||||
|
{#if selectedSource}
|
||||||
|
<div class="search-context">
|
||||||
|
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
|
||||||
|
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||||
|
<button class="search-context-change" on:click={() => { step = "source"; results = []; }}>Change</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="search-row">
|
||||||
|
<div class="search-bar">
|
||||||
|
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||||
|
<input class="search-input" bind:value={query}
|
||||||
|
on:keydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||||
|
placeholder="Search title…" autofocus />
|
||||||
|
</div>
|
||||||
|
<button class="search-btn"
|
||||||
|
on:click={() => selectedSource && searchSource(selectedSource, query)}
|
||||||
|
disabled={searching || !selectedSource}>
|
||||||
|
{#if searching}
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<MagnifyingGlass size={12} weight="bold" /> Search
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
||||||
|
|
||||||
|
<div class="results">
|
||||||
|
{#if searching}
|
||||||
|
{#each Array(6) as _, i}
|
||||||
|
<div class="sk-result">
|
||||||
|
<div class="skeleton sk-cover"></div>
|
||||||
|
<div class="sk-meta">
|
||||||
|
<div class="skeleton sk-title"></div>
|
||||||
|
<div class="skeleton sk-title" style="width:40%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each results as { manga: m, similarity }, idx}
|
||||||
|
<button class="result-row"
|
||||||
|
on:click={() => selectMatch(m, similarity)}
|
||||||
|
disabled={loadingMatchId !== null}>
|
||||||
|
<div class="result-cover-wrap">
|
||||||
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
|
||||||
|
</div>
|
||||||
|
<div class="result-info">
|
||||||
|
<span class="result-title">{m.title}</span>
|
||||||
|
<div class="result-meta">
|
||||||
|
{#if idx === 0 && similarity > 0.5}
|
||||||
|
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
|
||||||
|
{/if}
|
||||||
|
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
|
||||||
|
<span class="sim-label">{Math.round(similarity * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if loadingMatchId === m.id}
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
|
||||||
|
{:else}
|
||||||
|
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if !searching && results.length === 0 && !error}
|
||||||
|
<div class="centered">
|
||||||
|
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Confirm -->
|
||||||
|
{:else if step === "confirm" && selectedMatch}
|
||||||
|
<div class="confirm-step">
|
||||||
|
<div class="confirm-row">
|
||||||
|
<div class="confirm-manga">
|
||||||
|
<div class="confirm-cover-wrap">
|
||||||
|
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
|
||||||
|
</div>
|
||||||
|
<p class="confirm-title">{manga.title}</p>
|
||||||
|
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
|
<span class="confirm-tag">Current</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-divider">
|
||||||
|
<ArrowRight size={16} weight="light" class="confirm-arrow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-manga">
|
||||||
|
<div class="confirm-cover-wrap">
|
||||||
|
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
|
||||||
|
</div>
|
||||||
|
<p class="confirm-title">{selectedMatch.manga.title}</p>
|
||||||
|
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
|
<span class="confirm-tag confirm-tag-new">New</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-stats">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Title match</span>
|
||||||
|
<span class="stat-val"
|
||||||
|
class:stat-good={selectedMatch.similarity > 0.7}
|
||||||
|
class:stat-warn={selectedMatch.similarity > 0.4 && selectedMatch.similarity <= 0.7}
|
||||||
|
class:stat-bad={selectedMatch.similarity <= 0.4}>
|
||||||
|
{Math.round(selectedMatch.similarity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Chapters on new source</span>
|
||||||
|
<span class="stat-val" class:stat-warn={chapterDiff < -5}>
|
||||||
|
{selectedMatch.chapters.length}
|
||||||
|
{#if chapterDiff !== 0}
|
||||||
|
<span class="chapter-diff">{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Read progress to carry over</span>
|
||||||
|
<span class="stat-val">{selectedMatch.readCount} / {readCount} chapters</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if chapterDiff < -5}
|
||||||
|
<div class="warn-box">
|
||||||
|
<Warning size={13} weight="light" />
|
||||||
|
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="confirm-note">The current entry will be removed from your library. Downloads are not transferred.</p>
|
||||||
|
|
||||||
|
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
|
||||||
|
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="back-btn" on:click={() => step = "search"} disabled={migrating}>Back</button>
|
||||||
|
<button class="migrate-btn" on:click={migrate} disabled={migrating}>
|
||||||
|
{#if migrating}
|
||||||
|
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
|
||||||
|
{:else}
|
||||||
|
<Check size={13} weight="bold" /> Migrate
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
|
||||||
|
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
|
||||||
|
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.modal-title { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.modal-title-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
|
.modal-title-manga { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||||
|
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||||
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.4; transition: opacity var(--t-base); }
|
||||||
|
.step + .step::before { content: "›"; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); }
|
||||||
|
.step-active { opacity: 1; }
|
||||||
|
.step-done { opacity: 0.6; }
|
||||||
|
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
|
||||||
|
.step-active .step-label { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
|
||||||
|
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
/* Source list */
|
||||||
|
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||||
|
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||||
|
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||||
|
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); }
|
||||||
|
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||||
|
|
||||||
|
/* Search step */
|
||||||
|
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
|
||||||
|
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||||
|
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
|
||||||
|
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
|
||||||
|
.search-context-change:hover { opacity: 0.75; }
|
||||||
|
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||||
|
.search-bar { 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: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
|
||||||
|
.search-bar:focus-within { border-color: var(--border-strong); }
|
||||||
|
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||||
|
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); padding: 7px 0; }
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
|
||||||
|
.search-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.search-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
|
||||||
|
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
|
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
|
.result-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
|
||||||
|
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
|
.sim-bar { width: 48px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; display: inline-block; }
|
||||||
|
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.2s ease; }
|
||||||
|
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); }
|
||||||
|
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
|
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
|
.sk-title { height: 13px; width: 65%; border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
/* Confirm step */
|
||||||
|
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
|
||||||
|
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
|
||||||
|
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
|
||||||
|
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||||
|
.confirm-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
|
||||||
|
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||||
|
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
:global(.confirm-arrow) { color: var(--text-faint); }
|
||||||
|
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
|
||||||
|
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
|
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
|
||||||
|
.stat-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||||
|
.stat-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||||
|
.stat-good { color: var(--color-success) !important; }
|
||||||
|
.stat-warn { color: #d97706 !important; }
|
||||||
|
.stat-bad { color: var(--color-error) !important; }
|
||||||
|
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
|
||||||
|
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); }
|
||||||
|
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
|
||||||
|
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; }
|
||||||
|
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||||
|
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||||
|
.back-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||||
|
.migrate-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader } from "../../store";
|
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader } from "../../store";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
|
import MigrateModal from "./MigrateModal.svelte";
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
let showRange = false;
|
let showRange = false;
|
||||||
let dlDropRef: HTMLDivElement;
|
let dlDropRef: HTMLDivElement;
|
||||||
let folderPickerRef: HTMLDivElement;
|
let folderPickerRef: HTMLDivElement;
|
||||||
|
let migrateOpen = false;
|
||||||
|
|
||||||
let mangaAbort: AbortController | null = null;
|
let mangaAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
let chapterAbort: AbortController | null = null;
|
||||||
@@ -316,9 +318,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $activeManga}
|
{#if $activeManga}
|
||||||
<div class="root" on:contextmenu|preventDefault>
|
<div class="root" role="presentation" on:contextmenu|preventDefault>
|
||||||
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<button class="back" on:click={() => activeManga.set(null)}>
|
<button class="back" on:click={() => activeManga.set(null)}>
|
||||||
<ArrowLeft size={13} weight="light" /> Back
|
<ArrowLeft size={13} weight="light" /> Back
|
||||||
@@ -410,7 +412,7 @@
|
|||||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
||||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
||||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
||||||
<button class="migrate-btn" on:click={() => {}}>
|
<button class="migrate-btn" on:click={() => migrateOpen = true}>
|
||||||
<ArrowsClockwise size={12} weight="light" /> Switch source
|
<ArrowsClockwise size={12} weight="light" /> Switch source
|
||||||
</button>
|
</button>
|
||||||
{#if downloadedCount > 0}
|
{#if downloadedCount > 0}
|
||||||
@@ -424,7 +426,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chapter list -->
|
|
||||||
<div class="list-wrap">
|
<div class="list-wrap">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="list-header-left">
|
<div class="list-header-left">
|
||||||
@@ -441,7 +443,7 @@
|
|||||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Folder picker -->
|
|
||||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||||
<button class="icon-btn" class:active={hasFolders} on:click={() => folderPickerOpen = !folderPickerOpen}>
|
<button class="icon-btn" class:active={hasFolders} on:click={() => folderPickerOpen = !folderPickerOpen}>
|
||||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||||
@@ -476,7 +478,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Jump to chapter -->
|
|
||||||
{#if chapters.length > 1}
|
{#if chapters.length > 1}
|
||||||
<div class="jump-wrap">
|
<div class="jump-wrap">
|
||||||
{#if !jumpOpen}
|
{#if !jumpOpen}
|
||||||
@@ -504,7 +506,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
|
||||||
{#if chapters.length > 0}
|
{#if chapters.length > 0}
|
||||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||||
<button class="icon-btn" on:click={() => dlOpen = !dlOpen}>
|
<button class="icon-btn" on:click={() => dlOpen = !dlOpen}>
|
||||||
@@ -631,6 +633,19 @@
|
|||||||
{#if ctx}
|
{#if ctx}
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if migrateOpen && manga}
|
||||||
|
<MigrateModal
|
||||||
|
{manga}
|
||||||
|
currentChapters={chapters}
|
||||||
|
onClose={() => migrateOpen = false}
|
||||||
|
onMigrated={(newManga) => {
|
||||||
|
activeManga.set(newManga);
|
||||||
|
migrateOpen = false;
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<script context="module">
|
<script context="module">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
|
||||||
import type { FitMode } from "../../store";
|
import type { FitMode } from "../../store";
|
||||||
|
|
||||||
// ── Page cache ────────────────────────────────────────────────────────────────
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
const cacheOrder: number[] = [];
|
const cacheOrder: number[] = [];
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Image helpers ─────────────────────────────────────────────────────────────
|
|
||||||
const aspectCache = new Map<string, number>();
|
const aspectCache = new Map<string, number>();
|
||||||
|
|
||||||
function preloadImage(url: string) { new Image().src = url; }
|
function preloadImage(url: string) { new Image().src = url; }
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
|
||||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
hideTimer = setTimeout(() => uiVisible = false, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load chapter ──────────────────────────────────────────────────────────────
|
|
||||||
$: if ($activeChapter) {
|
$: if ($activeChapter) {
|
||||||
loadChapter($activeChapter.id);
|
loadChapter($activeChapter.id);
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Append next chapter ───────────────────────────────────────────────────────
|
|
||||||
function appendNextChapter() {
|
function appendNextChapter() {
|
||||||
if (appending) return;
|
if (appending) return;
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
.catch(() => { appending = false; });
|
.catch(() => { appending = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll tracking ───────────────────────────────────────────────────────────
|
|
||||||
function setupScrollTracking() {
|
function setupScrollTracking() {
|
||||||
if (!containerEl || style !== "longstrip") return;
|
if (!containerEl || style !== "longstrip") return;
|
||||||
const READ_LINE_PCT = 0.20;
|
const READ_LINE_PCT = 0.20;
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
|
||||||
function advanceGroup(forward: boolean) {
|
function advanceGroup(forward: boolean) {
|
||||||
if (!pageGroups.length) return;
|
if (!pageGroups.length) return;
|
||||||
const gi = pageGroups.findIndex((g) => g.includes($pageNumber));
|
const gi = pageGroups.findIndex((g) => g.includes($pageNumber));
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── History + auto-mark (non-longstrip) ──────────────────────────────────────
|
|
||||||
$: if ($activeChapter && lastPage && $activeManga) {
|
$: if ($activeChapter && lastPage && $activeManga) {
|
||||||
addHistory({
|
addHistory({
|
||||||
mangaId: $activeManga.id, mangaTitle: $activeManga.title,
|
mangaId: $activeManga.id, mangaTitle: $activeManga.title,
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Double-page grouping ──────────────────────────────────────────────────────
|
|
||||||
$: if (style === "double" && $pageUrls.length) {
|
$: if (style === "double" && $pageUrls.length) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const snap = $pageUrls;
|
const snap = $pageUrls;
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
});
|
});
|
||||||
} else { pageGroups = []; }
|
} else { pageGroups = []; }
|
||||||
|
|
||||||
// ── Preload pages ─────────────────────────────────────────────────────────────
|
|
||||||
$: {
|
$: {
|
||||||
const ahead = $settings.preloadPages ?? 3;
|
const ahead = $settings.preloadPages ?? 3;
|
||||||
for (let i = 1; i <= ahead; i++) { const url = $pageUrls[$pageNumber - 1 + i]; if (url) decodeImage(url); }
|
for (let i = 1; i <= ahead; i++) { const url = $pageUrls[$pageNumber - 1 + i]; if (url) decodeImage(url); }
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
if (behind) preloadImage(behind);
|
if (behind) preloadImage(behind);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prefetch next chapters ────────────────────────────────────────────────────
|
|
||||||
$: if ($activeChapter && $activeChapterList.length) {
|
$: if ($activeChapter && $activeChapterList.length) {
|
||||||
const idx = $activeChapterList.findIndex((c) => c.id === $activeChapter!.id);
|
const idx = $activeChapterList.findIndex((c) => c.id === $activeChapter!.id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -388,7 +388,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rebuild strip on autoNext toggle ─────────────────────────────────────────
|
|
||||||
$: if (style === "longstrip" && $pageUrls.length && $activeChapter) {
|
$: if (style === "longstrip" && $pageUrls.length && $activeChapter) {
|
||||||
appended = new Set([$activeChapter.id]);
|
appended = new Set([$activeChapter.id]);
|
||||||
appending = false;
|
appending = false;
|
||||||
@@ -402,18 +402,18 @@
|
|||||||
if (containerEl) containerEl.scrollTop = 0;
|
if (containerEl) containerEl.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll to top on chapter/page change ─────────────────────────────────────
|
|
||||||
$: if ($activeChapter?.id && containerEl) containerEl.scrollTop = 0;
|
$: if ($activeChapter?.id && containerEl) containerEl.scrollTop = 0;
|
||||||
$: if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0;
|
$: if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0;
|
||||||
|
|
||||||
// ── Ctrl+scroll zoom ─────────────────────────────────────────────────────────
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (!e.ctrlKey) return;
|
if (!e.ctrlKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
updateSettings({ maxPageWidth: Math.min(2400, Math.max(200, maxW + (e.deltaY < 0 ? 50 : -50))) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keybinds ──────────────────────────────────────────────────────────────────
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
const kb = $settings.keybinds ?? DEFAULT_KEYBINDS;
|
const kb = $settings.keybinds ?? DEFAULT_KEYBINDS;
|
||||||
@@ -495,9 +495,9 @@
|
|||||||
: [$pageNumber];
|
: [$pageNumber];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root" on:mousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
<div class="root" role="presentation" on:mousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
|
||||||
|
|
||||||
|
|
||||||
<!-- Topbar -->
|
|
||||||
<div class="topbar" class:hidden={!uiVisible}>
|
<div class="topbar" class:hidden={!uiVisible}>
|
||||||
<button class="icon-btn" on:click={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
<button class="icon-btn" on:click={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||||
<button class="icon-btn" on:click={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, $activeChapterList); } }} disabled={!adjacent.prev}>
|
<button class="icon-btn" on:click={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, $activeChapterList); } }} disabled={!adjacent.prev}>
|
||||||
@@ -557,12 +557,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Viewer -->
|
|
||||||
<div
|
<div
|
||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
class="viewer"
|
class="viewer"
|
||||||
class:strip={style === "longstrip"}
|
class:strip={style === "longstrip"}
|
||||||
style="--max-page-width:{maxW}px"
|
style="--max-page-width:{maxW}px"
|
||||||
|
role="presentation"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
on:click={handleTap}
|
on:click={handleTap}
|
||||||
on:wheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
on:wheel={(e) => { if (e.ctrlKey) e.preventDefault(); }}
|
||||||
@@ -605,7 +606,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom nav -->
|
|
||||||
<div class="bottombar" class:hidden={!uiVisible}>
|
<div class="bottombar" class:hidden={!uiVisible}>
|
||||||
<button class="nav-btn" on:click={goPrev}
|
<button class="nav-btn" on:click={goPrev}
|
||||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : ($pageNumber === 1 && !adjacent.prev))}>
|
disabled={loading || (style === "longstrip" ? !adjacent.prev : ($pageNumber === 1 && !adjacent.prev))}>
|
||||||
@@ -617,11 +618,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download modal -->
|
|
||||||
{#if dlOpen && $activeChapter}
|
{#if dlOpen && $activeChapter}
|
||||||
{@const queueable = adjacent.remaining.filter((c) => !c.isDownloaded)}
|
{@const queueable = adjacent.remaining.filter((c) => !c.isDownloaded)}
|
||||||
<div class="dl-backdrop" on:click={() => dlOpen = false}>
|
<div class="dl-backdrop" role="presentation" on:click={() => dlOpen = false}>
|
||||||
<div class="dl-modal" on:click|stopPropagation>
|
<div class="dl-modal" role="presentation" on:click|stopPropagation>
|
||||||
<p class="dl-title">Download</p>
|
<p class="dl-title">Download</p>
|
||||||
<button class="dl-option" disabled={dlBusy || !!$activeChapter.isDownloaded}
|
<button class="dl-option" disabled={dlBusy || !!$activeChapter.isDownloaded}
|
||||||
on:click={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: $activeChapter!.id }), $activeChapter!.name)}>
|
on:click={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: $activeChapter!.id }), $activeChapter!.name)}>
|
||||||
@@ -634,7 +635,7 @@
|
|||||||
Next chapters
|
Next chapters
|
||||||
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dl-stepper" on:click|stopPropagation>
|
<div class="dl-stepper" role="presentation" on:click|stopPropagation>
|
||||||
<button class="dl-step-btn" on:click={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
<button class="dl-step-btn" on:click={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}>−</button>
|
||||||
<span class="dl-step-val">{nextN}</span>
|
<span class="dl-step-val">{nextN}</span>
|
||||||
<button class="dl-step-btn" on:click={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
<button class="dl-step-btn" on:click={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,7 @@
|
|||||||
onMount(() => window.addEventListener("keydown", onKey));
|
onMount(() => window.addEventListener("keydown", onKey));
|
||||||
onDestroy(() => window.removeEventListener("keydown", onKey));
|
onDestroy(() => window.removeEventListener("keydown", onKey));
|
||||||
|
|
||||||
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
|
||||||
let listeningKey: keyof Keybinds | null = null;
|
let listeningKey: keyof Keybinds | null = null;
|
||||||
|
|
||||||
function startListen(key: keyof Keybinds) {
|
function startListen(key: keyof Keybinds) {
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
window.removeEventListener("keydown", onKeyCapture, true);
|
window.removeEventListener("keydown", onKeyCapture, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
||||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||||
let storageInfo: StorageInfo | null = null;
|
let storageInfo: StorageInfo | null = null;
|
||||||
let storageLoading = false;
|
let storageLoading = false;
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Folders ────────────────────────────────────────────────────────────────────
|
|
||||||
let newFolderName = "";
|
let newFolderName = "";
|
||||||
let editingId: string | null = null;
|
let editingId: string | null = null;
|
||||||
let editingName = "";
|
let editingName = "";
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
editingId = null; editingName = "";
|
editingId = null; editingName = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Select dropdown ────────────────────────────────────────────────────────────
|
|
||||||
let selectOpen: string | null = null;
|
let selectOpen: string | null = null;
|
||||||
|
|
||||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
onMount(() => document.addEventListener("mousedown", onSelectOutside));
|
onMount(() => document.addEventListener("mousedown", onSelectOutside));
|
||||||
onDestroy(() => document.removeEventListener("mousedown", onSelectOutside));
|
onDestroy(() => document.removeEventListener("mousedown", onSelectOutside));
|
||||||
|
|
||||||
// ── DevTools ──────────────────────────────────────────────────────────────────
|
|
||||||
let splashTriggered = false;
|
let splashTriggered = false;
|
||||||
function triggerSplash() {
|
function triggerSplash() {
|
||||||
splashTriggered = true;
|
splashTriggered = true;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="backdrop" on:click={(e) => { if (e.target === e.currentTarget) close(); }}>
|
<div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||||
<div class="modal" role="dialog" aria-label="Settings">
|
<div class="modal" role="dialog" aria-label="Settings">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<p class="modal-title">Settings</p>
|
<p class="modal-title">Settings</p>
|
||||||
@@ -154,12 +154,12 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
||||||
<button class="close-btn" on:click={close}><X size={15} weight="light" /></button>
|
<button class="close-btn" aria-label="Close settings" on:click={close}><X size={15} weight="light" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-body" bind:this={contentBodyEl}>
|
<div class="content-body" bind:this={contentBodyEl}>
|
||||||
|
|
||||||
<!-- GENERAL -->
|
|
||||||
{#if tab === "general"}
|
{#if tab === "general"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoStartServer} class="toggle" class:on={$settings.autoStartServer} on:click={() => updateSettings({ autoStartServer: !$settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={$settings.autoStartServer} on:click={() => updateSettings({ autoStartServer: !$settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- APPEARANCE -->
|
|
||||||
{:else if tab === "appearance"}
|
{:else if tab === "appearance"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- READER -->
|
|
||||||
{:else if tab === "reader"}
|
{:else if tab === "reader"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
||||||
<button role="switch" aria-checked={$settings.pageGap} class="toggle" class:on={$settings.pageGap} on:click={() => updateSettings({ pageGap: !$settings.pageGap })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.pageGap} aria-label="Page gap" class="toggle" class:on={$settings.pageGap} on:click={() => updateSettings({ pageGap: !$settings.pageGap })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -312,23 +312,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
||||||
<button role="switch" aria-checked={$settings.optimizeContrast} class="toggle" class:on={$settings.optimizeContrast} on:click={() => updateSettings({ optimizeContrast: !$settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={$settings.optimizeContrast} on:click={() => updateSettings({ optimizeContrast: !$settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Behaviour</p>
|
<p class="section-title">Behaviour</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoMarkRead} class="toggle" class:on={$settings.autoMarkRead} on:click={() => updateSettings({ autoMarkRead: !$settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={$settings.autoMarkRead} on:click={() => updateSettings({ autoMarkRead: !$settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
||||||
<button role="switch" aria-checked={$settings.autoNextChapter ?? false} class="toggle" class:on={$settings.autoNextChapter} on:click={() => updateSettings({ autoNextChapter: !($settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={$settings.autoNextChapter} on:click={() => updateSettings({ autoNextChapter: !($settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
{#if !($settings.autoNextChapter ?? false)}
|
{#if !($settings.autoNextChapter ?? false)}
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
||||||
<button role="switch" aria-checked={$settings.markReadOnNext ?? true} class="toggle" class:on={$settings.markReadOnNext ?? true} on:click={() => updateSettings({ markReadOnNext: !($settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={$settings.markReadOnNext ?? true} on:click={() => updateSettings({ markReadOnNext: !($settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
@@ -342,18 +342,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LIBRARY -->
|
|
||||||
{:else if tab === "library"}
|
{:else if tab === "library"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Display</p>
|
<p class="section-title">Display</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
||||||
<button role="switch" aria-checked={$settings.libraryCropCovers} class="toggle" class:on={$settings.libraryCropCovers} on:click={() => updateSettings({ libraryCropCovers: !$settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={$settings.libraryCropCovers} on:click={() => updateSettings({ libraryCropCovers: !$settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
||||||
<button role="switch" aria-checked={$settings.showNsfw} class="toggle" class:on={$settings.showNsfw} on:click={() => updateSettings({ showNsfw: !$settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={$settings.showNsfw} on:click={() => updateSettings({ showNsfw: !$settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -384,33 +384,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PERFORMANCE -->
|
|
||||||
{:else if tab === "performance"}
|
{:else if tab === "performance"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Rendering</p>
|
<p class="section-title">Rendering</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
||||||
<button role="switch" aria-checked={$settings.gpuAcceleration} class="toggle" class:on={$settings.gpuAcceleration} on:click={() => updateSettings({ gpuAcceleration: !$settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={$settings.gpuAcceleration} on:click={() => updateSettings({ gpuAcceleration: !$settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Idle / Splash Screen</p>
|
<p class="section-title">Idle / Splash Screen</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
||||||
<button role="switch" aria-checked={$settings.splashCards ?? true} class="toggle" class:on={$settings.splashCards ?? true} on:click={() => updateSettings({ splashCards: !($settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={$settings.splashCards ?? true} on:click={() => updateSettings({ splashCards: !($settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="section-title">Interface</p>
|
<p class="section-title">Interface</p>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
||||||
<button role="switch" aria-checked={$settings.compactSidebar} class="toggle" class:on={$settings.compactSidebar} on:click={() => updateSettings({ compactSidebar: !$settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
<button role="switch" aria-checked={$settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={$settings.compactSidebar} on:click={() => updateSettings({ compactSidebar: !$settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KEYBINDS -->
|
|
||||||
{:else if tab === "keybinds"}
|
{:else if tab === "keybinds"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -438,7 +438,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STORAGE -->
|
|
||||||
{:else if tab === "storage"}
|
{:else if tab === "storage"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -482,7 +482,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOLDERS -->
|
|
||||||
{:else if tab === "folders"}
|
{:else if tab === "folders"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ABOUT -->
|
|
||||||
{:else if tab === "about"}
|
{:else if tab === "about"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DEVTOOLS -->
|
|
||||||
{:else if tab === "devtools"}
|
{:else if tab === "devtools"}
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
$: pos = getPos();
|
$: pos = getPos();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={el} class="menu" style="left:{pos.left}px;top:{pos.top}px"
|
<div bind:this={el} class="menu" role="menu" style="left:{pos.left}px;top:{pos.top}px"
|
||||||
on:contextmenu|preventDefault>
|
on:contextmenu|preventDefault>
|
||||||
{#each items as item, i}
|
{#each items as item, i}
|
||||||
{#if "separator" in item}
|
{#if "separator" in item}
|
||||||
|
|||||||
@@ -1,5 +1,59 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import type { Source } from "./types";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return clsx(inputs);
|
return clsx(inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates sources by name, preferring the given language.
|
||||||
|
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately —
|
||||||
|
* only the preferred-lang variant (or alphabetically first fallback) is kept.
|
||||||
|
*/
|
||||||
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
|
const byName = new Map<string, Source[]>();
|
||||||
|
for (const src of sources) {
|
||||||
|
if (src.id === "0") continue;
|
||||||
|
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||||
|
byName.get(src.name)!.push(src);
|
||||||
|
}
|
||||||
|
const picked: Source[] = [];
|
||||||
|
for (const group of byName.values()) {
|
||||||
|
const preferred = group.find((s) => s.lang === preferredLang);
|
||||||
|
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
||||||
|
* Use this when merging results across sources — eliminates the same series
|
||||||
|
* appearing multiple times in grids from different source variants.
|
||||||
|
*/
|
||||||
|
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
const key = m.title.toLowerCase().trim();
|
||||||
|
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
||||||
|
* Use this when merging library results with source results for the same query,
|
||||||
|
* where the same manga id may appear in both sets.
|
||||||
|
*/
|
||||||
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const m of items) {
|
||||||
|
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user