mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Discover Removal Finalized
This commit is contained in:
@@ -21,7 +21,7 @@ Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://gith
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||||
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
<img src="docs/screenshots/Moku-TagSearch.png" width="49%" alt="TagSearch" />
|
||||||
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||||
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
|
||||||
import { gql } from "../../lib/client";
|
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
|
|
||||||
import { store, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
|
||||||
import type { Manga, Source, Category } from "../../lib/types";
|
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
|
||||||
|
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
|
||||||
const GRID_LIMIT = 200;
|
|
||||||
const CONCURRENCY = 6;
|
|
||||||
const PAGES_INIT = 3;
|
|
||||||
const PAGES_GENRE = 2;
|
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
|
||||||
query ExploreAllManga {
|
|
||||||
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const MANGAS_BY_GENRE = `
|
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function dKey(srcId: string, type: string, genre: string, page: number) {
|
|
||||||
return `${srcId}|${type}|${genre}:p${page}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allSources: Source[] = $state([]);
|
|
||||||
let loadingLib = $state(true);
|
|
||||||
let loadError = $state(false);
|
|
||||||
let currentGenre = $state("All");
|
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
|
||||||
let genreLoading = $state(false);
|
|
||||||
let refreshing = $state(false);
|
|
||||||
|
|
||||||
let activeCtrl: AbortController | null = null;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let categories: Category[] = $state([]);
|
|
||||||
let catsLoaded = false;
|
|
||||||
|
|
||||||
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
|
||||||
|
|
||||||
function dedup(items: Manga[]): Manga[] {
|
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOut(mangas: Manga[]): Manga[] {
|
|
||||||
return dedup(mangas.filter(m => {
|
|
||||||
if (m.inLibrary || store.discoverLibraryIds.has(m.id)) return false;
|
|
||||||
if (shouldHideNsfw(m, store.settings)) return false;
|
|
||||||
return true;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotatedSources(): Source[] {
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const eligible = allSources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
|
||||||
const srcs = dedupeSources(eligible, lang);
|
|
||||||
if (!srcs.length) return [];
|
|
||||||
const off = store.discoverSrcOffset % srcs.length;
|
|
||||||
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
|
||||||
let i = 0;
|
|
||||||
const worker = async () => {
|
|
||||||
while (i < items.length) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
await fn(items[i++]).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushToGrid(genre: string, incoming: Manga[]) {
|
|
||||||
const filtered = filterOut(incoming);
|
|
||||||
if (!filtered.length) return;
|
|
||||||
const cur = genreResults.get(genre) ?? [];
|
|
||||||
genreResults.set(genre, dedup([...cur, ...filtered]).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fanOut(genre: string, ctrl: AbortController) {
|
|
||||||
const srcs = rotatedSources();
|
|
||||||
if (!srcs.length) return;
|
|
||||||
|
|
||||||
const isAll = genre === "All";
|
|
||||||
const type = isAll ? "POPULAR" : "SEARCH";
|
|
||||||
const query = isAll ? null : genre;
|
|
||||||
const maxPages = isAll ? PAGES_INIT : PAGES_GENRE;
|
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const key = dKey(src.id, type, genre, page);
|
|
||||||
let mangas: Manga[];
|
|
||||||
let hasNextPage = false;
|
|
||||||
|
|
||||||
if (store.discoverCache.has(key)) {
|
|
||||||
mangas = store.discoverCache.get(key)!;
|
|
||||||
} else {
|
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
|
||||||
FETCH_SOURCE_MANGA,
|
|
||||||
{ source: src.id, type, page, query },
|
|
||||||
ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
|
||||||
mangas = result.mangas;
|
|
||||||
hasNextPage = result.hasNextPage;
|
|
||||||
store.discoverCache.set(key, mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
if (isAll) {
|
|
||||||
pushToGrid("All", mangas);
|
|
||||||
} else {
|
|
||||||
const matching = mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
);
|
|
||||||
pushToGrid(genre, matching.length ? matching : mangas);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNextPage) return;
|
|
||||||
}
|
|
||||||
}, ctrl.signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchGenre(genre: string) {
|
|
||||||
if (currentGenre === genre) return;
|
|
||||||
|
|
||||||
activeCtrl?.abort();
|
|
||||||
currentGenre = genre;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
|
|
||||||
if (genre === "All") {
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
genreLoading = false;
|
|
||||||
fanOut("All", ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
genreResults.set("All", []);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = true;
|
|
||||||
await fanOut("All", ctrl);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localKey = `local|${genre}`;
|
|
||||||
if (store.discoverCache.has(localKey)) {
|
|
||||||
genreResults.set(genre, store.discoverCache.get(localKey)!);
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
genreLoading = true;
|
|
||||||
try {
|
|
||||||
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
|
||||||
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
|
||||||
);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
|
|
||||||
const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
|
|
||||||
store.discoverCache.set(localKey, local);
|
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
genreLoading = false;
|
|
||||||
|
|
||||||
fanOut(genre, ctrl).catch(() => {});
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
activeCtrl?.abort();
|
|
||||||
clearDiscoverCache();
|
|
||||||
genreResults = new Map();
|
|
||||||
refreshing = true;
|
|
||||||
genreLoading = true;
|
|
||||||
const genre = currentGenre;
|
|
||||||
currentGenre = "";
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
await switchGenre(genre);
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true;
|
|
||||||
loadError = false;
|
|
||||||
|
|
||||||
if ((genreResults.get("All") ?? []).length > 0) {
|
|
||||||
loadingLib = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
store.discoverLibraryIds = new Set(
|
|
||||||
dedupeMangaById(m).filter(x => x.inLibrary).map(x => x.id)
|
|
||||||
);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => {
|
|
||||||
allSources = d.sources.nodes;
|
|
||||||
if ((currentGenre === "All" || currentGenre === "") &&
|
|
||||||
(genreResults.get("All") ?? []).length === 0) {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
activeCtrl = ctrl;
|
|
||||||
genreLoading = true;
|
|
||||||
fanOut("All", ctrl).then(() => {
|
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => { activeCtrl?.abort(); });
|
|
||||||
|
|
||||||
loadAll();
|
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
||||||
if (!catsLoaded) {
|
|
||||||
catsLoaded = true;
|
|
||||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
|
||||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
icon: BookmarkSimple, disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
|
||||||
}).catch(console.error),
|
|
||||||
},
|
|
||||||
...(categories.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...categories.map(cat => ({
|
|
||||||
label: (cat.mangas?.nodes ?? []).some(x => 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 n = prompt("Folder name:");
|
|
||||||
if (!n?.trim()) return;
|
|
||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
|
||||||
if (res) {
|
|
||||||
const cat = res.createCategory.category;
|
|
||||||
categories = [...categories, cat];
|
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if store.activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<span class="heading">Discover</span>
|
|
||||||
<div class="tab-strip">
|
|
||||||
{#each GENRE_TABS as tab (tab)}
|
|
||||||
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
|
|
||||||
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
|
||||||
<ArrowsClockwise size={13} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="body">
|
|
||||||
{#if isLoading && visibleGrid.length === 0}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each Array(24) as _, i (i)}
|
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadError && visibleGrid.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<button class="retry-btn" onclick={loadAll}>Retry</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if visibleGrid.length === 0}
|
|
||||||
<div class="empty"><span>Nothing found for "{currentGenre}"</span></div>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
<div class="manga-grid">
|
|
||||||
{#each visibleGrid as m (m.id)}
|
|
||||||
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
|
||||||
<div class="cover-gradient"></div>
|
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{m.title}</p>
|
|
||||||
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#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-4); flex-shrink: 0; padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
|
||||||
.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; }
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
||||||
.genre-tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
|
||||||
.genre-tab:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
|
||||||
.genre-tab.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
|
||||||
.refresh-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; margin-left: auto; transition: color var(--t-base), background var(--t-base); }
|
|
||||||
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
|
||||||
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
|
|
||||||
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
|
||||||
.manga-card:hover { will-change: transform; }
|
|
||||||
.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); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
|
||||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
|
||||||
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
|
||||||
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
|
|
||||||
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
|
||||||
.card-title { 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); transition: color var(--t-base); }
|
|
||||||
.card-source { 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; }
|
|
||||||
.card-skeleton { padding: 0; }
|
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.skeleton { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); padding: var(--sp-10) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.retry-btn { padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
|
||||||
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
|
||||||
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
|
||||||
</style>
|
|
||||||
@@ -2,34 +2,32 @@
|
|||||||
import { onDestroy, untrack } from "svelte";
|
import { onDestroy, untrack } from "svelte";
|
||||||
import { gql } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import Thumbnail from "../shared/Thumbnail.svelte";
|
import Thumbnail from "../shared/Thumbnail.svelte";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import { getPageSet } from "../../lib/cache";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
|
||||||
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
|
||||||
import { store, setSearchPrefill, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte";
|
import { store, setSearchPrefill, setPreviewManga, clearSearchCache } from "../../store/state.svelte";
|
||||||
import type { Manga, Source, Category } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
|
||||||
const DISCOVER_PAGES = 3;
|
const SEARCH_PAGES = 3;
|
||||||
const DISCOVER_LIMIT = 200;
|
const SEARCH_LIMIT = 200;
|
||||||
const DISCOVER_CONCUR = 6;
|
const SEARCH_CONCUR = 6;
|
||||||
|
|
||||||
function dKey(srcId: string, page: number) {
|
function dKey(srcId: string, page: number) {
|
||||||
return `${srcId}|POPULAR|All:p${page}`;
|
return `${srcId}|POPULAR|All:p${page}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let disc_results: Manga[] = $state([]);
|
let srch_results: Manga[] = $state([]);
|
||||||
let disc_loading = $state(false);
|
let srch_loading = $state(false);
|
||||||
let disc_abortCtrl: AbortController | null = null;
|
let srch_abortCtrl: AbortController | null = null;
|
||||||
|
|
||||||
function disc_filterOut(mangas: Manga[]): Manga[] {
|
function srch_filterOut(mangas: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(
|
return dedupeMangaByTitle(
|
||||||
dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))),
|
dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))),
|
||||||
store.settings.mangaLinks,
|
store.settings.mangaLinks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disc_rotatedSources(sources: Source[]): Source[] {
|
function srch_rotatedSources(sources: Source[]): Source[] {
|
||||||
const lang = store.settings?.preferredExtensionLang || "en";
|
const lang = store.settings?.preferredExtensionLang || "en";
|
||||||
const eligible = sources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
const eligible = sources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
|
||||||
const map = new Map<string, Source>();
|
const map = new Map<string, Source>();
|
||||||
@@ -41,17 +39,17 @@
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function disc_push(incoming: Manga[]) {
|
function srch_push(incoming: Manga[]) {
|
||||||
const filtered = disc_filterOut(incoming);
|
const filtered = srch_filterOut(incoming);
|
||||||
if (!filtered.length) return;
|
if (!filtered.length) return;
|
||||||
disc_results = dedupeMangaByTitle(
|
srch_results = dedupeMangaByTitle(
|
||||||
dedupeMangaById([...disc_results, ...filtered]),
|
dedupeMangaById([...srch_results, ...filtered]),
|
||||||
store.settings.mangaLinks,
|
store.settings.mangaLinks,
|
||||||
).slice(0, DISCOVER_LIMIT);
|
).slice(0, SEARCH_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disc_fanOut(sources: Source[], signal: AbortSignal) {
|
async function srch_fanOut(sources: Source[], signal: AbortSignal) {
|
||||||
const srcs = disc_rotatedSources(sources);
|
const srcs = srch_rotatedSources(sources);
|
||||||
if (!srcs.length) return;
|
if (!srcs.length) return;
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -59,12 +57,12 @@
|
|||||||
while (i < srcs.length) {
|
while (i < srcs.length) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
const src = srcs[i++];
|
const src = srcs[i++];
|
||||||
for (let page = 1; page <= DISCOVER_PAGES; page++) {
|
for (let page = 1; page <= SEARCH_PAGES; page++) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
const key = dKey(src.id, page);
|
const key = dKey(src.id, page);
|
||||||
let mangas: Manga[];
|
let mangas: Manga[];
|
||||||
if (store.discoverCache?.has(key)) {
|
if (store.searchCache?.has(key)) {
|
||||||
mangas = store.discoverCache.get(key)!;
|
mangas = store.searchCache.get(key)!;
|
||||||
} else {
|
} else {
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
@@ -73,25 +71,25 @@
|
|||||||
).then(d => d.fetchSourceManga).catch(() => null);
|
).then(d => d.fetchSourceManga).catch(() => null);
|
||||||
if (!result || signal.aborted) break;
|
if (!result || signal.aborted) break;
|
||||||
mangas = result.mangas;
|
mangas = result.mangas;
|
||||||
store.discoverCache?.set(key, mangas);
|
store.searchCache?.set(key, mangas);
|
||||||
if (!result.hasNextPage) { disc_push(mangas); break; }
|
if (!result.hasNextPage) { srch_push(mangas); break; }
|
||||||
}
|
}
|
||||||
disc_push(mangas);
|
srch_push(mangas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all(Array.from({ length: Math.min(DISCOVER_CONCUR, srcs.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(SEARCH_CONCUR, srcs.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
function disc_start(sources: Source[]) {
|
function srch_start(sources: Source[]) {
|
||||||
if (disc_results.length > 0) return;
|
if (srch_results.length > 0) return;
|
||||||
disc_abortCtrl?.abort();
|
srch_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
disc_abortCtrl = ctrl;
|
srch_abortCtrl = ctrl;
|
||||||
disc_loading = true;
|
srch_loading = true;
|
||||||
disc_fanOut(sources, ctrl.signal)
|
srch_fanOut(sources, ctrl.signal)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => { if (!ctrl.signal.aborted) disc_loading = false; });
|
.finally(() => { if (!ctrl.signal.aborted) srch_loading = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchTab = "keyword" | "tag" | "source";
|
type SearchTab = "keyword" | "tag" | "source";
|
||||||
@@ -321,7 +319,7 @@
|
|||||||
.then((d) => {
|
.then((d) => {
|
||||||
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
|
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
|
||||||
startSourceCacheBuild();
|
startSourceCacheBuild();
|
||||||
disc_start(allSources);
|
srch_start(allSources);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
@@ -353,7 +351,6 @@
|
|||||||
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
|
||||||
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
const hasMultipleLangs = $derived(availableLangs.length > 1);
|
||||||
|
|
||||||
// ── Keyword tab ───────────────────────────────────────────────────────────
|
|
||||||
let kw_query = $state("");
|
let kw_query = $state("");
|
||||||
let kw_results: SourceResult[] = $state([]);
|
let kw_results: SourceResult[] = $state([]);
|
||||||
let kw_showAdvanced = $state(false);
|
let kw_showAdvanced = $state(false);
|
||||||
@@ -454,7 +451,6 @@
|
|||||||
) as (Manga & { _sourceName?: string })[];
|
) as (Manga & { _sourceName?: string })[];
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tag tab ───────────────────────────────────────────────────────────────
|
|
||||||
let tag_activeTags: string[] = $state([]);
|
let tag_activeTags: string[] = $state([]);
|
||||||
let tag_activeStatuses: string[] = $state([]);
|
let tag_activeStatuses: string[] = $state([]);
|
||||||
let tag_tagMode: TagMode = $state("AND");
|
let tag_tagMode: TagMode = $state("AND");
|
||||||
@@ -471,16 +467,10 @@
|
|||||||
let tag_searchSources = $state(false);
|
let tag_searchSources = $state(false);
|
||||||
let tag_sourceFiltered: CachedManga[] = $state([]);
|
let tag_sourceFiltered: CachedManga[] = $state([]);
|
||||||
|
|
||||||
// Active source fan-out results (Discover-style live fetch per genre tag)
|
|
||||||
let tag_sourceFanOut: Manga[] = $state([]);
|
let tag_sourceFanOut: Manga[] = $state([]);
|
||||||
let tag_fanOutLoading = $state(false);
|
let tag_fanOutLoading = $state(false);
|
||||||
let tag_fanOutAbort: AbortController | null = null;
|
let tag_fanOutAbort: AbortController | null = null;
|
||||||
|
|
||||||
// Context menu state
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
|
||||||
let categories: Category[] = $state([]);
|
|
||||||
let catsLoaded = false;
|
|
||||||
|
|
||||||
const tag_filteredGenres = $derived.by(() => {
|
const tag_filteredGenres = $derived.by(() => {
|
||||||
const q = tag_tagFilter.trim().toLowerCase();
|
const q = tag_tagFilter.trim().toLowerCase();
|
||||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||||
@@ -510,7 +500,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fan-out live source search when a single genre tag is active + sources enabled
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _tags = tag_activeTags;
|
const _tags = tag_activeTags;
|
||||||
const _search = tag_searchSources;
|
const _search = tag_searchSources;
|
||||||
@@ -533,7 +522,7 @@
|
|||||||
tag_sourceFanOut = [];
|
tag_sourceFanOut = [];
|
||||||
tag_fanOutLoading = true;
|
tag_fanOutLoading = true;
|
||||||
|
|
||||||
const srcs = disc_rotatedSources(allSources);
|
const srcs = srch_rotatedSources(allSources);
|
||||||
const PAGES = 2;
|
const PAGES = 2;
|
||||||
|
|
||||||
await runConcurrent(srcs, async (src) => {
|
await runConcurrent(srcs, async (src) => {
|
||||||
@@ -543,8 +532,8 @@
|
|||||||
let mangas: Manga[];
|
let mangas: Manga[];
|
||||||
let hasNextPage = false;
|
let hasNextPage = false;
|
||||||
|
|
||||||
if (store.discoverCache?.has(cacheKey)) {
|
if (store.searchCache?.has(cacheKey)) {
|
||||||
mangas = store.discoverCache.get(cacheKey)!;
|
mangas = store.searchCache.get(cacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
@@ -554,7 +543,7 @@
|
|||||||
if (!result || ctrl.signal.aborted) return;
|
if (!result || ctrl.signal.aborted) return;
|
||||||
mangas = result.mangas;
|
mangas = result.mangas;
|
||||||
hasNextPage = result.hasNextPage;
|
hasNextPage = result.hasNextPage;
|
||||||
store.discoverCache?.set(cacheKey, mangas);
|
store.searchCache?.set(cacheKey, mangas);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
@@ -568,7 +557,7 @@
|
|||||||
tag_sourceFanOut = dedupeMangaByTitle(
|
tag_sourceFanOut = dedupeMangaByTitle(
|
||||||
dedupeMangaById([...tag_sourceFanOut, ...toAdd]),
|
dedupeMangaById([...tag_sourceFanOut, ...toAdd]),
|
||||||
store.settings.mangaLinks,
|
store.settings.mangaLinks,
|
||||||
).slice(0, DISCOVER_LIMIT);
|
).slice(0, SEARCH_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNextPage) return;
|
if (!hasNextPage) return;
|
||||||
@@ -660,52 +649,6 @@
|
|||||||
tag_searchSources = !tag_searchSources;
|
tag_searchSources = !tag_searchSources;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
||||||
if (!catsLoaded) {
|
|
||||||
catsLoaded = true;
|
|
||||||
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
|
|
||||||
.then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
|
||||||
disabled: m.inLibrary,
|
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
|
||||||
.then(() => {
|
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
|
||||||
store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]);
|
|
||||||
}).catch(console.error),
|
|
||||||
},
|
|
||||||
...(categories.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...categories.map(cat => ({
|
|
||||||
label: (cat.mangas?.nodes ?? []).some((x: any) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
|
|
||||||
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{
|
|
||||||
label: "New folder & add",
|
|
||||||
onClick: async () => {
|
|
||||||
const n = prompt("Folder name:");
|
|
||||||
if (!n?.trim()) return;
|
|
||||||
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error);
|
|
||||||
if (res) {
|
|
||||||
const cat = res.createCategory.category;
|
|
||||||
categories = [...categories, cat];
|
|
||||||
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
|
||||||
|
|
||||||
const tag_mergedResults = $derived.by(() => {
|
const tag_mergedResults = $derived.by(() => {
|
||||||
@@ -729,7 +672,6 @@
|
|||||||
|
|
||||||
const tag_totalVisible = $derived(tag_mergedResults.length);
|
const tag_totalVisible = $derived(tag_mergedResults.length);
|
||||||
|
|
||||||
// ── Source browse tab ─────────────────────────────────────────────────────
|
|
||||||
let src_selectedLang = $state(preferredLang || "all");
|
let src_selectedLang = $state(preferredLang || "all");
|
||||||
let src_activeSource: Source | null = $state(null);
|
let src_activeSource: Source | null = $state(null);
|
||||||
let src_browseResults: Manga[] = $state([]);
|
let src_browseResults: Manga[] = $state([]);
|
||||||
@@ -814,7 +756,7 @@
|
|||||||
tag_fanOutAbort?.abort();
|
tag_fanOutAbort?.abort();
|
||||||
src_abortCtrl?.abort();
|
src_abortCtrl?.abort();
|
||||||
sourceCacheAbort?.abort();
|
sourceCacheAbort?.abort();
|
||||||
disc_abortCtrl?.abort();
|
srch_abortCtrl?.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -903,31 +845,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !kw_query.trim()}
|
{#if !kw_query.trim()}
|
||||||
{#if disc_loading && disc_results.length === 0}
|
{#if srch_loading && srch_results.length === 0}
|
||||||
<div class="discoverGrid">
|
<div class="searchGrid">
|
||||||
{#each Array(24) as _, i (i)}
|
{#each Array(24) as _, i (i)}
|
||||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if disc_results.length > 0}
|
{:else if srch_results.length > 0}
|
||||||
<div class="discoverHeader">
|
<div class="searchHeader">
|
||||||
<span class="discoverLabel">Popular right now</span>
|
<span class="searchLabel">Popular right now</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="discoverGrid">
|
<div class="searchGrid">
|
||||||
{#each disc_results as m (m.id)}
|
{#each srch_results as m (m.id)}
|
||||||
<button class="discCard" onclick={() => setPreviewManga(m)}>
|
<button class="srchCard" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="discCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
<div class="discGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="discFooter">
|
<div class="srchFooter">
|
||||||
<p class="discTitle">{m.title}</p>
|
<p class="srchTitle">{m.title}</p>
|
||||||
{#if m.source?.displayName}<p class="discSource">{m.source.displayName}</p>{/if}
|
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if disc_loading}
|
{#if srch_loading}
|
||||||
{#each Array(6) as _, i (i)}
|
{#each Array(6) as _, i (i)}
|
||||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -950,19 +892,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#if kw_flatResults.length > 0}
|
{#if kw_flatResults.length > 0}
|
||||||
<div class="discoverHeader">
|
<div class="searchHeader">
|
||||||
<span class="discoverLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="discoverGrid">
|
<div class="searchGrid">
|
||||||
{#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)}
|
{#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)}
|
||||||
<button class="discCard" onclick={() => setPreviewManga(m)}>
|
<button class="srchCard" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="discCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
<div class="discGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="discFooter">
|
<div class="srchFooter">
|
||||||
<p class="discTitle">{m.title}</p>
|
<p class="srchTitle">{m.title}</p>
|
||||||
{#if (m as any)._sourceName}<p class="discSource">{(m as any)._sourceName}</p>{/if}
|
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -974,7 +916,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if kw_anyLoading}
|
{:else if kw_anyLoading}
|
||||||
<div class="discoverGrid">
|
<div class="searchGrid">
|
||||||
{#each Array(12) as _, i (i)}
|
{#each Array(12) as _, i (i)}
|
||||||
<div class="skCard"><div class="skeleton skCover"></div></div>
|
<div class="skCard"><div class="skeleton skCover"></div></div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -1110,7 +1052,7 @@
|
|||||||
{:else if tag_mergedResults.length > 0}
|
{:else if tag_mergedResults.length > 0}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each tag_mergedResults as m (m.id)}
|
{#each tag_mergedResults as m (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
|
<button class="card" onclick={() => setPreviewManga(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
@@ -1278,10 +1220,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ctx}
|
|
||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
@@ -1403,16 +1341,16 @@
|
|||||||
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
|
||||||
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||||
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
|
||||||
.discoverHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
|
||||||
.discoverLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.discoverGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; }
|
.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; }
|
||||||
.discCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.discCard:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.srchCard:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.discCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
|
||||||
.discGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
|
||||||
.discFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
.discTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
|
.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); }
|
||||||
.discSource { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.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; }
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
.anim-spin { animation: anim-spin 0.8s linear infinite; }
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ export const CACHE_KEYS = {
|
|||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
CATEGORIES: "categories",
|
CATEGORIES: "categories",
|
||||||
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
SEARCH: "search_all_manga", // Search's unfiltered fetch — separate from library
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
POPULAR: "popular",
|
POPULAR: "popular",
|
||||||
GENRE: (genre: string) => `genre:${genre}`,
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
|||||||
@@ -443,9 +443,9 @@ class Store {
|
|||||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||||
discoverCache: Map<string, any> = $state(new Map());
|
searchCache: Map<string, any> = $state(new Map());
|
||||||
discoverLibraryIds: Set<number> = $state(new Set());
|
searchLibraryIds: Set<number> = $state(new Set());
|
||||||
discoverSrcOffset: number = $state(0);
|
searchSrcOffset: number = $state(0);
|
||||||
readerSessionId: number = $state(0);
|
readerSessionId: number = $state(0);
|
||||||
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
libraryUpdates: LibraryUpdateEntry[] = $state(saved?.libraryUpdates ?? []);
|
||||||
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
lastLibraryRefresh: number = $state(saved?.lastLibraryRefresh ?? 0);
|
||||||
@@ -682,10 +682,10 @@ class Store {
|
|||||||
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
this.settings = { ...this.settings, hiddenCategoryIds: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDiscoverCache() {
|
clearSearchCache() {
|
||||||
this.discoverCache = new Map();
|
this.searchCache = new Map();
|
||||||
this.discoverLibraryIds = new Set();
|
this.searchLibraryIds = new Set();
|
||||||
this.discoverSrcOffset++;
|
this.searchSrcOffset++;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLibraryUpdates(entries: LibraryUpdateEntry[]) {
|
setLibraryUpdates(entries: LibraryUpdateEntry[]) {
|
||||||
@@ -738,7 +738,7 @@ export function setLibraryTagFilter(next: string[]) { sto
|
|||||||
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
|
||||||
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
|
||||||
export function resetKeybinds() { store.resetKeybinds(); }
|
export function resetKeybinds() { store.resetKeybinds(); }
|
||||||
export function clearDiscoverCache() { store.clearDiscoverCache(); }
|
export function clearSearchCache() { store.clearSearchCache(); }
|
||||||
export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); }
|
export function setLibraryUpdates(entries: LibraryUpdateEntry[]) { store.setLibraryUpdates(entries); }
|
||||||
export function clearLibraryUpdates() { store.clearLibraryUpdates(); }
|
export function clearLibraryUpdates() { store.clearLibraryUpdates(); }
|
||||||
export function acknowledgeUpdate(mangaId: number) { store.acknowledgeUpdate(mangaId); }
|
export function acknowledgeUpdate(mangaId: number) { store.acknowledgeUpdate(mangaId); }
|
||||||
|
|||||||
Reference in New Issue
Block a user