feat: manga metadata with status, authors, genres, tags, and search filters (0.15.0)
Adds first-class manga metadata across the stack: - **Status** (ongoing / completed), **alternative titles**, normalized **multi-author** support, **curated genres** (13 seeded), and **free-form user tags** (case-insensitive, globally shared). Each is modelled as its own table joined to mangas; `mangas.author` is backfilled into `authors` + `manga_authors` and dropped. - New endpoints: `PATCH /v1/mangas/:id` (three-state `description`), `POST/DELETE /v1/mangas/:id/tags[/:tag_id]`, `GET /v1/genres`, `GET /v1/tags?search=`. - `GET /v1/mangas` now returns `MangaCard` (with authors + genres batched in) and supports `?status=`, `?author_id=`, `?genre_id=`, `?tag_id=` filters — AND across facets, with empty-array no-op semantics for the unnest primitive. - `GET /v1/mangas/:id` returns the enriched `MangaDetail` with tags. - Frontend: reusable `Chip` component; manga detail page renders authors as chips linking to `/authors/:id` (Phase 2), a status badge, alt titles, genres, and tags with inline add/remove (only the attacher sees remove); upload form supports multi-author / multi-genre / alt titles / status; search page gets a collapsible URL-synced filter panel with keyboard-navigable tag autocomplete. - 126 backend tests (incl. AND-across-facets primitive, case-insens author/tag de-dup, transactional create rollback, PATCH semantics for missing / null / set on description); 72 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
frontend/src/lib/components/Chip.svelte
Normal file
118
frontend/src/lib/components/Chip.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
type Variant = 'neutral' | 'primary' | 'soft';
|
||||
|
||||
let {
|
||||
label,
|
||||
href,
|
||||
variant = 'neutral',
|
||||
onRemove,
|
||||
removeLabel = 'Remove',
|
||||
testid
|
||||
}: {
|
||||
label: string;
|
||||
href?: string;
|
||||
variant?: Variant;
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class="chip {variant}" {href} data-testid={testid}>
|
||||
<span class="chip-label">{label}</span>
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
onRemove?.();
|
||||
}}
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="chip {variant}" data-testid={testid}>
|
||||
<span class="chip-label">{label}</span>
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onclick={() => onRemove?.()}
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-tight);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.chip:hover {
|
||||
background: var(--primary-soft-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip.primary {
|
||||
background: var(--primary-soft-bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.chip.soft {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.chip-remove:hover {
|
||||
opacity: 1;
|
||||
background: var(--danger-soft-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user