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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.19.2"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.19.2"
|
||||
version = "0.20.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -8,14 +8,18 @@ const userFixture = {
|
||||
const mangaFixture = {
|
||||
id: 'm1',
|
||||
title: 'Berserk',
|
||||
author: 'Kentaro Miura',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
authors: [{ id: 'a1', name: 'Kentaro Miura' }],
|
||||
genres: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
async function mockBaseUploadApis(page: Page) {
|
||||
async function stubAuthenticatedAndGenres(page: Page) {
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
|
||||
body: JSON.stringify({ user: userFixture })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas?*', (route) =>
|
||||
await page.route('**/api/v1/genres', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [mangaFixture],
|
||||
page: { limit: 200, offset: 0, total: 1 }
|
||||
})
|
||||
body: JSON.stringify([
|
||||
{ id: 'g-action', name: 'Action' },
|
||||
{ id: 'g-fantasy', name: 'Fantasy' }
|
||||
])
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -45,61 +49,20 @@ test('anonymous user sees sign-in prompt on /upload', async ({ page }) => {
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas?*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
|
||||
})
|
||||
await page.route('**/api/v1/genres', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||
);
|
||||
|
||||
await page.goto('/upload');
|
||||
await expect(page.getByTestId('upload-signin')).toBeVisible();
|
||||
});
|
||||
|
||||
test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => {
|
||||
await mockBaseUploadApis(page);
|
||||
|
||||
// Backend rejects with 415 unsupported_media_type — we want to see
|
||||
// the human message rendered as the chapter error.
|
||||
await page.route('**/api/v1/mangas/m1/chapters', (route) =>
|
||||
route.fulfill({
|
||||
status: 415,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: 'unsupported_media_type',
|
||||
message: 'page[0]: unsupported image type application/pdf'
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/upload');
|
||||
await page.getByTestId('chapter-manga').selectOption('m1');
|
||||
await page.getByTestId('chapter-number').fill('1');
|
||||
|
||||
// Client validator allows image/png; we lie about the file type so
|
||||
// the request actually reaches the (mocked) backend, exercising the
|
||||
// 415 envelope path.
|
||||
await page.getByTestId('chapter-pages-input').setInputFiles({
|
||||
name: 'fake.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('%PDF-1.4', 'utf-8')
|
||||
});
|
||||
|
||||
await page.getByTestId('chapter-submit').click();
|
||||
await expect(page.getByTestId('chapter-error')).toContainText(
|
||||
'unsupported image type'
|
||||
);
|
||||
});
|
||||
|
||||
test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
||||
await mockBaseUploadApis(page);
|
||||
test('/upload creates a manga with no staged chapters and lands on the manga page', async ({
|
||||
page
|
||||
}) => {
|
||||
await stubAuthenticatedAndGenres(page);
|
||||
|
||||
let createdManga: typeof mangaFixture | null = null;
|
||||
let createdChapter: { id: string; number: number } | null = null;
|
||||
|
||||
await page.route('**/api/v1/mangas', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
|
||||
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
||||
route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
||||
await page.route('**/api/v1/mangas/m2', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...mangaFixture, id: 'm2', title: 'Naruto' })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m2/chapters*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/read-progress/m2', (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'not_found', message: 'no progress' }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/upload');
|
||||
await page.getByTestId('manga-title').fill('Naruto');
|
||||
await page.getByTestId('manga-submit').click();
|
||||
// After create, success → navigate to /manga/{id}.
|
||||
await expect(page).toHaveURL(/\/manga\/m2$/);
|
||||
expect(createdManga).not.toBeNull();
|
||||
});
|
||||
|
||||
test('/upload stages a chapter with renamed page files (page-NNN.<ext>)', async ({
|
||||
page
|
||||
}) => {
|
||||
await stubAuthenticatedAndGenres(page);
|
||||
|
||||
let createdManga: typeof mangaFixture | null = null;
|
||||
let submittedPageNames: string[] = [];
|
||||
|
||||
await page.route('**/api/v1/mangas', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
createdChapter = { id: 'c1', number: 1 };
|
||||
createdManga = { ...mangaFixture, id: 'm3', title: 'Vinland Saga' };
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdManga)
|
||||
});
|
||||
} else {
|
||||
route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/mangas/m3/chapters', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
|
||||
// Pull every Content-Disposition filename out of the
|
||||
// multipart body — that's what the server (and proxies,
|
||||
// logs) would see. We expect only renamed `page-NNN.*`
|
||||
// entries, never the original filenames.
|
||||
const matches = [
|
||||
...post.matchAll(/filename="([^"]+)"/g)
|
||||
].map((m) => m[1]);
|
||||
submittedPageNames = matches.filter((n) => n.startsWith('page-'));
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'c1',
|
||||
manga_id: 'm1',
|
||||
manga_id: 'm3',
|
||||
number: 1,
|
||||
title: null,
|
||||
page_count: 2,
|
||||
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
||||
route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/mangas/m3', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...mangaFixture, id: 'm3', title: 'Vinland Saga' })
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m3/chapters?*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/read-progress/m3', (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'not_found', message: 'no progress' }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/upload');
|
||||
|
||||
// Create manga.
|
||||
await page.getByTestId('manga-title').fill('Naruto');
|
||||
await page.getByTestId('manga-submit').click();
|
||||
await expect(page.getByTestId('manga-success')).toContainText('Created');
|
||||
expect(createdManga).not.toBeNull();
|
||||
|
||||
// Upload chapter with two pages.
|
||||
await page.getByTestId('chapter-manga').selectOption('m1');
|
||||
await page.getByTestId('chapter-number').fill('1');
|
||||
await page.getByTestId('chapter-pages-input').setInputFiles([
|
||||
{
|
||||
name: '1.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
},
|
||||
{
|
||||
name: '2.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
}
|
||||
await page.getByTestId('manga-title').fill('Vinland Saga');
|
||||
await page.getByTestId('add-chapter').click();
|
||||
const pngBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
]);
|
||||
await expect(page.getByTestId('chapter-pages-list')).toContainText('1.png');
|
||||
await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png');
|
||||
await page
|
||||
.getByTestId('staged-chapter-pages-input')
|
||||
.setInputFiles([
|
||||
{ name: 'IMG_2837.png', mimeType: 'image/png', buffer: pngBytes },
|
||||
{ name: 'random_file.png', mimeType: 'image/png', buffer: pngBytes }
|
||||
]);
|
||||
// The list renders "Page 001" / "Page 002" not the original filenames.
|
||||
const list = page.getByTestId('staged-chapter-pages-list');
|
||||
await expect(list).toContainText('Page 001');
|
||||
await expect(list).toContainText('Page 002');
|
||||
// Original filenames are visible as a dimmed caption (uploader-
|
||||
// reference; dropped after the row).
|
||||
await expect(list).toContainText('IMG_2837.png');
|
||||
|
||||
await page.getByTestId('chapter-submit').click();
|
||||
await expect(page.getByTestId('chapter-success')).toContainText(
|
||||
'2 pages'
|
||||
);
|
||||
expect(createdChapter).not.toBeNull();
|
||||
await page.getByTestId('manga-submit').click();
|
||||
await expect(page).toHaveURL(/\/manga\/m3$/);
|
||||
expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
|
||||
});
|
||||
|
||||
test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
|
||||
await mockBaseUploadApis(page);
|
||||
test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({
|
||||
page
|
||||
}) => {
|
||||
await stubAuthenticatedAndGenres(page);
|
||||
|
||||
await page.route('**/api/v1/mangas/m1', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaFixture)
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [{ id: 'c0', manga_id: 'm1', number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }],
|
||||
page: { limit: 200, offset: 0, total: 1 }
|
||||
})
|
||||
})
|
||||
);
|
||||
let submitted: string[] = [];
|
||||
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const post = route.request().postDataBuffer()?.toString('binary') ?? '';
|
||||
submitted = [...post.matchAll(/filename="([^"]+)"/g)].map((m) => m[1]);
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'c-new',
|
||||
manga_id: 'm1',
|
||||
number: 2,
|
||||
title: null,
|
||||
page_count: 1,
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
})
|
||||
});
|
||||
} else {
|
||||
route.fallback();
|
||||
}
|
||||
});
|
||||
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/me/read-progress/m1', (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'not_found', message: 'no progress' }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/manga/m1/upload-chapter');
|
||||
// Default chapter number is the next free one (existing max 1 → 2).
|
||||
await expect(page.getByTestId('chapter-number')).toHaveValue('2');
|
||||
|
||||
const pngBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
]);
|
||||
await page.getByTestId('pages-input').setInputFiles({
|
||||
name: 'whatever.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: pngBytes
|
||||
});
|
||||
await expect(page.getByTestId('pages-list')).toContainText('Page 001');
|
||||
|
||||
await page.getByTestId('chapter-submit').click();
|
||||
await expect(page).toHaveURL(/\/manga\/m1$/);
|
||||
expect(submitted.filter((n) => n.startsWith('page-'))).toEqual([
|
||||
'page-001.png'
|
||||
]);
|
||||
});
|
||||
|
||||
test('chapter upload client preflight blocks oversized files', async ({
|
||||
page
|
||||
}) => {
|
||||
await stubAuthenticatedAndGenres(page);
|
||||
await page.route('**/api/v1/mangas/m1', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mangaFixture)
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/mangas/m1/chapters?*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [],
|
||||
page: { limit: 200, offset: 0, total: 0 }
|
||||
})
|
||||
})
|
||||
);
|
||||
let chapterPostCalls = 0;
|
||||
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
||||
if (route.request().method() === 'POST') chapterPostCalls += 1;
|
||||
route.fallback();
|
||||
});
|
||||
|
||||
await page.goto('/upload');
|
||||
await page.getByTestId('chapter-manga').selectOption('m1');
|
||||
await page.getByTestId('chapter-number').fill('1');
|
||||
|
||||
// A ~21 MiB buffer — exceeds the 20 MiB client cap.
|
||||
await page.goto('/manga/m1/upload-chapter');
|
||||
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
|
||||
await page.getByTestId('chapter-pages-input').setInputFiles({
|
||||
await page.getByTestId('pages-input').setInputFiles({
|
||||
name: 'huge.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: big
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('chapter-pages-list')).toContainText('too large');
|
||||
await expect(page.getByTestId('pages-list')).toContainText('too large');
|
||||
await expect(page.getByTestId('chapter-submit')).toBeDisabled();
|
||||
expect(chapterPostCalls).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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