mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Restructure Repository for SvelteKit
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
|
||||
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
|
||||
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
|
||||
import type { Manga, Source, Category } from "@types/index";
|
||||
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
|
||||
import {
|
||||
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
|
||||
parseTags, tagsLabel, matchesAllTags, runConcurrent,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
|
||||
const prevNavPage = store.navPage;
|
||||
const tags = $derived(parseTags(store.genreFilter));
|
||||
const primaryTag = $derived(tags[0] ?? "");
|
||||
const label = $derived(tagsLabel(tags));
|
||||
|
||||
let libraryManga: Manga[] = $state([]);
|
||||
let sourceManga: Manga[] = $state([]);
|
||||
let loadingInitial = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||
let categories: Category[] = $state([]);
|
||||
let catsLoaded = false;
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = $state([]);
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m, store.settings));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.settings))]);
|
||||
});
|
||||
|
||||
const visibleItems = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMoreVisible = $derived(visibleCount < filtered.length);
|
||||
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
|
||||
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
|
||||
|
||||
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
|
||||
|
||||
async function load(filter: string) {
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
loadingInitial = true;
|
||||
sourceManga = [];
|
||||
libraryManga = [];
|
||||
visibleCount = PAGE_SIZE;
|
||||
nextPageMap.clear();
|
||||
|
||||
const preferredLang = store.settings.preferredExtensionLang || "en";
|
||||
const t = parseTags(filter);
|
||||
const pt = t[0] ?? "";
|
||||
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
|
||||
]).then(([all, lib]) => {
|
||||
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
|
||||
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
|
||||
}),
|
||||
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
||||
|
||||
cache.get(
|
||||
CACHE_KEYS.SOURCES,
|
||||
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const srcs = allSources.slice(0, MAX_SOURCES);
|
||||
sources = srcs;
|
||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", t);
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
ps.add(page);
|
||||
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||
pageItems.push(...matching);
|
||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
|
||||
loadingInitial = false;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) loadingInitial = false;
|
||||
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
|
||||
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||
if (!srcs.length) return;
|
||||
loadingMore = true;
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
try {
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
const page = nextPageMap.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga),
|
||||
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
ps.add(page);
|
||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
e.preventDefault();
|
||||
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",
|
||||
icon: BookmarkSimple,
|
||||
disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => {
|
||||
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
...(categories.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...categories.map((cat): MenuEntry => ({
|
||||
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
||||
icon: Folder,
|
||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: FolderSimplePlus,
|
||||
onClick: async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const res = await gql<{ createCategory: { category: Category } }>(
|
||||
CREATE_CATEGORY, { name: name.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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
$effect(() => () => { abortCtrl?.abort(); });
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||
</button>
|
||||
<span class="title">{label}</span>
|
||||
{#if !loadingInitial || filtered.length > 0}
|
||||
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
|
||||
{/if}
|
||||
{#if !loadingInitial && hasMoreNetwork}
|
||||
<span class="loading-hint">More loading…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingInitial && filtered.length === 0}
|
||||
<div class="grid">
|
||||
{#each Array(50) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No manga found for "{label}".</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visibleItems as m, i (m.id)}
|
||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||
<div class="cover-wrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<div class="show-more-cell">
|
||||
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
|
||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover :global(.cover) { filter: brightness(1.06); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { Manga, Source } from "@types";
|
||||
import type { CachedManga } from "@features/discover/lib/searchFilter";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
availableLangs: string[];
|
||||
hasMultipleLangs: boolean;
|
||||
loadingSources: boolean;
|
||||
pendingPrefill: string;
|
||||
popularResults: (Manga & { _priority: number })[];
|
||||
popularLoading: boolean;
|
||||
sourceCache: Map<number, CachedManga>;
|
||||
onPrefillConsumed: () => void;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let {
|
||||
allSources, availableLangs, hasMultipleLangs, loadingSources,
|
||||
pendingPrefill, popularResults, popularLoading,
|
||||
sourceCache,
|
||||
onPrefillConsumed, onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let kw_query = $state("");
|
||||
let kw_results: SourceResult[] = $state([]);
|
||||
let kw_showAdvanced = $state(false);
|
||||
let kw_selectedLangs: Set<string> = $state(new Set());
|
||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||
let kw_abortCtrl: AbortController | null = null;
|
||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface SourceResult {
|
||||
source: Source;
|
||||
mangas: Manga[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (allSources.length) {
|
||||
const available = new Set(allSources.map((s) => s.lang));
|
||||
kw_selectedLangs = available.has(preferredLang)
|
||||
? new Set([preferredLang])
|
||||
: new Set(availableLangs.slice(0, 1));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||
const q = pendingPrefill;
|
||||
onPrefillConsumed();
|
||||
kw_query = q;
|
||||
kwDoSearch(q);
|
||||
}
|
||||
});
|
||||
|
||||
$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[] {
|
||||
let filtered = allSources;
|
||||
if (kw_selectedLangs.size > 0)
|
||||
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
|
||||
if (store.settings.contentLevel !== "unrestricted")
|
||||
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function kwDoSearch(q: string) {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
const visible = kwGetVisibleSources();
|
||||
if (!visible.length) return;
|
||||
kw_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
kw_abortCtrl = ctrl;
|
||||
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
|
||||
kw_results = initial;
|
||||
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
|
||||
await runConcurrent(visible, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const idx = indexBySrcId.get(src.id)!;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||
preloadBlobUrls(
|
||||
mangas.map((m) => sourceCache.get(m.id)?.thumbnailUrl ?? m.thumbnailUrl),
|
||||
12,
|
||||
);
|
||||
const next = [...kw_results];
|
||||
next[idx] = { ...next[idx], mangas, loading: false };
|
||||
kw_results = next;
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||
const next = [...kw_results];
|
||||
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
|
||||
kw_results = next;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
}
|
||||
|
||||
function kwToggleLang(lang: string) {
|
||||
const next = new Set(kw_selectedLangs);
|
||||
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
|
||||
else next.add(lang);
|
||||
kw_selectedLangs = next;
|
||||
}
|
||||
|
||||
const kw_visibleCount = $derived(kwGetVisibleSources().length);
|
||||
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_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 }))
|
||||
);
|
||||
const deduped = dedupeMangaByTitle(dedupeMangaById(all), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
|
||||
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
kw_abortCtrl?.abort();
|
||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="keywordBar">
|
||||
<div class="searchBar">
|
||||
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" 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"/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={kw_inputEl}
|
||||
bind:value={kw_query}
|
||||
class="searchInput"
|
||||
placeholder="Search across sources…"
|
||||
use:focusOnMount
|
||||
/>
|
||||
{#if kw_anyLoading}
|
||||
<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 hasMultipleLangs}
|
||||
<button
|
||||
class="advancedBtn"
|
||||
class:advancedBtnActive={kw_showAdvanced}
|
||||
title="Language & filter options"
|
||||
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
|
||||
>
|
||||
<svg width="13" height="13" 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>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasMultipleLangs && kw_showAdvanced}
|
||||
<div class="advancedPanel">
|
||||
<div class="advancedHeader">
|
||||
<span class="advancedTitle">Languages</span>
|
||||
<div class="advancedActions">
|
||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
|
||||
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="langGrid">
|
||||
{#each availableLangs as lang (lang)}
|
||||
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
|
||||
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="advancedDivider"></div>
|
||||
<div class="advancedFooter">
|
||||
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !kw_query.trim()}
|
||||
{#if popularLoading && popularResults.length === 0}
|
||||
<div class="searchGrid">
|
||||
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
</div>
|
||||
{:else if popularResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">Popular right now</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each popularResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if popularLoading}
|
||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<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"/>
|
||||
</svg>
|
||||
<p class="emptyText">Search across sources</p>
|
||||
<p class="emptyHint">
|
||||
{#if hasMultipleLangs}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
|
||||
{:else}
|
||||
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if kw_flatResults.length > 0}
|
||||
<div class="searchHeader">
|
||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div class="searchGrid">
|
||||
{#each kw_flatResults as m (m.id)}
|
||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||
<div class="srchCoverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
||||
<div class="srchGradient"></div>
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
<div class="srchFooter">
|
||||
<p class="srchTitle">{m.title}</p>
|
||||
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if kw_anyLoading}
|
||||
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if kw_anyLoading}
|
||||
<div class="searchGrid">
|
||||
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||
</div>
|
||||
{:else if kw_allDone && !kw_hasResults}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results for "{kw_query.trim()}"</p>
|
||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<script module>
|
||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.clearBtn:hover { color: var(--text-muted); }
|
||||
|
||||
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
|
||||
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.advancedActions { display: flex; gap: var(--sp-2); }
|
||||
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||
.advancedLink:hover { opacity: 0.75; }
|
||||
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.advancedDivider { height: 1px; background: var(--border-dim); }
|
||||
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
|
||||
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.searchGrid { 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; will-change: scroll-position; }
|
||||
|
||||
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
|
||||
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
|
||||
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
|
||||
.srchTitle { 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); }
|
||||
.srchSource { 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; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { GET_SOURCES } from "@api/queries/extensions";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { FETCH_MANGA } from "@api/mutations/manga";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { deprioritizeQueue } from "@core/cache/imageCache";
|
||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||
import { shouldHideNsfw } from "@core/util";
|
||||
import { store, setSearchPrefill, setPreviewManga, setSearchQuery } from "@store/state.svelte";
|
||||
import {
|
||||
toCachedManga,
|
||||
type CachedManga,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
import KeywordTab from "./KeywordTab.svelte";
|
||||
import TagTab from "./TagTab.svelte";
|
||||
import SourceTab from "./SourceTab.svelte";
|
||||
|
||||
const anims = $derived(store.settings.qolAnimations ?? true);
|
||||
|
||||
const TABS = ["keyword", "tag", "source"] as const;
|
||||
|
||||
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
let tabIndicator = $state({ left: 0, width: 0 });
|
||||
|
||||
function updateIndicator() {
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
|
||||
if (!active) return;
|
||||
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
|
||||
}
|
||||
|
||||
$effect(() => { tab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
|
||||
|
||||
const SEARCH_PAGES = 3;
|
||||
const SEARCH_LIMIT = 200;
|
||||
const SEARCH_BATCH = 20;
|
||||
const POPULAR_CACHE_PAGES = 3;
|
||||
|
||||
type SearchTab = "keyword" | "tag" | "source";
|
||||
let tab: SearchTab = $state("keyword");
|
||||
|
||||
let pendingPrefill = $state("");
|
||||
$effect(() => {
|
||||
if (store.searchPrefill) {
|
||||
const prefill = store.searchPrefill;
|
||||
untrack(() => {
|
||||
pendingPrefill = prefill;
|
||||
tab = "keyword";
|
||||
setSearchPrefill("");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let allSources: Source[] = $state([]);
|
||||
let localSource: Source | null = $state(null);
|
||||
let loadingSources = $state(false);
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||
|
||||
loadingSources = true;
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => {
|
||||
const nodes = d.sources.nodes;
|
||||
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
|
||||
allSources = nodes.filter((src: Source) => src.id !== "0");
|
||||
startSourceCacheBuild();
|
||||
popularStart(allSources);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingSources = false; });
|
||||
|
||||
let popular_raw: Manga[] = $state([]);
|
||||
let popular_loading = $state(false);
|
||||
let popular_moreLoading = $state(false);
|
||||
let popular_abortCtrl: AbortController | null = null;
|
||||
let popular_sourcePool: Source[] = $state([]);
|
||||
let popular_sourceCursor = $state(0);
|
||||
let popular_hasMore = $state(false);
|
||||
let popular_seenIds = new Set<number>();
|
||||
let popular_seenTitles = new Set<string>();
|
||||
|
||||
const popular_results: (Manga & { _priority: number })[] = $derived(
|
||||
popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) }))
|
||||
);
|
||||
|
||||
function popular_push(incoming: Manga[]) {
|
||||
const toAdd: Manga[] = [];
|
||||
for (const m of incoming) {
|
||||
if (shouldHideNsfw(m, store.settings)) continue;
|
||||
if (popular_seenIds.has(m.id)) continue;
|
||||
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
||||
if (popular_seenTitles.has(norm)) continue;
|
||||
popular_seenIds.add(m.id);
|
||||
popular_seenTitles.add(norm);
|
||||
toAdd.push(m);
|
||||
}
|
||||
if (!toAdd.length) return;
|
||||
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
|
||||
async function popular_fanOut(signal: AbortSignal) {
|
||||
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
|
||||
if (!batch.length) { popular_hasMore = false; return; }
|
||||
|
||||
await runConcurrent(batch, async (src) => {
|
||||
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||
if (signal.aborted) return;
|
||||
const key = `${src.id}|POPULAR|All:p${page}`;
|
||||
let mangas: Manga[];
|
||||
if (store.searchCache?.has(key)) {
|
||||
mangas = store.searchCache.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.searchCache?.set(key, mangas);
|
||||
if (!result.hasNextPage) { popular_push(mangas); break; }
|
||||
}
|
||||
popular_push(mangas);
|
||||
}
|
||||
}, signal);
|
||||
|
||||
popular_sourceCursor += batch.length;
|
||||
popular_hasMore = popular_sourceCursor < popular_sourcePool.length;
|
||||
}
|
||||
|
||||
function popularStart(sources: Source[]) {
|
||||
if (popular_raw.length > 0) return;
|
||||
popular_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
popular_abortCtrl = ctrl;
|
||||
popular_seenIds.clear();
|
||||
popular_seenTitles.clear();
|
||||
popular_raw = [];
|
||||
popular_sourcePool = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
|
||||
popular_sourceCursor = 0;
|
||||
popular_hasMore = false;
|
||||
popular_moreLoading = false;
|
||||
popular_loading = true;
|
||||
(async () => {
|
||||
try {
|
||||
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
|
||||
await popular_fanOut(ctrl.signal);
|
||||
}
|
||||
} catch {}
|
||||
if (!ctrl.signal.aborted) popular_loading = false;
|
||||
})();
|
||||
}
|
||||
|
||||
export const sourceCache = new Map<number, CachedManga>();
|
||||
|
||||
let sourceCacheReady = $state(false);
|
||||
let sourceCacheLoading = $state(false);
|
||||
let sourceCacheEnriching = $state(false);
|
||||
let sourceCacheAbort: AbortController | null = null;
|
||||
|
||||
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
|
||||
const tasks: { src: Source; page: number }[] = [];
|
||||
for (const src of sources) {
|
||||
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
|
||||
}
|
||||
await runConcurrent(tasks, async ({ src, page }) => {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
|
||||
let mangas: Manga[];
|
||||
if (store.searchCache?.has(cacheKey)) {
|
||||
mangas = store.searchCache.get(cacheKey)!;
|
||||
} else {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "POPULAR", page },
|
||||
signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
mangas = d.fetchSourceManga.mangas;
|
||||
store.searchCache?.set(cacheKey, mangas);
|
||||
}
|
||||
for (const m of mangas) {
|
||||
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
}
|
||||
}, signal);
|
||||
}
|
||||
|
||||
async function enrichGenres(signal: AbortSignal) {
|
||||
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
|
||||
if (!unenriched.length) return;
|
||||
sourceCacheEnriching = true;
|
||||
await runConcurrent(unenriched, async (entry) => {
|
||||
if (signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
|
||||
FETCH_MANGA, { id: entry.id }, signal,
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) {
|
||||
updated.genre = d.fetchManga.manga.genre ?? [];
|
||||
updated.status = d.fetchManga.manga.status ?? updated.status;
|
||||
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
|
||||
updated.genreEnriched = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
const updated = sourceCache.get(entry.id);
|
||||
if (updated) updated.genreEnriched = true;
|
||||
}
|
||||
}, signal);
|
||||
if (!signal.aborted) sourceCacheEnriching = false;
|
||||
}
|
||||
|
||||
function startSourceCacheBuild() {
|
||||
if (sourceCacheLoading || sourceCacheReady) return;
|
||||
sourceCacheAbort?.abort();
|
||||
const ctrl = new AbortController();
|
||||
sourceCacheAbort = ctrl;
|
||||
sourceCacheLoading = true;
|
||||
sourceCache.clear();
|
||||
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||
buildSourceCache(dedupedSources, ctrl.signal)
|
||||
.then(() => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
sourceCacheReady = true;
|
||||
sourceCacheLoading = false;
|
||||
enrichGenres(ctrl.signal);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
sourceCacheLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
popular_abortCtrl?.abort();
|
||||
sourceCacheAbort?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="root anim-fade-in">
|
||||
<div class="header">
|
||||
<span class="heading">Search</span>
|
||||
|
||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||
{#if anims && tabIndicator.width > 0}
|
||||
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" 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"/>
|
||||
</svg>
|
||||
Keyword
|
||||
</button>
|
||||
<button class="tab" class:tabActive={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "tag"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||
</svg>
|
||||
Tags
|
||||
</button>
|
||||
<button class="tab" class:tabActive={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "source"; }}>
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||
</svg>
|
||||
Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tab === "keyword"}
|
||||
<KeywordTab
|
||||
{allSources}
|
||||
{availableLangs}
|
||||
{hasMultipleLangs}
|
||||
{loadingSources}
|
||||
{pendingPrefill}
|
||||
popularResults={popular_results}
|
||||
popularLoading={popular_loading}
|
||||
{sourceCache}
|
||||
query={store.searchQuery}
|
||||
onQueryChange={setSearchQuery}
|
||||
onPrefillConsumed={() => (pendingPrefill = "")}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{:else if tab === "tag"}
|
||||
<TagTab
|
||||
{allSources}
|
||||
{sourceCache}
|
||||
{sourceCacheReady}
|
||||
{sourceCacheLoading}
|
||||
{sourceCacheEnriching}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{:else}
|
||||
<SourceTab
|
||||
{allSources}
|
||||
{availableLangs}
|
||||
{loadingSources}
|
||||
{localSource}
|
||||
onPreview={setPreviewManga}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
|
||||
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
|
||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
|
||||
.tabActive:hover { color: var(--accent-fg); }
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { shouldHideNsfw, shouldHideSource } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import ContextMenu from "@shared/ui/ContextMenu.svelte";
|
||||
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
availableLangs: string[];
|
||||
loadingSources: boolean;
|
||||
localSource: Source | null;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
|
||||
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let src_selectedLang = $state(preferredLang || "all");
|
||||
let src_activeSource: Source | null = $state(null);
|
||||
let src_browseResults: Manga[] = $state([]);
|
||||
let src_loadingBrowse = $state(false);
|
||||
let src_browseQuery = $state("");
|
||||
let src_submitted = $state("");
|
||||
let src_hasNextPage = $state(false);
|
||||
let src_currentPage = $state(1);
|
||||
let src_abortCtrl: AbortController | null = null;
|
||||
|
||||
let ctx_x = $state(0);
|
||||
let ctx_y = $state(0);
|
||||
let ctx_source: Source | null = $state(null);
|
||||
|
||||
const pinnedIds = $derived(store.settings.pinnedSourceIds ?? []);
|
||||
const pinnedSources = $derived(
|
||||
pinnedIds
|
||||
.map(id => allSources.find(s => s.id === id))
|
||||
.filter((s): s is Source => !!s)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!allSources.length) return;
|
||||
const langs = new Set(allSources.map((s) => s.lang));
|
||||
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
|
||||
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
|
||||
}
|
||||
});
|
||||
|
||||
const src_visibleSources = $derived.by(() => {
|
||||
const hide = (s: Source) => shouldHideSource(s, store.settings);
|
||||
if (src_selectedLang !== "all") {
|
||||
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
|
||||
}
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of allSources) {
|
||||
if (hide(s)) continue;
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
const existingPref = existing.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
if (newPref && !existingPref) map.set(s.name, s);
|
||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
});
|
||||
|
||||
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||
src_abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
src_abortCtrl = ctrl;
|
||||
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type, page, query: q ?? null },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
|
||||
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
|
||||
src_hasNextPage = d.fetchSourceManga.hasNextPage;
|
||||
src_currentPage = page;
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) src_loadingBrowse = false;
|
||||
}
|
||||
}
|
||||
|
||||
function srcSelectSource(src: Source) {
|
||||
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
|
||||
srcFetchBrowse(src, "POPULAR");
|
||||
}
|
||||
|
||||
function srcHandleSearch() {
|
||||
if (!src_activeSource || !src_browseQuery.trim()) return;
|
||||
src_submitted = src_browseQuery.trim();
|
||||
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
|
||||
}
|
||||
|
||||
function srcClearSearch() {
|
||||
src_browseQuery = ""; src_submitted = "";
|
||||
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, src: Source) {
|
||||
e.preventDefault();
|
||||
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
|
||||
}
|
||||
function closeCtx() { ctx_source = null; }
|
||||
|
||||
onDestroy(() => { src_abortCtrl?.abort(); });
|
||||
</script>
|
||||
|
||||
<div class="splitRoot">
|
||||
<div class="splitSidebar">
|
||||
<div class="srcLangRow">
|
||||
<span class="langPocketLabel">Language</span>
|
||||
<select class="langSelect" bind:value={src_selectedLang}>
|
||||
<option value="all">All</option>
|
||||
{#each availableLangs as lang (lang)}
|
||||
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loadingSources}
|
||||
<div class="splitLoading">
|
||||
<svg width="16" height="16" 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>
|
||||
{:else}
|
||||
<div class="splitList">
|
||||
{#if localSource}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === localSource.id}
|
||||
onclick={() => srcSelectSource(localSource)}
|
||||
oncontextmenu={(e) => openCtx(e, localSource)}
|
||||
>
|
||||
<div class="localSourceIcon">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
|
||||
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="splitItemLabel">Local Source</span>
|
||||
</button>
|
||||
<div class="localDivider"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedSources.length > 0}
|
||||
<p class="sectionLabel">Pinned</p>
|
||||
{#each pinnedSources as src (src.id)}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === src.id}
|
||||
onclick={() => srcSelectSource(src)}
|
||||
oncontextmenu={(e) => openCtx(e, src)}
|
||||
>
|
||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitItemLabel">{src.name}</span>
|
||||
<span class="pinIndicator" title="Pinned">
|
||||
<PushPin size={9} weight="fill" />
|
||||
</span>
|
||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="localDivider"></div>
|
||||
<p class="sectionLabel">All Sources</p>
|
||||
{/if}
|
||||
|
||||
{#each src_visibleSources as src (src.id)}
|
||||
<button
|
||||
class="splitItem splitItemSource"
|
||||
class:splitItemActive={src_activeSource?.id === src.id}
|
||||
onclick={() => srcSelectSource(src)}
|
||||
oncontextmenu={(e) => openCtx(e, src)}
|
||||
>
|
||||
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitItemLabel">{src.name}</span>
|
||||
{#if src_selectedLang === "all"}
|
||||
<span class="sourceLang">{src.lang.toUpperCase()}</span>
|
||||
{/if}
|
||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if src_visibleSources.length === 0}
|
||||
<p class="splitEmpty">No sources for this language</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContent">
|
||||
{#if !src_activeSource}
|
||||
<div class="empty">
|
||||
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Browse a source</p>
|
||||
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="splitContentHeader">
|
||||
<div class="splitSourceTitle">
|
||||
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span class="splitContentTitle">{src_activeSource.displayName}</span>
|
||||
{#if src_loadingBrowse}
|
||||
<svg width="13" height="13" 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>
|
||||
{:else if src_browseResults.length > 0}
|
||||
<span class="splitResultCount">{src_browseResults.length} results</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sourceBrowseBar">
|
||||
<div class="searchBar" style="flex:1">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" 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"/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={src_browseQuery}
|
||||
class="searchInput"
|
||||
placeholder="Search {src_activeSource.displayName}…"
|
||||
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
|
||||
/>
|
||||
{#if src_submitted}
|
||||
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
|
||||
</div>
|
||||
|
||||
{#if src_loadingBrowse && src_browseResults.length === 0}
|
||||
<div class="tagGrid">
|
||||
{#each Array(18) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if src_browseResults.length > 0}
|
||||
<div class="tagGrid">
|
||||
{#each src_browseResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if src_hasNextPage}
|
||||
<div class="showMoreCell">
|
||||
<button
|
||||
class="showMoreBtn"
|
||||
disabled={src_loadingBrowse}
|
||||
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
|
||||
>
|
||||
{src_loadingBrowse ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !src_loadingBrowse}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results</p>
|
||||
<p class="emptyHint">Try a different search term.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ctx_source}
|
||||
{@const isPinned = pinnedIds.includes(ctx_source.id)}
|
||||
<ContextMenu
|
||||
x={ctx_x}
|
||||
y={ctx_y}
|
||||
onClose={closeCtx}
|
||||
items={[
|
||||
{
|
||||
label: isPinned ? "Unpin source" : "Pin source",
|
||||
icon: isPinned ? PushPinSlash : PushPin,
|
||||
onClick: () => { store.togglePinnedSource(ctx_source!.id); },
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Browse source",
|
||||
icon: ArrowRight,
|
||||
onClick: () => { srcSelectSource(ctx_source!); },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
|
||||
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); 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); }
|
||||
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
|
||||
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
|
||||
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||||
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.splitItemActive:hover { background: var(--accent-muted); }
|
||||
.splitItemSource { gap: var(--sp-2); }
|
||||
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
|
||||
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
|
||||
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
|
||||
.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; }
|
||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
|
||||
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||
.searchBar:focus-within { border-color: var(--border-strong); }
|
||||
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.searchInput::placeholder { color: var(--text-faint); }
|
||||
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.clearBtn:hover { color: var(--text-muted); }
|
||||
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
|
||||
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
|
||||
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||
.skTitle { height: 10px; width: 80%; }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
|
||||
import { MANGAS_BY_GENRE } from "@api/queries/manga";
|
||||
import { runConcurrent } from "@core/async/batchRequests";
|
||||
import { dedupeSourcesByLang }from "@core/algorithms/filter";
|
||||
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
|
||||
import { store } from "@store/state.svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import {
|
||||
buildTagFilter,
|
||||
filterSourceCache,
|
||||
COMMON_GENRES,
|
||||
MANGA_STATUSES,
|
||||
type TagMode,
|
||||
type CachedManga,
|
||||
} from "@features/discover/lib/searchFilter";
|
||||
import type { Manga, Source } from "@types";
|
||||
|
||||
interface Props {
|
||||
allSources: Source[];
|
||||
sourceCache: Map<number, CachedManga>;
|
||||
sourceCacheReady: boolean;
|
||||
sourceCacheLoading: boolean;
|
||||
sourceCacheEnriching: boolean;
|
||||
onPreview: (m: Manga) => void;
|
||||
}
|
||||
let {
|
||||
allSources, sourceCache,
|
||||
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
const SEARCH_LIMIT = 200;
|
||||
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
|
||||
|
||||
let tag_activeTags: string[] = $state([]);
|
||||
let tag_activeStatuses: string[] = $state([]);
|
||||
let tag_tagMode: TagMode = $state("AND");
|
||||
let tag_tagFilter = $state("");
|
||||
|
||||
const tag_filteredGenres = $derived.by(() => {
|
||||
const q = tag_tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
|
||||
});
|
||||
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
|
||||
|
||||
let tag_localResults: Manga[] = $state([]);
|
||||
let tag_totalCount = $state(0);
|
||||
let tag_loadingLocal = $state(false);
|
||||
let tag_loadingMoreLocal = $state(false);
|
||||
let tag_localOffset = $state(0);
|
||||
let tag_localHasNext = $state(false);
|
||||
let tag_abortLocal: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
const _statuses = tag_activeStatuses;
|
||||
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
|
||||
});
|
||||
|
||||
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
|
||||
if (activeTags.length === 0 && activeStatuses.length === 0) {
|
||||
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
|
||||
return;
|
||||
}
|
||||
tag_abortLocal?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_abortLocal = ctrl;
|
||||
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
|
||||
tag_loadingLocal = true;
|
||||
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
|
||||
MANGAS_BY_GENRE,
|
||||
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
|
||||
ctrl.signal,
|
||||
).then((d) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
|
||||
tag_totalCount = d.mangas.totalCount;
|
||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||
tag_localOffset = (store.settings.renderLimit ?? 48);
|
||||
}).catch((e: any) => {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
}).finally(() => {
|
||||
if (!ctrl.signal.aborted) tag_loadingLocal = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function tagLoadMoreLocal() {
|
||||
if (tag_loadingMoreLocal || !tag_localHasNext) return;
|
||||
tag_loadingMoreLocal = true;
|
||||
tag_abortLocal?.abort();
|
||||
const ctrl = new AbortController();
|
||||
tag_abortLocal = ctrl;
|
||||
try {
|
||||
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
|
||||
MANGAS_BY_GENRE,
|
||||
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
|
||||
ctrl.signal,
|
||||
);
|
||||
if (ctrl.signal.aborted) return;
|
||||
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
|
||||
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
|
||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||
tag_localOffset += (store.settings.renderLimit ?? 48);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
|
||||
}
|
||||
}
|
||||
|
||||
let tag_searchSources = $state(false);
|
||||
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||
|
||||
let tag_sourceFanOut: Manga[] = $state([]);
|
||||
let tag_fanOutLoading = $state(false);
|
||||
let tag_fanOutAbort: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _mode = tag_tagMode;
|
||||
const _statuses = tag_activeStatuses;
|
||||
const _ready = sourceCacheReady;
|
||||
const _search = tag_searchSources;
|
||||
untrack(() => {
|
||||
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
|
||||
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, store.settings);
|
||||
} else {
|
||||
tag_sourceFiltered = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$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 seenIds = new Set<number>();
|
||||
const seenTitles = new Set<string>();
|
||||
const genreLower = genre.toLowerCase();
|
||||
const srcs = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
|
||||
let mangas: Manga[];
|
||||
let hasNextPage = false;
|
||||
if (store.searchCache?.has(cacheKey)) {
|
||||
mangas = store.searchCache.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.searchCache?.set(cacheKey, mangas);
|
||||
}
|
||||
if (ctrl.signal.aborted) return;
|
||||
const matching = mangas.filter((m) =>
|
||||
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
|
||||
);
|
||||
const candidates = (matching.length ? matching : mangas).filter(
|
||||
(m) => !shouldHideNsfw(m, store.settings)
|
||||
);
|
||||
const toAdd: Manga[] = [];
|
||||
for (const m of candidates) {
|
||||
if (seenIds.has(m.id)) continue;
|
||||
const norm = normalizeTitle(m.title);
|
||||
if (seenTitles.has(norm)) continue;
|
||||
seenIds.add(m.id);
|
||||
seenTitles.add(norm);
|
||||
toAdd.push(m);
|
||||
}
|
||||
if (toAdd.length) {
|
||||
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
|
||||
}
|
||||
if (!hasNextPage) return;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
|
||||
}
|
||||
|
||||
let tag_autoSearchFired = $state(false);
|
||||
$effect(() => {
|
||||
const _tags = tag_activeTags;
|
||||
const _statuses = tag_activeStatuses;
|
||||
untrack(() => { tag_autoSearchFired = false; });
|
||||
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
|
||||
if (tag_localResults.length < 20) {
|
||||
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||
|
||||
const tag_mergedResults = $derived.by(() => {
|
||||
const fanOutMapped = tag_sourceFanOut.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) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
|
||||
return dedupeMangaByTitle(
|
||||
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
|
||||
store.settings.mangaLinks,
|
||||
);
|
||||
});
|
||||
|
||||
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||
|
||||
function tagToggleTag(tag: string) {
|
||||
tag_activeTags = tag_activeTags.includes(tag)
|
||||
? tag_activeTags.filter((t) => t !== tag)
|
||||
: [...tag_activeTags, tag];
|
||||
}
|
||||
|
||||
function tagToggleStatus(status: string) {
|
||||
tag_activeStatuses = tag_activeStatuses.includes(status)
|
||||
? tag_activeStatuses.filter((s) => s !== status)
|
||||
: [...tag_activeStatuses, status];
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
tag_abortLocal?.abort();
|
||||
tag_fanOutAbort?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="splitRoot">
|
||||
|
||||
<div class="splitSidebar">
|
||||
<div class="splitSearchWrap">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" 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"/>
|
||||
</svg>
|
||||
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
|
||||
{#if tag_tagFilter}
|
||||
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="splitList">
|
||||
<div class="splitSectionLabel">Status</div>
|
||||
{#each MANGA_STATUSES as { value, label } (value)}
|
||||
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
|
||||
<span class="splitItemLabel">{label}</span>
|
||||
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
|
||||
{#each tag_filteredGenres as tag (tag)}
|
||||
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
|
||||
<span class="splitItemLabel">{tag}</span>
|
||||
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if tag_filteredGenres.length === 0}
|
||||
<p class="splitEmpty">No matching genres</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContent">
|
||||
{#if !tag_hasActiveFilters}
|
||||
<div class="empty">
|
||||
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
|
||||
</svg>
|
||||
<p class="emptyText">Browse by tag</p>
|
||||
<p class="emptyHint">Select a status or genre to find matching manga.</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
<div class="tagActiveBar">
|
||||
<div class="tagPillRow">
|
||||
{#each tag_activeStatuses as status (status)}
|
||||
<span class="tagPill tagPillStatus">
|
||||
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
|
||||
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#each tag_activeTags as tag (tag)}
|
||||
<span class="tagPill">
|
||||
{tag}
|
||||
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tagBarRight">
|
||||
{#if tag_activeTags.length > 1}
|
||||
<div class="tagModeToggle">
|
||||
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
|
||||
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="tagModeBtn"
|
||||
class:tagModeBtnActive={tag_searchSources}
|
||||
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
|
||||
disabled={!sourceCacheReady && !sourceCacheLoading}
|
||||
onclick={() => (tag_searchSources = !tag_searchSources)}
|
||||
>
|
||||
{#if sourceCacheLoading || tag_fanOutLoading}
|
||||
<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"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
|
||||
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
Sources{sourceCacheEnriching ? " ·" : ""}
|
||||
</button>
|
||||
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="splitContentHeader">
|
||||
<span class="splitContentTitle">
|
||||
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
|
||||
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
|
||||
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
|
||||
{tag_activeTags[0]}
|
||||
{:else}
|
||||
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
|
||||
{/if}
|
||||
{#if tag_searchSources}
|
||||
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if tag_loadingLocal}
|
||||
<svg width="13" height="13" 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>
|
||||
{:else}
|
||||
<span class="splitResultCount">
|
||||
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
|
||||
{#if tag_searchSources && sourceCacheReady}
|
||||
· {sourceCache.size} cached
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
{#if tag_loadingLocal}
|
||||
<div class="tagGrid">
|
||||
{#each Array(48) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tag_mergedResults.length > 0}
|
||||
<div class="tagGrid">
|
||||
{#each tag_mergedResults as m, i (m.id)}
|
||||
<button class="card" onclick={() => onPreview(m)}>
|
||||
<div class="coverWrap">
|
||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="cardTitle">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if tag_loadingMoreLocal}
|
||||
{#each Array(12) as _, i (i)}
|
||||
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p class="emptyText">No results</p>
|
||||
<p class="emptyHint">
|
||||
{#if tag_searchSources}Try OR mode or broader tags.
|
||||
{:else}Try OR mode, enable Sources, or check your library.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.splitRoot { flex: 1; display: flex; overflow: hidden; }
|
||||
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
|
||||
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
|
||||
.splitSearchInput::placeholder { color: var(--text-faint); }
|
||||
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.splitSearchClear:hover { color: var(--text-muted); }
|
||||
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
|
||||
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
|
||||
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
.splitItemActive:hover { background: var(--accent-muted); }
|
||||
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
|
||||
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
|
||||
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
|
||||
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
|
||||
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
|
||||
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
|
||||
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
|
||||
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
|
||||
.tagPillRemove:hover { opacity: 1; }
|
||||
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.tagModeBtn:last-child { border-right: none; }
|
||||
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
|
||||
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
|
||||
|
||||
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
|
||||
|
||||
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
||||
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
|
||||
.skTitle { height: 10px; width: 80%; }
|
||||
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
|
||||
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
|
||||
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
|
||||
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
|
||||
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user