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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user