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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.19.2"
|
version = "0.20.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.19.2"
|
version = "0.20.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ const userFixture = {
|
|||||||
const mangaFixture = {
|
const mangaFixture = {
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
title: 'Berserk',
|
title: 'Berserk',
|
||||||
author: 'Kentaro Miura',
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
description: null,
|
description: null,
|
||||||
cover_image_path: null,
|
cover_image_path: null,
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
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) =>
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -23,14 +27,14 @@ async function mockBaseUploadApis(page: Page) {
|
|||||||
body: JSON.stringify({ user: userFixture })
|
body: JSON.stringify({ user: userFixture })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/mangas?*', (route) =>
|
await page.route('**/api/v1/genres', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify([
|
||||||
items: [mangaFixture],
|
{ id: 'g-action', name: 'Action' },
|
||||||
page: { limit: 200, offset: 0, total: 1 }
|
{ 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) =>
|
await page.route('**/api/v1/genres', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } })
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto('/upload');
|
await page.goto('/upload');
|
||||||
await expect(page.getByTestId('upload-signin')).toBeVisible();
|
await expect(page.getByTestId('upload-signin')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uploading a non-image page surfaces the backend 415 message', async ({ page }) => {
|
test('/upload creates a manga with no staged chapters and lands on the manga page', async ({
|
||||||
await mockBaseUploadApis(page);
|
page
|
||||||
|
}) => {
|
||||||
// Backend rejects with 415 unsupported_media_type — we want to see
|
await stubAuthenticatedAndGenres(page);
|
||||||
// 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);
|
|
||||||
|
|
||||||
let createdManga: typeof mangaFixture | null = null;
|
let createdManga: typeof mangaFixture | null = null;
|
||||||
let createdChapter: { id: string; number: number } | null = null;
|
|
||||||
|
|
||||||
await page.route('**/api/v1/mangas', (route) => {
|
await page.route('**/api/v1/mangas', (route) => {
|
||||||
if (route.request().method() === 'POST') {
|
if (route.request().method() === 'POST') {
|
||||||
createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
|
createdManga = { ...mangaFixture, id: 'm2', title: 'Naruto' };
|
||||||
@@ -112,15 +75,88 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
|||||||
route.fallback();
|
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') {
|
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({
|
route.fulfill({
|
||||||
status: 201,
|
status: 201,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: 'c1',
|
id: 'c1',
|
||||||
manga_id: 'm1',
|
manga_id: 'm3',
|
||||||
number: 1,
|
number: 1,
|
||||||
title: null,
|
title: null,
|
||||||
page_count: 2,
|
page_count: 2,
|
||||||
@@ -131,62 +167,188 @@ test('happy path: create manga + upload chapter (mocked)', async ({ page }) => {
|
|||||||
route.fallback();
|
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');
|
await page.goto('/upload');
|
||||||
|
await page.getByTestId('manga-title').fill('Vinland Saga');
|
||||||
// Create manga.
|
await page.getByTestId('add-chapter').click();
|
||||||
await page.getByTestId('manga-title').fill('Naruto');
|
const pngBytes = Buffer.from([
|
||||||
await page.getByTestId('manga-submit').click();
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||||
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 expect(page.getByTestId('chapter-pages-list')).toContainText('1.png');
|
await page
|
||||||
await expect(page.getByTestId('chapter-pages-list')).toContainText('2.png');
|
.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 page.getByTestId('manga-submit').click();
|
||||||
await expect(page.getByTestId('chapter-success')).toContainText(
|
await expect(page).toHaveURL(/\/manga\/m3$/);
|
||||||
'2 pages'
|
expect(submittedPageNames).toEqual(['page-001.png', 'page-002.png']);
|
||||||
);
|
|
||||||
expect(createdChapter).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('client preflight blocks oversized files without hitting the network', async ({ page }) => {
|
test('/manga/[id]/upload-chapter happy path uploads renamed pages', async ({
|
||||||
await mockBaseUploadApis(page);
|
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;
|
let chapterPostCalls = 0;
|
||||||
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
await page.route('**/api/v1/mangas/m1/chapters', (route) => {
|
||||||
if (route.request().method() === 'POST') chapterPostCalls += 1;
|
if (route.request().method() === 'POST') chapterPostCalls += 1;
|
||||||
route.fallback();
|
route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/upload');
|
await page.goto('/manga/m1/upload-chapter');
|
||||||
await page.getByTestId('chapter-manga').selectOption('m1');
|
|
||||||
await page.getByTestId('chapter-number').fill('1');
|
|
||||||
|
|
||||||
// A ~21 MiB buffer — exceeds the 20 MiB client cap.
|
|
||||||
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
|
const big = Buffer.alloc(21 * 1024 * 1024, 0xff);
|
||||||
await page.getByTestId('chapter-pages-input').setInputFiles({
|
await page.getByTestId('pages-input').setInputFiles({
|
||||||
name: 'huge.png',
|
name: 'huge.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
buffer: big
|
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();
|
await expect(page.getByTestId('chapter-submit')).toBeDisabled();
|
||||||
expect(chapterPostCalls).toBe(0);
|
expect(chapterPostCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.19.2",
|
"version": "0.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
afterEach,
|
afterEach,
|
||||||
type MockInstance
|
type MockInstance
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { listChapters, getChapter, getChapterPages } from './chapters';
|
import {
|
||||||
|
listChapters,
|
||||||
|
getChapter,
|
||||||
|
getChapterPages,
|
||||||
|
createChapter
|
||||||
|
} from './chapters';
|
||||||
|
|
||||||
function ok(body: unknown): Response {
|
function ok(body: unknown): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
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 () => {
|
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(
|
fetchSpy.mockResolvedValueOnce(
|
||||||
ok({
|
ok({
|
||||||
|
|||||||
@@ -55,3 +55,66 @@ export async function getChapterPages(
|
|||||||
);
|
);
|
||||||
return r.pages;
|
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 AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
import FolderPlus from '@lucide/svelte/icons/folder-plus';
|
||||||
|
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const manga = $derived(data.manga);
|
const manga = $derived(data.manga);
|
||||||
@@ -323,6 +324,14 @@
|
|||||||
<FolderPlus size={16} aria-hidden="true" />
|
<FolderPlus size={16} aria-hidden="true" />
|
||||||
<span>Add to collection</span>
|
<span>Add to collection</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="action" href="/login" data-testid="bookmark-signin">
|
<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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
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 { 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 { session } from '$lib/session.svelte';
|
||||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
import ChapterPagesEditor, {
|
||||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
type PendingPage
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
} from '$lib/components/ChapterPagesEditor.svelte';
|
||||||
import UploadCloud from '@lucide/svelte/icons/upload-cloud';
|
|
||||||
import BookImage from '@lucide/svelte/icons/book-image';
|
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const mangas = $derived(data.mangas);
|
|
||||||
const genres = $derived(data.genres);
|
const genres = $derived(data.genres);
|
||||||
|
|
||||||
// -------- Manga form state --------
|
// ---- Manga form state ----
|
||||||
|
|
||||||
let mangaTitle = $state('');
|
let mangaTitle = $state('');
|
||||||
let mangaStatus = $state<MangaStatus>('ongoing');
|
let mangaStatus = $state<MangaStatus>('ongoing');
|
||||||
let mangaDescription = $state('');
|
let mangaDescription = $state('');
|
||||||
@@ -29,13 +26,42 @@
|
|||||||
let mangaGenreIds = $state<string[]>([]);
|
let mangaGenreIds = $state<string[]>([]);
|
||||||
let coverFile = $state<File | null>(null);
|
let coverFile = $state<File | null>(null);
|
||||||
let coverError = $state<string | 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(
|
// ---- Initial-chapter staging ----
|
||||||
mangaTitle.trim().length > 0 && !coverError && !mangaSubmitting
|
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() {
|
function addAuthor() {
|
||||||
@@ -46,11 +72,9 @@
|
|||||||
}
|
}
|
||||||
authorDraft = '';
|
authorDraft = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAuthor(name: string) {
|
function removeAuthor(name: string) {
|
||||||
mangaAuthors = mangaAuthors.filter((a) => a !== name);
|
mangaAuthors = mangaAuthors.filter((a) => a !== name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAltTitle() {
|
function addAltTitle() {
|
||||||
const t = altTitleDraft.trim();
|
const t = altTitleDraft.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -59,17 +83,14 @@
|
|||||||
}
|
}
|
||||||
altTitleDraft = '';
|
altTitleDraft = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAltTitle(t: string) {
|
function removeAltTitle(t: string) {
|
||||||
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
|
mangaAltTitles = mangaAltTitles.filter((x) => x !== t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGenre(id: string) {
|
function toggleGenre(id: string) {
|
||||||
mangaGenreIds = mangaGenreIds.includes(id)
|
mangaGenreIds = mangaGenreIds.includes(id)
|
||||||
? mangaGenreIds.filter((g) => g !== id)
|
? mangaGenreIds.filter((g) => g !== id)
|
||||||
: [...mangaGenreIds, id];
|
: [...mangaGenreIds, id];
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCoverChange(e: Event) {
|
function onCoverChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0] ?? null;
|
const file = input.files?.[0] ?? null;
|
||||||
@@ -77,19 +98,40 @@
|
|||||||
coverError = file ? validateImageFile(file) : null;
|
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();
|
e.preventDefault();
|
||||||
if (!canSubmitManga) return;
|
if (!canSubmit) return;
|
||||||
// Pick up an unsubmitted token if the user hit Submit without
|
|
||||||
// pressing Add — otherwise the typed name silently disappears.
|
|
||||||
if (authorDraft.trim()) addAuthor();
|
if (authorDraft.trim()) addAuthor();
|
||||||
if (altTitleDraft.trim()) addAltTitle();
|
if (altTitleDraft.trim()) addAltTitle();
|
||||||
mangaSubmitting = true;
|
submitting = true;
|
||||||
mangaError = null;
|
mangaError = null;
|
||||||
mangaFieldErrors = {};
|
success = null;
|
||||||
mangaSuccess = null;
|
let manga;
|
||||||
try {
|
try {
|
||||||
const manga = await createManga(
|
manga = await createManga(
|
||||||
{
|
{
|
||||||
title: mangaTitle.trim(),
|
title: mangaTitle.trim(),
|
||||||
status: mangaStatus,
|
status: mangaStatus,
|
||||||
@@ -100,141 +142,45 @@
|
|||||||
},
|
},
|
||||||
coverFile ?? undefined
|
coverFile ?? undefined
|
||||||
);
|
);
|
||||||
mangaSuccess = `Created "${manga.title}".`;
|
|
||||||
mangaTitle = '';
|
|
||||||
mangaStatus = 'ongoing';
|
|
||||||
mangaAuthors = [];
|
|
||||||
mangaAltTitles = [];
|
|
||||||
mangaGenreIds = [];
|
|
||||||
mangaDescription = '';
|
|
||||||
coverFile = null;
|
|
||||||
} catch (e) {
|
} 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) {
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
goto('/login');
|
await goto('/login?next=/upload');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
mangaError = e instanceof Error ? e.message : String(e);
|
||||||
setMessage(message);
|
submitting = false;
|
||||||
setFields({});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manga is created; ship chapters one at a time and surface
|
||||||
|
// per-row status. Failures don't roll back the manga — the
|
||||||
|
// user can retry just the failed chapters from the manga
|
||||||
|
// page's Upload-chapter button.
|
||||||
|
for (const c of stagedChapters) {
|
||||||
|
c.status = 'uploading';
|
||||||
|
c.error = null;
|
||||||
|
try {
|
||||||
|
await createChapter(
|
||||||
|
manga.id,
|
||||||
|
{ number: c.number, title: c.title.trim() || null },
|
||||||
|
c.pages.map((p) => p.file)
|
||||||
|
);
|
||||||
|
c.status = 'done';
|
||||||
|
} catch (e) {
|
||||||
|
c.status = 'failed';
|
||||||
|
c.error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = stagedChapters.filter((c) => c.status === 'failed');
|
||||||
|
if (failed.length === 0) {
|
||||||
|
// All-good — land the user on the manga page where they
|
||||||
|
// can confirm and continue uploading.
|
||||||
|
await goto(`/manga/${manga.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
success = `"${manga.title}" was created, but ${failed.length} of ${stagedChapters.length} chapters failed. Fix them and retry from the manga page.`;
|
||||||
|
submitting = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -242,18 +188,18 @@
|
|||||||
<title>Upload — Mangalord</title>
|
<title>Upload — Mangalord</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Upload</h1>
|
<h1>Create manga</h1>
|
||||||
|
|
||||||
{#if !session.loaded}
|
{#if !session.loaded}
|
||||||
<p class="status" data-testid="upload-loading">Loading…</p>
|
<p class="status" data-testid="upload-loading">Loading…</p>
|
||||||
{:else if !session.user}
|
{:else if !session.user}
|
||||||
<p class="status" data-testid="upload-signin">
|
<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>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
<form onsubmit={submit} action="javascript:void(0)" data-testid="manga-form">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Create manga</h2>
|
<h2>Manga details</h2>
|
||||||
<form onsubmit={submitManga} action="javascript:void(0)" data-testid="manga-form">
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Title <span aria-hidden="true">*</span></span>
|
<span>Title <span aria-hidden="true">*</span></span>
|
||||||
<input
|
<input
|
||||||
@@ -263,9 +209,6 @@
|
|||||||
maxlength="200"
|
maxlength="200"
|
||||||
data-testid="manga-title"
|
data-testid="manga-title"
|
||||||
/>
|
/>
|
||||||
{#if mangaFieldErrors.title}
|
|
||||||
<span class="field-error" role="alert">{mangaFieldErrors.title}</span>
|
|
||||||
{/if}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
@@ -383,179 +326,112 @@
|
|||||||
<span class="field-error" role="alert">{coverError}</span>
|
<span class="field-error" role="alert">{coverError}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<button class="primary" type="submit" disabled={!canSubmitManga} data-testid="manga-submit">
|
</section>
|
||||||
{mangaSubmitting ? 'Creating…' : 'Create manga'}
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="chapters-header">
|
||||||
|
<h2>Initial chapters (optional)</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="add-chapter"
|
||||||
|
onclick={addChapter}
|
||||||
|
data-testid="add-chapter"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
<span>Add chapter</span>
|
||||||
</button>
|
</button>
|
||||||
{#if mangaSuccess}
|
</div>
|
||||||
<p class="success" data-testid="manga-success">{mangaSuccess}</p>
|
{#if stagedChapters.length === 0}
|
||||||
|
<p class="hint" data-testid="no-chapters-hint">
|
||||||
|
You can skip this — chapters can also be added later from the
|
||||||
|
manga's page.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="chapter-list" data-testid="staged-chapter-list">
|
||||||
|
{#each stagedChapters as c, idx (c.id)}
|
||||||
|
<li class="staged-chapter" data-testid="staged-chapter">
|
||||||
|
<div class="staged-header">
|
||||||
|
<span class="staged-index">#{idx + 1}</span>
|
||||||
|
<label class="staged-field number-field">
|
||||||
|
<span class="visually-hidden">Chapter number</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
bind:value={c.number}
|
||||||
|
required
|
||||||
|
data-testid="staged-chapter-number"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="staged-field title-field">
|
||||||
|
<span class="visually-hidden">Chapter title</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chapter title (optional)"
|
||||||
|
bind:value={c.title}
|
||||||
|
maxlength="200"
|
||||||
|
data-testid="staged-chapter-title"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="staged-status status-{c.status}">
|
||||||
|
{#if c.status === 'uploading'}
|
||||||
|
Uploading…
|
||||||
|
{:else if c.status === 'done'}
|
||||||
|
✓ Uploaded
|
||||||
|
{:else if c.status === 'failed'}
|
||||||
|
Failed
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn danger"
|
||||||
|
onclick={() => removeChapter(c.id)}
|
||||||
|
aria-label="Remove chapter"
|
||||||
|
title="Remove chapter"
|
||||||
|
data-testid="staged-chapter-remove"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if c.error}
|
||||||
|
<p class="field-error" role="alert">{c.error}</p>
|
||||||
|
{/if}
|
||||||
|
<ChapterPagesEditor
|
||||||
|
bind:pages={c.pages}
|
||||||
|
testidPrefix="staged-chapter-pages"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if !allChapterNumbersUnique}
|
||||||
|
<p class="field-error" role="alert">
|
||||||
|
Two staged chapters share the same number — each must
|
||||||
|
be unique.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if !allChaptersHavePages && stagedChapters.length > 0}
|
||||||
|
<p class="field-error" role="alert">
|
||||||
|
Each staged chapter needs at least one page.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
data-testid="manga-submit"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting…' : 'Create manga'}
|
||||||
|
</button>
|
||||||
|
{#if success}
|
||||||
|
<p class="success" data-testid="manga-success">{success}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mangaError}
|
{#if mangaError}
|
||||||
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
|
<p role="alert" class="form-error" data-testid="manga-error">{mangaError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -563,15 +439,17 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
@@ -581,7 +459,6 @@
|
|||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: var(--primary-contrast);
|
color: var(--primary-contrast);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
margin-top: var(--space-1);
|
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,114 +517,6 @@
|
|||||||
cursor: pointer;
|
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 {
|
.icon-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -780,4 +549,97 @@
|
|||||||
.icon-btn.danger:hover:not(:disabled) {
|
.icon-btn.danger:hover:not(:disabled) {
|
||||||
color: var(--danger);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { listMangas, type MangaCard } from '$lib/api/mangas';
|
|
||||||
import { listGenres, type Genre } from '$lib/api/genres';
|
import { listGenres, type Genre } from '$lib/api/genres';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
// The chapter form needs a list of mangas to attach the new chapter
|
// /upload is now for new-manga creation only — additional
|
||||||
// to. There's no ownership concept yet, so any authenticated user can
|
// chapters land on /manga/[id]/upload-chapter via a button on the
|
||||||
// see and add to any manga. Genres are needed for the create-manga
|
// manga page. The only async dep here is the curated genre list
|
||||||
// form's picker.
|
// for the picker.
|
||||||
const [{ items }, genres] = await Promise.all([
|
const genres = await listGenres();
|
||||||
listMangas({ limit: 200, sort: 'title' }),
|
return { genres: genres as Genre[] };
|
||||||
listGenres()
|
|
||||||
]);
|
|
||||||
return { mangas: items as MangaCard[], genres: genres as Genre[] };
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user