feat: edit existing manga metadata (0.31.0)

Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so
covers can be replaced or cleared after creation, and wires a dedicated
/manga/[id]/edit SvelteKit route that combines the existing PATCH with
the new cover endpoints. Cover PUT cleans up the old blob when the
extension changes, swallowing StorageError::NotFound so a manually-gone
file doesn't surface as a 404 to the client. Edit link on the manga
detail page is gated on session.user, matching the auth posture of the
underlying handlers.

Also pins the local-dev port story via loadEnv() in vite.config.ts so
VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL
stable across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 20:26:23 +02:00
parent 9ff49166a5
commit fa0a7da311
14 changed files with 1277 additions and 19 deletions

View File

@@ -0,0 +1,147 @@
import { test, expect, type Page } from '@playwright/test';
const userFixture = {
id: 'u1',
username: 'alice',
created_at: '2026-01-01T00:00:00Z'
};
const baseManga = {
id: 'm1',
title: 'Berserk',
status: 'ongoing',
alt_titles: ['Old Alt'],
description: 'Original description',
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
genres: [],
tags: []
};
async function stubAuthenticatedAndGenres(page: Page) {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: userFixture })
})
);
await page.route('**/api/v1/genres', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 'g-action', name: 'Action' },
{ id: 'g-fantasy', name: 'Fantasy' }
])
})
);
}
test('anonymous user sees sign-in prompt on /manga/[id]/edit', async ({ page }) => {
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'unauthenticated', message: 'unauthenticated' }
})
})
);
await page.route('**/api/v1/genres', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
await page.route('**/api/v1/mangas/m1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(baseManga)
})
);
await page.goto('/manga/m1/edit');
await expect(page.getByTestId('edit-signin')).toBeVisible();
});
test('/manga/[id]/edit PATCHes the changed metadata and lands on the manga page', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
let patchBody: Record<string, unknown> | null = null;
let mangaAfter = { ...baseManga };
await page.route('**/api/v1/mangas/m1', async (route) => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaAfter)
});
} else if (method === 'PATCH') {
patchBody = JSON.parse(route.request().postData() ?? '{}');
mangaAfter = {
...mangaAfter,
title: (patchBody.title as string) ?? mangaAfter.title,
description:
'description' in (patchBody as Record<string, unknown>)
? ((patchBody.description as string | null) ?? null)
: mangaAfter.description
};
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaAfter)
});
} else {
await route.fallback();
}
});
await page.route('**/api/v1/mangas/m1/chapters*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m1', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/manga/m1');
// Edit link is gated on session.user — it should be visible to the
// stubbed authenticated user.
await page.getByTestId('edit-manga-link').click();
await expect(page).toHaveURL(/\/manga\/m1\/edit$/);
const titleInput = page.getByTestId('manga-title');
await expect(titleInput).toHaveValue('Berserk');
await titleInput.fill('Berserk (Deluxe)');
await page.getByTestId('manga-edit-submit').click();
await expect(page).toHaveURL(/\/manga\/m1$/);
await expect(page.getByTestId('manga-title')).toHaveText('Berserk (Deluxe)');
expect(patchBody).not.toBeNull();
expect((patchBody as Record<string, unknown>).title).toBe('Berserk (Deluxe)');
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.30.0",
"version": "0.31.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -4,6 +4,8 @@ import {
createManga,
getManga,
updateManga,
updateMangaCover,
deleteMangaCover,
attachTag,
detachTag
} from './mangas';
@@ -184,6 +186,49 @@ describe('mangas api client', () => {
});
});
it('updateMangaCover PUTs multipart with the cover blob', async () => {
fetchSpy.mockResolvedValueOnce(
ok(detailFixture({ cover_image_path: 'mangas/b1/cover.png' }))
);
const cover = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' });
const updated = await updateMangaCover('b1', cover);
expect(updated.cover_image_path).toBe('mangas/b1/cover.png');
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PUT');
expect(init.body).toBeInstanceOf(FormData);
const form = init.body as FormData;
expect(form.get('cover')).toBeInstanceOf(Blob);
// Boundary is filled in by the browser when body is FormData.
expect(init.headers).toBeUndefined();
});
it('updateMangaCover throws ApiError on payload_too_large', async () => {
fetchSpy.mockResolvedValue(
envelope(413, 'payload_too_large', 'cover exceeds size cap')
);
const cover = new Blob([new Uint8Array(1)]);
await expect(updateMangaCover('b1', cover)).rejects.toMatchObject({
name: 'ApiError',
status: 413,
code: 'payload_too_large'
});
});
it('deleteMangaCover DELETEs and returns the refreshed detail with null path', async () => {
fetchSpy.mockResolvedValueOnce(
ok(detailFixture({ cover_image_path: null }))
);
const updated = await deleteMangaCover('b1');
expect(updated.cover_image_path).toBeNull();
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/b1\/cover$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('DELETE');
expect(init.body).toBeUndefined();
});
it('attachTag POSTs the name and returns the TagRef', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ id: 't9', name: 'Dark Fantasy', added_by: 'u1' }, 201)

