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] ?? '';
}

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>