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

2
backend/Cargo.lock generated
View File

@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.19.2" version = "0.20.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.19.2" version = "0.20.0"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -8,14 +8,18 @@ const userFixture = {
const mangaFixture = { const mangaFixture = {
id: 'm1', id: 'm1',
title: 'Berserk', title: 'Berserk',
author: 'Kentaro Miura', status: 'ongoing',
alt_titles: [],
description: null, description: null,
cover_image_path: null, cover_image_path: null,
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z' updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
genres: [],
tags: []
}; };
async function mockBaseUploadApis(page: Page) { async function stubAuthenticatedAndGenres(page: Page) {
await page.route('**/api/v1/auth/me', (route) => await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
body: JSON.stringify({ user: userFixture }) body: JSON.stringify({ user: userFixture })
}) })
); );
await page.route('**/api/v1/mangas?*', (route) => await page.route('**/api/v1/genres', (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify([
items: [mangaFixture], { id: 'g-action', name: 'Action' },
page: { limit: 200, offset: 0, total: 1 } { id: 'g-fantasy', name: 'Fantasy' }
}) ])
}) })
); );
} }
@@ -45,61 +49,20 @@ test('anonymous user sees sign-in prompt on /upload', async ({ page }) => {
}) })
}) })
); );
await page.route('**/api/v1/mangas?*', (route) => await page.route('**/api/v1/genres', (route) =>
route.fulfill({ route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
})
); );
await page.goto('/upload'); await page.goto('/upload');
await expect(page.getByTestId('upload-signin')).toBeVisible(); await expect(page.getByTestId('upload-signin')).toBeVisible();
}); });
test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => { test('/upload creates a manga with no staged chapters and lands on the manga page', async ({
await mockBaseUploadApis(page); page
}) => {
// Backend rejects with 415 unsupported_media_type — we want to see await stubAuthenticatedAndGenres(page);
// 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 createdManga: typeof mangaFixture | null = null;
let createdChapter: { id: string; number: number } | null = null;
await page.route('**/api/v1/mangas', (route) => { await page.route('**/api/v1/mangas', (route) => {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST') {
createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' }; createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
route.fallback(); route.fallback();
} }
}); });
await page.route('**/api/v1/mangas/m1/chapters', (route) => { await page.route('**/api/v1/mangas/m2', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mangaFixture, id: 'm2', title: 'Naruto' })
})
);
await page.route('**/api/v1/mangas/m2/chapters*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m2', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/upload');
await page.getByTestId('manga-title').fill('Naruto');
await page.getByTestId('manga-submit').click();
// After create, success → navigate to /manga/{id}.
await expect(page).toHaveURL(/\/manga\/m2$/);
expect(createdManga).not.toBeNull();
});
test('/upload stages a chapter with renamed page files (page-NNN.<ext>)', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
let createdManga: typeof mangaFixture | null = null;
let submittedPageNames: string[] = [];
await page.route('**/api/v1/mangas', (route) => {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST') {
createdChapter = { id: 'c1', number: 1 }; createdManga = { ...mangaFixture, id: 'm3', title: 'Vinland Saga' };
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(createdManga)
});
} else {
route.fallback();
}
});
await page.route('**/api/v1/mangas/m3/chapters', (route) => {
if (route.request().method() === 'POST') {
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
// Pull every Content-Disposition filename out of the
// multipart body — that's what the server (and proxies,
// logs) would see. We expect only renamed `page-NNN.*`
// entries, never the original filenames.
const matches = [
...post.matchAll(/filename="([^"]+)"/g)
].map((m) => m[1]);
submittedPageNames = matches.filter((n) => n.startsWith('page-'));
route.fulfill({ route.fulfill({
status: 201, status: 201,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
id: 'c1', id: 'c1',
manga_id: 'm1', manga_id: 'm3',
number: 1, number: 1,
title: null, title: null,
page_count: 2, page_count: 2,
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
route.fallback(); route.fallback();
} }
}); });
await page.route('**/api/v1/mangas/m3', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mangaFixture, id: 'm3', title: 'Vinland Saga' })
})
);
await page.route('**/api/v1/mangas/m3/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m3', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/upload'); await page.goto('/upload');
await page.getByTestId('manga-title').fill('Vinland Saga');
// Create manga. await page.getByTestId('add-chapter').click();
await page.getByTestId('manga-title').fill('Naruto'); const pngBytes = Buffer.from([
await page.getByTestId('manga-submit').click(); 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
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 page
await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png'); .getByTestId('staged-chapter-pages-input')
.setInputFiles([
{ name: 'IMG_2837.png', mimeType: 'image/png', buffer: pngBytes },
{ name: 'random_file.png', mimeType: 'image/png', buffer: pngBytes }
]);
// The list renders "Page 001" / "Page 002" not the original filenames.
const list = page.getByTestId('staged-chapter-pages-list');
await expect(list).toContainText('Page 001');
await expect(list).toContainText('Page 002');
// Original filenames are visible as a dimmed caption (uploader-
// reference; dropped after the row).
await expect(list).toContainText('IMG_2837.png');
await page.getByTestId('chapter-submit').click(); await page.getByTestId('manga-submit').click();
await expect(page.getByTestId('chapter-success')).toContainText( await expect(page).toHaveURL(/\/manga\/m3$/);
'2 pages' expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
);
expect(createdChapter).not.toBeNull();
}); });
test('client preflight blocks oversized files without hitting the network', async ({ page }) => { test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({
await mockBaseUploadApis(page); page
}) => {
await stubAuthenticatedAndGenres(page);
await page.route('**/api/v1/mangas/m1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [{ id: 'c0', manga_id: 'm1', number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }],
page: { limit: 200, offset: 0, total: 1 }
})
})
);
let submitted: string[] = [];
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
if (route.request().method() === 'POST') {
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
submitted = [...post.matchAll(/filename="([^"]+)"/g)].map((m) => m[1]);
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c-new',
manga_id: 'm1',
number: 2,
title: null,
page_count: 1,
created_at: '2026-01-01T00:00:00Z'
})
});
} else {
route.fallback();
}
});
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
})
);
await page.route('**/api/v1/me/read-progress/m1', (route) =>
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({
error: { code: 'not_found', message: 'no progress' }
})
})
);
await page.goto('/manga/m1/upload-chapter');
// Default chapter number is the next free one (existing max 1 → 2).
await expect(page.getByTestId('chapter-number')).toHaveValue('2');
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
await page.getByTestId('pages-input').setInputFiles({
name: 'whatever.png',
mimeType: 'image/png',
buffer: pngBytes
});
await expect(page.getByTestId('pages-list')).toContainText('Page 001');
await page.getByTestId('chapter-submit').click();
await expect(page).toHaveURL(/\/manga\/m1$/);
expect(submitted.filter((n) => n.startsWith('page-'))).toEqual([
'page-001.png'
]);
});
test('chapter upload client preflight blocks oversized files', async ({
page
}) => {
await stubAuthenticatedAndGenres(page);
await page.route('**/api/v1/mangas/m1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture)
})
);
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 200, offset: 0, total: 0 }
})
})
);
let chapterPostCalls = 0; let chapterPostCalls = 0;
await page.route('**/api/v1/mangas/m1/chapters', (route) => { await page.route('**/api/v1/mangas/m1/chapters', (route) => {
if (route.request().method() === 'POST') chapterPostCalls += 1; if (route.request().method() === 'POST') chapterPostCalls += 1;
route.fallback(); route.fallback();
}); });
await page.goto('/upload'); await page.goto('/manga/m1/upload-chapter');
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); const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
await page.getByTestId('chapter-pages-input').setInputFiles({ await page.getByTestId('pages-input').setInputFiles({
name: 'huge.png', name: 'huge.png',
mimeType: 'image/png', mimeType: 'image/png',
buffer: big buffer: big
}); });
await expect(page.getByTestId('chapter-pages-list')).toContainText('too large'); await expect(page.getByTestId('pages-list')).toContainText('too large');
await expect(page.getByTestId('chapter-submit')).toBeDisabled(); await expect(page.getByTestId('chapter-submit')).toBeDisabled();
expect(chapterPostCalls).toBe(0); expect(chapterPostCalls).toBe(0);
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.19.2", "version": "0.20.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -7,7 +7,12 @@ import {
afterEach, afterEach,
type MockInstance type MockInstance
} from 'vitest'; } from 'vitest';
import { listChapters, getChapter, getChapterPages } from './chapters'; import {
listChapters,
getChapter,
getChapterPages,
createChapter
} from './chapters';
function ok(body: unknown): Response { function ok(body: unknown): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -87,6 +92,43 @@ describe('chapters api client', () => {
}); });
}); });
it('createChapter POSTs multipart and renames page files to page-NNN.<ext>', async () => {
fetchSpy.mockResolvedValueOnce(ok({ ...chapterFixture, page_count: 3 }));
const pages = [
new File([new Uint8Array([1, 2])], 'IMG_2837.HEIC', { type: 'image/jpeg' }),
new File([new Uint8Array([3, 4])], 'random.png', { type: 'image/png' }),
// No extension; MIME-derived fallback should kick in.
new File([new Uint8Array([5])], 'scan_42', { type: 'image/webp' })
];
const result = await createChapter(
'm1',
{ number: 1, title: null },
pages
);
expect(result.page_count).toBe(3);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/mangas\/m1\/chapters$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
const form = init.body as FormData;
// Metadata part is JSON.
const metadata = form.get('metadata') as Blob;
expect(metadata.type).toBe('application/json');
// Three pages, all renamed; original filenames discarded.
const submitted = form.getAll('page') as File[];
expect(submitted).toHaveLength(3);
// Original-extension preferred over MIME-derived; capitalised
// .HEIC dropped because it's not in the allowed list, so the
// MIME-derived `.jpg` wins.
expect(submitted[0].name).toBe('page-001.jpg');
expect(submitted[1].name).toBe('page-002.png');
expect(submitted[2].name).toBe('page-003.webp');
// No original filenames leak through.
for (const f of submitted) {
expect(f.name).not.toMatch(/IMG_2837|random|scan_42/);
}
});
it('getChapterPages unwraps the {pages} envelope into the array', async () => { it('getChapterPages unwraps the {pages} envelope into the array', async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
ok({ ok({

View File

@@ -55,3 +55,66 @@ export async function getChapterPages(
); );
return r.pages; return r.pages;
} }
export type NewChapter = {
number: number;
title?: string | null;
};
/**
* `POST /api/v1/mangas/:id/chapters` is multipart: a `metadata` part
* (JSON) plus one or more ordered `page` parts. Each page file is
* renamed to `page-NNN.<ext>` before submission so the user's
* original filenames (often personally-identifying or just messy:
* `IMG_2837.HEIC`, `~/scans/full chapter pack/`) don't end up in
* request bodies or server logs. The bytes are unchanged — the
* backend still sniffs the MIME from magic bytes and stores under
* its own `{nnnn}.{ext}` scheme.
*/
export async function createChapter(
mangaId: string,
metadata: NewChapter,
pages: File[]
): Promise<Chapter> {
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
pages.forEach((file, i) => {
const ext = extensionFor(file);
const renamed = new File(
[file],
`page-${String(i + 1).padStart(3, '0')}${ext}`,
{ type: file.type }
);
form.append('page', renamed);
});
return request<Chapter>(
`/v1/mangas/${encodeURIComponent(mangaId)}/chapters`,
{ method: 'POST', body: form }
);
}
/**
* Pick a sensible extension for the renamed multipart part. Prefer
* the original filename's extension when present (jpg/jpeg/png/webp/
* gif/avif), otherwise derive from the MIME type. Falls back to an
* empty string so the renamed file is just `page-001` — the
* server sniffs bytes anyway.
*/
function extensionFor(file: File): string {
const dot = file.name.lastIndexOf('.');
if (dot > 0) {
const ext = file.name.slice(dot).toLowerCase();
if (/^\.(jpe?g|png|webp|gif|avif)$/.test(ext)) return ext;
}
const fromMime: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/avif': '.avif'
};
return fromMime[file.type] ?? '';
}

View File

@@ -0,0 +1,337 @@
<script lang="ts" module>
/**
* Working type for a staged page. Owned by the parent so it can
* read/write `pages` via `bind:pages`. The component is responsible
* for `previewUrl` lifecycle (created on add, revoked on remove /
* unmount).
*/
export type PendingPage = {
id: string;
file: File;
error: string | null;
previewUrl: string;
};
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Modal from './Modal.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';
let {
pages = $bindable<PendingPage[]>([]),
testidPrefix = 'pages'
}: {
pages?: PendingPage[];
testidPrefix?: string;
} = $props();
let isDragOver = $state(false);
let previewIndex = $state<number | null>(null);
const previewPage = $derived(
previewIndex != null ? pages[previewIndex] ?? null : null
);
function addFiles(files: File[] | FileList) {
const arr = Array.from(files);
const additions: PendingPage[] = arr.map((file) => ({
id: crypto.randomUUID(),
file,
error: validateImageFile(file),
previewUrl: URL.createObjectURL(file)
}));
pages = [...pages, ...additions];
}
function removePage(id: string) {
const idx = pages.findIndex((p) => p.id === id);
if (idx < 0) return;
URL.revokeObjectURL(pages[idx].previewUrl);
pages = pages.filter((p) => p.id !== id);
}
function movePage(id: string, dir: -1 | 1) {
const i = pages.findIndex((p) => p.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= pages.length) return;
const copy = pages.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
pages = copy;
}
function onPagesInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) addFiles(input.files);
input.value = '';
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
if (e.dataTransfer?.files) addFiles(e.dataTransfer.files);
}
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragOver = true;
}
function onDragLeave() {
isDragOver = false;
}
function pageLabel(i: number): string {
// Mirror the server's `{nnnn}` storage convention so the visible
// label matches what the file ends up named on disk.
return `Page ${String(i + 1).padStart(3, '0')}`;
}
onDestroy(() => {
// Revoke any outstanding object URLs so the browser can free the
// backing image data. Closing the page would do this eventually
// anyway, but components inside long-lived single-page apps
// benefit from explicit cleanup.
for (const p of pages) URL.revokeObjectURL(p.previewUrl);
});
</script>
<div
class="drop-zone"
class:drag-over={isDragOver}
ondrop={onDrop}
ondragover={onDragOver}
ondragleave={onDragLeave}
role="region"
aria-label="page upload"
data-testid="{testidPrefix}-drop-zone"
>
<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="{testidPrefix}-input"
/>
</label>
</p>
</div>
{#if pages.length > 0}
<ol class="pages" data-testid="{testidPrefix}-list">
{#each pages as p, i (p.id)}
<li class:invalid={p.error} data-testid="{testidPrefix}-row">
<button
type="button"
class="thumb-btn"
onclick={() => (previewIndex = i)}
aria-label="Preview {pageLabel(i)}"
title="Preview"
data-testid="{testidPrefix}-thumb"
>
<img src={p.previewUrl} alt="" class="thumb" loading="lazy" />
</button>
<div class="page-meta">
<span class="page-label">{pageLabel(i)}</span>
<span class="page-origin" title={p.file.name}>
from {p.file.name} · {formatBytes(p.file.size)}
</span>
</div>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, -1)}
disabled={i === 0}
aria-label="Move {pageLabel(i)} up"
title="Move up"
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
class="icon-btn"
type="button"
onclick={() => movePage(p.id, 1)}
disabled={i === pages.length - 1}
aria-label="Move {pageLabel(i)} 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 {pageLabel(i)}"
title="Remove page"
data-testid="{testidPrefix}-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}
<Modal
open={previewIndex != null}
title={previewPage ? pageLabel(previewIndex ?? 0) : 'Preview'}
onClose={() => (previewIndex = null)}
size="lg"
closeOnBackdrop={true}
testid="page-preview-modal"
>
{#if previewPage}
<img
src={previewPage.previewUrl}
alt={pageLabel(previewIndex ?? 0)}
class="preview-large"
/>
{/if}
</Modal>
<style>
.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: var(--space-3) 0 0;
list-style: none;
}
.pages li {
display: grid;
grid-template-columns: 56px 1fr auto auto auto;
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);
}
.thumb-btn {
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
line-height: 0;
overflow: hidden;
width: 56px;
height: 80px;
}
.thumb-btn:hover {
border-color: var(--primary);
}
.thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.page-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.page-label {
font-weight: var(--weight-semibold);
color: var(--text);
font-size: var(--font-sm);
}
.page-origin {
color: var(--text-muted);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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.danger:hover:not(:disabled) {
color: var(--danger);
}
.field-error {
grid-column: 1 / -1;
color: var(--danger);
font-size: var(--font-sm);
}
.preview-large {
display: block;
max-width: 100%;
max-height: 75vh;
margin: 0 auto;
object-fit: contain;
background: var(--surface-elevated);
border-radius: var(--radius-sm);
}
</style>

View File

@@ -14,6 +14,7 @@
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte'; import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import FolderPlus from '@lucide/svelte/icons/folder-plus'; import FolderPlus from '@lucide/svelte/icons/folder-plus';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
let { data } = $props(); let { data } = $props();
const manga = $derived(data.manga); const manga = $derived(data.manga);
@@ -323,6 +324,14 @@
<FolderPlus size={16} aria-hidden="true" /> <FolderPlus size={16} aria-hidden="true" />
<span>Add to collection</span> <span>Add to collection</span>
</button> </button>
<a
class="action"
href="/manga/{manga.id}/upload-chapter"
data-testid="upload-chapter-link"
>
<UploadCloud size={16} aria-hidden="true" />
<span>Upload chapter</span>
</a>
</div> </div>
{:else} {:else}
<a class="action" href="/login" data-testid="bookmark-signin"> <a class="action" href="/login" data-testid="bookmark-signin">

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError, fileUrl } from '$lib/api/client';
import { createChapter } from '$lib/api/chapters';
import { session } from '$lib/session.svelte';
import ChapterPagesEditor, {
type PendingPage
} from '$lib/components/ChapterPagesEditor.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import BookImage from '@lucide/svelte/icons/book-image';
let { data } = $props();
const manga = $derived(data.manga);
// svelte-ignore state_referenced_locally
let number = $state<number | null>(data.defaultNumber);
let title = $state('');
let pages = $state<PendingPage[]>([]);
let submitting = $state(false);
let error: string | null = $state(null);
const allPagesValid = $derived(pages.every((p) => !p.error));
const canSubmit = $derived(
Boolean(session.user) &&
number != null &&
number >= 1 &&
pages.length > 0 &&
allPagesValid &&
!submitting
);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit || number == null) return;
submitting = true;
error = null;
try {
const created = await createChapter(
manga.id,
{ number, title: title.trim() || null },
pages.map((p) => p.file)
);
// Land on the chapter list — the new chapter is at the
// bottom in chapter-number order; uploader-friendly.
await goto(`/manga/${manga.id}`);
void created;
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
await goto(`/login?next=/manga/${manga.id}/upload-chapter`);
return;
}
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>Upload chapter — {manga.title} — Mangalord</title>
</svelte:head>
<nav class="back">
<a href="/manga/{manga.id}" class="back-link">
<ArrowLeft size={16} aria-hidden="true" />
<span>Back to {manga.title}</span>
</a>
</nav>
<header class="header">
<div class="cover">
{#if manga.cover_image_path}
<img
src={fileUrl(manga.cover_image_path)}
alt=""
class="cover-img"
loading="lazy"
/>
{:else}
<span class="cover-placeholder" aria-hidden="true">
<BookImage size={22} aria-hidden="true" />
</span>
{/if}
</div>
<div>
<h1>Upload chapter</h1>
<p class="subtitle">to <strong>{manga.title}</strong></p>
</div>
</header>
{#if !session.loaded}
<p class="status">Loading…</p>
{:else if !session.user}
<p class="status">
<a href="/login?next=/manga/{manga.id}/upload-chapter">Sign in</a>
to upload chapters.
</p>
{:else}
<form
class="card"
onsubmit={submit}
action="javascript:void(0)"
data-testid="upload-chapter-form"
>
<label class="form-field">
<span>Chapter number <span aria-hidden="true">*</span></span>
<input
type="number"
min="1"
step="1"
bind:value={number}
required
data-testid="chapter-number"
/>
</label>
<label class="form-field">
<span>Title (optional)</span>
<input
type="text"
bind:value={title}
maxlength="200"
data-testid="chapter-title"
/>
</label>
<ChapterPagesEditor bind:pages />
<button
class="primary"
type="submit"
disabled={!canSubmit}
data-testid="chapter-submit"
>
{submitting ? 'Uploading…' : 'Upload chapter'}
</button>
{#if error}
<p role="alert" class="form-error" data-testid="chapter-error">{error}</p>
{/if}
</form>
{/if}
<style>
.back {
margin-bottom: var(--space-3);
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
font-size: var(--font-sm);
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.cover {
flex-shrink: 0;
}
.cover-img {
width: 48px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--surface);
}
.cover-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 72px;
background: var(--surface);
color: var(--text-muted);
border-radius: var(--radius-sm);
}
h1 {
margin: 0 0 var(--space-1);
}
.subtitle {
color: var(--text-muted);
margin: 0;
}
.status {
color: var(--text-muted);
}
.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);
}
.primary {
background: var(--primary);
color: var(--primary-contrast);
border-color: var(--primary);
align-self: flex-start;
}
.primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.form-error {
color: var(--danger);
}
</style>