View File

@@ -109,6 +109,31 @@ export async function updateManga(
});
}
/**
* PUT /api/v1/mangas/:id/cover (multipart). Replaces the cover image and
* returns the refreshed detail. As with createManga the browser fills in
* the multipart boundary automatically, so we must NOT set Content-Type.
*/
export async function updateMangaCover(
id: string,
cover: Blob
): Promise<MangaDetail> {
const form = new FormData();
form.append('cover', cover);
return request<MangaDetail>(
`/v1/mangas/${encodeURIComponent(id)}/cover`,
{ method: 'PUT', body: form }
);
}
/** DELETE /api/v1/mangas/:id/cover. Returns the refreshed detail. */
export async function deleteMangaCover(id: string): Promise<MangaDetail> {
return request<MangaDetail>(
`/v1/mangas/${encodeURIComponent(id)}/cover`,
{ method: 'DELETE' }
);
}
export async function attachTag(
mangaId: string,
name: string

View File

@@ -14,6 +14,7 @@
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus';
import FolderPlus from '@lucide/svelte/icons/folder-plus';
import Pencil from '@lucide/svelte/icons/pencil';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
let { data } = $props();
@@ -327,6 +328,14 @@
<FolderPlus size={16} aria-hidden="true" />
<span>Add to collection</span>
</button>
<a
class="action"
href="/manga/{manga.id}/edit"
data-testid="edit-manga-link"
>
<Pencil size={16} aria-hidden="true" />
<span>Edit</span>
</a>
<a
class="action"
href="/manga/{manga.id}/upload-chapter"

View File

@@ -0,0 +1,481 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import {
deleteMangaCover,
updateManga,
updateMangaCover,
type MangaStatus
} from '$lib/api/mangas';
import { session } from '$lib/session.svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Chip from '$lib/components/Chip.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let { data } = $props();
const manga = $derived(data.manga);
const genres = $derived(data.genres);
// Snapshot data.manga into local state once. The edit form is the
// source of truth from here on — we deliberately don't re-derive
// from `data` after the initial paint.
/* svelte-ignore state_referenced_locally */
let mangaTitle = $state(data.manga.title);
/* svelte-ignore state_referenced_locally */
let mangaStatus = $state<MangaStatus>(data.manga.status);
/* svelte-ignore state_referenced_locally */
let mangaDescription = $state(data.manga.description ?? '');
/* svelte-ignore state_referenced_locally */
let mangaAuthors = $state<string[]>(data.manga.authors.map((a) => a.name));
let authorDraft = $state('');
/* svelte-ignore state_referenced_locally */
let mangaAltTitles = $state<string[]>([...data.manga.alt_titles]);
let altTitleDraft = $state('');
/* svelte-ignore state_referenced_locally */
let mangaGenreIds = $state<string[]>(data.manga.genres.map((g) => g.id));
let coverFile = $state<File | null>(null);
let coverError = $state<string | null>(null);
let pendingCoverRemoval = $state(false);
/* svelte-ignore state_referenced_locally */
let currentCoverPath = $state<string | null>(data.manga.cover_image_path);
let submitting = $state(false);
let mangaError = $state<string | null>(null);
const canSubmit = $derived(
mangaTitle.trim().length > 0 && !coverError && !submitting
);
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;
coverFile = file;
coverError = file ? validateImageFile(file) : null;
// Picking a replacement supersedes a pending "remove" click.
if (file) pendingCoverRemoval = false;
}
function markCoverForRemoval() {
pendingCoverRemoval = true;
coverFile = null;
coverError = null;
// Clear the file input so re-picking the same file still fires
// `change` and undoes the removal.
const input = document.getElementById('cover-input') as HTMLInputElement | null;
if (input) input.value = '';
}
function undoCoverRemoval() {
pendingCoverRemoval = false;
}
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
if (authorDraft.trim()) addAuthor();
if (altTitleDraft.trim()) addAltTitle();
submitting = true;
mangaError = null;
try {
// The textarea is the source of truth for description on
// screen, so we always send it — trimmed-empty collapses to
// null (explicit clear).
await updateManga(manga.id, {
title: mangaTitle.trim(),
status: mangaStatus,
authors: mangaAuthors,
alt_titles: mangaAltTitles,
genre_ids: mangaGenreIds,
description: mangaDescription.trim() || null
});
if (pendingCoverRemoval) {
const refreshed = await deleteMangaCover(manga.id);
currentCoverPath = refreshed.cover_image_path;
} else if (coverFile) {
const refreshed = await updateMangaCover(manga.id, coverFile);
currentCoverPath = refreshed.cover_image_path;
}
await goto(`/manga/${manga.id}`);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
await goto(`/login?next=/manga/${manga.id}/edit`);
return;
}
mangaError = e instanceof Error ? e.message : String(e);
submitting = false;
}
}
</script>
<svelte:head>
<title>Edit {manga.title} — Mangalord</title>
</svelte:head>
<h1>Edit manga</h1>
{#if !session.loaded}
<p class="status" data-testid="edit-loading">Loading…</p>
{:else if !session.user}
<p class="status" data-testid="edit-signin">
<a href="/login?next=/manga/{manga.id}/edit">Sign in</a> to edit this manga.
</p>
{:else}
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-edit-form">
<section class="card">
<h2>Manga details</h2>
<label class="form-field">
<span>Title <span aria-hidden="true">*</span></span>
<input
type="text"
bind:value={mangaTitle}
required
maxlength="200"
data-testid="manga-title"
/>
</label>
<label class="form-field">
<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
bind:value={mangaDescription}
rows="4"
data-testid="manga-description"
></textarea>
</label>
<div class="form-field">
<span>Cover</span>
{#if currentCoverPath && !pendingCoverRemoval}
<div class="cover-preview" data-testid="cover-preview">
<img
src={fileUrl(currentCoverPath)}
alt="Current cover"
/>
<button
type="button"
class="icon-btn danger"
onclick={markCoverForRemoval}
aria-label="Remove cover"
title="Remove cover"
data-testid="cover-remove"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
{:else if pendingCoverRemoval}
<p class="hint" data-testid="cover-pending-removal">
Cover will be removed on save.
<button
type="button"
class="text-link"
onclick={undoCoverRemoval}
data-testid="cover-undo-remove"
>
Undo
</button>
</p>
{/if}
<input
id="cover-input"
type="file"
accept="image/*"
onchange={onCoverChange}
data-testid="manga-cover"
/>
{#if coverFile}
<span class="hint">
Will upload: {coverFile.name} ({formatBytes(coverFile.size)})
</span>
{/if}
{#if coverError}
<span class="field-error" role="alert">{coverError}</span>
{/if}
</div>
</section>
<div class="actions">
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="manga-edit-submit"
>
{submitting ? 'Saving…' : 'Save changes'}
</button>
<a class="cancel" href="/manga/{manga.id}" data-testid="manga-edit-cancel">
Cancel
</a>
</div>
{#if mangaError}
<p role="alert" class="form-error" data-testid="manga-edit-error">{mangaError}</p>
{/if}
</form>
{/if}
<style>
.status {
color: var(--text-muted);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.actions {
display: flex;
gap: var(--space-3);
align-items: center;
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.cancel {
color: var(--text-muted);
}
.hint {
color: var(--text-muted);
font-size: var(--font-sm);
}
.field-error {
color: var(--danger);
font-size: var(--font-sm);
}
.form-error {
color: var(--danger);
}
.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;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.icon-btn:hover:not(:disabled) {
background: var(--surface-elevated);
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);
}
.cover-preview {
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
.cover-preview img {
max-width: 160px;
height: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: block;
}
.text-link {
background: none;
border: none;
padding: 0;
color: var(--primary);
cursor: pointer;
font: inherit;
}
.text-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,13 @@
import { getManga } from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params }) => {
const [manga, genres] = await Promise.all([
getManga(params.id),
listGenres()
]);
return { manga, genres: genres as Genre[] };
};

View File

@@ -1,20 +1,26 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.BACKEND_URL ?? 'http://localhost:8080',
changeOrigin: true
export default defineConfig(({ mode }) => {
// Pull in .env so VITE_PORT / BACKEND_URL pin the dev URL across runs.
// Empty prefix loads every key, not just VITE_*.
const env = { ...process.env, ...loadEnv(mode, process.cwd(), '') };
return {
plugins: [sveltekit()],
server: {
port: Number(env.VITE_PORT ?? 5173),
strictPort: env.VITE_PORT != null,
proxy: {
'/api': {
target: env.BACKEND_URL ?? 'http://localhost:8080',
changeOrigin: true
}
}
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: false
}
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: false
}
};
});