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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user