mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Feat: Cover-Image Switching & Auto-Link (#55)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { resolvedCover } from "@features/series/lib/coverResolver";
|
||||||
import type { Manga, Category } from "@types";
|
import type { Manga, Category } from "@types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
onpointerleave={onCardPointerLeave}
|
onpointerleave={onCardPointerLeave}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{cropCovers ? 'cover' : 'contain'}" draggable="false" />
|
||||||
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
|
||||||
<div class="overlay-badges">
|
<div class="overlay-badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import {
|
import {
|
||||||
X, CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
||||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple,
|
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
|
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||||
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
|
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
|
||||||
import {
|
import {
|
||||||
store, addToast, openReader, setActiveManga, linkManga, unlinkManga,
|
store, addToast, openReader, setActiveManga,
|
||||||
addBookmark, acknowledgeUpdate,
|
addBookmark, acknowledgeUpdate,
|
||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||||
clearMarkersForManga,
|
clearMarkersForManga,
|
||||||
@@ -28,11 +28,14 @@
|
|||||||
import TrackingPanel from "../panels/TrackingPanel.svelte";
|
import TrackingPanel from "../panels/TrackingPanel.svelte";
|
||||||
import AutomationPanel from "../panels/AutomationPanel.svelte";
|
import AutomationPanel from "../panels/AutomationPanel.svelte";
|
||||||
import MarkersPanel from "../panels/MarkersPanel.svelte";
|
import MarkersPanel from "../panels/MarkersPanel.svelte";
|
||||||
|
import CoverPickerPanel from "../panels/CoverPickerPanel.svelte";
|
||||||
|
import SeriesLinkPanel from "../panels/SeriesLinkPanel.svelte";
|
||||||
import SeriesHeader from "./SeriesHeader.svelte";
|
import SeriesHeader from "./SeriesHeader.svelte";
|
||||||
import SeriesActions from "./SeriesActions.svelte";
|
import SeriesActions from "./SeriesActions.svelte";
|
||||||
import ChapterList from "./ChapterList.svelte";
|
import ChapterList from "./ChapterList.svelte";
|
||||||
import { buildChapterList, chaptersAscending } from "../lib/chapterList";
|
import { buildChapterList, chaptersAscending } from "../lib/chapterList";
|
||||||
import { getPref, setPref } from "../lib/mangaPrefs";
|
import { getPref, setPref } from "../lib/mangaPrefs";
|
||||||
|
import { autoLinkLibrary } from "@features/series/lib/autoLink";
|
||||||
|
|
||||||
const CHAPTERS_PER_PAGE = 25;
|
const CHAPTERS_PER_PAGE = 25;
|
||||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
let trackingOpen: boolean = $state(false);
|
let trackingOpen: boolean = $state(false);
|
||||||
let markersOpen: boolean = $state(false);
|
let markersOpen: boolean = $state(false);
|
||||||
let linkPickerOpen: boolean = $state(false);
|
let linkPickerOpen: boolean = $state(false);
|
||||||
let linkSearch: string = $state("");
|
let coverPickerOpen: boolean = $state(false);
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
let loadingLinkList: boolean = $state(false);
|
let loadingLinkList: boolean = $state(false);
|
||||||
let mangaCategories: Category[] = $state([]);
|
let mangaCategories: Category[] = $state([]);
|
||||||
@@ -138,16 +141,6 @@
|
|||||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
|
||||||
const id = store.activeManga?.id;
|
|
||||||
const others = allMangaForLink.filter(m => m.id !== id);
|
|
||||||
const q = linkSearch.trim().toLowerCase();
|
|
||||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
function clearSelection() { selectedIds = new Set(); }
|
function clearSelection() { selectedIds = new Set(); }
|
||||||
@@ -269,6 +262,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const m = store.activeManga;
|
const m = store.activeManga;
|
||||||
|
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
||||||
if (m) untrack(() => {
|
if (m) untrack(() => {
|
||||||
acknowledgeUpdate(m.id);
|
acknowledgeUpdate(m.id);
|
||||||
loadManga(m.id);
|
loadManga(m.id);
|
||||||
@@ -277,6 +271,22 @@
|
|||||||
trackingState.loadForManga(m.id).then(() => {
|
trackingState.loadForManga(m.id).then(() => {
|
||||||
syncTrackersIntoChapters(m.id, chapters);
|
syncTrackersIntoChapters(m.id, chapters);
|
||||||
});
|
});
|
||||||
|
if (shouldAutoLink) {
|
||||||
|
if (allMangaForLink.length) {
|
||||||
|
autoLinkLibrary(m, allMangaForLink)
|
||||||
|
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
||||||
|
} else {
|
||||||
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then(d => {
|
||||||
|
allMangaForLink = d.mangas.nodes;
|
||||||
|
return autoLinkLibrary(m, d.mangas.nodes);
|
||||||
|
})
|
||||||
|
.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; });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,7 +529,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
linkPickerOpen = true; linkSearch = "";
|
linkPickerOpen = true;
|
||||||
if (allMangaForLink.length) return;
|
if (allMangaForLink.length) return;
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
@@ -528,12 +538,16 @@
|
|||||||
.finally(() => { loadingLinkList = false; });
|
.finally(() => { loadingLinkList = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
function closeLinkPicker() { linkPickerOpen = false; }
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
async function openCoverPicker() {
|
||||||
if (!store.activeManga) return;
|
coverPickerOpen = true;
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
if (allMangaForLink.length) return;
|
||||||
else linkManga(store.activeManga.id, other.id);
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { loadingLinkList = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCategory(cat: Category) {
|
async function toggleCategory(cat: Category) {
|
||||||
@@ -598,6 +612,7 @@
|
|||||||
onAutoOpen={() => autoOpen = true}
|
onAutoOpen={() => autoOpen = true}
|
||||||
onMarkersToggle={() => markersOpen = !markersOpen}
|
onMarkersToggle={() => markersOpen = !markersOpen}
|
||||||
onLinkPickerOpen={openLinkPicker}
|
onLinkPickerOpen={openLinkPicker}
|
||||||
|
onCoverPickerOpen={openCoverPicker}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="list-wrap">
|
<div class="list-wrap">
|
||||||
@@ -684,40 +699,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
{#if coverPickerOpen && store.activeManga}
|
||||||
<div class="link-backdrop" role="presentation"
|
<CoverPickerPanel
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
manga={manga ?? store.activeManga}
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
allManga={allMangaForLink}
|
||||||
<div class="link-modal">
|
onClose={() => coverPickerOpen = false}
|
||||||
<div class="link-header">
|
/>
|
||||||
<span class="link-title">Link as same series</span>
|
|
||||||
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search. Click a linked entry again to unlink.</p>
|
|
||||||
<div class="link-search-wrap">
|
|
||||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
|
||||||
</div>
|
|
||||||
<div class="link-list">
|
|
||||||
{#if loadingLinkList}
|
|
||||||
<p class="link-empty">Loading…</p>
|
|
||||||
{:else if linkPickerResults.length === 0}
|
|
||||||
<p class="link-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each linkPickerResults as m (m.id)}
|
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
{#if linkPickerOpen && store.activeManga}
|
||||||
</div>
|
<SeriesLinkPanel
|
||||||
|
manga={manga ?? store.activeManga}
|
||||||
|
allManga={allMangaForLink}
|
||||||
|
onClose={closeLinkPicker}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -731,82 +726,6 @@
|
|||||||
|
|
||||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
.link-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;
|
|
||||||
}
|
|
||||||
.link-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;
|
|
||||||
}
|
|
||||||
.link-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;
|
|
||||||
}
|
|
||||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.link-close {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.link-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;
|
|
||||||
}
|
|
||||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-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);
|
|
||||||
}
|
|
||||||
.link-search:focus { border-color: var(--border-strong); }
|
|
||||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.link-list::-webkit-scrollbar { display: none; }
|
|
||||||
.link-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);
|
|
||||||
}
|
|
||||||
.link-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);
|
|
||||||
}
|
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
|
||||||
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-status {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
|
||||||
color: var(--text-faint); flex-shrink: 0; padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
.markers-panel-overlay {
|
.markers-panel-overlay {
|
||||||
position: fixed; inset: 0; z-index: var(--z-settings);
|
position: fixed; inset: 0; z-index: var(--z-settings);
|
||||||
display: flex; align-items: stretch; justify-content: flex-start;
|
display: flex; align-items: stretch; justify-content: flex-start;
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
|
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
|
||||||
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
|
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
|
||||||
MapPin, Gear, Trash, X,
|
MapPin, Gear, Trash, Image,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { resolvedCover } from "@features/series/lib/coverResolver";
|
||||||
import type { Manga, Chapter, Category } from "@types";
|
import type { Manga, Chapter, Category } from "@types";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte";
|
import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte";
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
onAutoOpen: () => void;
|
onAutoOpen: () => void;
|
||||||
onMarkersToggle: () => void;
|
onMarkersToggle: () => void;
|
||||||
onLinkPickerOpen: () => void;
|
onLinkPickerOpen: () => void;
|
||||||
|
onCoverPickerOpen: () => void;
|
||||||
togglingLibrary: boolean;
|
togglingLibrary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
mangaCategories,
|
mangaCategories,
|
||||||
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
|
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
|
||||||
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen,
|
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen,
|
||||||
togglingLibrary,
|
onCoverPickerOpen, togglingLibrary,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let manageOpen: boolean = $state(false);
|
let manageOpen: boolean = $state(false);
|
||||||
@@ -62,6 +64,10 @@
|
|||||||
store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0
|
store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasCoverOverride = $derived(
|
||||||
|
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
|
||||||
|
);
|
||||||
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<Thumbnail src={store.activeManga!.thumbnailUrl} alt={store.activeManga!.title} class="cover" />
|
<Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loadingManga}
|
{#if loadingManga}
|
||||||
@@ -157,6 +163,9 @@
|
|||||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="detail-action-btn" class:detail-action-active={hasCoverOverride} onclick={onCoverPickerOpen}>
|
||||||
|
<Image size={12} weight={hasCoverOverride ? "fill" : "light"} /> Cover Image
|
||||||
|
</button>
|
||||||
<button class="detail-action-btn" onclick={onTrackingOpen}>
|
<button class="detail-action-btn" onclick={onTrackingOpen}>
|
||||||
<ChartLineUp size={12} weight="light" /> Tracking
|
<ChartLineUp size={12} weight="light" /> Tracking
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { store, linkManga } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
|
||||||
|
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("./autoLinkWorker.ts", import.meta.url),
|
||||||
|
{ type: "module" },
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent<number[]>) => {
|
||||||
|
const matches = e.data;
|
||||||
|
for (const id of matches) linkManga(focal.id, id);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(matches.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = () => { worker.terminate(); resolve(0); };
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
focalTitle: focal.title,
|
||||||
|
focalId: focal.id,
|
||||||
|
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
|
||||||
|
linkedIds: store.settings.mangaLinks?.[focal.id] ?? [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface WorkerMsg {
|
||||||
|
focalTitle: string;
|
||||||
|
focalId: number;
|
||||||
|
allManga: { id: number; title: string }[];
|
||||||
|
linkedIds: 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||||
|
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||||
|
const matches: number[] = [];
|
||||||
|
|
||||||
|
for (const m of allManga) {
|
||||||
|
if (m.id === focalId) continue;
|
||||||
|
if (linkedIds.includes(m.id)) continue;
|
||||||
|
if (titleSimilarity(focalTitle, m.title) >= 0.4) matches.push(m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage(matches);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { getBlobUrl } from "@core/cache/imageCache";
|
||||||
|
|
||||||
|
const HASH_SIZE = 8;
|
||||||
|
const HASH_PIXELS = HASH_SIZE * HASH_SIZE;
|
||||||
|
const CANVAS_SIZE = 32;
|
||||||
|
const DUPE_THRESH = 10;
|
||||||
|
|
||||||
|
const hashCache = new Map<string, Uint8Array>();
|
||||||
|
|
||||||
|
async function loadGrayscale(blobUrl: string): Promise<Uint8ClampedArray> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvas.height = CANVAS_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||||
|
const { data } = ctx.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
||||||
|
const gray = new Uint8ClampedArray(CANVAS_SIZE * CANVAS_SIZE);
|
||||||
|
for (let i = 0; i < gray.length; i++) {
|
||||||
|
const o = i * 4;
|
||||||
|
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||||
|
}
|
||||||
|
resolve(gray);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = blobUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dct8x8(gray: Uint8ClampedArray): number[] {
|
||||||
|
const N = CANVAS_SIZE;
|
||||||
|
const step = N / HASH_SIZE;
|
||||||
|
const block: number[] = [];
|
||||||
|
|
||||||
|
for (let by = 0; by < HASH_SIZE; by++) {
|
||||||
|
for (let bx = 0; bx < HASH_SIZE; bx++) {
|
||||||
|
let sum = 0, count = 0;
|
||||||
|
for (let dy = 0; dy < step; dy++) {
|
||||||
|
for (let dx = 0; dx < step; dx++) {
|
||||||
|
sum += gray[(by * step + dy) * N + (bx * step + dx)];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block.push(sum / count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pHash(block: number[]): Uint8Array {
|
||||||
|
const mean = block.reduce((a, b) => a + b, 0) / HASH_PIXELS;
|
||||||
|
const bits = new Uint8Array(Math.ceil(HASH_PIXELS / 8));
|
||||||
|
for (let i = 0; i < HASH_PIXELS; i++) {
|
||||||
|
if (block[i] >= mean) bits[i >> 3] |= 1 << (i & 7);
|
||||||
|
}
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hammingDistance(a: Uint8Array, b: Uint8Array): number {
|
||||||
|
let dist = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
let x = a[i] ^ b[i];
|
||||||
|
while (x) { dist += x & 1; x >>= 1; }
|
||||||
|
}
|
||||||
|
return dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHash(url: string, priority = -1): Promise<Uint8Array | null> {
|
||||||
|
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||||
|
try {
|
||||||
|
const blob = await getBlobUrl(url, priority);
|
||||||
|
const gray = await loadGrayscale(blob);
|
||||||
|
const block = dct8x8(gray);
|
||||||
|
const hash = pHash(block);
|
||||||
|
hashCache.set(url, hash);
|
||||||
|
return hash;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areDuplicates(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
return hammingDistance(a, b) <= DUPE_THRESH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHashCache(): void {
|
||||||
|
hashCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { store } from "@store/state.svelte";
|
||||||
|
import { searchWithScore } from "@core/algorithms/search";
|
||||||
|
import { getHash, areDuplicates } from "@features/series/lib/coverHash";
|
||||||
|
|
||||||
|
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null };
|
||||||
|
|
||||||
|
export type CoverCandidate = {
|
||||||
|
mangaId: number;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FUZZY_SCORE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.search = "";
|
||||||
|
return u.href.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedCover(mangaId: number, ownUrl: string): string {
|
||||||
|
return store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuzzyMatchIds(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): number[] {
|
||||||
|
const results = searchWithScore(
|
||||||
|
[...mangaById.values()].filter(m => m.id !== mangaId),
|
||||||
|
title,
|
||||||
|
m => m.title,
|
||||||
|
);
|
||||||
|
return results
|
||||||
|
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
|
||||||
|
.map(r => r.item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverCandidatesSync(
|
||||||
|
mangaId: number,
|
||||||
|
title: string,
|
||||||
|
ownUrl: string,
|
||||||
|
mangaById: Map<number, CoverManga & { title: string }>,
|
||||||
|
): CoverCandidate[] {
|
||||||
|
const linkedIds = store.getLinkedMangaIds(mangaId);
|
||||||
|
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById);
|
||||||
|
const current = store.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl;
|
||||||
|
|
||||||
|
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]));
|
||||||
|
|
||||||
|
const raw: { mangaId: number; url: string; label: string }[] = [
|
||||||
|
{ mangaId, url: ownUrl, label: "This source" },
|
||||||
|
...allIds.flatMap(id => {
|
||||||
|
const m = mangaById.get(id);
|
||||||
|
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return raw
|
||||||
|
.filter(c => {
|
||||||
|
const key = normalizeUrl(c.url);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
|
||||||
|
const hashes = await Promise.all(candidates.map(c => getHash(c.url)));
|
||||||
|
|
||||||
|
const keptIndices: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const hi = hashes[i];
|
||||||
|
if (!hi) { keptIndices.push(i); continue; }
|
||||||
|
|
||||||
|
const isDupe = keptIndices.some(j => {
|
||||||
|
const hj = hashes[j];
|
||||||
|
return hj ? areDuplicates(hi, hj) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDupe) keptIndices.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keptIndices.map(i => candidates[i]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, CaretLeft, CaretRight } from "phosphor-svelte";
|
||||||
|
import { setPref } from "@features/series/lib/mangaPrefs";
|
||||||
|
import { coverCandidatesSync, dedupeByImage } from "@features/series/lib/coverResolver";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
manga: Manga;
|
||||||
|
allManga: Manga[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { manga, allManga, onClose }: Props = $props();
|
||||||
|
|
||||||
|
type MangaWithTitle = Manga & { title: string };
|
||||||
|
|
||||||
|
const mangaById = $derived(new Map(allManga.map(m => [m.id, m as MangaWithTitle])));
|
||||||
|
|
||||||
|
const syncCandidates = $derived(
|
||||||
|
coverCandidatesSync(manga.id, manga.title, manga.thumbnailUrl, mangaById)
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidates = $state(syncCandidates);
|
||||||
|
let hashingDone = $state(false);
|
||||||
|
let index = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const snap = syncCandidates;
|
||||||
|
candidates = snap;
|
||||||
|
hashingDone = false;
|
||||||
|
index = Math.max(0, snap.findIndex(c => c.isActive));
|
||||||
|
|
||||||
|
dedupeByImage(snap).then(deduped => {
|
||||||
|
const activeInDeduped = deduped.some(c => c.isActive);
|
||||||
|
candidates = activeInDeduped
|
||||||
|
? deduped
|
||||||
|
: (() => { const a = snap.find(c => c.isActive); return a ? [a, ...deduped.filter(c => !c.isActive)] : deduped; })();
|
||||||
|
index = Math.max(0, candidates.findIndex(c => c.isActive));
|
||||||
|
hashingDone = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = $derived(candidates[index]);
|
||||||
|
|
||||||
|
function prev() { index = (index - 1 + candidates.length) % candidates.length; }
|
||||||
|
function next() { index = (index + 1) % candidates.length; }
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!current) return;
|
||||||
|
if (current.mangaId === manga.id) setPref(manga.id, "coverUrl", undefined as any);
|
||||||
|
else setPref(manga.id, "coverUrl", current.url);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
|
||||||
|
if (e.key === "ArrowRight") { e.preventDefault(); next(); }
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); confirm(); }
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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="Choose cover image" onkeydown={onKeydown}>
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">Cover Image</span>
|
||||||
|
{#if !hashingDone}
|
||||||
|
<span class="comparing">Comparing…</span>
|
||||||
|
{/if}
|
||||||
|
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<button class="arrow" onclick={prev} disabled={candidates.length <= 1} aria-label="Previous">
|
||||||
|
<CaretLeft size={18} weight="bold" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="cover-wrap">
|
||||||
|
{#if current}
|
||||||
|
<Thumbnail src={current.url} alt="" class="cover-img" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="arrow" onclick={next} disabled={candidates.length <= 1} aria-label="Next">
|
||||||
|
<CaretRight size={18} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if candidates.length > 1}
|
||||||
|
<div class="filmstrip">
|
||||||
|
{#each candidates as c, i (c.url)}
|
||||||
|
<button
|
||||||
|
class="film-thumb"
|
||||||
|
class:film-active={i === index}
|
||||||
|
onclick={() => index = i}
|
||||||
|
aria-label="Cover {i + 1}"
|
||||||
|
>
|
||||||
|
<Thumbnail src={c.url} alt="" class="film-img" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button class="confirm-btn" onclick={confirm}>Use this cover</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
z-index: calc(var(--z-settings) + 2);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(380px, calc(100vw - 48px));
|
||||||
|
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;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.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; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary); flex: 1;
|
||||||
|
}
|
||||||
|
.comparing {
|
||||||
|
font-family: var(--font-ui); font-size: 9px;
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 26px; height: 26px; 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); }
|
||||||
|
.stage {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||||
|
}
|
||||||
|
.cover-wrap {
|
||||||
|
flex: 1; max-width: 200px; aspect-ratio: 2/3;
|
||||||
|
border-radius: var(--radius-md); overflow: hidden;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.arrow {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 36px; height: 36px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.arrow:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
.arrow:disabled { opacity: 0.2; cursor: default; }
|
||||||
|
.filmstrip {
|
||||||
|
display: flex; gap: var(--sp-2); align-items: center; justify-content: center;
|
||||||
|
padding: 0 var(--sp-4) var(--sp-4);
|
||||||
|
overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.filmstrip::-webkit-scrollbar { display: none; }
|
||||||
|
.film-thumb {
|
||||||
|
flex-shrink: 0; width: 44px; aspect-ratio: 2/3;
|
||||||
|
border-radius: var(--radius-sm); overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
cursor: pointer; padding: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: border-color var(--t-base), opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.film-thumb:hover { opacity: 0.8; }
|
||||||
|
.film-active { border-color: var(--accent); opacity: 1; }
|
||||||
|
:global(.film-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.footer { padding: 0 var(--sp-4) var(--sp-4); flex-shrink: 0; }
|
||||||
|
.confirm-btn {
|
||||||
|
width: 100%; padding: 9px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent); border: 1px solid var(--accent);
|
||||||
|
color: var(--accent-contrast, #fff);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-btn:hover { opacity: 0.88; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, LinkSimple, LinkBreak, Sparkle } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import { store, linkManga, unlinkManga } from "@store/state.svelte";
|
||||||
|
import type { Manga } from "@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedIds = $derived(store.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.4)
|
||||||
|
.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="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>
|
||||||
|
.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>
|
||||||
@@ -63,7 +63,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">History</p>
|
<p class="s-section-title">Series</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries and notify you of new matches</span></div>
|
||||||
|
<button role="switch" aria-checked={store.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={store.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(store.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{store.history.length} entries</span></div>
|
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{store.history.length} entries</span></div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import {
|
import {
|
||||||
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
|
||||||
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak,
|
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
@@ -10,10 +10,14 @@
|
|||||||
import { FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations";
|
import { FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations";
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
import {
|
import {
|
||||||
store, openReader, addToast, linkManga, unlinkManga,
|
store, openReader, addToast,
|
||||||
setPreviewManga, setActiveManga, setNavPage, setGenreFilter,
|
setPreviewManga, setActiveManga, setNavPage, setGenreFilter,
|
||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted, addBookmark,
|
||||||
} from "@store/state.svelte";
|
} from "@store/state.svelte";
|
||||||
|
import { resolvedCover } from "@features/series/lib/coverResolver";
|
||||||
|
import CoverPickerPanel from "@features/series/panels/CoverPickerPanel.svelte";
|
||||||
|
import SeriesLinkPanel from "@features/series/panels/SeriesLinkPanel.svelte";
|
||||||
|
import { autoLinkLibrary } from "@features/series/lib/autoLink";
|
||||||
import type { Manga, Chapter, Category } from "@types/index";
|
import type { Manga, Chapter, Category } from "@types/index";
|
||||||
|
|
||||||
|
|
||||||
@@ -33,25 +37,18 @@
|
|||||||
let fetchError: string | null = $state(null);
|
let fetchError: string | null = $state(null);
|
||||||
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
|
||||||
|
|
||||||
|
|
||||||
let linkPickerOpen = $state(false);
|
let linkPickerOpen = $state(false);
|
||||||
let linkSearch = $state("");
|
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
let loadingLinkList = $state(false);
|
let loadingLinkList = $state(false);
|
||||||
|
let coverPickerOpen = $state(false);
|
||||||
|
|
||||||
const linkedIds = $derived(
|
const linkedIds = $derived(
|
||||||
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkPickerResults = $derived.by(() => {
|
const hasCoverOverride = $derived(
|
||||||
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
|
!!store.settings.mangaPrefs?.[store.previewManga?.id ?? -1]?.coverUrl
|
||||||
const q = linkSearch.trim().toLowerCase();
|
);
|
||||||
const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others;
|
|
||||||
const linked = filtered.filter((m) => linkedIds.includes(m.id));
|
|
||||||
const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30);
|
|
||||||
return [...linked, ...rest];
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const displayManga = $derived(manga ?? store.previewManga);
|
const displayManga = $derived(manga ?? store.previewManga);
|
||||||
const totalCount = $derived(chapters.length);
|
const totalCount = $derived(chapters.length);
|
||||||
@@ -128,7 +125,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openLinkPicker() {
|
async function openLinkPicker() {
|
||||||
linkPickerOpen = true; linkSearch = "";
|
linkPickerOpen = true;
|
||||||
|
if (allMangaForLink.length) return;
|
||||||
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then((d) => {
|
||||||
|
allMangaForLink = d.mangas.nodes;
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { loadingLinkList = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkPicker() { linkPickerOpen = false; }
|
||||||
|
|
||||||
|
async function openCoverPicker() {
|
||||||
|
coverPickerOpen = true;
|
||||||
if (allMangaForLink.length) return;
|
if (allMangaForLink.length) return;
|
||||||
loadingLinkList = true;
|
loadingLinkList = true;
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
@@ -137,18 +148,28 @@
|
|||||||
.finally(() => { loadingLinkList = false; });
|
.finally(() => { loadingLinkList = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
|
||||||
|
|
||||||
function handleLink(other: Manga) {
|
|
||||||
if (!store.previewManga) return;
|
|
||||||
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
|
|
||||||
else linkManga(store.previewManga.id, other.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (store.previewManga) {
|
const shouldAutoLink = store.settings.autoLinkOnOpen;
|
||||||
load(store.previewManga.id);
|
const focal = store.previewManga;
|
||||||
loadCategories(store.previewManga.id);
|
if (focal) {
|
||||||
|
load(focal.id);
|
||||||
|
loadCategories(focal.id);
|
||||||
|
if (shouldAutoLink) {
|
||||||
|
if (allMangaForLink.length) {
|
||||||
|
autoLinkLibrary(focal, allMangaForLink)
|
||||||
|
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
|
||||||
|
} else {
|
||||||
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then((d) => {
|
||||||
|
allMangaForLink = d.mangas.nodes;
|
||||||
|
return autoLinkLibrary(focal, d.mangas.nodes);
|
||||||
|
})
|
||||||
|
.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; });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -343,7 +364,7 @@
|
|||||||
|
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
|
<Thumbnail src={resolvedCover(store.previewManga.id, store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="cover-spinner">
|
<div class="cover-spinner">
|
||||||
<CircleNotch size={18} weight="light" class="anim-spin" />
|
<CircleNotch size={18} weight="light" class="anim-spin" />
|
||||||
@@ -435,6 +456,17 @@
|
|||||||
{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}
|
{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
class:active={hasCoverOverride}
|
||||||
|
onclick={openCoverPicker}
|
||||||
|
>
|
||||||
|
<span class="action-icon">
|
||||||
|
<Image size={13} weight={hasCoverOverride ? "fill" : "light"} />
|
||||||
|
</span>
|
||||||
|
<span class="action-label">Cover Image</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -632,53 +664,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
{#if linkPickerOpen}
|
{#if linkPickerOpen && store.previewManga}
|
||||||
<div
|
<SeriesLinkPanel
|
||||||
class="link-backdrop"
|
manga={displayManga ?? store.previewManga}
|
||||||
role="presentation"
|
allManga={allMangaForLink}
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
onClose={closeLinkPicker}
|
||||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
|
||||||
>
|
|
||||||
<div class="link-modal">
|
|
||||||
<div class="link-header">
|
|
||||||
<span class="link-title">Link as same series</span>
|
|
||||||
<button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
<p class="link-hint">
|
|
||||||
Mark two manga as the same series so duplicates are merged in search.
|
|
||||||
Click a linked entry again to unlink.
|
|
||||||
</p>
|
|
||||||
<div class="link-search-wrap">
|
|
||||||
<input
|
|
||||||
class="link-search"
|
|
||||||
placeholder="Search your library…"
|
|
||||||
bind:value={linkSearch}
|
|
||||||
use:focusAction
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="link-list">
|
|
||||||
{#if loadingLinkList}
|
|
||||||
<p class="link-empty">Loading…</p>
|
|
||||||
{:else if linkPickerResults.length === 0}
|
|
||||||
<p class="link-empty">No results</p>
|
|
||||||
{:else}
|
|
||||||
{#each linkPickerResults as m (m.id)}
|
|
||||||
{@const isLinked = linkedIds.includes(m.id)}
|
|
||||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
|
|
||||||
<div class="link-info">
|
|
||||||
<span class="link-manga-title">{m.title}</span>
|
|
||||||
{#if m.source?.displayName}
|
|
||||||
<span class="link-source">{m.source.displayName}</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
{#if coverPickerOpen && store.previewManga}
|
||||||
</button>
|
<CoverPickerPanel
|
||||||
{/each}
|
manga={displayManga ?? store.previewManga}
|
||||||
{/if}
|
allManga={allMangaForLink}
|
||||||
</div>
|
onClose={() => { coverPickerOpen = false; }}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<script module>
|
<script module>
|
||||||
@@ -911,54 +910,6 @@
|
|||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||||
.meta-link:hover { opacity: 0.75; }
|
.meta-link:hover { opacity: 0.75; }
|
||||||
|
|
||||||
.link-backdrop {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: rgba(0,0,0,0.65);
|
|
||||||
z-index: calc(var(--z-settings) + 1);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
|
||||||
animation: fadeIn 0.1s ease both;
|
|
||||||
}
|
|
||||||
.link-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;
|
|
||||||
}
|
|
||||||
.link-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; }
|
|
||||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
|
||||||
.link-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; }
|
|
||||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
|
||||||
.link-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);
|
|
||||||
}
|
|
||||||
.link-search:focus { border-color: var(--border-strong); }
|
|
||||||
.link-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
|
||||||
.link-list::-webkit-scrollbar { display: none; }
|
|
||||||
.link-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); }
|
|
||||||
.link-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);
|
|
||||||
}
|
|
||||||
.link-row:hover { background: var(--bg-raised); }
|
|
||||||
.link-row-linked { background: var(--accent-muted) !important; }
|
|
||||||
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
|
||||||
.link-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
||||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
|
||||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
|
||||||
|
|
||||||
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
||||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface MangaPrefs {
|
|||||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||||
preferredScanlator: string; scanlatorFilter: string[];
|
preferredScanlator: string; scanlatorFilter: string[];
|
||||||
autoDownloadScanlators: string[];
|
autoDownloadScanlators: string[];
|
||||||
|
coverUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||||
@@ -117,6 +118,7 @@ export interface Settings {
|
|||||||
trackerSyncBackThreshold: number | null;
|
trackerSyncBackThreshold: number | null;
|
||||||
trackerRespectScanlatorFilter: boolean;
|
trackerRespectScanlatorFilter: boolean;
|
||||||
pinchZoom?: boolean;
|
pinchZoom?: boolean;
|
||||||
|
autoLinkOnOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -153,4 +155,5 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
trackerSyncBackThreshold: 20,
|
trackerSyncBackThreshold: 20,
|
||||||
trackerRespectScanlatorFilter: true,
|
trackerRespectScanlatorFilter: true,
|
||||||
pinchZoom: false,
|
pinchZoom: false,
|
||||||
|
autoLinkOnOpen: false,
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user