Files
Mangalord/frontend/src/routes/search/+page.ts
2026-07-01 07:19:38 +02:00

133 lines
4.3 KiB
TypeScript

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;
}
};