import { ApiError } from '$lib/api/client'; import { listMyDistinctPageTags, listMyPageTags, listTaggedChapters, listTaggedMangas, searchPages, CONTENT_WARNINGS, type ContentWarning, type PageSearchItem, type PageTagSummary, type TaggedChapterAggregate, type TaggedMangaAggregate, type TaggedPageItem } from '$lib/api/page_tags'; import type { PageLoad } from './$types'; export const ssr = false; type View = 'pages' | 'chapters' | 'mangas'; type Order = 'desc' | 'asc'; /** Parse a CSV of content warnings, keeping only valid vocabulary values. */ function parseWarnings(raw: string | null): ContentWarning[] { if (!raw) return []; return raw .split(',') .map((s) => s.trim().toLowerCase()) .filter((s): s is ContentWarning => (CONTENT_WARNINGS as string[]).includes(s) ); } /** * Loader for the /search page. URL is the source of truth — refresh * or share a link lands the user on the same view. * * - `?tag=` exact-match tag filter. Empty → no results, just the * chip cloud for browsing. * - `?view=pages|chapters|mangas` — defaults to `pages` when omitted. * - `?order=desc|asc` — only meaningful for chapters/mangas tabs. * `desc` (most matches first) is the default. * * `?text=` drives OCR full-text search against the page OCR index (the * active OCR backend populates it). A non-empty `text` (or an included * content-warning) switches the page into content-search mode below. */ export const load: PageLoad = async ({ url }) => { const tag = url.searchParams.get('tag'); const viewParam = url.searchParams.get('view'); const view: View = viewParam === 'chapters' || viewParam === 'mangas' ? viewParam : 'pages'; const order: Order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; // Content-search inputs (OCR/scene text + content-warning filters). const text = (url.searchParams.get('text') ?? '').trim(); const cwInclude = parseWarnings(url.searchParams.get('cw_include')); const cwExclude = parseWarnings(url.searchParams.get('cw_exclude')); // A positive filter (text or an included warning) is required for the // backend; cw_exclude alone can't drive a search. const contentSearch = text !== '' || cwInclude.length > 0; const empty = { authenticated: true, tag, view, order, text, cwInclude, cwExclude, contentSearch, distinct: [] as PageTagSummary[], pages: [] as TaggedPageItem[], chapters: [] as TaggedChapterAggregate[], mangas: [] as TaggedMangaAggregate[], results: [] as PageSearchItem[], total: 0, error: null as string | null }; try { const distinct = await listMyDistinctPageTags(undefined, 100); // Content search takes precedence over tag browsing. if (contentSearch) { const r = await searchPages({ text: text || undefined, cwInclude, cwExclude, limit: 100 }); return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 }; } // No tag selected → just the chip cloud. if (!tag) return { ...empty, distinct }; if (view === 'chapters') { const r = await listTaggedChapters({ tag, order, limit: 100 }); return { ...empty, distinct, chapters: r.items, total: r.page.total ?? 0 }; } if (view === 'mangas') { const r = await listTaggedMangas({ tag, order, limit: 100 }); return { ...empty, distinct, mangas: r.items, total: r.page.total ?? 0 }; } const r = await listMyPageTags({ tag, limit: 100 }); return { ...empty, distinct, pages: r.items, total: r.page.total ?? 0 }; } catch (e) { if (e instanceof ApiError && e.status === 401) { return { ...empty, authenticated: false }; } if (e instanceof ApiError) { return { ...empty, error: e.message }; } throw e; } };