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:
MechaCat02
2026-05-17 14:32:03 +02:00
parent 60cc7712fa
commit 59d380b6d7
34 changed files with 3614 additions and 174 deletions

View 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>