View File

@@ -0,0 +1,36 @@
import { error, redirect } from '@sveltejs/kit';
import { ApiError } from '$lib/api/client';
import { getManga } from '$lib/api/mangas';
import { listChapters } from '$lib/api/chapters';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, url }) => {
try {
// Need the manga (so we can show title + cover for context)
// and existing chapters so we can default the chapter-number
// field to "next available" — saves the uploader a click.
const [manga, chapters] = await Promise.all([
getManga(params.id),
listChapters(params.id, { limit: 200 })
]);
// The chapter list endpoint sorts by number ASC; the next
// suggested number is one more than the largest. 200 covers
// every realistic series; users with more chapters can edit
// the number manually.
const maxNumber = chapters.items.reduce(
(max, c) => Math.max(max, c.number),
0
);
return { manga, defaultNumber: maxNumber + 1 };
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
redirect(302, `/login?next=${encodeURIComponent(url.pathname)}`);
}
if (e instanceof ApiError && e.status === 404) {
error(404, 'Manga not found');
}
throw e;
}
};

View File

@@ -1,24 +1,21 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; 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 { 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 { session } from '$lib/session.svelte';
import { formatBytes, validateImageFile } from '$lib/upload-validation'; import { formatBytes, validateImageFile } from '$lib/upload-validation';
import Chip from '$lib/components/Chip.svelte'; import Chip from '$lib/components/Chip.svelte';
import ArrowUp from '@lucide/svelte/icons/arrow-up'; import ChapterPagesEditor, {
import ArrowDown from '@lucide/svelte/icons/arrow-down'; type PendingPage
import Trash2 from '@lucide/svelte/icons/trash-2'; } from '$lib/components/ChapterPagesEditor.svelte';
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
import BookImage from '@lucide/svelte/icons/book-image';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let { data } = $props(); let { data } = $props();
const mangas = $derived(data.mangas);
const genres = $derived(data.genres); const genres = $derived(data.genres);
// -------- Manga form state -------- // ---- Manga form state ----
let mangaTitle = $state(''); let mangaTitle = $state('');
let mangaStatus = $state<MangaStatus>('ongoing'); let mangaStatus = $state<MangaStatus>('ongoing');
let mangaDescription = $state(''); let mangaDescription = $state('');
@@ -29,13 +26,42 @@
let mangaGenreIds = $state<string[]>([]); let mangaGenreIds = $state<string[]>([]);
let coverFile = $state<File | null>(null); let coverFile = $state<File | null>(null);
let coverError = $state<string | 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( // ---- Initial-chapter staging ----
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting 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() { function addAuthor() {
@@ -46,11 +72,9 @@
} }
authorDraft = ''; authorDraft = '';
} }
function removeAuthor(name: string) { function removeAuthor(name: string) {
mangaAuthors = mangaAuthors.filter((a) => a !== name); mangaAuthors = mangaAuthors.filter((a) => a !== name);
} }
function addAltTitle() { function addAltTitle() {
const t = altTitleDraft.trim(); const t = altTitleDraft.trim();
if (!t) return; if (!t) return;
@@ -59,17 +83,14 @@
} }
altTitleDraft = ''; altTitleDraft = '';
} }
function removeAltTitle(t: string) { function removeAltTitle(t: string) {
mangaAltTitles = mangaAltTitles.filter((x) => x !== t); mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
} }
function toggleGenre(id: string) { function toggleGenre(id: string) {
mangaGenreIds = mangaGenreIds.includes(id) mangaGenreIds = mangaGenreIds.includes(id)
? mangaGenreIds.filter((g) => g !== id) ? mangaGenreIds.filter((g) => g !== id)
: [...mangaGenreIds, id]; : [...mangaGenreIds, id];
} }
function onCoverChange(e: Event) { function onCoverChange(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0] ?? null; const file = input.files?.[0] ?? null;
@@ -77,19 +98,40 @@
coverError = file ? validateImageFile(file) : null; 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(); e.preventDefault();
if (!canSubmitManga) return; if (!canSubmit) return;
// Pick up an unsubmitted token if the user hit Submit without
// pressing Add — otherwise the typed name silently disappears.
if (authorDraft.trim()) addAuthor(); if (authorDraft.trim()) addAuthor();
if (altTitleDraft.trim()) addAltTitle(); if (altTitleDraft.trim()) addAltTitle();
mangaSubmitting = true; submitting = true;
mangaError = null; mangaError = null;
mangaFieldErrors = {}; success = null;
mangaSuccess = null; let manga;
try { try {
const manga = await createManga( manga = await createManga(
{ {
title: mangaTitle.trim(), title: mangaTitle.trim(),
status: mangaStatus, status: mangaStatus,
@@ -100,141 +142,45 @@
}, },
coverFile ?? undefined coverFile ?? undefined
); );
mangaSuccess = `Created "${manga.title}".`;
mangaTitle = '';
mangaStatus = 'ongoing';
mangaAuthors = [];
mangaAltTitles = [];
mangaGenreIds = [];
mangaDescription = '';
coverFile = null;
} catch (e) { } 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) { if (e instanceof ApiError && e.status === 401) {
goto('/login'); await goto('/login?next=/upload');
return; return;
} }
const message = e instanceof Error ? e.message : String(e); mangaError = e instanceof Error ? e.message : String(e);
setMessage(message); submitting = false;
setFields({}); return;
}
// 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> </script>
@@ -242,18 +188,18 @@
<title>Upload — Mangalord</title> <title>Upload — Mangalord</title>
</svelte:head> </svelte:head>
<h1>Upload</h1> <h1>Create manga</h1>
{#if !session.loaded} {#if !session.loaded}
<p class="status" data-testid="upload-loading">Loading…</p> <p class="status" data-testid="upload-loading">Loading…</p>
{:else if !session.user} {:else if !session.user}
<p class="status" data-testid="upload-signin"> <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> </p>
{:else} {:else}
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
<section class="card"> <section class="card">
<h2>Create manga</h2> <h2>Manga details</h2>
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
<label class="form-field"> <label class="form-field">
<span>Title <span aria-hidden="true">*</span></span> <span>Title <span aria-hidden="true">*</span></span>
<input <input
@@ -263,9 +209,6 @@
maxlength="200" maxlength="200"
data-testid="manga-title" data-testid="manga-title"
/> />
{#if mangaFieldErrors.title}
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
{/if}
</label> </label>
<label class="form-field"> <label class="form-field">
@@ -383,179 +326,112 @@
<span class="field-error" role="alert">{coverError}</span> <span class="field-error" role="alert">{coverError}</span>
{/if} {/if}
</label> </label>
<button class="primary" type="submit" disabled={!canSubmitManga} data-testid="manga-submit"> </section>
{mangaSubmitting ? 'Creating…' : 'Create manga'}
<section class="card">
<div class="chapters-header">
<h2>Initial chapters (optional)</h2>
<button
type="button"
class="add-chapter"
onclick={addChapter}
data-testid="add-chapter"
>
<Plus size={14} aria-hidden="true" />
<span>Add chapter</span>
</button> </button>
{#if mangaSuccess} </div>
<p class="success" data-testid="manga-success">{mangaSuccess}</p> {#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
type="button"
class="icon-btn danger"
onclick={() => removeChapter(c.id)}
aria-label="Remove chapter"
title="Remove chapter"
data-testid="staged-chapter-remove"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</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}
{#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} {/if}
{#if mangaError} {#if mangaError}
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p> <p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
{/if} {/if}
</form> </form>
</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"
>
<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>
<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"
>
<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}
</p>
{/if}
</form>
{/if}
</section>
{/if} {/if}
<style> <style>
@@ -563,15 +439,17 @@
color: var(--text-muted); color: var(--text-muted);
} }
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-4); padding: var(--space-4);
margin-bottom: var(--space-4);
}
form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
@@ -581,7 +459,6 @@
background: var(--primary); background: var(--primary);
color: var(--primary-contrast); color: var(--primary-contrast);
border-color: var(--primary); border-color: var(--primary);
margin-top: var(--space-1);
align-self: flex-start; align-self: flex-start;
} }
@@ -640,114 +517,6 @@
cursor: pointer; 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 { .icon-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -780,4 +549,97 @@
.icon-btn.danger:hover:not(:disabled) { .icon-btn.danger:hover:not(:disabled) {
color: var(--danger); 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> </style>

View File

@@ -1,17 +1,13 @@
import { listMangas, type MangaCard } from '$lib/api/mangas';
import { listGenres, type Genre } from '$lib/api/genres'; import { listGenres, type Genre } from '$lib/api/genres';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const ssr = false; export const ssr = false;
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
// The chapter form needs a list of mangas to attach the new chapter // /upload is now for new-manga creation only — additional
// to. There's no ownership concept yet, so any authenticated user can // chapters land on /manga/[id]/upload-chapter via a button on the
// see and add to any manga. Genres are needed for the create-manga // manga page. The only async dep here is the curated genre list
// form's picker. // for the picker.
const [{ items }, genres] = await Promise.all([ const genres = await listGenres();
listMangas({ limit: 200, sort: 'title' }), return { genres: genres as Genre[] };
listGenres()
]);
return { mangas: items as MangaCard[], genres: genres as Genre[] };
}; };