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

@@ -7,7 +7,12 @@ import {
afterEach,
type MockInstance
} from 'vitest';
import { listChapters, getChapter, getChapterPages } from './chapters';
import {
listChapters,
getChapter,
getChapterPages,
createChapter
} from './chapters';
function ok(body: unknown): Response {
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 () => {
fetchSpy.mockResolvedValueOnce(
ok({

View File

@@ -55,3 +55,66 @@ export async function getChapterPages(
);
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] ?? '';
}