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

@@ -1,24 +1,32 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { createManga } from '$lib/api/mangas';
import { createManga, type MangaStatus } from '$lib/api/mangas';
import { request } from '$lib/api/client';
import { session } from '$lib/session.svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Chip from '$lib/components/Chip.svelte';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import Trash2 from '@lucide/svelte/icons/trash-2';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import BookImage from '@lucide/svelte/icons/book-image';
import Plus from '@lucide/svelte/icons/plus';
let { data } = $props();
const mangas = $derived(data.mangas);
const genres = $derived(data.genres);
// -------- Manga form state --------
let mangaTitle = $state('');
let mangaAuthor = $state('');
let mangaStatus = $state<MangaStatus>('ongoing');
let mangaDescription = $state('');
let mangaAuthors = $state<string[]>([]);
let authorDraft = $state('');
let mangaAltTitles = $state<string[]>([]);
let altTitleDraft = $state('');
let mangaGenreIds = $state<string[]>([]);
let coverFile = $state<File | null>(null);
let coverError = $state<string | null>(null);
let mangaSubmitting = $state(false);
@@ -30,6 +38,38 @@
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting
);
function addAuthor() {
const name = authorDraft.trim();
if (!name) return;
if (!mangaAuthors.some((a) => a.toLowerCase() === name.toLowerCase())) {
mangaAuthors = [...mangaAuthors, name];
}
authorDraft = '';
}
function removeAuthor(name: string) {
mangaAuthors = mangaAuthors.filter((a) => a !== name);
}
function addAltTitle() {
const t = altTitleDraft.trim();
if (!t) return;
if (!mangaAltTitles.includes(t)) {
mangaAltTitles = [...mangaAltTitles, t];
}
altTitleDraft = '';
}
function removeAltTitle(t: string) {
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
}
function toggleGenre(id: string) {
mangaGenreIds = mangaGenreIds.includes(id)
? mangaGenreIds.filter((g) => g !== id)
: [...mangaGenreIds, id];
}
function onCoverChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
@@ -40,6 +80,10 @@
async function submitManga(e: SubmitEvent) {
e.preventDefault();
if (!canSubmitManga) return;
// Pick up an unsubmitted token if the user hit Submit without
// pressing Add — otherwise the typed name silently disappears.
if (authorDraft.trim()) addAuthor();
if (altTitleDraft.trim()) addAltTitle();
mangaSubmitting = true;
mangaError = null;
mangaFieldErrors = {};
@@ -48,14 +92,20 @@
const manga = await createManga(
{
title: mangaTitle.trim(),
author: mangaAuthor.trim() || null,
status: mangaStatus,
authors: mangaAuthors,
alt_titles: mangaAltTitles,
genre_ids: mangaGenreIds,
description: mangaDescription.trim() || null
},
coverFile ?? undefined
);
mangaSuccess = `Created "${manga.title}".`;
mangaTitle = '';
mangaAuthor = '';
mangaStatus = 'ongoing';
mangaAuthors = [];
mangaAltTitles = [];
mangaGenreIds = [];
mangaDescription = '';
coverFile = null;
} catch (e) {
@@ -80,6 +130,9 @@
let isDragOver = $state(false);
const selectedManga = $derived(mangas.find((m) => m.id === chapterMangaId) ?? null);
const selectedMangaAuthors = $derived(
selectedManga ? selectedManga.authors.map((a) => a.name).join(', ') : ''
);
const allChapterPagesValid = $derived(chapterPages.every((p) => !p.error));
const canSubmitChapter = $derived(
Boolean(chapterMangaId) &&
@@ -181,9 +234,6 @@
}
const message = e instanceof Error ? e.message : String(e);
setMessage(message);
// ApiError doesn't carry the details object yet; the API surfaces
// the most actionable field in the message itself, so we keep
// setFields available for a future refinement and clear it now.
setFields({});
}
</script>
@@ -217,15 +267,99 @@
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
{/if}
</label>
<label class="form-field">
<span>Author</span>
<input
type="text"
bind:value={mangaAuthor}
maxlength="200"
data-testid="manga-author"
/>
<span>Status</span>
<select bind:value={mangaStatus} data-testid="manga-status">
<option value="ongoing">Ongoing</option>
<option value="completed">Completed</option>
</select>
</label>
<div class="form-field">
<span>Authors</span>
<div class="token-row">
{#each mangaAuthors as a (a)}
<Chip label={a} variant="primary" onRemove={() => removeAuthor(a)} />
{/each}
</div>
<div class="token-input-row">
<input
type="text"
bind:value={authorDraft}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addAuthor();
}
}}
placeholder="Add author"
maxlength="200"
data-testid="manga-author-input"
/>
<button
type="button"
class="icon-btn primary"
onclick={addAuthor}
disabled={!authorDraft.trim()}
aria-label="Add author"
title="Add author"
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
</div>
<div class="form-field">
<span>Genres</span>
<div class="genre-grid" data-testid="manga-genres">
{#each genres as g (g.id)}
<label class="genre-toggle">
<input
type="checkbox"
checked={mangaGenreIds.includes(g.id)}
onchange={() => toggleGenre(g.id)}
/>
<span>{g.name}</span>
</label>
{/each}
</div>
</div>
<div class="form-field">
<span>Alternative titles</span>
<div class="token-row">
{#each mangaAltTitles as t (t)}
<Chip label={t} onRemove={() => removeAltTitle(t)} />
{/each}
</div>
<div class="token-input-row">
<input
type="text"
bind:value={altTitleDraft}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addAltTitle();
}
}}
placeholder="Add alternative title"
maxlength="200"
data-testid="manga-alt-input"
/>
<button
type="button"
class="icon-btn primary"
onclick={addAltTitle}
disabled={!altTitleDraft.trim()}
aria-label="Add alternative title"
title="Add alternative title"
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
</div>
<label class="form-field">
<span>Description</span>
<textarea
@@ -283,7 +417,9 @@
<option value="">Choose…</option>
{#each mangas as m (m.id)}
<option value={m.id}>
{m.title}{#if m.author}{m.author}{/if}
{m.title}{#if m.authors.length > 0}{m.authors
.map((a) => a.name)
.join(', ')}{/if}
</option>
{/each}
</select>
@@ -304,8 +440,8 @@
{/if}
<div class="preview-meta">
<span class="preview-title">{selectedManga.title}</span>
{#if selectedManga.author}
<span class="preview-author">{selectedManga.author}</span>
{#if selectedMangaAuthors}
<span class="preview-author">{selectedMangaAuthors}</span>
{/if}
</div>
</div>
@@ -472,6 +608,38 @@
color: var(--success);
}
.token-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.token-input-row {
display: flex;
gap: var(--space-2);
}
.token-input-row input {
flex: 1;
min-width: 0;
}
.genre-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-2);
}
.genre-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-size: var(--font-sm);
cursor: pointer;
}
.manga-preview {
display: flex;
align-items: center;
@@ -598,6 +766,17 @@
color: var(--text);
}
.icon-btn.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
}
.icon-btn.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.icon-btn.danger:hover:not(:disabled) {
color: var(--danger);
}

View File

@@ -1,4 +1,5 @@
import { listMangas, type Manga } from '$lib/api/mangas';
import { listMangas, type MangaCard } from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres';
import type { PageLoad } from './$types';
export const ssr = false;
@@ -6,7 +7,11 @@ export const ssr = false;
export const load: PageLoad = async () => {
// The chapter form needs a list of mangas to attach the new chapter
// to. There's no ownership concept yet, so any authenticated user can
// see and add to any manga.
const { items } = await listMangas({ limit: 200, sort: 'title' });
return { mangas: items as Manga[] };
// see and add to any manga. Genres are needed for the create-manga
// form's picker.
const [{ items }, genres] = await Promise.all([
listMangas({ limit: 200, sort: 'title' }),
listGenres()
]);
return { mangas: items as MangaCard[], genres: genres as Genre[] };
};