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

@@ -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">

View 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>

View 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;
}
};