Chore: Merge Discover into Search (WIP)

This commit is contained in:
Youwes09
2026-04-14 11:09:53 -05:00
parent d98ca76036
commit 0ff148f720
2 changed files with 389 additions and 144 deletions
+22 -2
View File
@@ -157,11 +157,31 @@
$effect(() => { $effect(() => {
if (!appReady) return; if (!appReady) return;
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
let paused = false;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error);
};
poll(); poll();
pollInterval = setInterval(poll, 2000); pollInterval = setInterval(poll, 2000);
return () => clearInterval(pollInterval);
const onVisibility = () => { paused = document.hidden; };
document.addEventListener("visibilitychange", onVisibility);
let unlistenFocus: (() => void) | undefined;
win.onFocusChanged(({ payload: focused }) => {
paused = !focused;
}).then(fn => { unlistenFocus = fn; });
return () => {
clearInterval(pollInterval);
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus?.();
};
}); });
async function checkForUpdateSilently() { async function checkForUpdateSilently() {
+336 -111
View File
@@ -2,11 +2,97 @@
import { onDestroy, untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
import { gql } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte"; import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries"; import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte"; import { store, setSearchPrefill, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source, Category } from "../../lib/types";
const DISCOVER_PAGES = 3;
const DISCOVER_LIMIT = 200;
const DISCOVER_CONCUR = 6;
function dKey(srcId: string, page: number) {
return `${srcId}|POPULAR|All:p${page}`;
}
let disc_results: Manga[] = $state([]);
let disc_loading = $state(false);
let disc_abortCtrl: AbortController | null = null;
function disc_filterOut(mangas: Manga[]): Manga[] {
return dedupeMangaByTitle(
dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))),
store.settings.mangaLinks,
);
}
function disc_rotatedSources(sources: Source[]): Source[] {
const lang = store.settings?.preferredExtensionLang || "en";
const eligible = sources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
const map = new Map<string, Source>();
for (const s of eligible) {
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
if (s.lang === lang && existing.lang !== lang) map.set(s.name, s);
}
return Array.from(map.values());
}
function disc_push(incoming: Manga[]) {
const filtered = disc_filterOut(incoming);
if (!filtered.length) return;
disc_results = dedupeMangaByTitle(
dedupeMangaById([...disc_results, ...filtered]),
store.settings.mangaLinks,
).slice(0, DISCOVER_LIMIT);
}
async function disc_fanOut(sources: Source[], signal: AbortSignal) {
const srcs = disc_rotatedSources(sources);
if (!srcs.length) return;
let i = 0;
async function worker() {
while (i < srcs.length) {
if (signal.aborted) return;
const src = srcs[i++];
for (let page = 1; page <= DISCOVER_PAGES; page++) {
if (signal.aborted) return;
const key = dKey(src.id, page);
let mangas: Manga[];
if (store.discoverCache?.has(key)) {
mangas = store.discoverCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page, query: null },
signal,
).then(d => d.fetchSourceManga).catch(() => null);
if (!result || signal.aborted) break;
mangas = result.mangas;
store.discoverCache?.set(key, mangas);
if (!result.hasNextPage) { disc_push(mangas); break; }
}
disc_push(mangas);
}
}
}
await Promise.all(Array.from({ length: Math.min(DISCOVER_CONCUR, srcs.length) }, worker));
}
function disc_start(sources: Source[]) {
if (disc_results.length > 0) return;
disc_abortCtrl?.abort();
const ctrl = new AbortController();
disc_abortCtrl = ctrl;
disc_loading = true;
disc_fanOut(sources, ctrl.signal)
.catch(() => {})
.finally(() => { if (!ctrl.signal.aborted) disc_loading = false; });
}
type SearchTab = "keyword" | "tag" | "source"; type SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR"; type TagMode = "AND" | "OR";
@@ -18,7 +104,6 @@
error: string | null; error: string | null;
} }
// ── Cached manga entry for tag/source browsing ────────────────────────────
interface CachedManga { interface CachedManga {
id: number; id: number;
title: string; title: string;
@@ -27,11 +112,11 @@
status: string; status: string;
genre: string[]; genre: string[];
sourceId: string; sourceId: string;
genreEnriched: boolean; // true once fetchManga has been called for this entry genreEnriched: boolean;
} }
const CONCURRENCY = 6; const CONCURRENCY = 6;
const POPULAR_PAGES = 3; // pages to pre-fetch per source const POPULAR_PAGES = 3;
const COMMON_GENRES = [ const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance", "Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -49,7 +134,6 @@
{ value: "UNKNOWN", label: "Unknown" }, { value: "UNKNOWN", label: "Unknown" },
]; ];
// ── Concurrency helper ────────────────────────────────────────────────────
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> { async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0; let i = 0;
async function worker() { async function worker() {
@@ -62,21 +146,16 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
} }
// ── Source dedup by preferred lang ────────────────────────────────────────
// For each unique source name, keep only the preferred-lang variant (or the
// first alphabetically if preferred lang isn't available). This collapses
// MangaDex (60+ lang variants) and Manga Ball (40+) down to one each.
function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] { function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] {
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of sources) { for (const s of sources) {
if (s.id === "0") continue; // skip local source if (s.id === "0") continue;
const key = s.name; const key = s.name;
const existing = map.get(key); const existing = map.get(key);
if (!existing) { if (!existing) {
map.set(key, s); map.set(key, s);
continue; continue;
} }
// Prefer the preferred lang; otherwise keep alphabetically first lang
const existingIsPreferred = existing.lang === preferredLang; const existingIsPreferred = existing.lang === preferredLang;
const newIsPreferred = s.lang === preferredLang; const newIsPreferred = s.lang === preferredLang;
if (newIsPreferred && !existingIsPreferred) { if (newIsPreferred && !existingIsPreferred) {
@@ -88,15 +167,12 @@
return Array.from(map.values()); return Array.from(map.values());
} }
// ── In-memory source manga cache ─────────────────────────────────────────
// Keyed by manga id. Shared across tag searches for the session.
const sourceCache = new Map<number, CachedManga>(); const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false); // true once phase 1 (popular fetch) is done let sourceCacheReady = $state(false);
let sourceCacheLoading = $state(false); let sourceCacheLoading = $state(false);
let sourceCacheEnriching = $state(false); // true while background genre enrichment runs let sourceCacheEnriching = $state(false);
let sourceCacheAbort: AbortController | null = null; let sourceCacheAbort: AbortController | null = null;
// Phase 1: fetch 3 pages of POPULAR per deduped source, store in sourceCache
async function buildSourceCache(sources: Source[], signal: AbortSignal) { async function buildSourceCache(sources: Source[], signal: AbortSignal) {
const pages = [1, 2, 3]; const pages = [1, 2, 3];
const tasks: { src: Source; page: number }[] = []; const tasks: { src: Source; page: number }[] = [];
@@ -129,12 +205,10 @@
} }
} catch (e: any) { } catch (e: any) {
if (e?.name === "AbortError") return; if (e?.name === "AbortError") return;
// Individual source failures are silently skipped
} }
}, signal); }, signal);
} }
// Phase 2: background genre enrichment — only for entries with empty genre[]
async function enrichGenres(signal: AbortSignal) { async function enrichGenres(signal: AbortSignal) {
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched); const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
if (!unenriched.length) return; if (!unenriched.length) return;
@@ -156,7 +230,6 @@
} }
} catch (e: any) { } catch (e: any) {
if (e?.name === "AbortError") return; if (e?.name === "AbortError") return;
// Mark as enriched anyway so we don't retry endlessly
const updated = sourceCache.get(entry.id); const updated = sourceCache.get(entry.id);
if (updated) updated.genreEnriched = true; if (updated) updated.genreEnriched = true;
} }
@@ -164,7 +237,6 @@
if (!signal.aborted) sourceCacheEnriching = false; if (!signal.aborted) sourceCacheEnriching = false;
} }
// MANGAS_BY_GENRE — local library query
const MANGAS_BY_GENRE = ` const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) { mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
@@ -178,7 +250,6 @@
} }
`; `;
// Build GraphQL filter for local library query
function buildTagFilter( function buildTagFilter(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
@@ -202,15 +273,12 @@
return { and: [genrePart, statusPart] }; return { and: [genrePart, statusPart] };
} }
// Filter the in-memory source cache by active tags + statuses
function filterSourceCache( function filterSourceCache(
tags: string[], tags: string[],
mode: TagMode, mode: TagMode,
statuses: string[], statuses: string[],
): CachedManga[] { ): CachedManga[] {
return [...sourceCache.values()].filter((m) => { return [...sourceCache.values()].filter((m) => {
if (!shouldHideNsfw(m as any, store.settings)) return false; // keep non-nsfw
// Actually: shouldHideNsfw returns true when we SHOULD hide, so:
if (shouldHideNsfw(m as any, store.settings)) return false; if (shouldHideNsfw(m as any, store.settings)) return false;
const statusMatch = const statusMatch =
@@ -230,7 +298,6 @@
}); });
} }
// ── Global state ──────────────────────────────────────────────────────────
let tab: SearchTab = $state("keyword"); let tab: SearchTab = $state("keyword");
let preferredLang = store.settings?.preferredExtensionLang ?? "en"; let preferredLang = store.settings?.preferredExtensionLang ?? "en";
@@ -249,13 +316,12 @@
} }
}); });
// Load sources then kick off the cache build
loadingSources = true; loadingSources = true;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { .then((d) => {
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0"); allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
// Kick off source cache build immediately after sources load
startSourceCacheBuild(); startSourceCacheBuild();
disc_start(allSources);
}) })
.catch(console.error) .catch(console.error)
.finally(() => { loadingSources = false; }); .finally(() => { loadingSources = false; });
@@ -276,7 +342,6 @@
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
sourceCacheReady = true; sourceCacheReady = true;
sourceCacheLoading = false; sourceCacheLoading = false;
// Phase 2: enrich genres in background at low priority
enrichGenres(ctrl.signal); enrichGenres(ctrl.signal);
}) })
.catch((e) => { .catch((e) => {
@@ -290,12 +355,12 @@
// ── Keyword tab ─────────────────────────────────────────────────────────── // ── Keyword tab ───────────────────────────────────────────────────────────
let kw_query = $state(""); let kw_query = $state("");
let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]); let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false); let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set()); let kw_selectedLangs: Set<string> = $state(new Set());
let kw_inputEl: HTMLInputElement | null = $state(null); let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null; let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { $effect(() => {
if (allSources.length) { if (allSources.length) {
@@ -307,7 +372,7 @@
}); });
$effect(() => { $effect(() => {
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) { if (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill; const q = pendingPrefill;
pendingPrefill = ""; pendingPrefill = "";
kw_query = q; kw_query = q;
@@ -315,6 +380,20 @@
} }
}); });
$effect(() => {
const q = kw_query;
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
if (!q.trim()) {
kw_abortCtrl?.abort();
kw_results = [];
return;
}
kw_debounceTimer = setTimeout(() => {
kwDoSearch(q);
}, 350);
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
});
function kwGetVisibleSources(): Source[] { function kwGetVisibleSources(): Source[] {
let filtered = allSources; let filtered = allSources;
if (kw_selectedLangs.size > 0) if (kw_selectedLangs.size > 0)
@@ -332,7 +411,6 @@
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
kw_abortCtrl = ctrl; kw_abortCtrl = ctrl;
kw_submitted = trimmed;
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })); kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
await runConcurrent(visible, async (src) => { await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
@@ -364,6 +442,17 @@
const kw_visibleCount = $derived(kwGetVisibleSources().length); const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0)); const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading)); const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
const kw_flatResults = $derived.by(() => {
const all = kw_results.flatMap((r) =>
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
);
return dedupeMangaByTitle(
dedupeMangaById(all),
store.settings.mangaLinks,
) as (Manga & { _sourceName?: string })[];
});
// ── Tag tab ─────────────────────────────────────────────────────────────── // ── Tag tab ───────────────────────────────────────────────────────────────
let tag_activeTags: string[] = $state([]); let tag_activeTags: string[] = $state([]);
@@ -371,7 +460,6 @@
let tag_tagMode: TagMode = $state("AND"); let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state(""); let tag_tagFilter = $state("");
// Local library results
let tag_localResults: Manga[] = $state([]); let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0); let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false); let tag_loadingLocal = $state(false);
@@ -380,10 +468,19 @@
let tag_localHasNext = $state(false); let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null; let tag_abortLocal: AbortController | null = null;
// Source cache results (filtered client-side from sourceCache)
let tag_searchSources = $state(false); let tag_searchSources = $state(false);
let tag_sourceFiltered: CachedManga[] = $state([]); let tag_sourceFiltered: CachedManga[] = $state([]);
// Active source fan-out results (Discover-style live fetch per genre tag)
let tag_sourceFanOut: Manga[] = $state([]);
let tag_fanOutLoading = $state(false);
let tag_fanOutAbort: AbortController | null = null;
// Context menu state
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const tag_filteredGenres = $derived.by(() => { const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase(); const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
@@ -391,7 +488,6 @@
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0); const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
// Local library fetch — triggered when tags or statuses change
$effect(() => { $effect(() => {
const _tags = tag_activeTags; const _tags = tag_activeTags;
const _mode = tag_tagMode; const _mode = tag_tagMode;
@@ -399,7 +495,6 @@
untrack(() => tagFetchLocal(_tags, _mode, _statuses)); untrack(() => tagFetchLocal(_tags, _mode, _statuses));
}); });
// Source cache filter — reactive to filters + cache readiness
$effect(() => { $effect(() => {
const _tags = tag_activeTags; const _tags = tag_activeTags;
const _mode = tag_tagMode; const _mode = tag_tagMode;
@@ -415,7 +510,74 @@
}); });
}); });
// Auto-enable source search when local results are sparse // Fan-out live source search when a single genre tag is active + sources enabled
$effect(() => {
const _tags = tag_activeTags;
const _search = tag_searchSources;
untrack(() => {
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
tagStartFanOut(_tags[0]);
} else {
tag_fanOutAbort?.abort();
tag_fanOutAbort = null;
tag_sourceFanOut = [];
tag_fanOutLoading = false;
}
});
});
async function tagStartFanOut(genre: string) {
tag_fanOutAbort?.abort();
const ctrl = new AbortController();
tag_fanOutAbort = ctrl;
tag_sourceFanOut = [];
tag_fanOutLoading = true;
const srcs = disc_rotatedSources(allSources);
const PAGES = 2;
await runConcurrent(srcs, async (src) => {
for (let page = 1; page <= PAGES; page++) {
if (ctrl.signal.aborted) return;
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
let mangas: Manga[];
let hasNextPage = false;
if (store.discoverCache?.has(cacheKey)) {
mangas = store.discoverCache.get(cacheKey)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
).then(d => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
hasNextPage = result.hasNextPage;
store.discoverCache?.set(cacheKey, mangas);
}
if (ctrl.signal.aborted) return;
const matching = mangas.filter(m =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genre.toLowerCase())
);
const toAdd = (matching.length ? matching : mangas).filter(m => !shouldHideNsfw(m, store.settings));
if (toAdd.length) {
tag_sourceFanOut = dedupeMangaByTitle(
dedupeMangaById([...tag_sourceFanOut, ...toAdd]),
store.settings.mangaLinks,
).slice(0, DISCOVER_LIMIT);
}
if (!hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
}
let tag_autoSearchFired = $state(false); let tag_autoSearchFired = $state(false);
$effect(() => { $effect(() => {
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) { if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
@@ -498,13 +660,59 @@
tag_searchSources = !tag_searchSources; tag_searchSources = !tag_searchSources;
} }
// Deduplicate merged results: local library wins over source cache on id, function openCtx(e: MouseEvent, m: Manga) {
// then dedupe by title to avoid cross-source duplicates. e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
cache.clear(CACHE_KEYS.LIBRARY);
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
}).catch(console.error),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map(cat => ({
label: (cat.mangas?.nodes ?? []).some((x: any) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
onClick: async () => {
const n = prompt("Folder name:");
if (!n?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
if (res) {
const cat = res.createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
},
},
];
}
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id))); const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived.by(() => { const tag_mergedResults = $derived.by(() => {
const localMapped = tag_localResults; const localMapped = tag_localResults;
const sourceMapped: Manga[] = tag_sourceFiltered const fanOutMapped = tag_sourceFanOut.filter(m => !tag_localIds.has(m.id));
.filter((m) => !tag_localIds.has(m.id)) const cacheMapped: Manga[] = tag_sourceFiltered
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some(f => f.id === m.id))
.map((m) => ({ .map((m) => ({
id: m.id, id: m.id,
title: m.title, title: m.title,
@@ -514,7 +722,7 @@
status: m.status, status: m.status,
} as Manga)); } as Manga));
return dedupeMangaByTitle( return dedupeMangaByTitle(
dedupeMangaById([...localMapped, ...sourceMapped]), dedupeMangaById([...localMapped, ...fanOutMapped, ...cacheMapped]),
store.settings.mangaLinks, store.settings.mangaLinks,
); );
}); });
@@ -540,13 +748,11 @@
} }
}); });
// Source tab visible sources — deduped by preferred lang when showing "all"
const src_visibleSources = $derived.by(() => { const src_visibleSources = $derived.by(() => {
const hide = (s: Source) => shouldHideSource(s, store.settings); const hide = (s: Source) => shouldHideSource(s, store.settings);
if (src_selectedLang !== "all") { if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s)); return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
} }
// Dedup by name, prefer preferredLang
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of allSources) { for (const s of allSources) {
if (hide(s)) continue; if (hide(s)) continue;
@@ -603,9 +809,12 @@
onDestroy(() => { onDestroy(() => {
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
tag_abortLocal?.abort(); tag_abortLocal?.abort();
tag_fanOutAbort?.abort();
src_abortCtrl?.abort(); src_abortCtrl?.abort();
sourceCacheAbort?.abort(); sourceCacheAbort?.abort();
disc_abortCtrl?.abort();
}); });
</script> </script>
@@ -646,10 +855,14 @@
bind:value={kw_query} bind:value={kw_query}
class="searchInput" class="searchInput"
placeholder="Search across sources…" placeholder="Search across sources…"
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)} use:focusOnMount
/> />
{#if kw_query} {#if kw_anyLoading}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}>×</button> <svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if kw_query}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; kw_results = []; kw_inputEl?.focus(); }}>×</button>
{/if} {/if}
{#if hasMultipleLangs} {#if hasMultipleLangs}
<button <button
@@ -663,15 +876,6 @@
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="searchBtn" onclick={() => kwDoSearch(kw_query)} disabled={!kw_query.trim() || loadingSources}>
{#if loadingSources}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else}
Search
{/if}
</button>
</div> </div>
{#if hasMultipleLangs && kw_showAdvanced} {#if hasMultipleLangs && kw_showAdvanced}
@@ -698,7 +902,38 @@
{/if} {/if}
</div> </div>
{#if !kw_submitted} {#if !kw_query.trim()}
{#if disc_loading && disc_results.length === 0}
<div class="discoverGrid">
{#each Array(24) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
</div>
{:else if disc_results.length > 0}
<div class="discoverHeader">
<span class="discoverLabel">Popular right now</span>
</div>
<div class="discoverGrid">
{#each disc_results as m (m.id)}
<button class="discCard" onclick={() => setPreviewManga(m)}>
<div class="discCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
<div class="discGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="discFooter">
<p class="discTitle">{m.title}</p>
{#if m.source?.displayName}<p class="discSource">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if disc_loading}
{#each Array(6) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
{/if}
</div>
{:else}
<div class="empty"> <div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true"> <svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/> <path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
@@ -711,70 +946,45 @@
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
{/if} {/if}
</p> </p>
{#if hasMultipleLangs && !kw_showAdvanced}
<button class="advancedLinkStandalone" onclick={() => (kw_showAdvanced = true)}>
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
</svg>
Adjust language filters
</button>
{/if}
</div> </div>
{/if}
{:else} {:else}
<div class="results"> {#if kw_flatResults.length > 0}
{#if kw_results.length === 0} <div class="discoverHeader">
<div class="empty"> <span class="discoverLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
<svg width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
</div> </div>
{/if} <div class="discoverGrid">
{#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)}
{#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)} <button class="discCard" onclick={() => setPreviewManga(m)}>
<div class="sourceSection"> <div class="discCoverWrap">
<div class="sourceHeader">
<Thumbnail src={source.iconUrl} alt={source.displayName} class="sourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="sourceName">{source.displayName}</span>
{#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if}
{#if loading}
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);margin-left:auto" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg>
{:else if mangas.length > 0}
<span class="resultCount">{mangas.length} results</span>
{/if}
</div>
{#if error}
<p class="sourceError">{error}</p>
{:else if loading}
<div class="sourceRow">
{#each Array(4) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if mangas.length > 0}
<div class="sourceRow">
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
<div class="discGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="discFooter">
<p class="discTitle">{m.title}</p>
{#if (m as any)._sourceName}<p class="discSource">{(m as any)._sourceName}</p>{/if}
</div>
</div> </div>
<p class="cardTitle">{m.title}</p>
</button> </button>
{/each} {/each}
</div> {#if kw_anyLoading}
{#each Array(6) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
{/if} {/if}
</div> </div>
{:else if kw_anyLoading}
<div class="discoverGrid">
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each} {/each}
</div>
{#if kw_allDone && !kw_hasResults} {:else if kw_allDone && !kw_hasResults}
<div class="empty"> <div class="empty">
<p class="emptyText">No results for "{kw_submitted}"</p> <p class="emptyText">No results for "{kw_query.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p> <p class="emptyHint">Try a different spelling or fewer words</p>
</div> </div>
{/if} {/if}
</div>
{/if} {/if}
{:else if tab === "tag"} {:else if tab === "tag"}
@@ -849,7 +1059,7 @@
disabled={!sourceCacheReady && !sourceCacheLoading} disabled={!sourceCacheReady && !sourceCacheLoading}
onclick={tagToggleSearchSources} onclick={tagToggleSearchSources}
> >
{#if sourceCacheLoading} {#if sourceCacheLoading || tag_fanOutLoading}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/> <path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
</svg> </svg>
@@ -900,7 +1110,7 @@
{:else if tag_mergedResults.length > 0} {:else if tag_mergedResults.length > 0}
<div class="tagGrid"> <div class="tagGrid">
{#each tag_mergedResults as m (m.id)} {#each tag_mergedResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
<div class="coverWrap"> <div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1068,6 +1278,10 @@
{/if} {/if}
</div> </div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); } .header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
@@ -1105,8 +1319,6 @@
.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } .langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.advancedDivider { height: 1px; background: var(--border-dim); margin: 2px 0; } .advancedDivider { height: 1px; background: var(--border-dim); margin: 2px 0; }
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .advancedFooter { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.advancedLinkStandalone { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
.advancedLinkStandalone:hover { opacity: 1; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); } .empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
.emptyIcon { color: var(--text-faint); } .emptyIcon { color: var(--text-faint); }
.emptyText { font-size: var(--text-base); color: var(--text-muted); } .emptyText { font-size: var(--text-base); color: var(--text-muted); }
@@ -1191,6 +1403,19 @@
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); } .langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); } .langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; } .nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.discoverHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.discoverLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.discoverGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; }
.discCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.discCard:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.discCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
.discGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.discFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.discTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.discSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style> </style>
<script module> <script module>