mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Chore: Port over SeriesDetail + Panels
This commit is contained in:
@@ -4,18 +4,22 @@
|
||||
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
||||
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
||||
} from "phosphor-svelte";
|
||||
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
|
||||
import { appState } from "$lib/state/app.svelte";
|
||||
import { settings } from "$lib/state/settings.svelte";
|
||||
import { requestManager } from "$lib/request-manager/index";
|
||||
import { queryCache } from "$lib/core/cache/queryCache";
|
||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
|
||||
import { toast } from "$lib/state/notifications.svelte";
|
||||
import { addBookmark } from "$lib/state/app.svelte";
|
||||
import CoverPickerPanel from "$lib/components/series/CoverPickerPanel.svelte";
|
||||
import SeriesLinkPanel from "$lib/components/series/SeriesLinkPanel.svelte";
|
||||
import type { Manga, Chapter, Category } from "$lib/types/index";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import CoverPickerPanel from "$lib/components/series/panels/CoverPickerPanel.svelte";
|
||||
import SeriesLinkPanel from "$lib/components/shared/manga/SeriesLinkPanel.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { cache, CACHE_KEYS } from "$lib/core/cache/queryCache";
|
||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { addToast } from "$lib/state/notifications.svelte";
|
||||
import {
|
||||
seriesState,
|
||||
setPreviewManga, setActiveManga, openReader, addBookmark,
|
||||
} from "$lib/state/series.svelte";
|
||||
import { app, setNavPage, setGenreFilter } from "$lib/state/app.svelte";
|
||||
import type { Manga, Chapter, Category } from "$lib/types";
|
||||
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
@@ -32,26 +36,31 @@
|
||||
let queueingAll = $state(false);
|
||||
let fetchError: string | null = $state(null);
|
||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||
let linkPickerOpen = $state(false);
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList = $state(false);
|
||||
let coverPickerOpen = $state(false);
|
||||
|
||||
let originNavPage = appState.navPage;
|
||||
let linkPickerOpen = $state(false);
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList = $state(false);
|
||||
let coverPickerOpen = $state(false);
|
||||
|
||||
let originNavPage = app.navPage;
|
||||
|
||||
const linkedIds = $derived(
|
||||
appState.previewManga ? (settings.mangaLinks?.[appState.previewManga.id] ?? []) : [],
|
||||
seriesState.previewManga
|
||||
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
|
||||
: [],
|
||||
);
|
||||
|
||||
const hasCoverOverride = $derived(
|
||||
!!settings.mangaPrefs?.[appState.previewManga?.id ?? -1]?.coverUrl
|
||||
!!settingsState.settings.mangaPrefs?.[seriesState.previewManga?.id ?? -1]?.coverUrl,
|
||||
);
|
||||
const displayManga = $derived(manga ?? appState.previewManga);
|
||||
|
||||
const displayManga = $derived(manga ?? seriesState.previewManga);
|
||||
const totalCount = $derived(chapters.length);
|
||||
const readCount = $derived(chapters.filter((c) => c.isRead).length);
|
||||
const readCount = $derived(chapters.filter((c) => c.read).length);
|
||||
const unreadCount = $derived(totalCount - readCount);
|
||||
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
|
||||
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
|
||||
const inLibrary = $derived(manga?.inLibrary ?? appState.previewManga?.inLibrary ?? false);
|
||||
const downloadedCount = $derived(chapters.filter((c) => c.downloaded).length);
|
||||
const bookmarkCount = $derived(chapters.filter((c) => c.bookmarked).length);
|
||||
const inLibrary = $derived(manga?.inLibrary ?? seriesState.previewManga?.inLibrary ?? false);
|
||||
const scanlators = $derived(
|
||||
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
|
||||
);
|
||||
@@ -60,7 +69,9 @@
|
||||
.map((c) => (c.uploadDate ? new Date(c.uploadDate).getTime() : null))
|
||||
.filter((d): d is number => d !== null && !isNaN(d)),
|
||||
);
|
||||
const statusLabel = $derived(
|
||||
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
|
||||
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
|
||||
const statusLabel = $derived(
|
||||
displayManga?.status
|
||||
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
|
||||
: null,
|
||||
@@ -69,24 +80,25 @@
|
||||
|
||||
const continueChapter = $derived.by(() => {
|
||||
if (!chapters.length) return null;
|
||||
const asc = [...chapters];
|
||||
const anyRead = asc.some((c) => c.isRead);
|
||||
const asc = [...chapters];
|
||||
const anyRead = asc.some((c) => c.read);
|
||||
const bookmark = displayManga
|
||||
? appState.bookmarks.find((b) => b.mangaId === displayManga!.id)
|
||||
? seriesState.bookmarks.find((b) => b.mangaId === displayManga!.id)
|
||||
: null;
|
||||
|
||||
if (bookmark) {
|
||||
const ch = asc.find((c) => c.id === bookmark.chapterId);
|
||||
if (ch) {
|
||||
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
||||
const allRead = asc.every((c) => c.isRead);
|
||||
const allRead = asc.every((c) => c.read);
|
||||
if (!(isLastChapter && allRead))
|
||||
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
||||
}
|
||||
}
|
||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
|
||||
const inProgress = asc.find((c) => !c.read && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
||||
const firstUnread = asc.find((c) => !c.isRead);
|
||||
const firstUnread = asc.find((c) => !c.read);
|
||||
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||
return { ch: asc[0], type: "reread" as const, resumePage: null };
|
||||
});
|
||||
@@ -99,55 +111,64 @@
|
||||
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
|
||||
});
|
||||
|
||||
|
||||
let detailAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
|
||||
|
||||
function close() {
|
||||
detailAbort?.abort();
|
||||
chapterAbort?.abort();
|
||||
appState.previewManga = null;
|
||||
setPreviewManga(null);
|
||||
manga = null; chapters = []; descExpanded = false;
|
||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true;
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
requestManager.getAllManga()
|
||||
.then((d) => { allMangaForLink = d; })
|
||||
getAdapter().getMangaList({})
|
||||
.then((d) => { allMangaForLink = d.items; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
function closeLinkPicker() { linkPickerOpen = false; }
|
||||
|
||||
async function openCoverPicker() {
|
||||
coverPickerOpen = true;
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
requestManager.getAllManga()
|
||||
.then((d) => { allMangaForLink = d; })
|
||||
getAdapter().getMangaList({})
|
||||
.then((d) => { allMangaForLink = d.items; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const focal = appState.previewManga;
|
||||
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
|
||||
const focal = seriesState.previewManga;
|
||||
if (focal) {
|
||||
originNavPage = appState.navPage;
|
||||
originNavPage = app.navPage;
|
||||
load(focal.id);
|
||||
loadCategories(focal.id);
|
||||
if (settings.autoLinkOnOpen) {
|
||||
if (shouldAutoLink) {
|
||||
if (allMangaForLink.length) {
|
||||
autoLinkLibrary(focal, allMangaForLink)
|
||||
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
||||
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
||||
} else {
|
||||
loadingLinkList = true;
|
||||
requestManager.getAllManga()
|
||||
.then((nodes) => {
|
||||
allMangaForLink = nodes;
|
||||
return autoLinkLibrary(focal, nodes);
|
||||
getAdapter().getMangaList({})
|
||||
.then((d) => {
|
||||
allMangaForLink = d.items;
|
||||
return autoLinkLibrary(focal, d.items);
|
||||
})
|
||||
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
|
||||
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
@@ -159,29 +180,42 @@
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
||||
manga = appState.previewManga as Manga;
|
||||
manga = seriesState.previewManga as Manga;
|
||||
chapters = []; descExpanded = false; fetchError = null;
|
||||
loadingDetail = true; loadingChapters = true;
|
||||
|
||||
requestManager.fetchManga(id, dCtrl.signal)
|
||||
(async (): Promise<Manga> => {
|
||||
const key = CACHE_KEYS.MANGA(id);
|
||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>;
|
||||
try {
|
||||
return await getAdapter().fetchManga(String(id), dCtrl.signal);
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") throw e;
|
||||
const local = await getAdapter().getManga(String(id), dCtrl.signal);
|
||||
if (local) return local;
|
||||
throw new Error("Could not load manga details");
|
||||
}
|
||||
})()
|
||||
.then((fullManga) => {
|
||||
if (dCtrl.signal.aborted) return;
|
||||
if (!cache.has(CACHE_KEYS.MANGA(id)))
|
||||
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||
manga = fullManga; loadingDetail = false;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
manga = appState.previewManga as Manga;
|
||||
manga = seriesState.previewManga as Manga;
|
||||
fetchError = "Could not load full details — showing cached data";
|
||||
loadingDetail = false;
|
||||
});
|
||||
|
||||
requestManager.getChapters(id, cCtrl.signal)
|
||||
getAdapter().getChapters(String(id), cCtrl.signal)
|
||||
.then(async (nodes) => {
|
||||
if (cCtrl.signal.aborted) return;
|
||||
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
if (sorted.length === 0) {
|
||||
try {
|
||||
const fetched = await requestManager.fetchChapters(id, cCtrl.signal);
|
||||
const fetched = await getAdapter().fetchChapters(String(id), cCtrl.signal);
|
||||
if (!cCtrl.signal.aborted)
|
||||
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
} catch (e: any) {
|
||||
@@ -201,36 +235,38 @@
|
||||
if (!manga) return;
|
||||
togglingLib = true;
|
||||
const next = !manga.inLibrary;
|
||||
await requestManager.updateManga(manga.id, { inLibrary: next }).catch(console.error);
|
||||
if (next) await getAdapter().addToLibrary(String(manga.id)).catch(console.error);
|
||||
else await getAdapter().removeFromLibrary(String(manga.id)).catch(console.error);
|
||||
manga = { ...manga, inLibrary: next };
|
||||
queryCache.clear(`manga:${manga.id}`);
|
||||
queryCache.clear("library");
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
togglingLib = false;
|
||||
toast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||
const ids = chapters.filter((c) => !c.downloaded && !c.read).map((c) => String(c.id));
|
||||
if (!ids.length) return;
|
||||
queueingAll = true;
|
||||
await requestManager.enqueueChaptersDownload(ids).catch(console.error);
|
||||
toast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
await getAdapter().enqueueDownloads(ids).catch(console.error);
|
||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
queueingAll = false;
|
||||
}
|
||||
|
||||
function openSeriesDetail() {
|
||||
if (!displayManga) return;
|
||||
appState.activeManga = displayManga;
|
||||
appState.navPage = originNavPage;
|
||||
setActiveManga(displayManga);
|
||||
setNavPage(originNavPage);
|
||||
close();
|
||||
}
|
||||
|
||||
function loadCategories(mangaId: number) {
|
||||
catsLoading = true;
|
||||
requestManager.getCategories()
|
||||
getAdapter().getCategories()
|
||||
.then((cats) => {
|
||||
allCategories = cats.filter((c: Category) => c.id !== 0);
|
||||
mangaCategories = allCategories.filter((c: Category) => c.mangas?.nodes.some((m: Manga) => m.id === mangaId));
|
||||
allCategories = cats.filter((c) => c.id !== 0);
|
||||
mangaCategories = allCategories.filter((c) => c.mangas?.some((m) => m.id === mangaId));
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { catsLoading = false; });
|
||||
@@ -239,26 +275,35 @@
|
||||
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
|
||||
const mangaStatus = (manga ?? displayManga)?.status;
|
||||
const isOngoing = mangaStatus === "ONGOING";
|
||||
if (chaps.length && !isOngoing) {
|
||||
const allRead = chaps.every((c) => c.isRead);
|
||||
const completed = allCategories.find((c) => c.name === "Completed");
|
||||
if (completed) {
|
||||
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
||||
}
|
||||
if (!chaps.length || isOngoing) return;
|
||||
|
||||
const allRead = chaps.every((c) => c.read);
|
||||
const completed = allCategories.find((c) => c.name === "Completed");
|
||||
if (!completed) return;
|
||||
|
||||
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
|
||||
if (allRead && !inCompleted) {
|
||||
await getAdapter().updateMangaCategories(String(mangaId), [completed.id], []).catch(console.error);
|
||||
mangaCategories = [...mangaCategories, completed];
|
||||
} else if (!allRead && inCompleted) {
|
||||
await getAdapter().updateMangaCategories(String(mangaId), [], [completed.id]).catch(console.error);
|
||||
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCategory(cat: Category) {
|
||||
if (!appState.previewManga) return;
|
||||
const mangaId = appState.previewManga.id;
|
||||
if (!seriesState.previewManga) return;
|
||||
const mangaId = seriesState.previewManga.id;
|
||||
const inCat = mangaCategories.some((c) => c.id === cat.id);
|
||||
await requestManager.updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
|
||||
await getAdapter().updateMangaCategories(
|
||||
String(mangaId),
|
||||
inCat ? [] : [cat.id],
|
||||
inCat ? [cat.id] : [],
|
||||
).catch(console.error);
|
||||
if (!inCat && !inLibrary) {
|
||||
await requestManager.updateManga(mangaId, { inLibrary: true }).catch(console.error);
|
||||
await getAdapter().addToLibrary(String(mangaId)).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
queryCache.clear("library");
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = inCat
|
||||
? mangaCategories.filter((c) => c.id !== cat.id)
|
||||
@@ -267,15 +312,15 @@
|
||||
|
||||
async function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !appState.previewManga) return;
|
||||
if (!name || !seriesState.previewManga) return;
|
||||
try {
|
||||
const cat = await requestManager.createCategory(name);
|
||||
const cat = await getAdapter().createCategory(name);
|
||||
allCategories = [...allCategories, cat];
|
||||
await requestManager.updateMangaCategories(appState.previewManga.id, [cat.id], []);
|
||||
await getAdapter().updateMangaCategories(String(seriesState.previewManga.id), [cat.id], []);
|
||||
if (!inLibrary) {
|
||||
await requestManager.updateManga(appState.previewManga.id, { inLibrary: true }).catch(console.error);
|
||||
await getAdapter().addToLibrary(String(seriesState.previewManga.id)).catch(console.error);
|
||||
if (manga) manga = { ...manga, inLibrary: true };
|
||||
queryCache.clear("library");
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
mangaCategories = [...mangaCategories, cat];
|
||||
} catch (e) { console.error(e); }
|
||||
@@ -295,6 +340,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
function focusAction(node: HTMLElement) { node.focus(); }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
||||
onMount(() => window.addEventListener("keydown", onKey));
|
||||
onDestroy(() => {
|
||||
@@ -302,11 +349,9 @@
|
||||
detailAbort?.abort();
|
||||
chapterAbort?.abort();
|
||||
});
|
||||
|
||||
function focusAction(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
{#if appState.previewManga}
|
||||
{#if seriesState.previewManga}
|
||||
<div
|
||||
class="backdrop"
|
||||
role="button"
|
||||
@@ -319,7 +364,7 @@
|
||||
|
||||
<div class="cover-col">
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={resolvedCover(appState.previewManga.id, appState.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||
<Thumbnail src={resolvedCover(seriesState.previewManga.id, seriesState.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||
{#if loadingDetail}
|
||||
<div class="cover-spinner">
|
||||
<CircleNotch size={18} weight="light" class="anim-spin" />
|
||||
@@ -503,7 +548,7 @@
|
||||
<button class="read-btn" onclick={() => {
|
||||
const { ch, type, resumePage } = continueChapter!;
|
||||
if (type === "continue" && resumePage && resumePage > 1) {
|
||||
const existing = appState.bookmarks.find((b) => b.chapterId === ch.id);
|
||||
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
|
||||
if (!existing || existing.pageNumber < resumePage) {
|
||||
addBookmark({
|
||||
mangaId: displayManga!.id,
|
||||
@@ -515,7 +560,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
appState.openReader(ch, chapters, displayManga);
|
||||
openReader(ch, chapters, displayManga);
|
||||
close();
|
||||
}}>
|
||||
<Play size={12} weight="fill" />{continueLabel}
|
||||
@@ -553,7 +598,7 @@
|
||||
{#each displayManga.genre as g}
|
||||
<button
|
||||
class="genre-tag"
|
||||
onclick={() => { appState.genreFilter = g; appState.navPage = "search"; close(); }}
|
||||
onclick={() => { setGenreFilter(g); setNavPage("search"); close(); }}
|
||||
>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -612,22 +657,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPickerOpen && appState.previewManga}
|
||||
{#if linkPickerOpen && seriesState.previewManga}
|
||||
<SeriesLinkPanel
|
||||
manga={displayManga ?? appState.previewManga}
|
||||
manga={displayManga ?? seriesState.previewManga}
|
||||
allManga={allMangaForLink}
|
||||
onClose={() => linkPickerOpen = false}
|
||||
onClose={closeLinkPicker}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if coverPickerOpen && appState.previewManga}
|
||||
{#if coverPickerOpen && seriesState.previewManga}
|
||||
<CoverPickerPanel
|
||||
manga={displayManga ?? appState.previewManga}
|
||||
manga={displayManga ?? seriesState.previewManga}
|
||||
allManga={allMangaForLink}
|
||||
onClose={() => coverPickerOpen = false}
|
||||
onClose={() => { coverPickerOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
@@ -640,7 +687,8 @@
|
||||
.modal {
|
||||
width: min(800px, calc(100vw - 48px));
|
||||
height: min(560px, calc(100vh - 80px));
|
||||
display: flex; flex-direction: row;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -655,18 +703,22 @@
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||
gap: var(--sp-3); overflow: hidden;
|
||||
gap: var(--sp-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover-wrap { position: relative; width: 100%; }
|
||||
.cover-wrap { position: relative; width: 100%; aspect-ratio: 2/3; }
|
||||
:global(.cover) {
|
||||
width: 100%; aspect-ratio: 2/3;
|
||||
object-fit: cover; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); display: block;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
display: block;
|
||||
}
|
||||
.cover-spinner {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.35); border-radius: var(--radius-md);
|
||||
background: rgba(0,0,0,0.35);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@@ -686,20 +738,25 @@
|
||||
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
|
||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
|
||||
.folder-wrap { position: relative; width: 100%; }
|
||||
.folder-menu {
|
||||
position: absolute; bottom: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md);
|
||||
padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10;
|
||||
animation: scaleIn 0.1s ease both; transform-origin: bottom center;
|
||||
.folder-wrap { position: relative; width: 100%; }
|
||||
.folder-menu {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base); border-radius: var(--radius-md);
|
||||
padding: var(--sp-1);
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
z-index: 10;
|
||||
animation: scaleIn 0.1s ease both;
|
||||
transform-origin: top center;
|
||||
}
|
||||
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
|
||||
.folder-item {
|
||||
.folder-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left;
|
||||
color: var(--text-muted); background: none; border: none;
|
||||
cursor: pointer; text-align: left;
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
@@ -708,16 +765,19 @@
|
||||
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
|
||||
.folder-input {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
padding: 4px 8px; color: var(--text-secondary);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); outline: none;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
outline: none;
|
||||
}
|
||||
.folder-input:focus { border-color: var(--border-focus); }
|
||||
.folder-ok {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
padding: 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
|
||||
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.folder-ok:disabled { opacity: 0.4; cursor: default; }
|
||||
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
@@ -729,11 +789,16 @@
|
||||
}
|
||||
.folder-new:hover { color: var(--accent-fg); }
|
||||
|
||||
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.content {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
|
||||
@@ -749,7 +814,8 @@
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.content-body {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
flex: 1; min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||
scrollbar-width: none;
|
||||
@@ -788,7 +854,8 @@
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
@@ -799,7 +866,8 @@
|
||||
padding: 8px var(--sp-4); border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||
cursor: pointer; transition: filter var(--t-base);
|
||||
cursor: pointer;
|
||||
transition: filter var(--t-base);
|
||||
}
|
||||
.read-btn:hover { filter: brightness(1.1); }
|
||||
|
||||
@@ -809,7 +877,8 @@
|
||||
.desc-toggle {
|
||||
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
|
||||
background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base);
|
||||
background: none; border: none; cursor: pointer; padding: 0;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.desc-toggle:hover { color: var(--accent-fg); }
|
||||
|
||||
@@ -818,7 +887,8 @@
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
@@ -834,6 +904,6 @@
|
||||
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -1,14 +1,530 @@
|
||||
<script lang="ts">
|
||||
let { onClose, ...rest }: { onClose?: () => void; [k: string]: any } = $props()
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||
import { updateManga } from "$lib/request-manager/manga";
|
||||
import { updateChaptersProgress } from "$lib/request-manager/chapters";
|
||||
import { libraryState } from "$lib/state/library.svelte";
|
||||
import type { Manga, Chapter, Source } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
currentChapters: Chapter[];
|
||||
onClose: () => void;
|
||||
onMigrated: (newManga: Manga) => void;
|
||||
}
|
||||
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
|
||||
|
||||
type Step = "source" | "search" | "confirm";
|
||||
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
|
||||
|
||||
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;
|
||||
return intersection / new Set([...wordsA, ...wordsB]).size;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||
|
||||
let step: Step = $state("source");
|
||||
let sources: Source[] = $state([]);
|
||||
let loadingSources = $state(true);
|
||||
let selectedSource: Source | null = $state(null);
|
||||
let selectedLang = $state("all");
|
||||
let langStripEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const stepIdx = $derived(STEPS.indexOf(step));
|
||||
const availableLangs = $derived.by(() => {
|
||||
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
|
||||
const en = langs.indexOf("en");
|
||||
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
|
||||
return langs;
|
||||
});
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
const visibleSources = $derived.by(() => {
|
||||
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
const existing = map.get(s.name);
|
||||
if (!existing || s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
let query = $state(untrack(() => manga.title));
|
||||
let results: { manga: Manga; similarity: number }[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let selectedMatch: Match | null = $state(null);
|
||||
let loadingMatchId: number | null = $state(null);
|
||||
let migrating = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
const readCount = $derived(currentChapters.filter(c => c.isRead).length);
|
||||
const totalCount = $derived(currentChapters.length);
|
||||
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
|
||||
|
||||
function scrollLangStrip(dir: -1 | 1) {
|
||||
if (!langStripEl) return;
|
||||
const chips = Array.from(langStripEl.children) as HTMLElement[];
|
||||
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
|
||||
if (dir === 1) {
|
||||
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
|
||||
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
|
||||
} else {
|
||||
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
|
||||
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
getAdapter().getSources()
|
||||
.then((all: Source[]) => {
|
||||
const filtered = all.filter(s => s.id !== "0" && s.id !== manga.source?.id);
|
||||
sources = filtered;
|
||||
const langs = new Set(filtered.map(s => s.lang));
|
||||
const prefLang = (libraryState as any).preferredExtensionLang ?? "";
|
||||
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
async function searchSource(src: Source, q: string) {
|
||||
if (!src || !q.trim()) return;
|
||||
searching = true; results = []; error = null;
|
||||
try {
|
||||
const items = await getAdapter().searchManga(q.trim(), src.id);
|
||||
results = items
|
||||
.map((m: Manga) => ({ manga: m, similarity: titleSimilarity(manga.title, m.title) }))
|
||||
.sort((a: { similarity: number }, b: { similarity: number }) => b.similarity - a.similarity);
|
||||
} 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 chapters = await getAdapter().fetchChapters(String(m.id));
|
||||
const matchReadCount = chapters.filter((c: Chapter) => {
|
||||
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 old = oldByNum.get(Math.round(nc.chapterNumber * 100));
|
||||
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 updateChaptersProgress(toMarkRead.map(String), { isRead: true });
|
||||
if (toMarkBookmarked.length)
|
||||
await updateChaptersProgress(toMarkBookmarked.map(String), { isBookmarked: true });
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await updateChaptersProgress([String(id)], { lastPageRead });
|
||||
|
||||
await updateManga(newManga.id, { inLibrary: true });
|
||||
await updateManga(manga.id, { inLibrary: false });
|
||||
|
||||
onMigrated({ ...newManga, inLibrary: true });
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
migrating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stub-panel">
|
||||
<button class="stub-close" onclick={onClose}>✕</button>
|
||||
<p class="stub-label">MigrateModal</p>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
<div class="manga-context">
|
||||
<div class="manga-context-cover">
|
||||
<Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="ctx-cover" />
|
||||
</div>
|
||||
<div class="manga-context-info">
|
||||
<span class="modal-eyebrow">Migrate source</span>
|
||||
<span class="modal-title">{manga.title}</span>
|
||||
{#if manga.source?.displayName}
|
||||
<span class="modal-source">{manga.source.displayName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<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?.displayName ?? "Search") : "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
{#if step === "source"}
|
||||
{#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}
|
||||
{#if hasMultipleLangs}
|
||||
<div class="src-lang-bar">
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}>‹</button>
|
||||
<div class="src-lang-chips" bind:this={langStripEl}>
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
|
||||
{#each availableLangs as lang}
|
||||
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}>›</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="source-list">
|
||||
{#each visibleSources as src}
|
||||
<button class="source-row" class:source-row-active={selectedSource?.id === src.id} onclick={() => pickSource(src)}>
|
||||
<div class="source-icon-wrap">
|
||||
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if step === "search"}
|
||||
<div class="search-step">
|
||||
{#if selectedSource}
|
||||
<div class="search-context">
|
||||
<div class="source-icon-wrap" style="width:20px;height:20px;border-radius:var(--radius-sm)">
|
||||
<Thumbnail src={selectedSource.iconUrl} alt={selectedSource.name} class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
</div>
|
||||
<span class="search-context-name">{selectedSource.displayName}</span>
|
||||
<button class="search-context-change" onclick={() => { 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}
|
||||
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
autofocus />
|
||||
</div>
|
||||
<button class="search-btn"
|
||||
onclick={() => 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(5) as _}
|
||||
<div class="sk-result">
|
||||
<div class="skeleton sk-cover"></div>
|
||||
<div class="sk-meta">
|
||||
<div class="skeleton sk-line" style="width:60%"></div>
|
||||
<div class="skeleton sk-line" style="width:35%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each results as { manga: m, similarity }, idx}
|
||||
<button class="result-row" onclick={() => selectMatch(m, similarity)} disabled={loadingMatchId !== null}>
|
||||
<div class="result-cover-wrap">
|
||||
<Thumbnail src={resolvedCover(m.id, 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)}%</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.4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if results.length === 0 && !error && !searching}
|
||||
<div class="centered">
|
||||
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if step === "confirm" && selectedMatch}
|
||||
<div class="confirm-step">
|
||||
<div class="confirm-row">
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<Thumbnail src={resolvedCover(manga.id, 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-arrow-wrap">
|
||||
<ArrowRight size={18} weight="light" style="color:var(--text-faint)" />
|
||||
</div>
|
||||
<div class="confirm-manga">
|
||||
<div class="confirm-cover-wrap">
|
||||
<Thumbnail src={resolvedCover(selectedMatch.manga.id, 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</span>
|
||||
<span class="stat-val">{selectedMatch.readCount} / {readCount}</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" onclick={() => step = "search"} disabled={migrating}>Back</button>
|
||||
<button class="migrate-btn" onclick={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>
|
||||
.stub-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 32px; min-width: 240px; }
|
||||
.stub-close { align-self: flex-end; background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 16px; }
|
||||
.stub-label { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.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: 82vh; 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; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.manga-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.manga-context-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
:global(.ctx-cover) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.manga-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.modal-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.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; margin-top: 2px; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.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.35; transition: opacity var(--t-base); }
|
||||
.step + .step::before { content: "›"; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); opacity: 0.5; }
|
||||
.step-active { opacity: 1; }
|
||||
.step-done { opacity: 0.55; }
|
||||
.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 { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
|
||||
.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); }
|
||||
|
||||
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.src-lang-chips::-webkit-scrollbar { display: none; }
|
||||
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.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: 8px 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-wrap { width: 28px; height: 28px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); }
|
||||
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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); flex-shrink: 0; }
|
||||
.source-row:hover :global(.source-arrow) { opacity: 1; }
|
||||
|
||||
.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: 6px var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
|
||||
:global(.search-context-icon) { width: 100%; height: 100%; object-fit: cover; }
|
||||
.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; opacity: 0.8; transition: opacity var(--t-base); }
|
||||
.search-context-change:hover { opacity: 1; }
|
||||
.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: 6px 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; }
|
||||
:global(.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: 40px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; }
|
||||
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
|
||||
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
|
||||
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 6px 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-line { height: 12px; border-radius: var(--radius-sm); }
|
||||
|
||||
.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: flex-start; justify-content: center; gap: var(--sp-3); }
|
||||
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 150px; }
|
||||
.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); }
|
||||
:global(.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-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-arrow-wrap { display: flex; align-items: center; padding-top: 48px; flex-shrink: 0; }
|
||||
|
||||
.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); flex-shrink: 0; }
|
||||
.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; margin-top: auto; }
|
||||
.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; 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 { 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); flex-shrink: 0; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,14 +1,252 @@
|
||||
<script lang="ts">
|
||||
let { onClose, ...rest }: { onClose?: () => void; [k: string]: any } = $props()
|
||||
import { X, LinkSimple, LinkBreak, Sparkle } from "phosphor-svelte";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
manga: Manga;
|
||||
allManga: Manga[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { manga, allManga, onClose }: Props = $props();
|
||||
|
||||
let query = $state("");
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wa = new Set(norm(a));
|
||||
const wb = new Set(norm(b));
|
||||
if (!wa.size || !wb.size) return 0;
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||
return intersection / new Set([...wa, ...wb]).size;
|
||||
}
|
||||
|
||||
function linkManga(idA: number, idB: number) {
|
||||
if (idA === idB) return;
|
||||
const links = { ...settingsState.settings.mangaLinks };
|
||||
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
||||
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
||||
updateSettings({ mangaLinks: links });
|
||||
}
|
||||
|
||||
function unlinkManga(idA: number, idB: number) {
|
||||
const links = { ...settingsState.settings.mangaLinks };
|
||||
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
||||
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
||||
if (!links[idA].length) delete links[idA];
|
||||
if (!links[idB].length) delete links[idB];
|
||||
updateSettings({ mangaLinks: links });
|
||||
}
|
||||
|
||||
const linkedIds = $derived(settingsState.settings.mangaLinks?.[manga.id] ?? []);
|
||||
|
||||
const others = $derived(allManga.filter(m => m.id !== manga.id));
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
if (linkedIds.length === others.length) return [];
|
||||
return others
|
||||
.filter(m => !linkedIds.includes(m.id))
|
||||
.map(m => ({ manga: m, score: titleSimilarity(manga.title, m.title) }))
|
||||
.filter(r => r.score >= 0.65)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
const searchResults = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return others
|
||||
.filter(m => m.title.toLowerCase().includes(q))
|
||||
.slice(0, 30);
|
||||
});
|
||||
|
||||
const linked = $derived(others.filter(m => linkedIds.includes(m.id)));
|
||||
|
||||
function toggle(other: Manga) {
|
||||
if (linkedIds.includes(other.id)) unlinkManga(manga.id, other.id);
|
||||
else linkManga(manga.id, other.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stub-panel">
|
||||
<button class="stub-close" onclick={onClose}>✕</button>
|
||||
<p class="stub-label">SeriesLinkPanel</p>
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div class="modal" role="dialog" aria-label="Link as same series">
|
||||
<div class="header">
|
||||
<span class="title">Link as same series</span>
|
||||
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Linked entries share covers and are merged in search. Click a linked entry to unlink.</p>
|
||||
|
||||
<div class="search-wrap">
|
||||
<input class="search" placeholder="Search your library…" bind:value={query} />
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
{#if query.trim()}
|
||||
{#if searchResults.length === 0}
|
||||
<p class="empty">No results</p>
|
||||
{:else}
|
||||
{#each searchResults as m (m.id)}
|
||||
{@const isLinked = linkedIds.includes(m.id)}
|
||||
<button class="row" class:row-linked={isLinked} onclick={() => toggle(m)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
|
||||
<div class="info">
|
||||
<span class="manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
<span class="row-icon">{#if isLinked}<LinkBreak size={14} />{:else}<LinkSimple size={14} />{/if}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
{#if linked.length > 0}
|
||||
<p class="section-label">Linked</p>
|
||||
{#each linked as m (m.id)}
|
||||
<button class="row row-linked" onclick={() => toggle(m)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
|
||||
<div class="info">
|
||||
<span class="manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
<span class="row-icon"><LinkBreak size={14} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length > 0}
|
||||
<p class="section-label">
|
||||
<Sparkle size={10} weight="fill" style="color:var(--accent)" />
|
||||
Suggested
|
||||
</p>
|
||||
{#each suggestions as { manga: m, score } (m.id)}
|
||||
<button class="row" onclick={() => toggle(m)}>
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
|
||||
<div class="info">
|
||||
<span class="manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
<span class="sim-bar">
|
||||
<span class="sim-fill" style="width:{Math.round(score * 100)}%"></span>
|
||||
</span>
|
||||
<span class="row-icon"><LinkSimple size={14} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if linked.length === 0 && suggestions.length === 0}
|
||||
<p class="empty">No suggestions — search your library above.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stub-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 32px; min-width: 240px; }
|
||||
.stub-close { align-self: flex-end; background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 16px; }
|
||||
.stub-label { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, calc(100vw - 48px));
|
||||
max-height: 70vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-base); border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.14s ease both;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.close-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.hint {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
line-height: var(--leading-snug);
|
||||
padding: var(--sp-3) var(--sp-5) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-wrap {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search {
|
||||
width: 100%; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
|
||||
padding: 6px 10px; color: var(--text-primary);
|
||||
font-size: var(--text-sm); outline: none;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.list {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.list::-webkit-scrollbar { display: none; }
|
||||
.section-label {
|
||||
display: flex; align-items: center; gap: var(--sp-1);
|
||||
font-family: var(--font-ui); font-size: 9px;
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); padding: var(--sp-4) var(--sp-3);
|
||||
text-align: center; letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.row {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
width: 100%; padding: 8px var(--sp-3);
|
||||
border-radius: var(--radius-md); border: none;
|
||||
background: none; text-align: left; cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.row:hover { background: var(--bg-raised); }
|
||||
.row-linked { background: var(--accent-muted) !important; }
|
||||
:global(.thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.sim-bar {
|
||||
width: 36px; height: 3px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-full);
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
|
||||
.row-icon { display: flex; align-items: center; color: var(--text-faint); flex-shrink: 0; opacity: 0.6; transition: opacity var(--t-base); }
|
||||
.row:hover .row-icon { opacity: 1; }
|
||||
.row-linked .row-icon { color: var(--accent-fg); opacity: 1; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
onerror = undefined,
|
||||
...rest
|
||||
}: {
|
||||
src: string;
|
||||
src: string | null | undefined;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
loading?: string;
|
||||
@@ -27,7 +27,7 @@
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function plainThumbUrl(path: string): string {
|
||||
function plainThumbUrl(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
@@ -52,11 +52,8 @@
|
||||
.catch(() => { if (id === reqId) blobUrl = ""; });
|
||||
});
|
||||
|
||||
const resolved = $derived(
|
||||
isAuth
|
||||
? (blobUrl || undefined)
|
||||
: (src ? plainThumbUrl(src) : undefined)
|
||||
);
|
||||
const plainUrl = $derived(plainThumbUrl(src));
|
||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
||||
</script>
|
||||
|
||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||
Reference in New Issue
Block a user