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>
|
||||
@@ -14,6 +14,7 @@
|
||||
import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
||||
|
||||
let { data } = $props();
|
||||
const manga = $derived(data.manga);
|
||||
@@ -323,6 +324,14 @@
|
||||
<FolderPlus size={16} aria-hidden="true" />
|
||||
<span>Add to collection</span>
|
||||
</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>
|
||||
{:else}
|
||||
<a class="action" href="/login" data-testid="bookmark-signin">
|
||||
|
||||
223
frontend/src/routes/manga/[id]/upload-chapter/+page.svelte
Normal file
223
frontend/src/routes/manga/[id]/upload-chapter/+page.svelte
Normal 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>
|
||||
36
frontend/src/routes/manga/[id]/upload-chapter/+page.ts
Normal file
36
frontend/src/routes/manga/[id]/upload-chapter/+page.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -1,24 +1,21 @@
|
||||
<script lang="ts">
|
||||
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 { request } from '$lib/api/client';
|
||||
import { createChapter } from '$lib/api/chapters';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||
import Chip from '$lib/components/Chip.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';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import ChapterPagesEditor, {
|
||||
type PendingPage
|
||||
} from '$lib/components/ChapterPagesEditor.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let { data } = $props();
|
||||
const mangas = $derived(data.mangas);
|
||||
const genres = $derived(data.genres);
|
||||
|
||||
// -------- Manga form state --------
|
||||
|
||||
// ---- Manga form state ----
|
||||
let mangaTitle = $state('');
|
||||
let mangaStatus = $state<MangaStatus>('ongoing');
|
||||
let mangaDescription = $state('');
|
||||
@@ -29,13 +26,42 @@
|
||||
let mangaGenreIds = $state<string[]>([]);
|
||||
let coverFile = $state<File | 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(
|
||||
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting
|
||||
// ---- Initial-chapter staging ----
|
||||
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() {
|
||||
@@ -46,11 +72,9 @@
|
||||
}
|
||||
authorDraft = '';
|
||||
}
|
||||
|
||||
function removeAuthor(name: string) {
|
||||
mangaAuthors = mangaAuthors.filter((a) => a !== name);
|
||||
}
|
||||
|
||||
function addAltTitle() {
|
||||
const t = altTitleDraft.trim();
|
||||
if (!t) return;
|
||||
@@ -59,17 +83,14 @@
|
||||
}
|
||||
altTitleDraft = '';
|
||||
}
|
||||
|
||||
function removeAltTitle(t: string) {
|
||||
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
|
||||
}
|
||||
|
||||
function toggleGenre(id: string) {
|
||||
mangaGenreIds = mangaGenreIds.includes(id)
|
||||
? mangaGenreIds.filter((g) => g !== id)
|
||||
: [...mangaGenreIds, id];
|
||||
}
|
||||
|
||||
function onCoverChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
@@ -77,19 +98,40 @@
|
||||
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();
|
||||
if (!canSubmitManga) return;
|
||||
// Pick up an unsubmitted token if the user hit Submit without
|
||||
// pressing Add — otherwise the typed name silently disappears.
|
||||
if (!canSubmit) return;
|
||||
if (authorDraft.trim()) addAuthor();
|
||||
if (altTitleDraft.trim()) addAltTitle();
|
||||
mangaSubmitting = true;
|
||||
submitting = true;
|
||||
mangaError = null;
|
||||
mangaFieldErrors = {};
|
||||
mangaSuccess = null;
|
||||
success = null;
|
||||
let manga;
|
||||
try {
|
||||
const manga = await createManga(
|
||||
manga = await createManga(
|
||||
{
|
||||
title: mangaTitle.trim(),
|
||||
status: mangaStatus,
|
||||
@@ -100,141 +142,45 @@
|
||||
},
|
||||
coverFile ?? undefined
|
||||
);
|
||||
mangaSuccess = `Created "${manga.title}".`;
|
||||
mangaTitle = '';
|
||||
mangaStatus = 'ongoing';
|
||||
mangaAuthors = [];
|
||||
mangaAltTitles = [];
|
||||
mangaGenreIds = [];
|
||||
mangaDescription = '';
|
||||
coverFile = null;
|
||||
} 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) {
|
||||
goto('/login');
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
await goto('/login?next=/upload');
|
||||
return;
|
||||
}
|
||||
mangaError = e instanceof Error ? e.message : String(e);
|
||||
submitting = false;
|
||||
return;
|
||||
}
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setMessage(message);
|
||||
setFields({});
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -242,18 +188,18 @@
|
||||
<title>Upload — Mangalord</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Upload</h1>
|
||||
<h1>Create manga</h1>
|
||||
|
||||
{#if !session.loaded}
|
||||
<p class="status" data-testid="upload-loading">Loading…</p>
|
||||
{:else if !session.user}
|
||||
<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>
|
||||
{:else}
|
||||
<section class="card">
|
||||
<h2>Create manga</h2>
|
||||
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
|
||||
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
|
||||
<section class="card">
|
||||
<h2>Manga details</h2>
|
||||
<label class="form-field">
|
||||
<span>Title <span aria-hidden="true">*</span></span>
|
||||
<input
|
||||
@@ -263,9 +209,6 @@
|
||||
maxlength="200"
|
||||
data-testid="manga-title"
|
||||
/>
|
||||
{#if mangaFieldErrors.title}
|
||||
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
@@ -383,179 +326,112 @@
|
||||
<span class="field-error" role="alert">{coverError}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<button class="primary" type="submit" disabled={!canSubmitManga} data-testid="manga-submit">
|
||||
{mangaSubmitting ? 'Creating…' : 'Create manga'}
|
||||
</button>
|
||||
{#if mangaSuccess}
|
||||
<p class="success" data-testid="manga-success">{mangaSuccess}</p>
|
||||
{/if}
|
||||
{#if mangaError}
|
||||
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
</section>
|
||||
</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"
|
||||
<section class="card">
|
||||
<div class="chapters-header">
|
||||
<h2>Initial chapters (optional)</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="add-chapter"
|
||||
onclick={addChapter}
|
||||
data-testid="add-chapter"
|
||||
>
|
||||
<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>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
<span>Add chapter</span>
|
||||
</button>
|
||||
</div>
|
||||
{#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
|
||||
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"
|
||||
onclick={() => removeChapter(c.id)}
|
||||
aria-label="Remove chapter"
|
||||
title="Remove chapter"
|
||||
data-testid="staged-chapter-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}
|
||||
</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}
|
||||
</form>
|
||||
{#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}
|
||||
</section>
|
||||
{#if mangaError}
|
||||
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -563,15 +439,17 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
@@ -581,7 +459,6 @@
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
margin-top: var(--space-1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -640,114 +517,6 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -780,4 +549,97 @@
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
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>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { listMangas, type MangaCard } from '$lib/api/mangas';
|
||||
import { listGenres, type Genre } from '$lib/api/genres';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
// The chapter form needs a list of mangas to attach the new chapter
|
||||
// to. There's no ownership concept yet, so any authenticated user can
|
||||
// see and add to any manga. Genres are needed for the create-manga
|
||||
// form's picker.
|
||||
const [{ items }, genres] = await Promise.all([
|
||||
listMangas({ limit: 200, sort: 'title' }),
|
||||
listGenres()
|
||||
]);
|
||||
return { mangas: items as MangaCard[], genres: genres as Genre[] };
|
||||
// /upload is now for new-manga creation only — additional
|
||||
// chapters land on /manga/[id]/upload-chapter via a button on the
|
||||
// manga page. The only async dep here is the curated genre list
|
||||
// for the picker.
|
||||
const genres = await listGenres();
|
||||
return { genres: genres as Genre[] };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user