Files
Mangalord/frontend/src/routes/+page.svelte
MechaCat02 33f684887d feat(mangas): add tag-similarity "Similar" section on manga detail
Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.

Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.

Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.

Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:52:37 +02:00

898 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import {
listMangas,
type MangaCard as MangaCardData,
type MangaSort,
type MangaStatus
} from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres';
import { listTags, type Tag } from '$lib/api/tags';
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import Search from '@lucide/svelte/icons/search';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50;
let mangas: MangaCardData[] = $state([]);
let search = $state('');
let sort = $state<MangaSort>('recent');
let statusFilter = $state<'' | MangaStatus>('');
let selectedGenres = $state<Genre[]>([]);
let selectedTags = $state<Tag[]>([]);
let allGenres = $state<Genre[]>([]);
let tagDraft = $state('');
let tagSuggestions = $state<Tag[]>([]);
let tagSuggestHighlight = $state(-1);
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
// Monotonic counter — discards stale fetch results so a fast typist
// can't see an earlier query's results overwrite the current one.
let suggestSeq = 0;
const tagSuggestListId = 'tag-filter-suggest-list';
let filtersOpen = $state(false);
let sortSheetOpen = $state(false);
let total: number | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
let currentPage = $state(1);
// Tracks whether the viewport sits below the project's single 640px
// breakpoint. Drives the inline-panel-vs-bottom-sheet split for both
// filters and sort, so the same form body never renders twice and
// duplicate testids / focus-trap interference don't occur.
let isMobileViewport = $state(false);
const activeFilterCount = $derived(
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
);
const sortLabel = $derived(sort === 'title' ? 'Title (A→Z)' : 'Recent');
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
);
// 1-indexed range like "51100 of 237", clamped to the actual loaded set
// in case the last page is short.
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1);
const rangeEnd = $derived((currentPage - 1) * PAGE_SIZE + mangas.length);
async function load() {
loading = true;
error = null;
try {
const result = await listMangas({
search: search.trim() || undefined,
status: statusFilter || undefined,
genreIds: selectedGenres.map((g) => g.id),
tagIds: selectedTags.map((t) => t.id),
sort,
limit: PAGE_SIZE,
offset: (currentPage - 1) * PAGE_SIZE
});
mangas = result.items;
total = result.page.total;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
function syncUrl() {
if (!browser) return;
const params = new URLSearchParams();
if (search.trim()) params.set('q', search.trim());
if (sort !== 'recent') params.set('sort', sort);
if (statusFilter) params.set('status', statusFilter);
if (selectedGenres.length)
params.set('genres', selectedGenres.map((g) => g.id).join(','));
if (selectedTags.length)
params.set('tags', selectedTags.map((t) => t.id).join(','));
if (currentPage > 1) params.set('page', String(currentPage));
const qs = params.toString();
const url = qs ? `/?${qs}` : '/';
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
}
// Filter / search / sort changes invalidate the current page — drop back
// to page 1 so the user isn't stranded on an out-of-range page when the
// result set shrinks. Direct page navigation calls `goToPage()` instead.
function resetAndReload() {
currentPage = 1;
syncUrl();
load();
}
function goToPage(p: number) {
if (p === currentPage) return;
currentPage = p;
syncUrl();
load();
if (browser) window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function hydrateFromUrl() {
// Parse the query and resolve the supplied ids back to full Tag /
// Genre objects so the chip rows render real labels.
const url = new URL($page.url);
search = url.searchParams.get('q') ?? '';
const s = url.searchParams.get('sort');
if (s === 'title' || s === 'recent') sort = s;
const st = url.searchParams.get('status');
statusFilter = st === 'ongoing' || st === 'completed' ? st : '';
const genreIds = (url.searchParams.get('genres') ?? '')
.split(',')
.filter(Boolean);
if (genreIds.length) {
selectedGenres = allGenres.filter((g) => genreIds.includes(g.id));
}
const tagIds = (url.searchParams.get('tags') ?? '')
.split(',')
.filter(Boolean);
if (tagIds.length) {
// listTags doesn't take ids; fetch a generous page and filter.
// Tag count is small in the near term, so this is fine.
const tags = await listTags({ limit: 50 });
selectedTags = tags.filter((t) => tagIds.includes(t.id));
}
const pageParam = Number(url.searchParams.get('page') ?? '1');
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
// Auto-expand the inline filters panel on desktop so the user can
// see why results are filtered. On phones the active-filter chip
// row carries that signal already, and auto-opening the sheet would
// hide the catalog behind a scrim on first paint.
if (
(statusFilter || selectedGenres.length || selectedTags.length) &&
browser &&
!window.matchMedia('(max-width: 640px)').matches
) {
filtersOpen = true;
}
}
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
resetAndReload();
}
function onSortChange() {
resetAndReload();
}
function onStatusChange() {
resetAndReload();
}
function toggleGenre(g: Genre) {
selectedGenres = selectedGenres.some((x) => x.id === g.id)
? selectedGenres.filter((x) => x.id !== g.id)
: [...selectedGenres, g];
resetAndReload();
}
function removeTag(t: Tag) {
selectedTags = selectedTags.filter((x) => x.id !== t.id);
resetAndReload();
}
function pickTag(t: Tag) {
if (!selectedTags.some((x) => x.id === t.id)) {
selectedTags = [...selectedTags, t];
}
tagDraft = '';
tagSuggestions = [];
tagSuggestHighlight = -1;
resetAndReload();
}
function onTagDraftInput() {
tagSuggestHighlight = -1;
if (suggestTimer) clearTimeout(suggestTimer);
const q = tagDraft.trim();
if (q.length === 0) {
tagSuggestions = [];
suggestSeq++;
return;
}
const seq = ++suggestSeq;
suggestTimer = setTimeout(async () => {
try {
const matched = await listTags({ search: q, limit: 6 });
if (seq !== suggestSeq) return;
const chosen = new Set(selectedTags.map((t) => t.id));
tagSuggestions = matched.filter((m) => !chosen.has(m.id));
} catch {
if (seq === suggestSeq) tagSuggestions = [];
}
}, 180);
}
function onTagFilterKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' && tagSuggestions.length > 0) {
e.preventDefault();
tagSuggestHighlight = (tagSuggestHighlight + 1) % tagSuggestions.length;
} else if (e.key === 'ArrowUp' && tagSuggestions.length > 0) {
e.preventDefault();
tagSuggestHighlight =
tagSuggestHighlight <= 0
? tagSuggestions.length - 1
: tagSuggestHighlight - 1;
} else if (e.key === 'Enter' && tagSuggestHighlight >= 0) {
e.preventDefault();
pickTag(tagSuggestions[tagSuggestHighlight]);
} else if (e.key === 'Escape') {
tagSuggestions = [];
tagSuggestHighlight = -1;
}
}
function clearFilters() {
statusFilter = '';
selectedGenres = [];
selectedTags = [];
resetAndReload();
}
function clearStatus() {
statusFilter = '';
resetAndReload();
}
function pickSort(next: MangaSort) {
if (next === sort) {
sortSheetOpen = false;
return;
}
sort = next;
sortSheetOpen = false;
resetAndReload();
}
onMount(async () => {
try {
allGenres = await listGenres();
} catch {
// Filter UI still loads with an empty genre list rather than blocking.
}
await hydrateFromUrl();
await load();
});
// Track viewport in a separate $effect so the listener cleans up on
// unmount without entangling with the data-load lifecycle above.
$effect(() => {
if (!browser) return;
const mql = window.matchMedia('(max-width: 640px)');
isMobileViewport = mql.matches;
const onChange = (e: MediaQueryListEvent) => {
isMobileViewport = e.matches;
};
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
});
</script>
{#snippet filterFormContent()}
<div class="filter-group">
<span class="filter-label">Status</span>
<div class="status-row">
<label>
<input
type="radio"
name="status"
value=""
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Any</span>
</label>
<label>
<input
type="radio"
name="status"
value="ongoing"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Ongoing</span>
</label>
<label>
<input
type="radio"
name="status"
value="completed"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Completed</span>
</label>
</div>
</div>
<div class="filter-group">
<span class="filter-label">Genres (all must match)</span>
<div class="filter-chip-row">
{#each allGenres as g (g.id)}
{@const on = selectedGenres.some((x) => x.id === g.id)}
<button
type="button"
class="genre-pill"
class:active={on}
onclick={() => toggleGenre(g)}
data-testid={`genre-filter-${g.name}`}
>
{g.name}
</button>
{/each}
</div>
</div>
<div class="filter-group">
<span class="filter-label">Tags (all must match)</span>
{#if selectedTags.length > 0}
<div class="filter-chip-row">
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`tag-filter-chip-${t.name}`}
/>
{/each}
</div>
{/if}
<div class="tag-search">
<input
type="text"
role="combobox"
bind:value={tagDraft}
oninput={onTagDraftInput}
onkeydown={onTagFilterKeydown}
placeholder="Type to find a tag"
maxlength="64"
aria-label="Find a tag"
aria-controls={tagSuggestListId}
aria-expanded={tagSuggestions.length > 0}
aria-autocomplete="list"
aria-activedescendant={tagSuggestHighlight >= 0
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
: undefined}
data-testid="tag-filter-input"
/>
{#if tagSuggestions.length > 0}
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
{#each tagSuggestions as s, i (s.id)}
<li
id={`${tagSuggestListId}-opt-${i}`}
role="option"
aria-selected={i === tagSuggestHighlight}
class:active={i === tagSuggestHighlight}
>
<button
type="button"
tabindex="-1"
onmouseenter={() => (tagSuggestHighlight = i)}
onclick={() => pickTag(s)}
data-testid={`tag-filter-suggestion-${s.name}`}
>
<Plus size={12} aria-hidden="true" />
{s.name}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if activeFilterCount > 0}
<button type="button" class="clear" onclick={clearFilters}>
Clear filters
</button>
{/if}
{/snippet}
<div class="heading-row">
<h1>Mangas</h1>
<!--
Secondary entry point into the per-page search at /search,
which is headed "Page search". Label matches the destination's
title so the two read as one feature. It filters the user's
tagged pages and pivots between Pages / Chapters / Mangas;
title-search above stays the primary action. The name is also
forward-compatible with the planned OCR text search (which
searches page content, not just tags).
-->
<a class="alt-search" href="/search" data-testid="nav-page-search">
<span>Page search</span>
<ArrowRight size={14} aria-hidden="true" />
</a>
</div>
<form
onsubmit={onSubmit}
action="javascript:void(0)"
class="controls"
>
<div class="search-row">
<input
class="search"
type="search"
bind:value={search}
placeholder="Search by title or author"
data-testid="search-input"
/>
<button class="icon-btn primary" type="submit" aria-label="Search" title="Search">
<Search size={18} aria-hidden="true" />
</button>
<button
type="button"
class="filters-toggle"
class:active={filtersOpen}
onclick={() => (filtersOpen = !filtersOpen)}
aria-expanded={filtersOpen}
aria-controls="filters-panel"
data-testid="filters-toggle"
>
<SlidersHorizontal size={16} aria-hidden="true" />
<span>Filters</span>
{#if activeFilterCount > 0}
<span class="filter-count">{activeFilterCount}</span>
{/if}
</button>
<button
type="button"
class="sort-chip mobile-only"
onclick={() => (sortSheetOpen = true)}
data-testid="sort-chip"
>
<ArrowUpDown size={16} aria-hidden="true" />
<span>{sortLabel}</span>
</button>
</div>
{#if activeFilterCount > 0}
<div class="active-filters" data-testid="active-filters">
{#if statusFilter}
<Chip
label={statusFilter === 'ongoing' ? 'Ongoing' : 'Completed'}
variant="primary"
onRemove={clearStatus}
removeLabel="Clear status filter"
testid="active-filter-status"
/>
{/if}
{#each selectedGenres as g (g.id)}
<Chip
label={g.name}
variant="primary"
onRemove={() => toggleGenre(g)}
removeLabel={`Remove genre ${g.name}`}
testid={`active-filter-genre-${g.name}`}
/>
{/each}
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`active-filter-tag-${t.name}`}
/>
{/each}
</div>
{/if}
{#if filtersOpen && !isMobileViewport}
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
{@render filterFormContent()}
</div>
{/if}
<div class="config-row desktop-only">
<label class="sort">
<span>Sort</span>
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
<option value="recent">Recent</option>
<option value="title">Title (A→Z)</option>
</select>
</label>
</div>
</form>
<Sheet
open={filtersOpen && isMobileViewport}
title="Filter"
onClose={() => (filtersOpen = false)}
testid="filter-sheet"
>
{@render filterFormContent()}
</Sheet>
<Sheet
open={sortSheetOpen && isMobileViewport}
title="Sort"
onClose={() => (sortSheetOpen = false)}
testid="sort-sheet"
>
<div class="sort-options">
<label>
<input
type="radio"
name="sort-mobile"
value="recent"
aria-label="Recent"
checked={sort === 'recent'}
onchange={() => pickSort('recent')}
/>
<span>Recent</span>
</label>
<label>
<input
type="radio"
name="sort-mobile"
value="title"
aria-label="Title (A→Z)"
checked={sort === 'title'}
onchange={() => pickSort('title')}
/>
<span>Title (A→Z)</span>
</label>
</div>
</Sheet>
{#if loading}
<p class="status" data-testid="loading">Loading…</p>
{:else if error}
<p class="error" data-testid="error" role="alert">{error}</p>
{:else if mangas.length === 0}
<p class="status" data-testid="empty">No mangas yet. <a href="/upload">Upload one</a>.</p>
{:else}
{#if total !== null}
<p class="count" data-testid="manga-total">
Showing {rangeStart}{rangeEnd} of {total}
</p>
{/if}
<ul class="manga-grid" data-testid="manga-list">
{#each mangas as m (m.id)}
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
{/each}
</ul>
<Pager
page={currentPage}
{totalPages}
onChange={goToPage}
testid="manga-pager"
/>
{/if}
<style>
.heading-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.heading-row h1 {
margin-bottom: 0;
}
.alt-search {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: var(--font-sm);
text-decoration: none;
white-space: nowrap;
}
.alt-search:hover {
color: var(--primary);
}
.controls {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
margin-top: var(--space-3);
}
.search-row {
display: flex;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
.search {
flex: 1;
min-width: 0;
max-width: 28rem;
}
@media (max-width: 640px) {
/* Give the search input its own row so the Filter + Sort chips
can't squeeze it down to a few characters. The chips wrap to
a second row naturally; the icon-btn submit stays paired with
the input on row one. */
.search {
flex-basis: calc(100% - 36px - var(--space-2));
max-width: none;
}
}
.filters-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
background: var(--surface);
border: 1px solid var(--border-strong);
color: var(--text);
cursor: pointer;
}
.filters-toggle:hover,
.filters-toggle.active {
background: var(--surface-elevated);
border-color: var(--primary);
}
.filter-count {
background: var(--primary);
color: var(--primary-contrast);
border-radius: var(--radius-pill);
font-size: var(--font-xs);
padding: 0 6px;
min-width: 18px;
text-align: center;
}
.sort-chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
background: var(--surface);
border: 1px solid var(--border-strong);
color: var(--text);
cursor: pointer;
}
.sort-chip:hover {
background: var(--surface-elevated);
border-color: var(--primary);
}
.active-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.filters-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.filter-label {
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
}
.status-row {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.status-row label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
}
.filter-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.genre-pill {
display: inline-flex;
align-items: center;
padding: 4px var(--space-2);
background: var(--surface-elevated);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-pill);
font-size: var(--font-xs);
cursor: pointer;
}
.genre-pill:hover {
border-color: var(--primary);
}
.genre-pill.active {
background: var(--primary-soft-bg);
border-color: var(--primary);
color: var(--primary);
}
.tag-search {
position: relative;
max-width: 20rem;
}
.tag-search input {
width: 100%;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: var(--space-1) 0 0;
padding: var(--space-1) 0;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: var(--z-dropdown);
}
.tag-suggestions button {
width: 100%;
background: transparent;
border: 0;
text-align: left;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
color: var(--text);
cursor: pointer;
font-size: var(--font-sm);
}
.tag-suggestions li.active button,
.tag-suggestions button:hover {
background: var(--primary-soft-bg);
}
.clear {
align-self: flex-start;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: var(--font-sm);
}
.config-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.sort {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
}
.sort select {
width: auto;
}
.sort-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sort-options label {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
cursor: pointer;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-btn.primary {
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
}
.icon-btn.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
}
.count {
color: var(--text-muted);
font-size: var(--font-sm);
margin: var(--space-2) 0;
}
/* `.manga-grid` is a shared global (see lib/styles/tokens.css), including
its four-per-row phone layout. */
/* Mobile-only helpers. Defaults flip below the 640px breakpoint
so the same chrome doesn't render twice. */
.mobile-only {
display: none;
}
@media (max-width: 640px) {
.desktop-only {
display: none;
}
.mobile-only {
display: inline-flex;
}
.search {
/* Catalog search should fill the available width on phones —
the 28rem cap is desktop-only. */
max-width: none;
}
}
</style>