feat: upload flow revamp (0.20.0)

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

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

View File

@@ -8,14 +8,18 @@ const userFixture = {
const mangaFixture = {
id: 'm1',
title: 'Berserk',
author: 'Kentaro Miura',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
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) =>
route.fulfill({
status: 200,
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
body: JSON.stringify({ user: userFixture })
})
);
await page.route('**/api/v1/mangas?*', (route) =>
await page.route('**/api/v1/genres', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [mangaFixture],
page: { limit: 200, offset: 0, total: 1 }
})
body: JSON.stringify([
{ id: 'g-action', name: 'Action' },
{ 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) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
})
await page.route('**/api/v1/genres', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
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);
test('/upload creates a manga with no staged chapters and lands on the manga page', async ({
page
}) => {
await stubAuthenticatedAndGenres(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' };
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
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') {
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({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'c1',
manga_id: 'm1',
manga_id: 'm3',
number: 1,
title: null,
page_count: 2,
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
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');
// 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 page.getByTestId('manga-title').fill('Vinland Saga');
await page.getByTestId('add-chapter').click();
const pngBytes = 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('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 expect(page.getByTestId('chapter-success')).toContainText(
'2 pages'
);
expect(createdChapter).not.toBeNull();
await page.getByTestId('manga-submit').click();
await expect(page).toHaveURL(/\/manga\/m3$/);
expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
});
test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
await mockBaseUploadApis(page);
test('/manga/[id]/upload-chapter happy path uploads renamed pages', 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: [{ 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;
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.
await page.goto('/manga/m1/upload-chapter');
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
await page.getByTestId('chapter-pages-input').setInputFiles({
await page.getByTestId('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('pages-list')).toContainText('too large');
await expect(page.getByTestId('chapter-submit')).toBeDisabled();
expect(chapterPostCalls).toBe(0);
});