diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index dd47dbe..7fa981f 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mangalord"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index a4a9604..8b4d547 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.8.0"
+version = "0.9.0"
edition = "2021"
[lib]
diff --git a/frontend/e2e/upload.spec.ts b/frontend/e2e/upload.spec.ts
new file mode 100644
index 0000000..96c74ab
--- /dev/null
+++ b/frontend/e2e/upload.spec.ts
@@ -0,0 +1,192 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const userFixture = {
+ id: 'u1',
+ username: 'alice',
+ created_at: '2026-01-01T00:00:00Z'
+};
+const mangaFixture = {
+ id: 'm1',
+ title: 'Berserk',
+ author: 'Kentaro Miura',
+ description: null,
+ cover_image_path: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z'
+};
+
+async function mockBaseUploadApis(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/mangas?*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ items: [mangaFixture],
+ page: { limit: 200, offset: 0, total: 1 }
+ })
+ })
+ );
+}
+
+test('anonymous user sees sign-in prompt on /upload', 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/mangas?*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
+ })
+ );
+
+ await page.goto('/upload');
+ await expect(page.getByTestId('upload-signin')).toBeVisible();
+});
+
+test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => {
+ await mockBaseUploadApis(page);
+
+ // Backend rejects with 415 unsupported_media_type — we want to see
+ // the human message rendered as the chapter error.
+ await page.route('**/api/v1/mangas/m1/chapters', (route) =>
+ route.fulfill({
+ status: 415,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ error: {
+ code: 'unsupported_media_type',
+ message: 'page[0]: unsupported image type application/pdf'
+ }
+ })
+ })
+ );
+
+ await page.goto('/upload');
+ await page.getByTestId('chapter-manga').selectOption('m1');
+ await page.getByTestId('chapter-number').fill('1');
+
+ // Client validator allows image/png; we lie about the file type so
+ // the request actually reaches the (mocked) backend, exercising the
+ // 415 envelope path.
+ await page.getByTestId('chapter-pages-input').setInputFiles({
+ name: 'fake.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from('%PDF-1.4', 'utf-8')
+ });
+
+ await page.getByTestId('chapter-submit').click();
+ await expect(page.getByTestId('chapter-error')).toContainText(
+ 'unsupported image type'
+ );
+});
+
+test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
+ await mockBaseUploadApis(page);
+
+ let createdManga: typeof mangaFixture | null = null;
+ let createdChapter: { id: string; number: number } | null = null;
+
+ await page.route('**/api/v1/mangas', (route) => {
+ if (route.request().method() === 'POST') {
+ createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
+ route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify(createdManga)
+ });
+ } else {
+ route.fallback();
+ }
+ });
+ await page.route('**/api/v1/mangas/m1/chapters', (route) => {
+ if (route.request().method() === 'POST') {
+ createdChapter = { id: 'c1', number: 1 };
+ route.fulfill({
+ status: 201,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: 'c1',
+ manga_id: 'm1',
+ number: 1,
+ title: null,
+ page_count: 2,
+ created_at: '2026-01-01T00:00:00Z'
+ })
+ });
+ } else {
+ route.fallback();
+ }
+ });
+
+ await page.goto('/upload');
+
+ // Create manga.
+ await page.getByTestId('manga-title').fill('Naruto');
+ await page.getByTestId('manga-submit').click();
+ await expect(page.getByTestId('manga-success')).toContainText('Created');
+ expect(createdManga).not.toBeNull();
+
+ // Upload chapter with two pages.
+ await page.getByTestId('chapter-manga').selectOption('m1');
+ await page.getByTestId('chapter-number').fill('1');
+ await page.getByTestId('chapter-pages-input').setInputFiles([
+ {
+ name: '1.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ },
+ {
+ name: '2.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ }
+ ]);
+ await expect(page.getByTestId('chapter-pages-list')).toContainText('1.png');
+ await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png');
+
+ await page.getByTestId('chapter-submit').click();
+ await expect(page.getByTestId('chapter-success')).toContainText(
+ '2 pages'
+ );
+ expect(createdChapter).not.toBeNull();
+});
+
+test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
+ await mockBaseUploadApis(page);
+
+ let chapterPostCalls = 0;
+ await page.route('**/api/v1/mangas/m1/chapters', (route) => {
+ if (route.request().method() === 'POST') chapterPostCalls += 1;
+ route.fallback();
+ });
+
+ await page.goto('/upload');
+ await page.getByTestId('chapter-manga').selectOption('m1');
+ await page.getByTestId('chapter-number').fill('1');
+
+ // A ~21 MiB buffer — exceeds the 20 MiB client cap.
+ const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
+ await page.getByTestId('chapter-pages-input').setInputFiles({
+ name: 'huge.png',
+ mimeType: 'image/png',
+ buffer: big
+ });
+
+ await expect(page.getByTestId('chapter-pages-list')).toContainText('too large');
+ await expect(page.getByTestId('chapter-submit')).toBeDisabled();
+ expect(chapterPostCalls).toBe(0);
+});
diff --git a/frontend/package.json b/frontend/package.json
index e767409..dff96a2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.8.0",
+ "version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/upload-validation.test.ts b/frontend/src/lib/upload-validation.test.ts
new file mode 100644
index 0000000..03340f1
--- /dev/null
+++ b/frontend/src/lib/upload-validation.test.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/lib/upload-validation.ts b/frontend/src/lib/upload-validation.ts
new file mode 100644
index 0000000..9fbfa5a
--- /dev/null
+++ b/frontend/src/lib/upload-validation.ts
@@ -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;
+}
diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte
new file mode 100644
index 0000000..2e1aca8
--- /dev/null
+++ b/frontend/src/routes/upload/+page.svelte
@@ -0,0 +1,461 @@
+
+
+
Loading…
+{:else if !session.user} ++ Sign in to upload mangas or chapters. +
+{:else} ++ No mangas yet — create one above first. +
+ {:else} + + {/if} +