feat: drag-drop upload page for manga and chapters
Frontend-only branch consuming the multipart endpoints from feat/uploads.
- /upload page with two sections:
- "Create manga": title (required), author, description, optional
cover. Submit posts the FormData to POST /api/v1/mangas via the
existing createManga client.
- "Upload chapter": manga selector (preloaded via listMangas
sort=title, limit=200), chapter number, optional title, and a
drag-drop zone for page images. Pages render in an ordered list
with up/down/remove controls so the user can fix order without
re-uploading. The same hidden file input is used by both the
"browse" link and Playwright's setInputFiles, so the e2e test
exercises the real submission code path even though it doesn't
simulate the drag mechanics.
- Client-side preflight in lib/upload-validation.ts (extracted so
Vitest can target it directly): rejects files over 20 MiB with a
sized message and rejects MIME types outside the
jpeg/png/webp/gif/avif whitelist. Files with an empty file.type fall
through to the backend's magic-byte sniff, which stays the
authoritative check. The submit button is disabled while any pending
page has a client-side error, so an oversized file never reaches the
network.
- API errors are surfaced via the envelope: 401 redirects to /login,
everything else is rendered as the form's role=alert message. The
backend's 415/413/422/409 message strings carry enough context that
the user can act on them without us repeating the field name
client-side (matches what we already surface for /auth errors).
- /upload requires auth: anonymous users see a "Sign in to upload"
prompt linking to /login instead of empty forms.
Vitest coverage (10 cases):
- validateImageFile null on small images and on each of the five
whitelisted MIMEs.
- Oversized files → sized "too large" message that names the file.
- Non-image MIME → "unsupported image type X" naming the type.
- Empty file.type → passes (deferred to backend sniff).
- formatBytes handles B / KiB / MiB.
Playwright coverage (e2e/upload.spec.ts, 4 cases):
- Anonymous user sees the sign-in prompt.
- A "page.png" whose bytes are a PDF (client validator passes because
it trusts the declared MIME for preflight) reaches the mocked
backend, which 415s, and the form renders the backend's message.
- Happy path: create a manga, then upload a 2-page chapter, with both
successes asserted from the mocked 201 responses.
- A 21 MiB file is added to the pages list with a "too large" error,
the submit button stays disabled, and zero POSTs leave the browser.
Lockstep version bump to 0.9.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
frontend/src/lib/upload-validation.test.ts
Normal file
52
frontend/src/lib/upload-validation.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateImageFile, MAX_FILE_BYTES, formatBytes } from './upload-validation';
|
||||
|
||||
function makeFile(name: string, type: string, size: number): File {
|
||||
// jsdom's File doesn't honour `size` from a tiny Blob, so we expose
|
||||
// the desired size by overriding the getter on the constructed File.
|
||||
const f = new File([new Uint8Array(0)], name, { type });
|
||||
Object.defineProperty(f, 'size', { value: size });
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('validateImageFile', () => {
|
||||
it('returns null for a small image', () => {
|
||||
const f = makeFile('cover.png', 'image/png', 1024);
|
||||
expect(validateImageFile(f)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects oversized files with a sized message', () => {
|
||||
const f = makeFile('cover.png', 'image/png', MAX_FILE_BYTES + 1);
|
||||
const err = validateImageFile(f);
|
||||
expect(err).toContain('cover.png');
|
||||
expect(err).toContain('too large');
|
||||
});
|
||||
|
||||
it('rejects non-image MIME types', () => {
|
||||
const f = makeFile('doc.pdf', 'application/pdf', 1024);
|
||||
const err = validateImageFile(f);
|
||||
expect(err).toContain('unsupported image type');
|
||||
expect(err).toContain('application/pdf');
|
||||
});
|
||||
|
||||
it('allows files with unknown MIME (lets the backend sniff decide)', () => {
|
||||
const f = makeFile('mystery.bin', '', 1024);
|
||||
expect(validateImageFile(f)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'])(
|
||||
'allows %s',
|
||||
(type) => {
|
||||
const f = makeFile('x', type, 1024);
|
||||
expect(validateImageFile(f)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats KiB and MiB sensibly', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
expect(formatBytes(2048)).toBe('2 KiB');
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB');
|
||||
});
|
||||
});
|
||||
39
frontend/src/lib/upload-validation.ts
Normal file
39
frontend/src/lib/upload-validation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Client-side preflight for uploads. Mirrors the backend's whitelist so
|
||||
// obviously bad files don't make a doomed multipart round-trip; the
|
||||
// backend's magic-byte sniff (in /api/v1/mangas and /chapters) stays the
|
||||
// authoritative check and surfaces its own error envelope when the
|
||||
// client-supplied MIME is misleading.
|
||||
|
||||
export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MiB
|
||||
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/avif'
|
||||
] as const;
|
||||
|
||||
export type ImageType = (typeof ALLOWED_IMAGE_TYPES)[number];
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(0)} KiB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message if `file` is obviously unsuitable, or null
|
||||
* if it should be allowed through to the multipart request. An unknown
|
||||
* file.type (empty string — happens on some drag sources) is treated as
|
||||
* "let the backend sniff decide".
|
||||
*/
|
||||
export function validateImageFile(file: File): string | null {
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
return `${file.name} is too large (${formatBytes(file.size)} > ${formatBytes(MAX_FILE_BYTES)}).`;
|
||||
}
|
||||
if (file.type && !(ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type)) {
|
||||
return `${file.name}: unsupported image type ${file.type}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
461
frontend/src/routes/upload/+page.svelte
Normal file
461
frontend/src/routes/upload/+page.svelte
Normal file
@@ -0,0 +1,461 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { createManga } from '$lib/api/mangas';
|
||||
import { request } from '$lib/api/client';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||
|
||||
let { data } = $props();
|
||||
const mangas = $derived(data.mangas);
|
||||
|
||||
// -------- Manga form state --------
|
||||
|
||||
let mangaTitle = $state('');
|
||||
let mangaAuthor = $state('');
|
||||
let mangaDescription = $state('');
|
||||
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
|
||||
);
|
||||
|
||||
function onCoverChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
coverFile = file;
|
||||
coverError = file ? validateImageFile(file) : null;
|
||||
}
|
||||
|
||||
async function submitManga(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmitManga) return;
|
||||
mangaSubmitting = true;
|
||||
mangaError = null;
|
||||
mangaFieldErrors = {};
|
||||
mangaSuccess = null;
|
||||
try {
|
||||
const manga = await createManga(
|
||||
{
|
||||
title: mangaTitle.trim(),
|
||||
author: mangaAuthor.trim() || null,
|
||||
description: mangaDescription.trim() || null
|
||||
},
|
||||
coverFile ?? undefined
|
||||
);
|
||||
mangaSuccess = `Created "${manga.title}".`;
|
||||
mangaTitle = '';
|
||||
mangaAuthor = '';
|
||||
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 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');
|
||||
return;
|
||||
}
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Upload — Mangalord</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Upload</h1>
|
||||
|
||||
{#if !session.loaded}
|
||||
<p data-testid="upload-loading">Loading…</p>
|
||||
{:else if !session.user}
|
||||
<p data-testid="upload-signin">
|
||||
<a href="/login">Sign in</a> to upload mangas or chapters.
|
||||
</p>
|
||||
{:else}
|
||||
<section class="card">
|
||||
<h2>Create manga</h2>
|
||||
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
|
||||
<label>
|
||||
Title <span aria-hidden="true">*</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mangaTitle}
|
||||
required
|
||||
maxlength="200"
|
||||
data-testid="manga-title"
|
||||
/>
|
||||
{#if mangaFieldErrors.title}
|
||||
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label>
|
||||
Author
|
||||
<input
|
||||
type="text"
|
||||
bind:value={mangaAuthor}
|
||||
maxlength="200"
|
||||
data-testid="manga-author"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
bind:value={mangaDescription}
|
||||
rows="4"
|
||||
data-testid="manga-description"
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Cover (optional)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={onCoverChange}
|
||||
data-testid="manga-cover"
|
||||
/>
|
||||
{#if coverFile}
|
||||
<span class="hint">{coverFile.name} ({formatBytes(coverFile.size)})</span>
|
||||
{/if}
|
||||
{#if coverError}
|
||||
<span class="field-error" role="alert">{coverError}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<button 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 class="card">
|
||||
<h2>Upload chapter</h2>
|
||||
{#if mangas.length === 0}
|
||||
<p 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>
|
||||
Manga <span aria-hidden="true">*</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}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Chapter number <span aria-hidden="true">*</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
bind:value={chapterNumber}
|
||||
required
|
||||
data-testid="chapter-number"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Title (optional)
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => movePage(p.id, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => movePage(p.id, 1)}
|
||||
disabled={i === chapterPages.length - 1}
|
||||
aria-label="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePage(p.id)}
|
||||
aria-label="Remove"
|
||||
data-testid="page-remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{#if p.error}
|
||||
<span class="field-error" role="alert">{p.error}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
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}
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.hint {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.field-error {
|
||||
color: #b00020;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-error {
|
||||
color: #b00020;
|
||||
}
|
||||
.success {
|
||||
color: #0a7d2c;
|
||||
}
|
||||
.drop-zone {
|
||||
border: 2px dashed #aaa;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
.drop-zone.drag-over {
|
||||
background: #eef6ff;
|
||||
border-color: #06f;
|
||||
}
|
||||
.file-link input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
.file-link {
|
||||
color: #06f;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pages {
|
||||
padding: 0;
|
||||
list-style: decimal inside;
|
||||
}
|
||||
.pages li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.pages li.invalid {
|
||||
background: #fff5f5;
|
||||
}
|
||||
.page-name {
|
||||
flex: 1;
|
||||
}
|
||||
.page-size {
|
||||
color: #777;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid #06f;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/routes/upload/+page.ts
Normal file
12
frontend/src/routes/upload/+page.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { listMangas, type Manga } from '$lib/api/mangas';
|
||||
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.
|
||||
const { items } = await listMangas({ limit: 200, sort: 'title' });
|
||||
return { mangas: items as Manga[] };
|
||||
};
|
||||
Reference in New Issue
Block a user