feat: upload flow revamp (0.20.0)

- `/upload` is now manga-only with optional N initial chapters
  staged inline.
- Additional chapters from a new `/manga/[id]/upload-chapter` route,
  reached via an "Upload chapter" button on the manga page.
- New `ChapterPagesEditor` component: thumbnails next to each row,
  click-to-preview-modal, drag-drop + reorder.
- Pages renamed to `page-NNN.<ext>` before multipart submission;
  original filenames shown as dimmed reference text during upload
  and dropped on submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 18:59:22 +02:00
parent 21f44cea3f
commit c95c1805df
12 changed files with 1283 additions and 553 deletions

View File

@@ -1,24 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import { createManga, type MangaStatus } from '$lib/api/mangas';
import { request } from '$lib/api/client';
import { createChapter } from '$lib/api/chapters';
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 ChapterPagesEditor, {
type PendingPage
} from '$lib/components/ChapterPagesEditor.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let { data } = $props();
const mangas = $derived(data.mangas);
const genres = $derived(data.genres);
// -------- Manga form state --------
// ---- Manga form state ----
let mangaTitle = $state('');
let mangaStatus = $state<MangaStatus>('ongoing');
let mangaDescription = $state('');
@@ -29,13 +26,42 @@
let mangaGenreIds = $state<string[]>([]);
let coverFile = $state<File | null>(null);
let coverError = $state<string | null>(null);
let mangaSubmitting = $state(false);
let mangaError = $state<string | null>(null);
let mangaFieldErrors = $state<Record<string, string>>({});
let mangaSuccess = $state<string | null>(null);
const canSubmitManga = $derived(
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting
// ---- Initial-chapter staging ----
type StagedChapter = {
id: string;
number: number;
title: string;
pages: PendingPage[];
status: 'pending' | 'uploading' | 'done' | 'failed';
error: string | null;
};
let stagedChapters = $state<StagedChapter[]>([]);
let submitting = $state(false);
let mangaError = $state<string | null>(null);
let success = $state<string | null>(null);
const allChapterPagesValid = $derived(
stagedChapters.every((c) => c.pages.every((p) => !p.error))
);
const allChapterNumbersUnique = $derived(
new Set(stagedChapters.map((c) => c.number)).size === stagedChapters.length
);
const allChapterNumbersValid = $derived(
stagedChapters.every((c) => Number.isInteger(c.number) && c.number >= 1)
);
const allChaptersHavePages = $derived(
stagedChapters.every((c) => c.pages.length > 0)
);
const canSubmit = $derived(
mangaTitle.trim().length > 0 &&
!coverError &&
!submitting &&
allChapterPagesValid &&
allChapterNumbersUnique &&
allChapterNumbersValid &&
allChaptersHavePages
);
function addAuthor() {
@@ -46,11 +72,9 @@
}
authorDraft = '';
}
function removeAuthor(name: string) {
mangaAuthors = mangaAuthors.filter((a) => a !== name);
}
function addAltTitle() {
const t = altTitleDraft.trim();
if (!t) return;
@@ -59,17 +83,14 @@
}
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;
@@ -77,19 +98,40 @@
coverError = file ? validateImageFile(file) : null;
}
async function submitManga(e: SubmitEvent) {
function addChapter() {
// Auto-default to the next number after the highest pending
// one (or 1 if this is the first).
const next =
stagedChapters.reduce((max, c) => Math.max(max, c.number), 0) + 1;
stagedChapters = [
...stagedChapters,
{
id: crypto.randomUUID(),
number: next,
title: '',
pages: [],
status: 'pending',
error: null
}
];
}
function removeChapter(id: string) {
// Releasing the staged-chapter row drops the ChapterPagesEditor,
// which revokes its own object URLs on destroy — no leak.
stagedChapters = stagedChapters.filter((c) => c.id !== id);
}
async function submit(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 (!canSubmit) return;
if (authorDraft.trim()) addAuthor();
if (altTitleDraft.trim()) addAltTitle();
mangaSubmitting = true;
submitting = true;
mangaError = null;
mangaFieldErrors = {};
mangaSuccess = null;
success = null;
let manga;
try {
const manga = await createManga(
manga = await createManga(
{
title: mangaTitle.trim(),
status: mangaStatus,
@@ -100,141 +142,45 @@
},
coverFile ?? undefined
);
mangaSuccess = `Created "${manga.title}".`;
mangaTitle = '';
mangaStatus = 'ongoing';
mangaAuthors = [];
mangaAltTitles = [];
mangaGenreIds = [];
mangaDescription = '';
coverFile = null;
} catch (e) {
applyApiError(e, (msg) => (mangaError = msg), (fields) => (mangaFieldErrors = fields));
} finally {
mangaSubmitting = false;
}
}
// -------- Chapter form state --------
type PendingPage = { id: string; file: File; error: string | null };
let chapterMangaId = $state<string>('');
let chapterNumber = $state<number | null>(null);
let chapterTitle = $state('');
let chapterPages = $state<PendingPage[]>([]);
let chapterSubmitting = $state(false);
let chapterError = $state<string | null>(null);
let chapterFieldErrors = $state<Record<string, string>>({});
let chapterSuccess = $state<string | null>(null);
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) &&
chapterNumber != null &&
chapterNumber > 0 &&
chapterPages.length > 0 &&
allChapterPagesValid &&
!chapterSubmitting
);
function addPageFiles(files: File[] | FileList) {
const arr = Array.from(files);
const additions: PendingPage[] = arr.map((file) => ({
id: crypto.randomUUID(),
file,
error: validateImageFile(file)
}));
chapterPages = [...chapterPages, ...additions];
}
function removePage(id: string) {
chapterPages = chapterPages.filter((p) => p.id !== id);
}
function movePage(id: string, dir: -1 | 1) {
const i = chapterPages.findIndex((p) => p.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= chapterPages.length) return;
const copy = chapterPages.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
chapterPages = copy;
}
function onPagesInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) addPageFiles(input.files);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
if (e.dataTransfer?.files) addPageFiles(e.dataTransfer.files);
}
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragOver = true;
}
function onDragLeave() {
isDragOver = false;
}
async function submitChapter(e: SubmitEvent) {
e.preventDefault();
if (!canSubmitChapter || chapterNumber == null) return;
chapterSubmitting = true;
chapterError = null;
chapterFieldErrors = {};
chapterSuccess = null;
try {
const form = new FormData();
const metadata: Record<string, unknown> = { number: chapterNumber };
if (chapterTitle.trim()) metadata.title = chapterTitle.trim();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
for (const p of chapterPages) form.append('page', p.file);
await request<unknown>(
`/v1/mangas/${encodeURIComponent(chapterMangaId)}/chapters`,
{ method: 'POST', body: form }
);
chapterSuccess = `Uploaded chapter ${chapterNumber} (${chapterPages.length} pages).`;
chapterNumber = null;
chapterTitle = '';
chapterPages = [];
} catch (e) {
applyApiError(
e,
(msg) => (chapterError = msg),
(fields) => (chapterFieldErrors = fields)
);
} finally {
chapterSubmitting = false;
}
}
function applyApiError(
e: unknown,
setMessage: (m: string) => void,
setFields: (f: Record<string, string>) => void
) {
if (e instanceof ApiError && e.status === 401) {
goto('/login');
if (e instanceof ApiError && e.status === 401) {
await goto('/login?next=/upload');
return;
}
mangaError = e instanceof Error ? e.message : String(e);
submitting = false;
return;
}
const message = e instanceof Error ? e.message : String(e);
setMessage(message);
setFields({});
// Manga is created; ship chapters one at a time and surface
// per-row status. Failures don't roll back the manga — the
// user can retry just the failed chapters from the manga
// page's Upload-chapter button.
for (const c of stagedChapters) {
c.status = 'uploading';
c.error = null;
try {
await createChapter(
manga.id,
{ number: c.number, title: c.title.trim() || null },
c.pages.map((p) => p.file)
);
c.status = 'done';
} catch (e) {
c.status = 'failed';
c.error = e instanceof Error ? e.message : String(e);
}
}
const failed = stagedChapters.filter((c) => c.status === 'failed');
if (failed.length === 0) {
// All-good — land the user on the manga page where they
// can confirm and continue uploading.
await goto(`/manga/${manga.id}`);
return;
}
success = `"${manga.title}" was created, but ${failed.length} of ${stagedChapters.length} chapters failed. Fix them and retry from the manga page.`;
submitting = false;
}
</script>
@@ -242,18 +188,18 @@
<title>Upload — Mangalord</title>
</svelte:head>
<h1>Upload</h1>
<h1>Create manga</h1>
{#if !session.loaded}
<p class="status" data-testid="upload-loading">Loading…</p>
{:else if !session.user}
<p class="status" data-testid="upload-signin">
<a href="/login">Sign in</a> to upload mangas or chapters.
<a href="/login?next=/upload">Sign in</a> to upload a manga.
</p>
{:else}
<section class="card">
<h2>Create manga</h2>
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
<section class="card">
<h2>Manga details</h2>
<label class="form-field">
<span>Title <span aria-hidden="true">*</span></span>
<input
@@ -263,9 +209,6 @@
maxlength="200"
data-testid="manga-title"
/>
{#if mangaFieldErrors.title}
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
{/if}
</label>
<label class="form-field">
@@ -383,179 +326,112 @@
<span class="field-error" role="alert">{coverError}</span>
{/if}
</label>
<button class="primary" type="submit" disabled={!canSubmitManga} data-testid="manga-submit">
{mangaSubmitting ? 'Creating…' : 'Create manga'}
</button>
{#if mangaSuccess}
<p class="success" data-testid="manga-success">{mangaSuccess}</p>
{/if}
{#if mangaError}
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
{/if}
</form>
</section>
</section>
<section class="card">
<h2>Upload chapter</h2>
{#if mangas.length === 0}
<p class="status" data-testid="chapter-no-mangas">
No mangas yet — create one above first.
</p>
{:else}
<form
onsubmit={submitChapter}
action="javascript:void(0)"
data-testid="chapter-form"
>
<label class="form-field">
<span>Manga <span aria-hidden="true">*</span></span>
<select
bind:value={chapterMangaId}
required
data-testid="chapter-manga"
>
<option value="">Choose…</option>
{#each mangas as m (m.id)}
<option value={m.id}>
{m.title}{#if m.authors.length > 0}{m.authors
.map((a) => a.name)
.join(', ')}{/if}
</option>
{/each}
</select>
</label>
{#if selectedManga}
<div class="manga-preview" data-testid="chapter-manga-preview">
{#if selectedManga.cover_image_path}
<img
src={fileUrl(selectedManga.cover_image_path)}
alt=""
class="preview-cover"
loading="lazy"
/>
{:else}
<span class="preview-cover preview-cover-placeholder" aria-hidden="true">
<BookImage size={22} aria-hidden="true" />
</span>
{/if}
<div class="preview-meta">
<span class="preview-title">{selectedManga.title}</span>
{#if selectedMangaAuthors}
<span class="preview-author">{selectedMangaAuthors}</span>
{/if}
</div>
</div>
{/if}
<label class="form-field">
<span>Chapter number <span aria-hidden="true">*</span></span>
<input
type="number"
min="1"
step="1"
bind:value={chapterNumber}
required
data-testid="chapter-number"
/>
</label>
<label class="form-field">
<span>Title (optional)</span>
<input
type="text"
bind:value={chapterTitle}
maxlength="200"
data-testid="chapter-title"
/>
</label>
<div
class="drop-zone"
class:drag-over={isDragOver}
ondrop={onDrop}
ondragover={onDragOver}
ondragleave={onDragLeave}
role="region"
aria-label="page upload"
data-testid="drop-zone"
<section class="card">
<div class="chapters-header">
<h2>Initial chapters (optional)</h2>
<button
type="button"
class="add-chapter"
onclick={addChapter}
data-testid="add-chapter"
>
<UploadCloud size={32} aria-hidden="true" class="drop-icon" />
<p>
Drop pages here, or
<label class="file-link">
browse
<input
type="file"
accept="image/*"
multiple
onchange={onPagesInputChange}
data-testid="chapter-pages-input"
/>
</label>
</p>
</div>
{#if chapterPages.length > 0}
<ol class="pages" data-testid="chapter-pages-list">
{#each chapterPages as p, i (p.id)}
<li class:invalid={p.error}>
<span class="page-name">{p.file.name}</span>
<span class="page-size">{formatBytes(p.file.size)}</span>
<Plus size={14} aria-hidden="true" />
<span>Add chapter</span>
</button>
</div>
{#if stagedChapters.length === 0}
<p class="hint" data-testid="no-chapters-hint">
You can skip this — chapters can also be added later from the
manga's page.
</p>
{:else}
<ul class="chapter-list" data-testid="staged-chapter-list">
{#each stagedChapters as c, idx (c.id)}
<li class="staged-chapter" data-testid="staged-chapter">
<div class="staged-header">
<span class="staged-index">#{idx + 1}</span>
<label class="staged-field number-field">
<span class="visually-hidden">Chapter number</span>
<input
type="number"
min="1"
step="1"
bind:value={c.number}
required
data-testid="staged-chapter-number"
/>
</label>
<label class="staged-field title-field">
<span class="visually-hidden">Chapter title</span>
<input
type="text"
placeholder="Chapter title (optional)"
bind:value={c.title}
maxlength="200"
data-testid="staged-chapter-title"
/>
</label>
<span class="staged-status status-{c.status}">
{#if c.status === 'uploading'}
Uploading…
{:else if c.status === 'done'}
✓ Uploaded
{:else if c.status === 'failed'}
Failed
{/if}
</span>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, -1)}
disabled={i === 0}
aria-label="Move up"
title="Move up"
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, 1)}
disabled={i === chapterPages.length - 1}
aria-label="Move down"
title="Move down"
>
<ArrowDown size={16} aria-hidden="true" />
</button>
<button
class="icon-btn danger"
type="button"
onclick={() => removePage(p.id)}
aria-label="Remove page"
title="Remove page"
data-testid="page-remove"
onclick={() => removeChapter(c.id)}
aria-label="Remove chapter"
title="Remove chapter"
data-testid="staged-chapter-remove"
>
<Trash2 size={16} aria-hidden="true" />
</button>
{#if p.error}
<span class="field-error" role="alert">{p.error}</span>
{/if}
</li>
{/each}
</ol>
{/if}
<button
class="primary"
type="submit"
disabled={!canSubmitChapter}
data-testid="chapter-submit"
>
{chapterSubmitting ? 'Uploading…' : 'Upload chapter'}
</button>
{#if chapterSuccess}
<p class="success" data-testid="chapter-success">{chapterSuccess}</p>
{/if}
{#if chapterError}
<p role="alert" class="form-error" data-testid="chapter-error">
{chapterError}
</div>
{#if c.error}
<p class="field-error" role="alert">{c.error}</p>
{/if}
<ChapterPagesEditor
bind:pages={c.pages}
testidPrefix="staged-chapter-pages"
/>
</li>
{/each}
</ul>
{#if !allChapterNumbersUnique}
<p class="field-error" role="alert">
Two staged chapters share the same number — each must
be unique.
</p>
{/if}
</form>
{#if !allChaptersHavePages && stagedChapters.length > 0}
<p class="field-error" role="alert">
Each staged chapter needs at least one page.
</p>
{/if}
{/if}
</section>
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="manga-submit"
>
{submitting ? 'Submitting…' : 'Create manga'}
</button>
{#if success}
<p class="success" data-testid="manga-success">{success}</p>
{/if}
</section>
{#if mangaError}
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
{/if}
</form>
{/if}
<style>
@@ -563,15 +439,17 @@
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);
margin-bottom: var(--space-4);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
@@ -581,7 +459,6 @@
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start;
}
@@ -640,114 +517,6 @@
cursor: pointer;
}
.manga-preview {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-elevated);
}
.preview-cover {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
flex-shrink: 0;
}
.preview-cover-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.preview-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.preview-title {
font-weight: var(--weight-semibold);
color: var(--text);
}
.preview-author {
color: var(--text-muted);
font-size: var(--font-sm);
}
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
border: 2px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
background: var(--surface);
color: var(--text-muted);
transition:
background var(--transition),
border-color var(--transition);
}
.drop-zone :global(.drop-icon) {
color: var(--text-muted);
}
.drop-zone.drag-over {
background: var(--primary-soft-bg);
border-color: var(--primary);
}
.file-link input[type='file'] {
display: none;
}
.file-link {
color: var(--primary);
text-decoration: underline;
cursor: pointer;
}
.pages {
padding: 0;
margin: 0;
list-style: decimal inside;
}
.pages li {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border);
}
.pages li.invalid {
background: var(--danger-soft-bg);
}
.page-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-size {
color: var(--text-muted);
font-size: var(--font-sm);
}
.icon-btn {
display: inline-flex;
align-items: center;
@@ -780,4 +549,97 @@
.icon-btn.danger:hover:not(:disabled) {
color: var(--danger);
}
.chapters-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.chapters-header h2 {
margin: 0;
}
.add-chapter {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 32px;
border-radius: var(--radius-sm);
font-size: var(--font-sm);
cursor: pointer;
}
.add-chapter:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.chapter-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.staged-chapter {
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.staged-header {
display: grid;
grid-template-columns: auto 80px 1fr auto auto;
gap: var(--space-2);
align-items: center;
}
.staged-index {
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
white-space: nowrap;
}
.staged-field {
display: contents;
}
.staged-status {
font-size: var(--font-sm);
color: var(--text-muted);
white-space: nowrap;
}
.staged-status.status-done {
color: var(--success);
}
.staged-status.status-failed {
color: var(--danger);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View File

@@ -1,17 +1,13 @@
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;
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. 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[] };
// /upload is now for new-manga creation only — additional
// chapters land on /manga/[id]/upload-chapter via a button on the
// manga page. The only async dep here is the curated genre list
// for the picker.
const genres = await listGenres();
return { genres: genres as Genre[] };
};