From b259d1f57183f651601345d80da912a6fe90292f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 16 May 2026 22:54:00 +0200 Subject: [PATCH] feat: drag-drop upload page for manga and chapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/upload.spec.ts | 192 +++++++++ frontend/package.json | 2 +- frontend/src/lib/upload-validation.test.ts | 52 +++ frontend/src/lib/upload-validation.ts | 39 ++ frontend/src/routes/upload/+page.svelte | 461 +++++++++++++++++++++ frontend/src/routes/upload/+page.ts | 12 + 8 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/upload.spec.ts create mode 100644 frontend/src/lib/upload-validation.test.ts create mode 100644 frontend/src/lib/upload-validation.ts create mode 100644 frontend/src/routes/upload/+page.svelte create mode 100644 frontend/src/routes/upload/+page.ts 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 @@ + + + + Upload — Mangalord + + +

Upload

+ +{#if !session.loaded} +

Loading…

+{:else if !session.user} +

+ Sign in to upload mangas or chapters. +

+{:else} +
+

Create manga

+
+ + + + + + {#if mangaSuccess} +

{mangaSuccess}

+ {/if} + {#if mangaError} + + {/if} +
+
+ +
+

Upload chapter

+ {#if mangas.length === 0} +

+ No mangas yet — create one above first. +

+ {:else} +
+ + + + +
+

+ Drop pages here, or + +

+
+ + {#if chapterPages.length > 0} +
    + {#each chapterPages as p, i (p.id)} +
  1. + {p.file.name} + {formatBytes(p.file.size)} + + + + {#if p.error} + {p.error} + {/if} +
  2. + {/each} +
+ {/if} + + + {#if chapterSuccess} +

{chapterSuccess}

+ {/if} + {#if chapterError} + + {/if} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/routes/upload/+page.ts b/frontend/src/routes/upload/+page.ts new file mode 100644 index 0000000..96ca08a --- /dev/null +++ b/frontend/src/routes/upload/+page.ts @@ -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[] }; +};