- `/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>
646 lines
21 KiB
Svelte
646 lines
21 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { ApiError } from '$lib/api/client';
|
|
import { createManga, type MangaStatus } from '$lib/api/mangas';
|
|
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 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 genres = $derived(data.genres);
|
|
|
|
// ---- Manga form state ----
|
|
let mangaTitle = $state('');
|
|
let mangaStatus = $state<MangaStatus>('ongoing');
|
|
let mangaDescription = $state('');
|
|
let mangaAuthors = $state<string[]>([]);
|
|
let authorDraft = $state('');
|
|
let mangaAltTitles = $state<string[]>([]);
|
|
let altTitleDraft = $state('');
|
|
let mangaGenreIds = $state<string[]>([]);
|
|
let coverFile = $state<File | null>(null);
|
|
let coverError = $state<string | null>(null);
|
|
|
|
// ---- 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() {
|
|
const name = authorDraft.trim();
|
|
if (!name) return;
|
|
if (!mangaAuthors.some((a) => a.toLowerCase() === name.toLowerCase())) {
|
|
mangaAuthors = [...mangaAuthors, name];
|
|
}
|
|
authorDraft = '';
|
|
}
|
|
function removeAuthor(name: string) {
|
|
mangaAuthors = mangaAuthors.filter((a) => a !== name);
|
|
}
|
|
function addAltTitle() {
|
|
const t = altTitleDraft.trim();
|
|
if (!t) return;
|
|
if (!mangaAltTitles.includes(t)) {
|
|
mangaAltTitles = [...mangaAltTitles, t];
|
|
}
|
|
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;
|
|
coverFile = file;
|
|
coverError = file ? validateImageFile(file) : null;
|
|
}
|
|
|
|
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 (!canSubmit) return;
|
|
if (authorDraft.trim()) addAuthor();
|
|
if (altTitleDraft.trim()) addAltTitle();
|
|
submitting = true;
|
|
mangaError = null;
|
|
success = null;
|
|
let manga;
|
|
try {
|
|
manga = await createManga(
|
|
{
|
|
title: mangaTitle.trim(),
|
|
status: mangaStatus,
|
|
authors: mangaAuthors,
|
|
alt_titles: mangaAltTitles,
|
|
genre_ids: mangaGenreIds,
|
|
description: mangaDescription.trim() || null
|
|
},
|
|
coverFile ?? undefined
|
|
);
|
|
} catch (e) {
|
|
if (e instanceof ApiError && e.status === 401) {
|
|
await goto('/login?next=/upload');
|
|
return;
|
|
}
|
|
mangaError = e instanceof Error ? e.message : String(e);
|
|
submitting = false;
|
|
return;
|
|
}
|
|
|
|
// 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>
|
|
|
|
<svelte:head>
|
|
<title>Upload — Mangalord</title>
|
|
</svelte:head>
|
|
|
|
<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?next=/upload">Sign in</a> to upload a manga.
|
|
</p>
|
|
{:else}
|
|
<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
|
|
type="text"
|
|
bind:value={mangaTitle}
|
|
required
|
|
maxlength="200"
|
|
data-testid="manga-title"
|
|
/>
|
|
</label>
|
|
|
|
<label class="form-field">
|
|
<span>Status</span>
|
|
<select bind:value={mangaStatus} data-testid="manga-status">
|
|
<option value="ongoing">Ongoing</option>
|
|
<option value="completed">Completed</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="form-field">
|
|
<span>Authors</span>
|
|
<div class="token-row">
|
|
{#each mangaAuthors as a (a)}
|
|
<Chip label={a} variant="primary" onRemove={() => removeAuthor(a)} />
|
|
{/each}
|
|
</div>
|
|
<div class="token-input-row">
|
|
<input
|
|
type="text"
|
|
bind:value={authorDraft}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addAuthor();
|
|
}
|
|
}}
|
|
placeholder="Add author"
|
|
maxlength="200"
|
|
data-testid="manga-author-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="icon-btn primary"
|
|
onclick={addAuthor}
|
|
disabled={!authorDraft.trim()}
|
|
aria-label="Add author"
|
|
title="Add author"
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<span>Genres</span>
|
|
<div class="genre-grid" data-testid="manga-genres">
|
|
{#each genres as g (g.id)}
|
|
<label class="genre-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={mangaGenreIds.includes(g.id)}
|
|
onchange={() => toggleGenre(g.id)}
|
|
/>
|
|
<span>{g.name}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<span>Alternative titles</span>
|
|
<div class="token-row">
|
|
{#each mangaAltTitles as t (t)}
|
|
<Chip label={t} onRemove={() => removeAltTitle(t)} />
|
|
{/each}
|
|
</div>
|
|
<div class="token-input-row">
|
|
<input
|
|
type="text"
|
|
bind:value={altTitleDraft}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addAltTitle();
|
|
}
|
|
}}
|
|
placeholder="Add alternative title"
|
|
maxlength="200"
|
|
data-testid="manga-alt-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="icon-btn primary"
|
|
onclick={addAltTitle}
|
|
disabled={!altTitleDraft.trim()}
|
|
aria-label="Add alternative title"
|
|
title="Add alternative title"
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<label class="form-field">
|
|
<span>Description</span>
|
|
<textarea
|
|
bind:value={mangaDescription}
|
|
rows="4"
|
|
data-testid="manga-description"
|
|
></textarea>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>Cover (optional)</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onchange={onCoverChange}
|
|
data-testid="manga-cover"
|
|
/>
|
|
{#if coverFile}
|
|
<span class="hint">{coverFile.name} ({formatBytes(coverFile.size)})</span>
|
|
{/if}
|
|
{#if coverError}
|
|
<span class="field-error" role="alert">{coverError}</span>
|
|
{/if}
|
|
</label>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="chapters-header">
|
|
<h2>Initial chapters (optional)</h2>
|
|
<button
|
|
type="button"
|
|
class="add-chapter"
|
|
onclick={addChapter}
|
|
data-testid="add-chapter"
|
|
>
|
|
<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
|
|
type="button"
|
|
class="icon-btn danger"
|
|
onclick={() => removeChapter(c.id)}
|
|
aria-label="Remove chapter"
|
|
title="Remove chapter"
|
|
data-testid="staged-chapter-remove"
|
|
>
|
|
<Trash2 size={16} aria-hidden="true" />
|
|
</button>
|
|
</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}
|
|
{#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}
|
|
{#if mangaError}
|
|
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
|
|
{/if}
|
|
</form>
|
|
{/if}
|
|
|
|
<style>
|
|
.status {
|
|
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);
|
|
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);
|
|
}
|
|
|
|
.hint {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.field-error {
|
|
color: var(--danger);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.form-error {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.success {
|
|
color: var(--success);
|
|
}
|
|
|
|
.token-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-2);
|
|
margin-bottom: var(--space-1);
|
|
}
|
|
|
|
.token-input-row {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.token-input-row input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.genre-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.genre-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
color: var(--text);
|
|
font-size: var(--font-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.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.primary {
|
|
background: var(--primary);
|
|
color: var(--primary-contrast);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.icon-btn.primary:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
}
|
|
|
|
.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>
|