mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Discover Cache Refresh & Populating
This commit is contained in:
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: c78a3f002f898011c4e70e1af781b37dac0fd995b5623170256d88339c90ca74
|
sha256: b98f32eab8efa0701977f7e68bf2bb52da7be1dbf9c80887a737800fc05e1637
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onDestroy } from "svelte";
|
||||||
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle } from "phosphor-svelte";
|
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||||
const GRID_LIMIT = 60; // max rendered per tab
|
const GRID_LIMIT = 100;
|
||||||
const LOCAL_THRESHOLD = 20; // fan out to sources if local results below this
|
const LOCAL_THRESHOLD = 20;
|
||||||
const CONCURRENCY = 4; // parallel source requests — kept conservative to not saturate connections
|
const CONCURRENCY = 4;
|
||||||
const BATCH_INTERVAL = 400; // ms between DOM updates during background source fan-out
|
const BATCH_MS = 400;
|
||||||
|
|
||||||
const EXPLORE_ALL_MANGA = `
|
const EXPLORE_ALL_MANGA = `
|
||||||
query ExploreAllManga {
|
query ExploreAllManga {
|
||||||
@@ -27,46 +27,56 @@
|
|||||||
`;
|
`;
|
||||||
const MANGAS_BY_GENRE = `
|
const MANGAS_BY_GENRE = `
|
||||||
query MangasByGenre($genre: String!, $first: Int) {
|
query MangasByGenre($genre: String!, $first: Int) {
|
||||||
mangas(
|
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||||
filter: { genre: { includesInsensitive: $genre } }
|
nodes { id title thumbnailUrl inLibrary genre status source { id displayName } }
|
||||||
first: $first orderBy: IN_LIBRARY_AT orderByType: DESC
|
}
|
||||||
) { nodes { id title thumbnailUrl inLibrary genre status source { id displayName } } }
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────────
|
// ── Dedicated discover cache ───────────────────────────────────────────────
|
||||||
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
|
// Completely isolated from main app cache — refresh only wipes this,
|
||||||
let allSources: Source[] = $state([]); // all deduped sources — loaded once
|
// leaving library/chapter/source caches untouched.
|
||||||
let loadingLib = $state(true);
|
const discoverStore = new Map<string, Manga[]>();
|
||||||
let loadError = $state(false);
|
function dKey(srcId: string, type: string, tag: string) { return `${srcId}|${type}|${tag}`; }
|
||||||
|
function clearDiscover() { discoverStore.clear(); }
|
||||||
|
|
||||||
// Per-genre result map. Keyed by genre string.
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
// "All" key → local library deduped by title
|
let allManga: Manga[] = $state([]);
|
||||||
// Each tab key → local + background source results, deduped id+title
|
let allSources: Source[] = $state([]);
|
||||||
let genreResults = $state(new Map<string, Manga[]>());
|
let libraryIds: Set<number> = $state(new Set());
|
||||||
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
|
let loadingLib = $state(true);
|
||||||
let currentGenre = $state("All");
|
let loadError = $state(false);
|
||||||
let genreAbort: AbortController | null = null;
|
let currentGenre = $state("All");
|
||||||
|
let genreResults = $state(new Map<string, Manga[]>());
|
||||||
|
let genreLoading = $state(false);
|
||||||
|
let srcOffset = $state(0);
|
||||||
|
|
||||||
// batch timer handle for background source fan-out
|
let activeCtrl: AbortController | null = null;
|
||||||
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
let batchTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
// accumulator: source results collected between batches
|
let batchAccum = new Map<string, Manga[]>();
|
||||||
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
|
||||||
let isLoading = $state(false);
|
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
|
||||||
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
|
||||||
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
|
|
||||||
|
|
||||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
function dedup(items: Manga[]): Manga[] {
|
function dedup(items: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
function filterSource(mangas: Manga[]): Manga[] {
|
||||||
|
return dedup(mangas.filter(m => !m.inLibrary && !libraryIds.has(m.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatedSources(): Source[] {
|
||||||
|
const lang = store.settings.preferredExtensionLang || "en";
|
||||||
|
const srcs = dedupeSources(allSources.filter(s => s.id !== "0"), lang);
|
||||||
|
if (!srcs.length) return [];
|
||||||
|
const off = srcOffset % srcs.length;
|
||||||
|
return [...srcs.slice(off), ...srcs.slice(0, off)];
|
||||||
|
}
|
||||||
|
|
||||||
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
async function runConcurrent<T>(items: T[], fn: (i: T) => Promise<void>, signal: AbortSignal) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
@@ -78,139 +88,204 @@
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batched DOM flush ─────────────────────────────────────────────────────────
|
// ── Batch flush ───────────────────────────────────────────────────────────
|
||||||
// Source fan-out collects results in batchAccum. A timer fires every BATCH_INTERVAL
|
function startBatch() {
|
||||||
// ms and flushes them into genreResults in one shot — preventing a Svelte re-render
|
|
||||||
// per-source and keeping the grid smooth.
|
|
||||||
function startBatchFlush() {
|
|
||||||
if (batchTimer) return;
|
if (batchTimer) return;
|
||||||
batchTimer = setInterval(() => {
|
batchTimer = setInterval(() => {
|
||||||
if (batchAccum.size === 0) return;
|
if (!batchAccum.size) return;
|
||||||
for (const [genre, incoming] of batchAccum) {
|
for (const [genre, incoming] of batchAccum) {
|
||||||
const current = genreResults.get(genre) ?? [];
|
const cur = genreResults.get(genre) ?? [];
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
|
||||||
}
|
}
|
||||||
batchAccum.clear();
|
batchAccum.clear();
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
}, BATCH_INTERVAL);
|
}, BATCH_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopBatchFlush() {
|
function flushBatch() {
|
||||||
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
if (batchTimer) { clearInterval(batchTimer); batchTimer = null; }
|
||||||
// Final flush of anything remaining
|
if (!batchAccum.size) return;
|
||||||
if (batchAccum.size > 0) {
|
for (const [genre, incoming] of batchAccum) {
|
||||||
for (const [genre, incoming] of batchAccum) {
|
const cur = genreResults.get(genre) ?? [];
|
||||||
const current = genreResults.get(genre) ?? [];
|
genreResults.set(genre, dedup([...cur, ...incoming]).slice(0, GRID_LIMIT));
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
|
||||||
}
|
|
||||||
batchAccum.clear();
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}
|
}
|
||||||
|
batchAccum.clear();
|
||||||
|
genreResults = new Map(genreResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push source results into the accumulator (never touches the DOM directly)
|
|
||||||
function accumulate(genre: string, mangas: Manga[]) {
|
function accumulate(genre: string, mangas: Manga[]) {
|
||||||
|
const filtered = filterSource(mangas);
|
||||||
|
if (!filtered.length) return;
|
||||||
const existing = batchAccum.get(genre) ?? [];
|
const existing = batchAccum.get(genre) ?? [];
|
||||||
batchAccum.set(genre, [...existing, ...mangas]);
|
batchAccum.set(genre, [...existing, ...filtered]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Background source fan-out for a genre ────────────────────────────────────
|
// ── Source fan-out ────────────────────────────────────────────────────────
|
||||||
// Runs entirely in the background. Results appear in batches via batchAccum.
|
async function fanOut(genre: string, ctrl: AbortController) {
|
||||||
// Does NOT set genreLoading = true — the local result is already showing.
|
const srcs = rotatedSources();
|
||||||
async function fanOutSources(genre: string, ctrl: AbortController) {
|
if (!srcs.length) return;
|
||||||
if (!allSources.length) return;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
const srcs = dedupeSources(allSources, lang);
|
|
||||||
|
|
||||||
startBatchFlush();
|
const isAll = genre === "All";
|
||||||
|
const type = isAll ? "POPULAR" : "SEARCH";
|
||||||
|
const query = isAll ? null : genre;
|
||||||
|
|
||||||
|
startBatch();
|
||||||
|
|
||||||
await runConcurrent(srcs, async src => {
|
await runConcurrent(srcs, async src => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, [genre]);
|
const key = dKey(src.id, type, genre);
|
||||||
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: 1, query: genre }, ctrl.signal
|
|
||||||
).then(d => d.fetchSourceManga),
|
|
||||||
5 * 60 * 1000, // 5-min TTL — results are stable enough to cache
|
|
||||||
).catch(() => null);
|
|
||||||
|
|
||||||
if (!result || ctrl.signal.aborted) return;
|
let mangas: Manga[];
|
||||||
|
if (discoverStore.has(key)) {
|
||||||
|
mangas = discoverStore.get(key)!;
|
||||||
|
} else {
|
||||||
|
const result = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||||
|
FETCH_SOURCE_MANGA,
|
||||||
|
{ source: src.id, type, page: 1, query },
|
||||||
|
ctrl.signal
|
||||||
|
).then(d => d.fetchSourceManga).catch(() => null);
|
||||||
|
if (!result || ctrl.signal.aborted) return;
|
||||||
|
mangas = result.mangas;
|
||||||
|
discoverStore.set(key, mangas);
|
||||||
|
}
|
||||||
|
|
||||||
// Only accumulate results that actually match the genre (client-side AND check)
|
if (ctrl.signal.aborted) return;
|
||||||
const matching = result.mangas.filter(m =>
|
|
||||||
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
|
||||||
|| result.mangas.length <= 5 // source returns few results, trust them
|
|
||||||
);
|
|
||||||
|
|
||||||
accumulate(genre, matching.length > 0 ? matching : result.mangas);
|
if (isAll) {
|
||||||
|
accumulate("All", mangas);
|
||||||
|
} else {
|
||||||
|
const matching = mangas.filter(m =>
|
||||||
|
(m.genre ?? []).some(g => g.toLowerCase() === genre.toLowerCase())
|
||||||
|
);
|
||||||
|
accumulate(genre, matching.length ? matching : mangas);
|
||||||
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
|
|
||||||
if (!ctrl.signal.aborted) stopBatchFlush();
|
if (!ctrl.signal.aborted) flushBatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab switch ───────────────────────────────────────────────────────────────
|
// ── Tab switch ────────────────────────────────────────────────────────────
|
||||||
// 1. Show local results immediately (no spinner if already cached)
|
|
||||||
// 2. If local < LOCAL_THRESHOLD, kick off background fan-out silently
|
|
||||||
async function switchGenre(genre: string) {
|
async function switchGenre(genre: string) {
|
||||||
if (currentGenre === genre) return;
|
if (currentGenre === genre) return;
|
||||||
|
|
||||||
// Abort any in-flight fan-out for the previous tab
|
activeCtrl?.abort();
|
||||||
genreAbort?.abort();
|
flushBatch();
|
||||||
stopBatchFlush();
|
|
||||||
|
|
||||||
currentGenre = genre;
|
currentGenre = genre;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
activeCtrl = ctrl;
|
||||||
|
|
||||||
if (genre === "All") {
|
if (genre === "All") {
|
||||||
// "All" is just the deduped local library — no network needed
|
genreResults.set("All", []);
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
|
genreLoading = true;
|
||||||
|
await fanOut("All", ctrl);
|
||||||
|
if (!ctrl.signal.aborted) genreLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we already have a fully-populated cache for this genre, show it instantly
|
// Genre tab: check local cache first, always fan out to sources too
|
||||||
const cached = genreResults.get(genre);
|
const localKey = `local|${genre}`;
|
||||||
if (cached && cached.length >= LOCAL_THRESHOLD) return;
|
if (discoverStore.has(localKey)) {
|
||||||
|
// Serve cached local results immediately
|
||||||
|
genreResults.set(genre, discoverStore.get(localKey)!);
|
||||||
|
genreResults = new Map(genreResults);
|
||||||
|
// Always fan out in background to get source results too
|
||||||
|
fanOut(genre, ctrl).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch local results (fast — single DB query)
|
// Fetch local library results then fan out
|
||||||
genreLoading = true;
|
genreLoading = true;
|
||||||
const ctrl = new AbortController();
|
|
||||||
genreAbort = ctrl;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const localData = await cache.get(CACHE_KEYS.GENRE(genre), () =>
|
const d = await gql<{ mangas: { nodes: Manga[] } }>(
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal)
|
MANGAS_BY_GENRE, { genre, first: GRID_LIMIT }, ctrl.signal
|
||||||
.then(d => d.mangas.nodes)
|
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
const local = dedup(localData);
|
const local = dedup(d.mangas.nodes);
|
||||||
|
discoverStore.set(localKey, local);
|
||||||
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
genreResults.set(genre, local.slice(0, GRID_LIMIT));
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
genreLoading = false;
|
genreLoading = false;
|
||||||
|
|
||||||
// If sparse, fan out to sources in the background — no loading state shown
|
// Always fan out — show source results alongside library results
|
||||||
if (local.length < LOCAL_THRESHOLD) {
|
fanOut(genre, ctrl).catch(() => {});
|
||||||
fanOutSources(genre, ctrl).catch(() => {}); // fully detached background task
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
if (!ctrl.signal.aborted) genreLoading = false;
|
if (!ctrl.signal.aborted) genreLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context menu ──────────────────────────────────────────────────────────────
|
// ── Refresh ───────────────────────────────────────────────────────────────
|
||||||
|
let refreshing = $state(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
activeCtrl?.abort();
|
||||||
|
flushBatch();
|
||||||
|
clearDiscover();
|
||||||
|
srcOffset++;
|
||||||
|
genreResults = new Map();
|
||||||
|
refreshing = true;
|
||||||
|
genreLoading = true;
|
||||||
|
const genre = currentGenre;
|
||||||
|
currentGenre = "";
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
await switchGenre(genre);
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial load ──────────────────────────────────────────────────────────
|
||||||
|
function loadAll() {
|
||||||
|
loadingLib = true;
|
||||||
|
loadError = false;
|
||||||
|
|
||||||
|
// Load library for filtering — don't show stuff already in library
|
||||||
|
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||||
|
).then(m => {
|
||||||
|
allManga = dedupeMangaById(m);
|
||||||
|
libraryIds = new Set(allManga.filter(x => x.inLibrary).map(x => x.id));
|
||||||
|
}).catch(e => { console.error(e); loadError = true; })
|
||||||
|
.finally(() => { loadingLib = false; });
|
||||||
|
|
||||||
|
// Load sources then kick off initial All tab fan-out
|
||||||
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
|
.then(d => {
|
||||||
|
allSources = d.sources.nodes;
|
||||||
|
// Only trigger if still on All tab
|
||||||
|
if (currentGenre === "All" || currentGenre === "") {
|
||||||
|
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(); flushBatch(); });
|
||||||
|
|
||||||
|
loadAll();
|
||||||
|
|
||||||
|
// ── Context menu ──────────────────────────────────────────────────────────
|
||||||
function openCtx(e: MouseEvent, m: Manga) {
|
function openCtx(e: MouseEvent, m: Manga) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "In Library" : "Add to library",
|
label: m.inLibrary ? "In Library" : "Add to library",
|
||||||
icon: BookmarkSimple, disabled: m.inLibrary,
|
icon: BookmarkSimple, disabled: m.inLibrary,
|
||||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||||
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
|
.then(() => {
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
libraryIds = new Set([...libraryIds, m.id]);
|
||||||
|
}).catch(console.error),
|
||||||
},
|
},
|
||||||
...(store.settings.folders.length > 0 ? [
|
...(store.settings.folders.length > 0 ? [
|
||||||
{ separator: true } as MenuEntry,
|
{ separator: true } as MenuEntry,
|
||||||
@@ -230,49 +305,13 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial load ──────────────────────────────────────────────────────────────
|
|
||||||
// 1. Load local library → populate "All" tab immediately
|
|
||||||
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
|
|
||||||
function loadAll() {
|
|
||||||
loadingLib = true; loadError = false;
|
|
||||||
const lang = store.settings.preferredExtensionLang || "en";
|
|
||||||
|
|
||||||
// Local library — populates "All" tab
|
|
||||||
cache.get(CACHE_KEYS.DISCOVER, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
|
||||||
).then(m => {
|
|
||||||
allManga = dedupeMangaById(m);
|
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
|
||||||
genreResults = new Map(genreResults);
|
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
|
||||||
.finally(() => { loadingLib = false; });
|
|
||||||
|
|
||||||
// Source list — loaded silently in background, cached for the session
|
|
||||||
// Not awaited — the grid doesn't depend on this for the initial render
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then(d => dedupeSources(d.sources.nodes, lang)),
|
|
||||||
Infinity, // pin for session — source list is stable
|
|
||||||
).then(srcs => {
|
|
||||||
allSources = srcs;
|
|
||||||
}).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(loadAll);
|
|
||||||
onDestroy(() => {
|
|
||||||
genreAbort?.abort();
|
|
||||||
stopBatchFlush();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
|
|
||||||
{#if store.activeSource}
|
{#if store.activeSource}
|
||||||
<SourceBrowse />
|
<SourceBrowse />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="root">
|
<div class="root">
|
||||||
|
|
||||||
<!-- ── Header: page label + genre pill tabs ──────────────────────────────── -->
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Discover</span>
|
<span class="heading">Discover</span>
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
@@ -287,13 +326,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="refresh-btn" class:spinning={refreshing} onclick={refresh} title="Refresh results" disabled={refreshing}>
|
||||||
|
<ArrowsClockwise size={13} weight="bold" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Body ──────────────────────────────────────────────────────────────── -->
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<!-- Skeleton — shown only during first local fetch, never during bg fan-out -->
|
|
||||||
<div class="manga-grid">
|
<div class="manga-grid">
|
||||||
{#each Array(24) as _, i (i)}
|
{#each Array(24) as _, i (i)}
|
||||||
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
<div class="card-skeleton"><div class="skeleton cover-area"></div></div>
|
||||||
@@ -318,25 +357,20 @@
|
|||||||
oncontextmenu={(e) => openCtx(e, m)}
|
oncontextmenu={(e) => openCtx(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
|
||||||
class="cover" loading="lazy" decoding="async"
|
|
||||||
/>
|
|
||||||
<div class="cover-gradient"></div>
|
<div class="cover-gradient"></div>
|
||||||
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
{#if m.source?.displayName}
|
{#if m.source?.displayName}<p class="card-source">{m.source.displayName}</p>{/if}
|
||||||
<p class="card-source">{m.source.displayName}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -346,117 +380,36 @@
|
|||||||
|
|
||||||
<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; 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 ──────────────────────────────────────────────────────────────── */
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-4); flex-shrink: 0;
|
|
||||||
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
overflow-x: auto; scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.header::-webkit-scrollbar { display: none; }
|
.header::-webkit-scrollbar { display: none; }
|
||||||
.heading {
|
.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; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genre pill tabs */
|
|
||||||
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
.tab-strip { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
.genre-tab {
|
.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); }
|
||||||
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: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); }
|
.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); }
|
||||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
.refresh-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.body {
|
.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; }
|
||||||
flex: 1; overflow-y: auto;
|
.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; }
|
||||||
padding: var(--sp-4) var(--sp-5) var(--sp-6);
|
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
/* GPU-accelerated scroll — does NOT promote every card, only the scroll container */
|
|
||||||
will-change: scroll-position;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Grid ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr));
|
|
||||||
gap: var(--sp-2);
|
|
||||||
align-content: start;
|
|
||||||
/* Isolate the grid from the rest of the layout — prevents full-page reflow on update */
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card ────────────────────────────────────────────────────────────────── */
|
|
||||||
.manga-card {
|
|
||||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
|
||||||
/* NO will-change here — only promote on actual hover to avoid 60+ simultaneous GPU layers */
|
|
||||||
}
|
|
||||||
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.manga-card:hover .card-title { color: #fff; }
|
.manga-card:hover .card-title { color: #fff; }
|
||||||
/* Promote only the hovered card to its own GPU layer */
|
|
||||||
.manga-card:hover { will-change: transform; }
|
.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); }
|
||||||
.cover-wrap {
|
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
.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; }
|
||||||
border-radius: var(--radius-md); background: var(--bg-raised);
|
.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); }
|
||||||
border: 1px solid var(--border-dim);
|
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
.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; }
|
||||||
.cover {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
||||||
transition: filter 0.15s ease, transform 0.15s ease;
|
|
||||||
/* will-change removed — only the parent card gets it on hover */
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────────────────────────────────────── */
|
|
||||||
.card-skeleton { padding: 0; }
|
.card-skeleton { padding: 0; }
|
||||||
.cover-area { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.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; }
|
||||||
/* ── Empty / error ───────────────────────────────────────────────────────── */
|
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.85 } }
|
||||||
.empty {
|
.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); }
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
.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); }
|
||||||
gap: var(--sp-3); padding: var(--sp-10) var(--sp-6);
|
.refresh-btn.spinning { opacity: 0.5; cursor: default; }
|
||||||
color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs);
|
.refresh-btn.spinning :global(svg) { animation: spin 0.8s linear infinite; }
|
||||||
letter-spacing: var(--tracking-wide);
|
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||||
}
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy } from "svelte";
|
|
||||||
import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
|
||||||
import { UPDATE_MANGA, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
|
||||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
|
||||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/util";
|
|
||||||
import { settings, activeSource, genreFilter, previewManga, history, addFolder, assignMangaToFolder } from "../../store";
|
|
||||||
import type { Manga, Source } from "../../lib/types";
|
|
||||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
||||||
import SourceList from "../sources/SourceList.svelte";
|
|
||||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
|
||||||
import GenreDrillPage from "./GenreDrillPage.svelte";
|
|
||||||
|
|
||||||
type ExploreMode = "explore" | "sources";
|
|
||||||
let mode: ExploreMode = "explore";
|
|
||||||
|
|
||||||
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_EXPLORE = `
|
|
||||||
query MangasByGenreExplore($genre: String!, $first: Int) {
|
|
||||||
mangas(filter: { genre: { includesInsensitive: $genre } }, first: $first, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
|
||||||
nodes { id title thumbnailUrl inLibrary genre }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
|
||||||
const ROW_CAP = 25;
|
|
||||||
const GHOST_COUNT = 3;
|
|
||||||
|
|
||||||
let allManga: Manga[] = [];
|
|
||||||
let popularManga: Manga[] = [];
|
|
||||||
let sources: Source[] = [];
|
|
||||||
let genreResultsMap = new Map<string, Manga[]>();
|
|
||||||
let loadingLib = true;
|
|
||||||
let loadingPopular = true;
|
|
||||||
let loadingGenres = false;
|
|
||||||
let loadError = false;
|
|
||||||
let retryCount = 0;
|
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
|
||||||
let abortCtrl: AbortController | null = null;
|
|
||||||
let fetchedGenresKey = "";
|
|
||||||
|
|
||||||
function frecencyScore(readAt: number, count: number): number {
|
|
||||||
return count / Math.log((Date.now() - readAt) / 3_600_000 + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: frecencyGenres = (() => {
|
|
||||||
const mangaScores = new Map<number, number>();
|
|
||||||
const mangaReadAt = new Map<number, number>();
|
|
||||||
for (const e of $history) {
|
|
||||||
mangaScores.set(e.mangaId, (mangaScores.get(e.mangaId) ?? 0) + 1);
|
|
||||||
if (e.readAt > (mangaReadAt.get(e.mangaId) ?? 0)) mangaReadAt.set(e.mangaId, e.readAt);
|
|
||||||
}
|
|
||||||
const genreWeights = new Map<string, number>();
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
for (const [mangaId, count] of mangaScores.entries()) {
|
|
||||||
const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count);
|
|
||||||
for (const g of mangaMap.get(mangaId)?.genre ?? []) genreWeights.set(g, (genreWeights.get(g) ?? 0) + score);
|
|
||||||
}
|
|
||||||
if (genreWeights.size === 0) allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1)));
|
|
||||||
if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3);
|
|
||||||
return Array.from(genreWeights.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([g]) => g);
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: continueReading = (() => {
|
|
||||||
const mangaMap = new Map(allManga.map((m) => [m.id, m]));
|
|
||||||
const seen = new Set<number>();
|
|
||||||
const result: { manga: Manga; chapterName: string; progress: number }[] = [];
|
|
||||||
for (const e of $history) {
|
|
||||||
if (seen.has(e.mangaId)) continue;
|
|
||||||
seen.add(e.mangaId);
|
|
||||||
const manga = mangaMap.get(e.mangaId);
|
|
||||||
if (!manga) continue;
|
|
||||||
result.push({ manga, chapterName: e.chapterName, progress: e.pageNumber > 0 ? Math.min(e.pageNumber / 20, 1) : 0 });
|
|
||||||
if (result.length >= 12) break;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: recommended = allManga.length && frecencyGenres.length ? (() => {
|
|
||||||
const continueIds = new Set(continueReading.map((r) => r.manga.id));
|
|
||||||
return allManga.filter((m) => m.inLibrary && !continueIds.has(m.id) && frecencyGenres.some((g) => (m.genre ?? []).includes(g))).slice(0, 20);
|
|
||||||
})() : [];
|
|
||||||
|
|
||||||
$: if (frecencyGenres.length && allManga.length) loadGenreRows();
|
|
||||||
|
|
||||||
async function loadGenreRows() {
|
|
||||||
const key = frecencyGenres.join(",");
|
|
||||||
if (fetchedGenresKey === key) return;
|
|
||||||
fetchedGenresKey = key;
|
|
||||||
loadingGenres = true;
|
|
||||||
abortCtrl?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortCtrl = ctrl;
|
|
||||||
const streamMap = new Map<string, Manga[]>();
|
|
||||||
await Promise.allSettled(
|
|
||||||
frecencyGenres.map((genre) =>
|
|
||||||
cache.get(CACHE_KEYS.GENRE(genre), () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(MANGAS_BY_GENRE_EXPLORE, { genre, first: 25 }, ctrl.signal)
|
|
||||||
.then((d) => d.mangas.nodes)
|
|
||||||
).then((mangas) => {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
streamMap.set(genre, mangas);
|
|
||||||
genreResultsMap = new Map(streamMap);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).catch(() => {});
|
|
||||||
if (!ctrl.signal.aborted) loadingGenres = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (retryCount >= 0) loadData();
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
if (allManga.length > 0 && retryCount === 0) return;
|
|
||||||
loadingLib = true; loadingPopular = true; loadError = false;
|
|
||||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
|
||||||
if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresKey = ""; }
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then((d) => d.mangas.nodes)
|
|
||||||
).then((m) => { allManga = m; }).catch((e) => { console.error(e); loadError = true; }).finally(() => loadingLib = false);
|
|
||||||
|
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES).then((d) => dedupeSources(d.sources.nodes, preferredLang))
|
|
||||||
).then(async (allSources) => {
|
|
||||||
if (!allSources.length) { loadingPopular = false; return; }
|
|
||||||
const top = getTopSources(allSources).slice(0, 2);
|
|
||||||
sources = allSources;
|
|
||||||
cache.get(CACHE_KEYS.POPULAR, () =>
|
|
||||||
Promise.allSettled(top.map((src) =>
|
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "POPULAR", page: 1, query: null })
|
|
||||||
.then((d) => d.fetchSourceManga.mangas)
|
|
||||||
)).then((results) => {
|
|
||||||
const merged: Manga[] = [];
|
|
||||||
for (const r of results) if (r.status === "fulfilled") merged.push(...r.value);
|
|
||||||
return dedupeMangaByTitle(merged).slice(0, 30);
|
|
||||||
})
|
|
||||||
).then((m) => popularManga = m).catch(console.error).finally(() => loadingPopular = false);
|
|
||||||
}).catch((e) => { console.error(e); loadError = true; loadingPopular = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
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)).catch(console.error) },
|
|
||||||
...($settings.folders.length > 0 ? [
|
|
||||||
{ separator: true } as MenuEntry,
|
|
||||||
...$settings.folders.map((f): MenuEntry => ({
|
|
||||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
|
||||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
|
||||||
})),
|
|
||||||
] : []),
|
|
||||||
{ separator: true },
|
|
||||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowWheel(e: WheelEvent) {
|
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
||||||
const el = e.currentTarget as HTMLDivElement;
|
|
||||||
if (el.scrollLeft <= 0 && el.scrollLeft >= el.scrollWidth - el.clientWidth - 1) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
el.scrollLeft += e.deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => abortCtrl?.abort());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $activeSource}
|
|
||||||
<SourceBrowse />
|
|
||||||
{:else if $genreFilter}
|
|
||||||
<GenreDrillPage />
|
|
||||||
{:else}
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="heading">Explore</h1>
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab" class:active={mode === "explore"} on:click={() => mode = "explore"}>
|
|
||||||
<Compass size={11} weight="bold" /> Explore
|
|
||||||
</button>
|
|
||||||
<button class="tab" class:active={mode === "sources"} on:click={() => mode = "sources"}>
|
|
||||||
<List size={11} weight="bold" /> Sources
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:{mode === 'explore' ? 'contents' : 'none'}">
|
|
||||||
<div class="body">
|
|
||||||
|
|
||||||
{#if continueReading.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><BookOpen size={11} weight="bold" /> Continue Reading</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each continueReading.slice(0, ROW_CAP) as { manga, chapterName, progress }}
|
|
||||||
<button class="card" on:click={() => previewManga.set(manga)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga }; }}>
|
|
||||||
<div class="cover-wrap">
|
|
||||||
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="cover" loading="lazy" decoding="async" />
|
|
||||||
{#if manga.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
|
||||||
{#if progress > 0}<div class="progress-bar"><div class="progress-fill" style="width:{progress * 100}%"></div></div>{/if}
|
|
||||||
</div>
|
|
||||||
<p class="title">{manga.title}</p>
|
|
||||||
{#if chapterName}<p class="subtitle">{chapterName}</p>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if recommended.length > 0 || loadingLib}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><Star size={11} weight="bold" /> Recommended for You</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingLib}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each recommended.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if popularManga.length > 0 || loadingPopular}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">
|
|
||||||
<Fire size={11} weight="bold" />
|
|
||||||
{sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if loadingPopular}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else if sources.length === 0}
|
|
||||||
<div class="no-source">No sources installed. Add extensions first.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each popularManga.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each frecencyGenres as genre}
|
|
||||||
{@const items = genreResultsMap.get(genre) ?? []}
|
|
||||||
{@const isLoading = loadingGenres && items.length === 0}
|
|
||||||
{#if isLoading || items.length > 0}
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title">{genre}</span>
|
|
||||||
<button class="see-all" on:click={() => genreFilter.set(genre)}>See all <ArrowRight size={11} weight="light" /></button>
|
|
||||||
</div>
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="skeleton-row">{#each Array(8) as _}<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>{/each}</div>
|
|
||||||
{:else}
|
|
||||||
<div class="row" on:wheel={rowWheel}>
|
|
||||||
{#each items.slice(0, ROW_CAP) as m}
|
|
||||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
|
||||||
<div class="cover-wrap"><img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}</div>
|
|
||||||
<p class="title">{m.title}</p>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if items.length >= ROW_CAP}
|
|
||||||
<button class="explore-more-card" on:click={() => genreFilter.set(genre)}>
|
|
||||||
<div class="explore-more-inner">
|
|
||||||
<ArrowRight size={20} weight="light" class="explore-more-icon" />
|
|
||||||
<span class="explore-more-label">Explore more</span>
|
|
||||||
<span class="explore-more-genre">{genre}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#each Array(GHOST_COUNT) as _}<div class="ghost-card" aria-hidden></div>{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if !loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResultsMap.get(g)?.length)}
|
|
||||||
<div class="empty">
|
|
||||||
{#if loadError}
|
|
||||||
<span>Could not reach Suwayomi</span>
|
|
||||||
<span class="empty-hint">Make sure the server is running, then try again.</span>
|
|
||||||
<button class="retry-btn" on:click={() => { loadingLib = true; loadingPopular = true; retryCount++; }}>Retry</button>
|
|
||||||
{:else}
|
|
||||||
<span>Nothing to explore yet</span>
|
|
||||||
<span class="empty-hint">Add manga to your library or install sources to get started.</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if mode === "sources"}<SourceList />{/if}
|
|
||||||
</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; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-4); }
|
|
||||||
.header-left { display: flex; align-items: center; gap: var(--sp-4); }
|
|
||||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
|
||||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
|
||||||
.tab { 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); transition: background var(--t-base), color var(--t-base); white-space: nowrap; }
|
|
||||||
.tab:hover { color: var(--text-muted); }
|
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
|
||||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5) 0 var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
|
||||||
.section { margin-bottom: var(--sp-6); }
|
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); }
|
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 0; transition: color var(--t-base); }
|
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
|
||||||
.row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; scroll-behavior: smooth; }
|
|
||||||
.row::-webkit-scrollbar { display: none; }
|
|
||||||
.card { flex-shrink: 0; width: 110px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
|
||||||
.card:hover .cover { filter: brightness(1.06); }
|
|
||||||
.card:hover .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); }
|
|
||||||
.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); }
|
|
||||||
.progress-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: var(--bg-overlay); }
|
|
||||||
.progress-fill { height: 100%; background: var(--accent-fg); border-radius: 0 2px 0 0; transition: width 0.2s ease; }
|
|
||||||
.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); }
|
|
||||||
.subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); margin-top: 2px; letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ghost-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; pointer-events: none; visibility: hidden; }
|
|
||||||
.skeleton-row { display: flex; gap: var(--sp-3); padding: 0 var(--sp-6); overflow: hidden; }
|
|
||||||
.card-skeleton { flex-shrink: 0; width: 110px; }
|
|
||||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
|
||||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 80%; }
|
|
||||||
.explore-more-card { flex-shrink: 0; width: 110px; aspect-ratio: 2/3; border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--bg-raised); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: border-color var(--t-base), background var(--t-base); padding: 0; }
|
|
||||||
.explore-more-card:hover { border-color: var(--accent); background: var(--accent-muted); }
|
|
||||||
.explore-more-inner { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3); pointer-events: none; }
|
|
||||||
:global(.explore-more-icon) { color: var(--text-faint); transition: color var(--t-base); }
|
|
||||||
.explore-more-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); text-align: center; }
|
|
||||||
.explore-more-genre { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; text-align: center; font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
|
||||||
.no-source { display: flex; align-items: center; justify-content: center; padding: var(--sp-4) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
|
||||||
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--sp-8) var(--sp-6); color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); gap: var(--sp-2); text-align: center; }
|
|
||||||
.empty-hint { font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; }
|
|
||||||
.retry-btn { margin-top: var(--sp-3); 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); }
|
|
||||||
</style>
|
|
||||||
@@ -83,13 +83,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadingSources = true;
|
loadingSources = true;
|
||||||
cache.get(
|
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
CACHE_KEYS.SOURCES,
|
.then((d) => { allSources = d.sources.nodes.filter((src: Source) => src.id !== "0"); })
|
||||||
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
|
||||||
.then((d) => d.sources.nodes.filter((src: Source) => src.id !== "0")),
|
|
||||||
Infinity,
|
|
||||||
)
|
|
||||||
.then((srcs: Source[]) => { allSources = srcs; })
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { loadingSources = false; });
|
.finally(() => { loadingSources = false; });
|
||||||
|
|
||||||
@@ -383,11 +378,40 @@
|
|||||||
let src_hasNextPage = $state(false);
|
let src_hasNextPage = $state(false);
|
||||||
let src_currentPage = $state(1);
|
let src_currentPage = $state(1);
|
||||||
let src_abortCtrl: AbortController | null = null;
|
let src_abortCtrl: AbortController | null = null;
|
||||||
|
let src_langPocketOpen = $state(true);
|
||||||
|
let src_expandedGroups: Set<string> = $state(new Set());
|
||||||
|
|
||||||
|
// Group sources by displayName — sources with same name but different langs get grouped
|
||||||
|
interface SourceGroup {
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
sources: Source[];
|
||||||
|
isNsfw: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const src_visibleSources = $derived(src_selectedLang === "all"
|
const src_visibleSources = $derived(src_selectedLang === "all"
|
||||||
? allSources
|
? allSources
|
||||||
: allSources.filter((s) => s.lang === src_selectedLang));
|
: allSources.filter((s) => s.lang === src_selectedLang));
|
||||||
|
|
||||||
|
const src_groupedSources = $derived.by(() => {
|
||||||
|
const filtered = src_visibleSources;
|
||||||
|
const map = new Map<string, SourceGroup>();
|
||||||
|
for (const src of filtered) {
|
||||||
|
const key = src.displayName;
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, { name: src.displayName, iconUrl: src.iconUrl, sources: [], isNsfw: src.isNsfw });
|
||||||
|
}
|
||||||
|
map.get(key)!.sources.push(src);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
function srcToggleGroup(name: string) {
|
||||||
|
const next = new Set(src_expandedGroups);
|
||||||
|
if (next.has(name)) next.delete(name); else next.add(name);
|
||||||
|
src_expandedGroups = next;
|
||||||
|
}
|
||||||
|
|
||||||
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
src_abortCtrl?.abort();
|
src_abortCtrl?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -843,19 +867,27 @@
|
|||||||
<div class="splitRoot">
|
<div class="splitRoot">
|
||||||
|
|
||||||
<div class="splitSidebar">
|
<div class="splitSidebar">
|
||||||
{#if hasMultipleLangs}
|
<button class="langPocketToggle" onclick={() => (src_langPocketOpen = !src_langPocketOpen)}>
|
||||||
<div class="langFilterRow">
|
<span class="langPocketLabel">Languages</span>
|
||||||
{#each ["all", ...availableLangs] as lang (lang)}
|
<svg width="9" height="9" viewBox="0 0 256 256" fill="currentColor"
|
||||||
<button
|
style="transition: transform 0.2s ease; transform: rotate({src_langPocketOpen ? 180 : 0}deg)"
|
||||||
class="langChip"
|
aria-hidden="true">
|
||||||
class:langChipActive={src_selectedLang === lang}
|
<path 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"/>
|
||||||
onclick={() => (src_selectedLang = lang)}
|
</svg>
|
||||||
>
|
</button>
|
||||||
{lang === "all" ? "All" : lang.toUpperCase()}
|
{#if src_langPocketOpen}
|
||||||
</button>
|
<div class="langPocket">
|
||||||
{/each}
|
{#each ["all", ...availableLangs] as lang (lang)}
|
||||||
</div>
|
<button
|
||||||
{/if}
|
class="langChip"
|
||||||
|
class:langChipActive={src_selectedLang === lang}
|
||||||
|
onclick={() => (src_selectedLang = lang)}
|
||||||
|
>
|
||||||
|
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loadingSources}
|
{#if loadingSources}
|
||||||
<div class="splitLoading">
|
<div class="splitLoading">
|
||||||
@@ -865,23 +897,52 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="splitList">
|
<div class="splitList">
|
||||||
{#each src_visibleSources as src (src.id)}
|
{#each src_groupedSources as group (group.name)}
|
||||||
<button
|
{#if group.sources.length === 1}
|
||||||
class="splitItem splitItemSource"
|
<button
|
||||||
class:splitItemActive={src_activeSource?.id === src.id}
|
class="splitItem splitItemSource"
|
||||||
onclick={() => srcSelectSource(src)}
|
class:splitItemActive={src_activeSource?.id === group.sources[0].id}
|
||||||
>
|
onclick={() => srcSelectSource(group.sources[0])}
|
||||||
<img
|
>
|
||||||
src={thumbUrl(src.iconUrl)}
|
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
|
||||||
alt=""
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
class="splitSourceIcon"
|
<span class="splitItemLabel">{group.name}</span>
|
||||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{group.sources[0].lang.toUpperCase()}</span>
|
||||||
/>
|
{#if group.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
<span class="splitItemLabel">{src.displayName}</span>
|
</button>
|
||||||
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
{:else}
|
||||||
</button>
|
<button
|
||||||
|
class="splitItem splitItemSource splitItemGroup"
|
||||||
|
class:splitItemGroupOpen={src_expandedGroups.has(group.name)}
|
||||||
|
onclick={() => srcToggleGroup(group.name)}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(group.iconUrl)} alt="" class="splitSourceIcon"
|
||||||
|
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<span class="splitItemLabel">{group.name}</span>
|
||||||
|
<span class="groupLangCount">{group.sources.length}</span>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 256 256" fill="currentColor"
|
||||||
|
class="groupChevron"
|
||||||
|
style="transform: rotate({src_expandedGroups.has(group.name) ? 180 : 0}deg)"
|
||||||
|
aria-hidden="true">
|
||||||
|
<path 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"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if src_expandedGroups.has(group.name)}
|
||||||
|
{#each group.sources as src (src.id)}
|
||||||
|
<button
|
||||||
|
class="splitItem splitItemSource splitItemLangOption"
|
||||||
|
class:splitItemActive={src_activeSource?.id === src.id}
|
||||||
|
onclick={() => srcSelectSource(src)}
|
||||||
|
>
|
||||||
|
<span class="langOptionDot"></span>
|
||||||
|
<span class="splitItemLabel">{src.lang.toUpperCase()}</span>
|
||||||
|
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if src_visibleSources.length === 0}
|
{#if src_groupedSources.length === 0}
|
||||||
<p class="splitEmpty">No sources for this language</p>
|
<p class="splitEmpty">No sources for this language</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1793,4 +1854,79 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Language pocket ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.langPocketToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.langPocketToggle:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.langPocketLabel {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.langPocket {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: fadeIn 0.1s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Source group (multi-lang) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.splitItemGroup { }
|
||||||
|
.splitItemGroupOpen { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.groupLangCount {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0px 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupChevron {
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitItemLangOption {
|
||||||
|
padding-left: var(--sp-5);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
.splitItemLangOption:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.langOptionDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-strong);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.splitItemActive .langOptionDot { background: var(--accent-fg); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user