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:
@@ -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"
|
||||
|
||||
481
frontend/src/routes/manga/[id]/edit/+page.svelte
Normal file
481
frontend/src/routes/manga/[id]/edit/+page.svelte
Normal 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>
|
||||
13
frontend/src/routes/manga/[id]/edit/+page.ts
Normal file
13
frontend/src/routes/manga/[id]/edit/+page.ts
Normal 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[] };
|
||||
};
|
||||
Reference in New Issue
Block a user