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:
@@ -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({
|
||||
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
|
||||
337
frontend/src/lib/components/ChapterPagesEditor.svelte
Normal file
337
frontend/src/lib/components/ChapterPagesEditor.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user