Compare commits
19 Commits
ef8d226ba6
...
51ea254dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ea254dde | ||
|
|
fee68dd9ac | ||
|
|
3c8e264f48 | ||
|
|
16cdec051a | ||
|
|
5b51bcf056 | ||
|
|
f1e66141f4 | ||
|
|
cee1e73f98 | ||
|
|
ec73c6e001 | ||
|
|
bd6ae86a85 | ||
|
|
3622dcc02f | ||
|
|
9cb2f152d3 | ||
|
|
7c3c9cf699 | ||
|
|
0d9505ce9f | ||
|
|
7bdbe3ce5b | ||
|
|
3364ea52c9 | ||
|
|
def97d3087 | ||
|
|
09f12c8959 | ||
|
|
34c1122e4e | ||
|
|
200ab1c0a0 |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.1"
|
version = "0.122.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.1"
|
version = "0.122.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
66
frontend/e2e/author-page.spec.ts
Normal file
66
frontend/e2e/author-page.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The author page streams its manga grid: the cheap author header renders
|
||||||
|
// immediately while the (potentially large) grid shows a skeleton, then swaps
|
||||||
|
// in without a layout shift.
|
||||||
|
|
||||||
|
const authorId = 'a1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/authors/${authorId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: authorId, name: 'Kentaro Miura', manga_count: 1 })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows a grid skeleton while the author mangas stream, then the grid', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/authors/${authorId}/mangas*`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 50, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/authors/${authorId}`);
|
||||||
|
// Header renders immediately (not blocked on the grid).
|
||||||
|
await expect(page.getByTestId('author-name')).toHaveText('Kentaro Miura');
|
||||||
|
// Grid skeleton stands in while the stream is pending.
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('author-manga-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await expect(page.getByTestId('author-manga-list')).toContainText('Berserk');
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
57
frontend/e2e/bookmarks-skeleton.spec.ts
Normal file
57
frontend/e2e/bookmarks-skeleton.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The bookmarks page streams its list (the single fetch is also the auth
|
||||||
|
// gate), so a row skeleton shows while it loads, then resolves to the list.
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows a row skeleton while bookmarks stream, then the list', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'bk1', user_id: 'u1', manga_id: 'm1', chapter_id: null, page: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z', manga_title: 'Berserk', manga_cover_image_path: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 50, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/bookmarks');
|
||||||
|
await expect(page.getByTestId('list-row-skeleton')).toBeVisible();
|
||||||
|
|
||||||
|
release();
|
||||||
|
await expect(page.getByText('Berserk')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('list-row-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
67
frontend/e2e/collection-detail-skeleton.spec.ts
Normal file
67
frontend/e2e/collection-detail-skeleton.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The collection detail page streams its manga/page grids: the header renders
|
||||||
|
// immediately while a grid skeleton stands in, then the real grid swaps in.
|
||||||
|
|
||||||
|
const collectionId = 'd1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('streams a grid skeleton on the collection detail then the manga grid', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}/mangas*`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{ id: 'm1', title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }
|
||||||
|
],
|
||||||
|
page: { limit: 200, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/collections/${collectionId}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('collection-manga-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await expect(page.getByTestId('collection-manga-list')).toContainText('Berserk');
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
115
frontend/e2e/detail-bookmark.spec.ts
Normal file
115
frontend/e2e/detail-bookmark.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The detail-page bookmark toggle should feel instant (optimistic) and never
|
||||||
|
// fail silently: a rejected create/delete must roll the button back and
|
||||||
|
// surface a toast.
|
||||||
|
|
||||||
|
const mangaId = 'b1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockDetail(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bookmark toggles optimistically before the request resolves', async ({ page }) => {
|
||||||
|
await mockDetail(page);
|
||||||
|
|
||||||
|
// Hold the create request open so we can observe the pre-response state.
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/bookmarks', async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'bk1', user_id: 'u1', manga_id: mangaId, chapter_id: null, page: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
const btn = page.getByTestId('bookmark-toggle');
|
||||||
|
await expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
await btn.click();
|
||||||
|
// Optimistic: reflects bookmarked state while the POST is still pending.
|
||||||
|
await expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
release();
|
||||||
|
// Stays bookmarked once the server confirms.
|
||||||
|
await expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed bookmark rolls back and shows an error toast', async ({ page }) => {
|
||||||
|
await mockDetail(page);
|
||||||
|
await page.route('**/api/v1/bookmarks', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'internal', message: 'boom' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
const btn = page.getByTestId('bookmark-toggle');
|
||||||
|
await btn.click();
|
||||||
|
|
||||||
|
// Rolls back to un-bookmarked, and the failure is surfaced (not silent).
|
||||||
|
await expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect(page.getByTestId('toast')).toBeVisible();
|
||||||
|
});
|
||||||
85
frontend/e2e/detail-cta-preload.spec.ts
Normal file
85
frontend/e2e/detail-cta-preload.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The detail page's Continue/Read CTA is the highest-intent link to the
|
||||||
|
// heaviest route in the app. It should be programmatically preloaded so the
|
||||||
|
// reader's data (and first page images) are warm before the tap — proven here
|
||||||
|
// by the reader-only chapter-pages endpoint being requested on the detail page
|
||||||
|
// without any click.
|
||||||
|
|
||||||
|
const mangaId = 'c1111111-1111-1111-1111-111111111111';
|
||||||
|
const chId = 'cc111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockDetailAndReader(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.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/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
// Chapter list (detail + reader both use this).
|
||||||
|
const chapters = [
|
||||||
|
{ id: chId, manga_id: mangaId, number: 1, title: null, page_count: 3, created_at: '2026-01-01T00:00:00Z' }
|
||||||
|
];
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: chapters, page: { limit: 200, offset: 0, total: 1 } }) })
|
||||||
|
);
|
||||||
|
// Single-chapter fetch (reader only).
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(chapters[0]) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('programmatically preloads the reader from the detail CTA', async ({ page }) => {
|
||||||
|
await mockDetailAndReader(page);
|
||||||
|
|
||||||
|
// The chapter-pages endpoint is fetched only by the reader's load.
|
||||||
|
let pagesRequested: () => void = () => {};
|
||||||
|
const pagesHit = new Promise<void>((resolve) => {
|
||||||
|
pagesRequested = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chId}/pages`, (route) => {
|
||||||
|
pagesRequested();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ pages: [{ id: 'p1', number: 1, image_path: 'x', width: null, height: null }] })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
// Detail rendered (so ctaTarget is resolved to the first chapter).
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
|
|
||||||
|
// Preload fires the reader load (hence the pages endpoint) without a click.
|
||||||
|
await expect(pagesHit).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
77
frontend/e2e/detail-skeleton.spec.ts
Normal file
77
frontend/e2e/detail-skeleton.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The manga detail page streams its data bundle: a MangaDetailSkeleton shows
|
||||||
|
// while the six calls load, then DetailView swaps in. A missing manga (404)
|
||||||
|
// resolves to an inline not-found instead of the happy-path markup.
|
||||||
|
|
||||||
|
const mangaId = 'f1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows a detail skeleton while the bundle streams, then the page', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
await expect(page.getByTestId('manga-detail-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveCount(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
|
await expect(page.getByTestId('manga-detail-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders an inline not-found for a missing manga (404)', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||||
|
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { code: 'not_found', message: 'no' } }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
await expect(page.getByTestId('detail-error')).toContainText('Manga not found');
|
||||||
|
});
|
||||||
88
frontend/e2e/detail-tag-add.spec.ts
Normal file
88
frontend/e2e/detail-tag-add.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Adding a tag on the detail page should feel instant: the chip appears
|
||||||
|
// optimistically while the request is in flight, and is reconciled with the
|
||||||
|
// server's (normalized) tag on success.
|
||||||
|
|
||||||
|
const mangaId = 'e1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockDetail(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
|
||||||
|
);
|
||||||
|
// Autocomplete suggestions endpoint — keep it empty/fast.
|
||||||
|
await page.route('**/api/v1/tags*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
|
||||||
|
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('adds a tag optimistically before the request resolves', async ({ page }) => {
|
||||||
|
await mockDetail(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/tags`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ id: 'tag1', name: 'action', added_by: 'u1' })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/manga/${mangaId}`);
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
|
|
||||||
|
await page.getByTestId('tag-input').fill('action');
|
||||||
|
await page.getByTestId('tag-input').press('Enter');
|
||||||
|
|
||||||
|
// Optimistic: the chip shows while the POST is still gated.
|
||||||
|
await expect(page.getByTestId('manga-tags')).toContainText('action');
|
||||||
|
|
||||||
|
release();
|
||||||
|
// Still present after the server confirms (reconciled to the real tag).
|
||||||
|
await expect(page.getByTestId('manga-tags')).toContainText('action');
|
||||||
|
await expect(page.getByTestId('tag-chip-tag1')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -63,6 +63,51 @@ test('home page renders the Mangalord heading and search input', async ({ page }
|
|||||||
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
|
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows a skeleton grid while the catalog is loading, then the real grid', async ({ page }) => {
|
||||||
|
await mockAnonymous(page);
|
||||||
|
|
||||||
|
// Hold the catalog fetch open so the loading state is observable.
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/mangas*', async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 50, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Skeleton stands in for the grid while the fetch is pending.
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('manga-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Once data lands, the real grid replaces the skeleton.
|
||||||
|
release();
|
||||||
|
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
|
||||||
|
await expect(page.getByTestId('manga-grid-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
// Anti-drift guard: the desktop sort <select> and the mobile sort sheet must
|
// Anti-drift guard: the desktop sort <select> and the mobile sort sheet must
|
||||||
// both enumerate exactly SORT_FIELD_LABELS, in the same order. The desktop
|
// both enumerate exactly SORT_FIELD_LABELS, in the same order. The desktop
|
||||||
// select used to hard-code its <option>s, so a label rename in mangaSort.ts
|
// select used to hard-code its <option>s, so a label rename in mangaSort.ts
|
||||||
|
|||||||
148
frontend/e2e/nav-progress.spec.ts
Normal file
148
frontend/e2e/nav-progress.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The global nav progress bar (rendered once in the root layout) is the only
|
||||||
|
// feedback most data routes have while a client-side navigation is pending:
|
||||||
|
// they run `load` with `ssr = false`, so SvelteKit holds the previous page on
|
||||||
|
// screen with no indication anything is happening. This drives a real
|
||||||
|
// home -> detail navigation, holds the detail's `getManga` open, and asserts
|
||||||
|
// the bar activates during the pending load and clears once it settles.
|
||||||
|
|
||||||
|
const mangaId = 'm1111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
const listItem = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const mangaDetail = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Berserk',
|
||||||
|
status: 'ongoing',
|
||||||
|
alt_titles: [],
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
content_warnings: [],
|
||||||
|
chapter_storage_bytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/genres*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||||
|
);
|
||||||
|
// Catalog list (Pattern B). Registered before the detail routes so the
|
||||||
|
// more-specific handlers below win for the /mangas/:id URLs.
|
||||||
|
await page.route('**/api/v1/mangas*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [listItem], page: { limit: 50, offset: 0, total: 1 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Detail load's non-manga calls.
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows the nav progress bar during a pending navigation and clears it after', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
// Hold the detail's getManga open so the navigation stays pending long
|
||||||
|
// enough to observe the bar. Registered last => wins for the exact
|
||||||
|
// /mangas/:id URL over the catalog glob.
|
||||||
|
let releaseDetail: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
releaseDetail = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mangaDetail)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('manga-list')).toBeVisible();
|
||||||
|
|
||||||
|
const bar = page.getByTestId('nav-progress');
|
||||||
|
// Idle on a settled page.
|
||||||
|
await expect(bar).toHaveAttribute('data-phase', 'idle');
|
||||||
|
|
||||||
|
// Client-side navigate into the (gated) detail page.
|
||||||
|
await page.locator(`a[href="/manga/${mangaId}"]`).first().click();
|
||||||
|
|
||||||
|
// Bar enters the loading phase while the detail load is in flight.
|
||||||
|
await expect(bar).toHaveAttribute('data-phase', 'loading');
|
||||||
|
|
||||||
|
// Let the load resolve; the detail renders and the bar settles back to
|
||||||
|
// idle (via a brief done phase).
|
||||||
|
releaseDetail();
|
||||||
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
|
await expect(bar).toHaveAttribute('data-phase', 'idle');
|
||||||
|
});
|
||||||
74
frontend/e2e/search-skeleton.spec.ts
Normal file
74
frontend/e2e/search-skeleton.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The search page streams its results: the chip cloud / controls stay put
|
||||||
|
// while a re-query shows a results skeleton, which then swaps for the list.
|
||||||
|
|
||||||
|
const mangaId = 'a9999999-9999-9999-9999-999999999999';
|
||||||
|
const chapterId = 'c9999999-9999-9999-9999-999999999999';
|
||||||
|
const pageId = 'p11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
async function mockCommon(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: { id: 'u1', username: 'tester', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [{ tag: 'funny', count: 38 }] })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows a results skeleton while the tagged pages stream, then the list', async ({ page }) => {
|
||||||
|
await mockCommon(page);
|
||||||
|
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/me/page-tags?**', async (route) => {
|
||||||
|
await gate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
tag: 'funny', page_id: pageId, chapter_id: chapterId, manga_id: mangaId,
|
||||||
|
page_number: 5, chapter_number: 1, chapter_title: null, manga_title: 'Berserk',
|
||||||
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0005.png`,
|
||||||
|
tagged_at: '2026-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: { limit: 100, offset: 0, total: 1 }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/search?tag=funny');
|
||||||
|
// The chip cloud / active tag renders immediately (distinct resolved).
|
||||||
|
await expect(page.getByTestId('search-active-tag')).toBeVisible();
|
||||||
|
// Results skeleton while the tagged-pages query is pending.
|
||||||
|
await expect(page.getByTestId('search-results-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('search-pages-list')).toHaveCount(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await expect(page.getByTestId('search-pages-list')).toContainText('Berserk');
|
||||||
|
await expect(page.getByTestId('search-results-skeleton')).toHaveCount(0);
|
||||||
|
});
|
||||||
96
frontend/e2e/streamed-load-errors.spec.ts
Normal file
96
frontend/e2e/streamed-load-errors.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Streamed loaders (bookmarks / collections / library / search) capture a
|
||||||
|
// failed fetch into an in-band `error` field so the page renders an inline
|
||||||
|
// message instead of either the framework error page (network failure —
|
||||||
|
// a non-ApiError) or a misleading empty state (an HTTP error). These specs
|
||||||
|
// drive each distinct loader shape into that error path.
|
||||||
|
|
||||||
|
const collectionId = 'd2222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
async function authed(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/files/**', (r) => r.fulfill({ status: 200, body: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bookmarks: a network failure renders inline, not the framework error page', async ({ page }) => {
|
||||||
|
await authed(page);
|
||||||
|
// A raw connection failure surfaces as a non-ApiError (TypeError) from
|
||||||
|
// fetch — the case that previously escaped to the framework boundary.
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
|
||||||
|
|
||||||
|
await page.goto('/bookmarks');
|
||||||
|
await expect(page.getByTestId('bookmarks-error')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library: a network failure on one of the aggregated fetches renders inline', async ({ page }) => {
|
||||||
|
await authed(page);
|
||||||
|
await page.route('**/api/v1/me/collections*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/read-progress*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 100, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/page-tags?**', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 100, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
|
||||||
|
|
||||||
|
await page.goto('/library');
|
||||||
|
await expect(page.getByTestId('library-error')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search: a failed results query shows an error, not a false "no matches"', async ({ page }) => {
|
||||||
|
await authed(page);
|
||||||
|
await page.route('**/api/v1/me/page-tags/distinct*', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ tag: 'funny', count: 3 }] }) })
|
||||||
|
);
|
||||||
|
// The streamed tagged-pages query fails with a server error.
|
||||||
|
await page.route('**/api/v1/me/page-tags?**', (r) =>
|
||||||
|
r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/search?tag=funny');
|
||||||
|
// The chip cloud / active tag renders (distinct resolved) ...
|
||||||
|
await expect(page.getByTestId('search-active-tag')).toBeVisible();
|
||||||
|
// ... and the failed results render as an error, not "No pages tagged".
|
||||||
|
await expect(page.getByTestId('search-results-error')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('search-pages-empty')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collection detail: a failed content load shows an inline error, not an empty collection', async ({ page }) => {
|
||||||
|
await authed(page);
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: collectionId, name: 'Favourites', description: null, manga_count: 1 }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}/pages*`, (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 200, offset: 0, total: 0 } }) })
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/collections/${collectionId}/mangas*`, (r) =>
|
||||||
|
r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { code: 'internal_error', message: 'boom' } }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`/collections/${collectionId}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Favourites' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('collection-content-error')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('collection-empty')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.109.1",
|
"version": "0.122.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="cover"
|
class="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cover cover-placeholder">
|
<div class="cover cover-placeholder">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="collage-cover"
|
class="collage-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="cover"
|
class="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="cover cover-placeholder">
|
<span class="cover cover-placeholder">
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="cover"
|
class="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cover cover-placeholder">
|
<div class="cover cover-placeholder">
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ describe('HistoryList', () => {
|
|||||||
expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
|
expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders covers with lazy loading and async decode', () => {
|
||||||
|
render(HistoryList, {
|
||||||
|
props: { entries: [entry({ manga_cover_image_path: 'mangas/m1/cover.jpg' })] }
|
||||||
|
});
|
||||||
|
const img = document.querySelector('img.cover') as HTMLImageElement;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img.getAttribute('loading')).toBe('lazy');
|
||||||
|
expect(img.getAttribute('decoding')).toBe('async');
|
||||||
|
});
|
||||||
|
|
||||||
it('appends the page number to the continue line only past page 1', () => {
|
it('appends the page number to the continue line only past page 1', () => {
|
||||||
// The page suffix is a separate text node from "Continue Chapter N",
|
// The page suffix is a separate text node from "Continue Chapter N",
|
||||||
// so assert against the link's normalized text rather than getByText.
|
// so assert against the link's normalized text rather than getByText.
|
||||||
|
|||||||
49
frontend/src/lib/components/ListRowSkeleton.svelte
Normal file
49
frontend/src/lib/components/ListRowSkeleton.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for a cover + two-line row list (bookmarks, history, tagged
|
||||||
|
* pages). Reserves the list height so rows swap in without a shift.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
count = 6,
|
||||||
|
coverWidth = '64px',
|
||||||
|
testid = 'list-row-skeleton'
|
||||||
|
}: { count?: number; coverWidth?: string; testid?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="list" data-testid={testid} aria-hidden="true">
|
||||||
|
{#each Array(count) as _}
|
||||||
|
<li class="row" style="grid-template-columns: {coverWidth} 1fr;">
|
||||||
|
<Skeleton width={coverWidth} aspectRatio="2 / 3" radius="sm" />
|
||||||
|
<div class="meta">
|
||||||
|
<Skeleton variant="text" width="55%" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="35%" radius="sm" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
frontend/src/lib/components/ListRowSkeleton.svelte.test.ts
Normal file
25
frontend/src/lib/components/ListRowSkeleton.svelte.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import ListRowSkeleton from './ListRowSkeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('ListRowSkeleton', () => {
|
||||||
|
it('renders the requested number of rows', () => {
|
||||||
|
render(ListRowSkeleton, { props: { count: 5, testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 6 rows and is decorative', () => {
|
||||||
|
render(ListRowSkeleton, { props: { testid: 'sk' } });
|
||||||
|
const el = screen.getByTestId('sk');
|
||||||
|
expect(el.querySelectorAll('.row').length).toBe(6);
|
||||||
|
expect(el.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the cover width to the row template', () => {
|
||||||
|
render(ListRowSkeleton, { props: { coverWidth: '80px', testid: 'sk' } });
|
||||||
|
const row = screen.getByTestId('sk').querySelector('.row') as HTMLElement;
|
||||||
|
expect(row.style.gridTemplateColumns).toBe('80px 1fr');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="cover"
|
class="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cover cover-placeholder">
|
<div class="cover cover-placeholder">
|
||||||
@@ -89,17 +90,8 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.manga-card {
|
/* `.manga-card` layout lives in tokens.css so the catalog cards and the
|
||||||
display: flex;
|
loading skeleton share one definition. */
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
list-style: none;
|
|
||||||
/* Grid items default to min-width: auto which equals the
|
|
||||||
intrinsic content size — long author / title strings then
|
|
||||||
push the cell past `1fr`. Forcing 0 lets the column control
|
|
||||||
the width and the children ellipsize inside it. */
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-link {
|
.cover-link {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ describe('MangaCard badges & overlays', () => {
|
|||||||
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the cover with lazy loading and async decode to keep grid paint smooth', () => {
|
||||||
|
render(MangaCard, {
|
||||||
|
props: { manga: { ...baseManga, cover_image_path: 'mangas/m1/cover.jpg' } }
|
||||||
|
});
|
||||||
|
const img = document.querySelector('img.cover') as HTMLImageElement;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img.getAttribute('loading')).toBe('lazy');
|
||||||
|
expect(img.getAttribute('decoding')).toBe('async');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the progress overlay clamped to 0..1 and reflects the value as a width style', () => {
|
it('renders the progress overlay clamped to 0..1 and reflects the value as a width style', () => {
|
||||||
const { rerender } = render(MangaCard, {
|
const { rerender } = render(MangaCard, {
|
||||||
props: { manga: baseManga, progress: 0.4 }
|
props: { manga: baseManga, progress: 0.4 }
|
||||||
|
|||||||
85
frontend/src/lib/components/MangaDetailSkeleton.svelte
Normal file
85
frontend/src/lib/components/MangaDetailSkeleton.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for the manga detail page while its data streams in. Mirrors
|
||||||
|
* the desktop overview (cover + meta column) and the chapter list so the
|
||||||
|
* page keeps its shape and swaps in without a big layout shift.
|
||||||
|
*/
|
||||||
|
let { chapters = 6 }: { chapters?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div data-testid="manga-detail-skeleton" aria-hidden="true">
|
||||||
|
<div class="overview">
|
||||||
|
<div class="cover">
|
||||||
|
<Skeleton aspectRatio="2 / 3" radius="md" />
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<Skeleton variant="text" width="70%" height="1.6rem" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="40%" radius="sm" />
|
||||||
|
<div class="chips">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<Skeleton width="72px" height="1.6rem" radius="pill" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="text" width="100%" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="92%" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="60%" radius="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton variant="text" width="8rem" height="1.3rem" radius="sm" />
|
||||||
|
<ol class="chapter-list">
|
||||||
|
{#each Array(chapters) as _}
|
||||||
|
<li><Skeleton variant="text" width="100%" height="1.1rem" radius="sm" /></li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 200px) 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the real overview: stack on phones, but keep the cover from
|
||||||
|
ballooning to full width. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.overview {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list li {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import MangaDetailSkeleton from './MangaDetailSkeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('MangaDetailSkeleton', () => {
|
||||||
|
it('renders the overview + a default of 6 chapter-row placeholders', () => {
|
||||||
|
render(MangaDetailSkeleton);
|
||||||
|
const root = screen.getByTestId('manga-detail-skeleton');
|
||||||
|
expect(root.querySelector('.overview')).not.toBeNull();
|
||||||
|
expect(root.querySelectorAll('.chapter-list li').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the chapters prop', () => {
|
||||||
|
render(MangaDetailSkeleton, { props: { chapters: 3 } });
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('manga-detail-skeleton').querySelectorAll('.chapter-list li').length
|
||||||
|
).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is decorative (aria-hidden)', () => {
|
||||||
|
render(MangaDetailSkeleton);
|
||||||
|
expect(screen.getByTestId('manga-detail-skeleton').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/src/lib/components/MangaGridSkeleton.svelte
Normal file
21
frontend/src/lib/components/MangaGridSkeleton.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for a manga cover grid while it loads. Reuses the global
|
||||||
|
* `.manga-grid` and `.manga-card` layout (both single-sourced in
|
||||||
|
* tokens.css) so it lines up with the real grid at every breakpoint and
|
||||||
|
* content swaps in without a layout shift. The cover reserves its 2/3 box
|
||||||
|
* via `aspect-ratio` (height stays auto), matching MangaCard's cover.
|
||||||
|
*/
|
||||||
|
let { count = 12 }: { count?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="manga-grid" data-testid="manga-grid-skeleton" aria-hidden="true">
|
||||||
|
{#each Array(count) as _}
|
||||||
|
<li class="manga-card">
|
||||||
|
<Skeleton aspectRatio="2 / 3" radius="md" />
|
||||||
|
<Skeleton variant="text" width="80%" radius="sm" />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
43
frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts
Normal file
43
frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import MangaGridSkeleton from './MangaGridSkeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('MangaGridSkeleton', () => {
|
||||||
|
it('renders 12 card skeletons by default', () => {
|
||||||
|
render(MangaGridSkeleton);
|
||||||
|
const root = screen.getByTestId('manga-grid-skeleton');
|
||||||
|
expect(root.querySelectorAll('.manga-card').length).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the count prop', () => {
|
||||||
|
render(MangaGridSkeleton, { props: { count: 4 } });
|
||||||
|
const root = screen.getByTestId('manga-grid-skeleton');
|
||||||
|
expect(root.querySelectorAll('.manga-card').length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the shared .manga-grid layout class', () => {
|
||||||
|
render(MangaGridSkeleton);
|
||||||
|
expect(screen.getByTestId('manga-grid-skeleton').classList.contains('manga-grid')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is decorative so the container announces loading', () => {
|
||||||
|
render(MangaGridSkeleton);
|
||||||
|
expect(screen.getByTestId('manga-grid-skeleton').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves each cover as a 2/3 aspect box, not a collapsed zero-height one', () => {
|
||||||
|
render(MangaGridSkeleton, { props: { count: 1 } });
|
||||||
|
const card = screen
|
||||||
|
.getByTestId('manga-grid-skeleton')
|
||||||
|
.querySelector('.manga-card') as HTMLElement;
|
||||||
|
const cover = card.firstElementChild as HTMLElement;
|
||||||
|
expect(cover.style.aspectRatio).toBe('2 / 3');
|
||||||
|
// A definite height:0 would make aspect-ratio a no-op and collapse
|
||||||
|
// the cover — the exact layout-shift regression this guards.
|
||||||
|
expect(cover.style.height).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
111
frontend/src/lib/components/NavProgress.svelte
Normal file
111
frontend/src/lib/components/NavProgress.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { navigating } from '$app/stores';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin top-of-viewport progress bar shown during client-side navigation.
|
||||||
|
* Most data routes run their `load` with `ssr = false`, so SvelteKit holds
|
||||||
|
* the previous page on screen while the next one's data resolves — with no
|
||||||
|
* feedback. This bar is that feedback: it trickles toward the right edge
|
||||||
|
* while a navigation is pending, snaps to full and fades out once it
|
||||||
|
* settles.
|
||||||
|
*
|
||||||
|
* Decorative (`aria-hidden`). Animates `transform`/`opacity` only (both
|
||||||
|
* compositor-friendly), and drops `will-change` when idle. Under
|
||||||
|
* prefers-reduced-motion the trickle keyframe is neutralized by the global
|
||||||
|
* override, leaving the `.loading` base transform as a static visible bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// idle: hidden. loading: trickling. done: snap to full, then fade to idle.
|
||||||
|
let phase = $state<'idle' | 'loading' | 'done'>('idle');
|
||||||
|
// Bumped whenever a *new* navigation begins. Keying the element on it
|
||||||
|
// remounts the bar so the trickle animation restarts even when one
|
||||||
|
// navigation supersedes another (the store stays truthy the whole time,
|
||||||
|
// just swapping Navigation objects).
|
||||||
|
let run = $state(0);
|
||||||
|
// Plain locals (not $state) so reading them in the effect doesn't make the
|
||||||
|
// effect depend on its own writes. `runCount` mirrors `run` so we can bump
|
||||||
|
// it without reactively reading the `run` state.
|
||||||
|
let runCount = 0;
|
||||||
|
let wasNavigating = false;
|
||||||
|
let doneTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const isNavigating = !!$navigating;
|
||||||
|
if (isNavigating) {
|
||||||
|
clearTimeout(doneTimer);
|
||||||
|
run = ++runCount;
|
||||||
|
phase = 'loading';
|
||||||
|
} else if (wasNavigating) {
|
||||||
|
phase = 'done';
|
||||||
|
doneTimer = setTimeout(() => {
|
||||||
|
phase = 'idle';
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
wasNavigating = isNavigating;
|
||||||
|
return () => clearTimeout(doneTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key run}
|
||||||
|
<div
|
||||||
|
class="nav-progress"
|
||||||
|
class:loading={phase === 'loading'}
|
||||||
|
class:done={phase === 'done'}
|
||||||
|
data-testid="nav-progress"
|
||||||
|
data-phase={phase}
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--primary);
|
||||||
|
z-index: var(--z-nav-progress);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-progress.loading {
|
||||||
|
opacity: 1;
|
||||||
|
/* Base (non-animated) target — this is what reduced-motion users see,
|
||||||
|
and the keyframe's end state, so disabling the animation still
|
||||||
|
leaves a clearly visible bar. */
|
||||||
|
transform: scaleX(0.9);
|
||||||
|
animation: nav-progress-trickle 8s cubic-bezier(0.1, 0.7, 0.3, 1) forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-progress.done {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(1);
|
||||||
|
/* Complete to full quickly, then fade out. */
|
||||||
|
transition:
|
||||||
|
transform 120ms ease-out,
|
||||||
|
opacity 200ms ease-out 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The reader hides all its own chrome in fullscreen; the progress bar
|
||||||
|
would be the one visible element left, so hide it too. */
|
||||||
|
:global(html[data-reader-fullscreen='true']) .nav-progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nav-progress-trickle {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(0.65);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
frontend/src/lib/components/NavProgress.svelte.test.ts
Normal file
100
frontend/src/lib/components/NavProgress.svelte.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
// A controllable stand-in for SvelteKit's `navigating` store: `null` when
|
||||||
|
// idle, a Navigation-like object while a client-side navigation is pending.
|
||||||
|
// Built via vi.hoisted (a minimal writable, so no import is needed before
|
||||||
|
// the hoisted vi.mock factory runs).
|
||||||
|
const { navigating } = vi.hoisted(() => {
|
||||||
|
let value: unknown = null;
|
||||||
|
const subs = new Set<(v: unknown) => void>();
|
||||||
|
return {
|
||||||
|
navigating: {
|
||||||
|
subscribe(fn: (v: unknown) => void) {
|
||||||
|
subs.add(fn);
|
||||||
|
fn(value);
|
||||||
|
return () => subs.delete(fn);
|
||||||
|
},
|
||||||
|
set(v: unknown) {
|
||||||
|
value = v;
|
||||||
|
subs.forEach((fn) => fn(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$app/stores', () => ({ navigating }));
|
||||||
|
|
||||||
|
import NavProgress from './NavProgress.svelte';
|
||||||
|
|
||||||
|
const nav = (path: string) => ({ from: null, to: { url: new URL(`http://x${path}`) } });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
navigating.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NavProgress', () => {
|
||||||
|
it('renders a decorative bar carrying the nav-progress testid', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
const bar = screen.getByTestId('nav-progress');
|
||||||
|
expect(bar).toBeTruthy();
|
||||||
|
expect(bar.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idle when not navigating', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
const bar = screen.getByTestId('nav-progress');
|
||||||
|
expect(bar.dataset.phase).toBe('idle');
|
||||||
|
expect(bar.classList.contains('loading')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters the loading phase while a navigation is pending', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
navigating.set(nav('/manga/1'));
|
||||||
|
flushSync();
|
||||||
|
const bar = screen.getByTestId('nav-progress');
|
||||||
|
expect(bar.dataset.phase).toBe('loading');
|
||||||
|
expect(bar.classList.contains('loading')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps to done then returns to idle once navigation settles', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(NavProgress);
|
||||||
|
navigating.set(nav('/manga/1'));
|
||||||
|
flushSync();
|
||||||
|
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('loading');
|
||||||
|
|
||||||
|
navigating.set(null);
|
||||||
|
flushSync();
|
||||||
|
// Completes to full and fades, rather than snapping away instantly.
|
||||||
|
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('done');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
flushSync();
|
||||||
|
expect(screen.getByTestId('nav-progress').dataset.phase).toBe('idle');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts the bar when a new navigation supersedes a pending one', () => {
|
||||||
|
render(NavProgress);
|
||||||
|
navigating.set(nav('/manga/1'));
|
||||||
|
flushSync();
|
||||||
|
const first = screen.getByTestId('nav-progress');
|
||||||
|
|
||||||
|
// A second navigation begins before the first settles — the store
|
||||||
|
// stays truthy the whole time, just swapping objects.
|
||||||
|
navigating.set(nav('/manga/2'));
|
||||||
|
flushSync();
|
||||||
|
const second = screen.getByTestId('nav-progress');
|
||||||
|
|
||||||
|
expect(second.dataset.phase).toBe('loading');
|
||||||
|
// {#key run} remounts the element so the trickle animation replays
|
||||||
|
// rather than staying parked at its previous width.
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
frontend/src/lib/components/SearchResultsSkeleton.svelte
Normal file
48
frontend/src/lib/components/SearchResultsSkeleton.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for a tagged-page / chapter / manga results list while a
|
||||||
|
* search re-queries. Mirrors the TaggedPageRow shape (56px 2/3 cover +
|
||||||
|
* two text lines) so the list keeps its height as results swap in.
|
||||||
|
*/
|
||||||
|
let { count = 6, testid = 'search-results-skeleton' }: { count?: number; testid?: string } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="list" data-testid={testid} aria-hidden="true">
|
||||||
|
{#each Array(count) as _}
|
||||||
|
<li class="row">
|
||||||
|
<Skeleton width="56px" aspectRatio="2 / 3" radius="sm" />
|
||||||
|
<div class="meta">
|
||||||
|
<Skeleton variant="text" width="60%" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="40%" radius="sm" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import SearchResultsSkeleton from './SearchResultsSkeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('SearchResultsSkeleton', () => {
|
||||||
|
it('renders the requested number of row placeholders', () => {
|
||||||
|
render(SearchResultsSkeleton, { props: { count: 3, testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 6 rows', () => {
|
||||||
|
render(SearchResultsSkeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').querySelectorAll('.row').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is decorative (aria-hidden)', () => {
|
||||||
|
render(SearchResultsSkeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
frontend/src/lib/components/ShelfSkeleton.svelte
Normal file
49
frontend/src/lib/components/ShelfSkeleton.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for a horizontal home shelf (Continue reading / Recommended)
|
||||||
|
* while it loads. Reserves the shelf's vertical space so the catalog below
|
||||||
|
* doesn't get shoved down when the real shelf pops in after its late fetch.
|
||||||
|
* Dimensions mirror ContinueReadingShelf's 108×162 cards.
|
||||||
|
*/
|
||||||
|
let { count = 6, testid = 'shelf-skeleton' }: { count?: number; testid?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="shelf" data-testid={testid} aria-hidden="true">
|
||||||
|
<Skeleton variant="text" width="10rem" height="1.1rem" radius="sm" />
|
||||||
|
<ul class="track">
|
||||||
|
{#each Array(count) as _}
|
||||||
|
<li class="card">
|
||||||
|
<Skeleton width="108px" height="162px" radius="sm" />
|
||||||
|
<Skeleton variant="text" width="80%" radius="sm" />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shelf {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0 0 var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
width: 108px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
Normal file
22
frontend/src/lib/components/ShelfSkeleton.svelte.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import ShelfSkeleton from './ShelfSkeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('ShelfSkeleton', () => {
|
||||||
|
it('renders the requested number of card placeholders', () => {
|
||||||
|
render(ShelfSkeleton, { props: { count: 4, testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 6 cards', () => {
|
||||||
|
render(ShelfSkeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').querySelectorAll('.card').length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is decorative (aria-hidden)', () => {
|
||||||
|
render(ShelfSkeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
93
frontend/src/lib/components/Skeleton.svelte
Normal file
93
frontend/src/lib/components/Skeleton.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Decorative loading placeholder. Renders a muted block with a subtle
|
||||||
|
* shimmer that sweeps across it. Purely visual — `aria-hidden` keeps it
|
||||||
|
* out of the accessibility tree; the loading state should be announced by
|
||||||
|
* the container (e.g. `role="status"`).
|
||||||
|
*
|
||||||
|
* Under prefers-reduced-motion the shimmer gradient/animation is dropped
|
||||||
|
* entirely, leaving a flat `--surface-elevated` block.
|
||||||
|
*/
|
||||||
|
let {
|
||||||
|
width = '100%',
|
||||||
|
height,
|
||||||
|
aspectRatio,
|
||||||
|
radius = 'md',
|
||||||
|
variant = 'block',
|
||||||
|
testid
|
||||||
|
}: {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
/**
|
||||||
|
* `aspect-ratio` for the box (e.g. `'2 / 3'`). Lets a fluid-width
|
||||||
|
* placeholder reserve its shape without a hard-coded height — the
|
||||||
|
* height stays `auto` so the ratio actually applies.
|
||||||
|
*/
|
||||||
|
aspectRatio?: string;
|
||||||
|
/** Corner radius, mapped to the design-token scale. */
|
||||||
|
radius?: 'sm' | 'md' | 'lg' | 'pill';
|
||||||
|
/** `circle` forces a pill radius (for avatars / dots). */
|
||||||
|
variant?: 'block' | 'text' | 'circle';
|
||||||
|
testid?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const radiusClass = $derived(variant === 'circle' ? 'radius-pill' : `radius-${radius}`);
|
||||||
|
|
||||||
|
// A text line gets a sensible default height so callers don't have to; an
|
||||||
|
// explicit `height` still wins. Resolving it here (rather than in CSS)
|
||||||
|
// keeps a single source of truth for the box height and avoids an inline
|
||||||
|
// style silently shadowing a stylesheet rule.
|
||||||
|
const resolvedHeight = $derived(height ?? (variant === 'text' ? '0.9em' : undefined));
|
||||||
|
|
||||||
|
const style = $derived(
|
||||||
|
`width: ${width};` +
|
||||||
|
(resolvedHeight ? ` height: ${resolvedHeight};` : '') +
|
||||||
|
(aspectRatio ? ` aspect-ratio: ${aspectRatio};` : '')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="skeleton {radiusClass}" data-testid={testid} aria-hidden="true" {style}></span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
/* Flat base — this is the whole appearance under reduced motion. */
|
||||||
|
background-color: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.skeleton {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--surface) 0%,
|
||||||
|
var(--surface-elevated) 50%,
|
||||||
|
var(--surface) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: skeleton-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-sm {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.radius-md {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.radius-lg {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.radius-pill {
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
frontend/src/lib/components/Skeleton.svelte.test.ts
Normal file
67
frontend/src/lib/components/Skeleton.svelte.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('Skeleton primitive', () => {
|
||||||
|
it('renders an element carrying the given testid', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is decorative (aria-hidden) so screen readers ignore it', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies width and height as inline styles', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', width: '120px', height: '2rem' } });
|
||||||
|
const el = screen.getByTestId('sk') as HTMLElement;
|
||||||
|
expect(el.style.width).toBe('120px');
|
||||||
|
expect(el.style.height).toBe('2rem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults width to 100% when omitted', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk' } });
|
||||||
|
expect((screen.getByTestId('sk') as HTMLElement).style.width).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the radius prop to a radius class', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', radius: 'lg' } });
|
||||||
|
expect(screen.getByTestId('sk').classList.contains('radius-lg')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the md radius', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').classList.contains('radius-md')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a pill radius for the circle variant', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', variant: 'circle' } });
|
||||||
|
expect(screen.getByTestId('sk').classList.contains('radius-pill')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the skeleton base class for the shimmer', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk' } });
|
||||||
|
expect(screen.getByTestId('sk').classList.contains('skeleton')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits aspect-ratio inline (and no fixed height) so a fluid box keeps its shape', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', aspectRatio: '2 / 3' } });
|
||||||
|
const el = screen.getByTestId('sk') as HTMLElement;
|
||||||
|
expect(el.style.aspectRatio).toBe('2 / 3');
|
||||||
|
// A definite height would defeat aspect-ratio and collapse the box.
|
||||||
|
expect(el.style.height).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a text-variant line a default height without a caller-supplied one', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', variant: 'text' } });
|
||||||
|
expect((screen.getByTestId('sk') as HTMLElement).style.height).toBe('0.9em');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an explicit height override the text-variant default', () => {
|
||||||
|
render(Skeleton, { props: { testid: 'sk', variant: 'text', height: '1rem' } });
|
||||||
|
expect((screen.getByTestId('sk') as HTMLElement).style.height).toBe('1rem');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<li class="row" data-testid={testid}>
|
<li class="row" data-testid={testid}>
|
||||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||||
{#if primaryCover}
|
{#if primaryCover}
|
||||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
|
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cover cover-placeholder"></div>
|
<div class="cover cover-placeholder"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{#if item.sample_storage_keys.length > 1}
|
{#if item.sample_storage_keys.length > 1}
|
||||||
<div class="samples">
|
<div class="samples">
|
||||||
{#each item.sample_storage_keys.slice(1) as key (key)}
|
{#each item.sample_storage_keys.slice(1) as key (key)}
|
||||||
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
|
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" decoding="async" />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<li class="row" data-testid={testid}>
|
<li class="row" data-testid={testid}>
|
||||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||||
{#if primaryCover}
|
{#if primaryCover}
|
||||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
|
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="cover cover-placeholder"></div>
|
<div class="cover cover-placeholder"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
{#if sampleStrip.length > 0}
|
{#if sampleStrip.length > 0}
|
||||||
<div class="samples">
|
<div class="samples">
|
||||||
{#each sampleStrip as key (key)}
|
{#each sampleStrip as key (key)}
|
||||||
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" />
|
<img src={fileUrl(key)} alt="" class="sample" loading="lazy" decoding="async" />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
alt=""
|
alt=""
|
||||||
class="cover"
|
class="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
|
|||||||
104
frontend/src/lib/components/Toaster.svelte
Normal file
104
frontend/src/lib/components/Toaster.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
// Errors get an assertive `alert` role so screen readers interrupt;
|
||||||
|
// success/info use the polite `status` role.
|
||||||
|
const roleFor = (kind: string) => (kind === 'error' ? 'alert' : 'status');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toaster" data-testid="toaster">
|
||||||
|
{#each toast.toasts as t (t.id)}
|
||||||
|
<div class="toast {t.kind}" role={roleFor(t.kind)} data-testid="toast" data-kind={t.kind}>
|
||||||
|
<span class="message">{t.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dismiss"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
onclick={() => toast.dismiss(t.id)}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(var(--safe-bottom) + var(--space-4));
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
/* Let clicks fall through the gaps; individual toasts opt back in. */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sit above the mobile bottom nav so a toast never hides behind it. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.toaster {
|
||||||
|
bottom: calc(var(--app-bottom-nav-h) + var(--safe-bottom) + var(--space-3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 30rem;
|
||||||
|
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: var(--danger-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: var(--success-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss:hover {
|
||||||
|
background: rgb(0 0 0 / 0.06);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
frontend/src/lib/components/Toaster.svelte.test.ts
Normal file
38
frontend/src/lib/components/Toaster.svelte.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import Toaster from './Toaster.svelte';
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
toast.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Toaster', () => {
|
||||||
|
it('renders queued toasts with their message', async () => {
|
||||||
|
toast.info('Saved');
|
||||||
|
render(Toaster);
|
||||||
|
expect(await screen.findByText('Saved')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives errors an assertive alert role and others a status role', () => {
|
||||||
|
toast.error('Boom');
|
||||||
|
toast.success('Yay');
|
||||||
|
render(Toaster);
|
||||||
|
const toasts = screen.getAllByTestId('toast');
|
||||||
|
const error = toasts.find((t) => t.dataset.kind === 'error')!;
|
||||||
|
const success = toasts.find((t) => t.dataset.kind === 'success')!;
|
||||||
|
expect(error.getAttribute('role')).toBe('alert');
|
||||||
|
expect(success.getAttribute('role')).toBe('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a toast when its dismiss button is clicked', async () => {
|
||||||
|
toast.error('Dismiss me');
|
||||||
|
render(Toaster);
|
||||||
|
const btn = screen.getByLabelText('Dismiss notification');
|
||||||
|
btn.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.queryByText('Dismiss me')).toBeNull();
|
||||||
|
expect(toast.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
--danger: #b00020;
|
--danger: #b00020;
|
||||||
--danger-soft-bg: #fff5f5;
|
--danger-soft-bg: #fff5f5;
|
||||||
--success: #0a7d2c;
|
--success: #0a7d2c;
|
||||||
|
--success-soft-bg: #eaf7ee;
|
||||||
--warning-soft-bg: #fff5d6;
|
--warning-soft-bg: #fff5d6;
|
||||||
--warning-border: #d6a800;
|
--warning-border: #d6a800;
|
||||||
--focus-ring: #2563eb;
|
--focus-ring: #2563eb;
|
||||||
@@ -28,6 +29,9 @@
|
|||||||
|
|
||||||
--font-xs: 0.75rem;
|
--font-xs: 0.75rem;
|
||||||
--font-sm: 0.875rem;
|
--font-sm: 0.875rem;
|
||||||
|
/* Alias of --font-base at the body size; named on the xs/sm/md/lg/xl
|
||||||
|
scale that some components reach for. */
|
||||||
|
--font-md: 1rem;
|
||||||
--font-base: 1rem;
|
--font-base: 1rem;
|
||||||
--font-lg: 1.125rem;
|
--font-lg: 1.125rem;
|
||||||
--font-xl: 1.5rem;
|
--font-xl: 1.5rem;
|
||||||
@@ -95,6 +99,9 @@
|
|||||||
|
|
||||||
--z-dropdown: 10;
|
--z-dropdown: 10;
|
||||||
--z-sticky: 50;
|
--z-sticky: 50;
|
||||||
|
/* Above the fixed header/app-bar (--z-sticky) so the nav progress bar
|
||||||
|
is never occluded, but below modals and toasts. */
|
||||||
|
--z-nav-progress: 60;
|
||||||
--z-modal: 100;
|
--z-modal: 100;
|
||||||
--z-toast: 1000;
|
--z-toast: 1000;
|
||||||
}
|
}
|
||||||
@@ -114,6 +121,7 @@
|
|||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
--danger-soft-bg: #3a1620;
|
--danger-soft-bg: #3a1620;
|
||||||
--success: #4ade80;
|
--success: #4ade80;
|
||||||
|
--success-soft-bg: #12301c;
|
||||||
--warning-soft-bg: #3a2e10;
|
--warning-soft-bg: #3a2e10;
|
||||||
--warning-border: #a37800;
|
--warning-border: #a37800;
|
||||||
--focus-ring: #60a5fa;
|
--focus-ring: #60a5fa;
|
||||||
@@ -341,6 +349,20 @@ img {
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A single cover cell — the catalog/author/collection cards and the
|
||||||
|
loading skeleton share this so their layout can't drift. */
|
||||||
|
.manga-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
/* Grid items default to min-width: auto which equals the intrinsic
|
||||||
|
content size — long author / title strings then push the cell past
|
||||||
|
`1fr`. Forcing 0 lets the column control the width and the children
|
||||||
|
ellipsize inside it. */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.manga-grid {
|
.manga-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
32
frontend/src/lib/styles/tokens.test.ts
Normal file
32
frontend/src/lib/styles/tokens.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// Guards against components referencing design tokens that were never
|
||||||
|
// declared. `var(--x)` with no declaration silently falls through to the
|
||||||
|
// inherited/initial value, so a typo'd or missing token degrades quietly
|
||||||
|
// rather than failing loudly. These two were referenced by components
|
||||||
|
// (RecommendationShelf, the manga detail page) but absent from tokens.css.
|
||||||
|
|
||||||
|
const TOKENS = readFileSync(resolve(process.cwd(), 'src/lib/styles/tokens.css'), 'utf8');
|
||||||
|
|
||||||
|
// Every design token must be declared at least once (in :root and/or a theme
|
||||||
|
// override). We only assert declaration, not value.
|
||||||
|
function declares(name: string): boolean {
|
||||||
|
return new RegExp(`${name}\\s*:`).test(TOKENS);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('design tokens', () => {
|
||||||
|
it('declares --font-md (referenced by RecommendationShelf)', () => {
|
||||||
|
expect(declares('--font-md')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares --success-soft-bg (referenced by the manga detail page)', () => {
|
||||||
|
expect(declares('--success-soft-bg')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares --success-soft-bg in the dark theme override too', () => {
|
||||||
|
const darkBlock = TOKENS.slice(TOKENS.indexOf("[data-theme='dark']"));
|
||||||
|
expect(/--success-soft-bg\s*:/.test(darkBlock)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
frontend/src/lib/toast.svelte.test.ts
Normal file
61
frontend/src/lib/toast.svelte.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { toast } from './toast.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
toast.clear();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toast store', () => {
|
||||||
|
it('shows a toast with the given message and kind, returning its id', () => {
|
||||||
|
const id = toast.show('hello', 'info');
|
||||||
|
expect(toast.toasts).toHaveLength(1);
|
||||||
|
expect(toast.toasts[0]).toMatchObject({ id, message: 'hello', kind: 'info' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks multiple toasts in insertion order with distinct ids', () => {
|
||||||
|
const a = toast.info('first');
|
||||||
|
const b = toast.error('second');
|
||||||
|
expect(toast.toasts.map((t) => t.message)).toEqual(['first', 'second']);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes kind helpers', () => {
|
||||||
|
toast.error('boom');
|
||||||
|
toast.success('yay');
|
||||||
|
expect(toast.toasts.map((t) => t.kind)).toEqual(['error', 'success']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses a toast by id', () => {
|
||||||
|
const id = toast.info('bye');
|
||||||
|
toast.dismiss(id);
|
||||||
|
expect(toast.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-dismisses after the timeout', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
toast.show('temp', 'info', 3000);
|
||||||
|
expect(toast.toasts).toHaveLength(1);
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(toast.toasts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-dismiss when the timeout is 0 (sticky)', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
toast.show('sticky', 'error', 0);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(toast.toasts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear() removes everything and cancels pending timers', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
toast.info('a');
|
||||||
|
toast.info('b');
|
||||||
|
toast.clear();
|
||||||
|
expect(toast.toasts).toHaveLength(0);
|
||||||
|
// A previously-scheduled auto-dismiss must not fire against a new toast.
|
||||||
|
const id = toast.info('c', 0);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(toast.toasts).toEqual([{ id, message: 'c', kind: 'info' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
frontend/src/lib/toast.svelte.ts
Normal file
64
frontend/src/lib/toast.svelte.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// App-wide transient notifications (toasts).
|
||||||
|
//
|
||||||
|
// A single client-side store; the <Toaster /> in the root layout renders it.
|
||||||
|
// Mutated only from the browser (event handlers / catch blocks), so the
|
||||||
|
// module-level singleton can't leak across SSR requests — SSR renders an
|
||||||
|
// empty list and the client takes over after hydration.
|
||||||
|
|
||||||
|
export type ToastKind = 'info' | 'success' | 'error';
|
||||||
|
export type Toast = { id: number; kind: ToastKind; message: string };
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 5000;
|
||||||
|
// Errors linger longer — the user may need to read what failed.
|
||||||
|
const ERROR_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
class ToastStore {
|
||||||
|
toasts = $state<Toast[]>([]);
|
||||||
|
private nextId = 1;
|
||||||
|
private timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a toast. `timeoutMs = 0` makes it sticky (dismiss only via the
|
||||||
|
* close button / `dismiss`). Returns the id so callers can dismiss early.
|
||||||
|
*/
|
||||||
|
show(message: string, kind: ToastKind = 'info', timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.toasts = [...this.toasts, { id, kind, message }];
|
||||||
|
if (timeoutMs > 0) {
|
||||||
|
this.timers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => this.dismiss(id), timeoutMs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, timeoutMs: number = ERROR_TIMEOUT_MS): number {
|
||||||
|
return this.show(message, 'error', timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
|
||||||
|
return this.show(message, 'success', timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
|
||||||
|
return this.show(message, 'info', timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number): void {
|
||||||
|
const timer = this.timers.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.timers.delete(id);
|
||||||
|
}
|
||||||
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.timers.forEach((t) => clearTimeout(t));
|
||||||
|
this.timers.clear();
|
||||||
|
this.toasts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = new ToastStore();
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
import AppBar from '$lib/components/AppBar.svelte';
|
import AppBar from '$lib/components/AppBar.svelte';
|
||||||
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
||||||
|
import NavProgress from '$lib/components/NavProgress.svelte';
|
||||||
|
import Toaster from '$lib/components/Toaster.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||||
@@ -198,6 +200,8 @@
|
|||||||
<title>{layoutTitle}</title>
|
<title>{layoutTitle}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<NavProgress />
|
||||||
|
|
||||||
{#if showMobileChrome}
|
{#if showMobileChrome}
|
||||||
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
||||||
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
||||||
@@ -268,6 +272,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
|
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.svelte';
|
||||||
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
|
import RecommendationShelf from '$lib/components/RecommendationShelf.svelte';
|
||||||
|
import ShelfSkeleton from '$lib/components/ShelfSkeleton.svelte';
|
||||||
|
import { session } from '$lib/session.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
import Sheet from '$lib/components/Sheet.svelte';
|
import Sheet from '$lib/components/Sheet.svelte';
|
||||||
@@ -47,6 +50,10 @@
|
|||||||
let mangas: MangaCardData[] = $state([]);
|
let mangas: MangaCardData[] = $state([]);
|
||||||
let continueEntries = $state<ReadProgressSummary[]>([]);
|
let continueEntries = $state<ReadProgressSummary[]>([]);
|
||||||
let recommendations = $state<MangaCardData[]>([]);
|
let recommendations = $state<MangaCardData[]>([]);
|
||||||
|
// True while the personal shelves are being fetched. Drives a reserved
|
||||||
|
// ShelfSkeleton (for logged-in users) so the catalog below isn't shoved
|
||||||
|
// down when the shelves resolve.
|
||||||
|
let shelvesLoading = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let sort = $state<MangaSort>(DEFAULT_SORT);
|
let sort = $state<MangaSort>(DEFAULT_SORT);
|
||||||
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
let order = $state<SortOrder>(defaultOrderFor(DEFAULT_SORT));
|
||||||
@@ -310,26 +317,31 @@
|
|||||||
// Filter UI still loads with an empty genre list rather than blocking.
|
// Filter UI still loads with an empty genre list rather than blocking.
|
||||||
}
|
}
|
||||||
await hydrateFromUrl();
|
await hydrateFromUrl();
|
||||||
|
// Fetch the personal shelves concurrently with the catalogue (rather
|
||||||
|
// than after it) so their reserved skeleton and the grid resolve
|
||||||
|
// together instead of the shelves popping in late and shoving the grid
|
||||||
|
// down. The `/me/*` calls are isolated: a failure (or 401 for guests)
|
||||||
|
// hides its own shelf via the *OrEmpty wrappers without touching the
|
||||||
|
// catalogue or the other shelf.
|
||||||
|
shelvesLoading = true;
|
||||||
|
const shelves = Promise.allSettled([listMyReadProgressOrEmpty(), listMyRecommendations()])
|
||||||
|
.then(([progressResult, recsResult]) => {
|
||||||
|
if (progressResult.status === 'fulfilled') {
|
||||||
|
// Drop finished series (read to the end, nothing new) — a
|
||||||
|
// "Continue reading" shelf is for what's still in progress.
|
||||||
|
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
|
||||||
|
}
|
||||||
|
if (recsResult.status === 'fulfilled') {
|
||||||
|
// Personal "Recommended for you" feed (empty for guests / no
|
||||||
|
// taste signals yet → shelf hidden).
|
||||||
|
recommendations = recsResult.value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
shelvesLoading = false;
|
||||||
|
});
|
||||||
await load();
|
await load();
|
||||||
// Fetch the personal shelves after the catalogue so the public browse
|
await shelves;
|
||||||
// path stays unauthenticated and unblocked. Both are independent
|
|
||||||
// `/me/*` calls, so fire them concurrently rather than in series.
|
|
||||||
// Each is isolated: a failure (or 401 for guests) hides its own shelf
|
|
||||||
// without touching the catalogue or the other shelf.
|
|
||||||
const [progressResult, recsResult] = await Promise.allSettled([
|
|
||||||
listMyReadProgressOrEmpty(),
|
|
||||||
listMyRecommendations()
|
|
||||||
]);
|
|
||||||
if (progressResult.status === 'fulfilled') {
|
|
||||||
// Drop finished series (read to the end, nothing new) — a
|
|
||||||
// "Continue reading" shelf is for what's still in progress.
|
|
||||||
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
|
|
||||||
}
|
|
||||||
if (recsResult.status === 'fulfilled') {
|
|
||||||
// Personal "Recommended for you" feed (empty for guests / no
|
|
||||||
// taste signals yet → shelf hidden).
|
|
||||||
recommendations = recsResult.value;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track viewport in a separate $effect so the listener cleans up on
|
// Track viewport in a separate $effect so the listener cleans up on
|
||||||
@@ -484,12 +496,18 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if continueEntries.length > 0}
|
{#if shelvesLoading && session.user}
|
||||||
<ContinueReadingShelf entries={continueEntries} />
|
<!-- Reserve the shelf area for logged-in users so the grid below doesn't
|
||||||
{/if}
|
jump when the real shelves resolve. -->
|
||||||
|
<ShelfSkeleton testid="shelf-skeleton" />
|
||||||
|
{:else}
|
||||||
|
{#if continueEntries.length > 0}
|
||||||
|
<ContinueReadingShelf entries={continueEntries} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if recommendations.length > 0}
|
{#if recommendations.length > 0}
|
||||||
<RecommendationShelf mangas={recommendations} />
|
<RecommendationShelf mangas={recommendations} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -659,7 +677,10 @@
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="status" data-testid="loading">Loading…</p>
|
<div data-testid="loading" role="status">
|
||||||
|
<span class="visually-hidden">Loading manga…</span>
|
||||||
|
<MangaGridSkeleton />
|
||||||
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error" data-testid="error" role="alert">{error}</p>
|
<p class="error" data-testid="error" role="alert">{error}</p>
|
||||||
{:else if mangas.length === 0}
|
{:else if mangas.length === 0}
|
||||||
@@ -972,6 +993,18 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
import Pager from '$lib/components/Pager.svelte';
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -7,15 +8,8 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const author = $derived(data.author);
|
const author = $derived(data.author);
|
||||||
const mangas = $derived(data.mangas);
|
|
||||||
const total = $derived(data.total);
|
|
||||||
const currentPage = $derived(data.currentPage);
|
const currentPage = $derived(data.currentPage);
|
||||||
const pageSize = $derived(data.pageSize);
|
const pageSize = $derived(data.pageSize);
|
||||||
const totalPages = $derived(
|
|
||||||
total != null && total > 0 ? Math.ceil(total / pageSize) : 1
|
|
||||||
);
|
|
||||||
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1);
|
|
||||||
const rangeEnd = $derived((currentPage - 1) * pageSize + mangas.length);
|
|
||||||
|
|
||||||
function goToPage(p: number) {
|
function goToPage(p: number) {
|
||||||
if (p === currentPage) return;
|
if (p === currentPage) return;
|
||||||
@@ -45,28 +39,36 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if mangas.length === 0}
|
{#await data.mangas}
|
||||||
<p class="status" data-testid="author-no-mangas">
|
<MangaGridSkeleton />
|
||||||
No mangas attributed to this author.
|
{:then result}
|
||||||
</p>
|
{@const mangas = result.items}
|
||||||
{:else}
|
{@const total = result.total}
|
||||||
{#if total != null}
|
{@const totalPages = total != null && total > 0 ? Math.ceil(total / pageSize) : 1}
|
||||||
<p class="meta" data-testid="author-shown-of-total">
|
{@const rangeStart = mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1}
|
||||||
Showing {rangeStart}–{rangeEnd} of {total}
|
{@const rangeEnd = (currentPage - 1) * pageSize + mangas.length}
|
||||||
|
{#if mangas.length === 0}
|
||||||
|
<p class="status" data-testid="author-no-mangas">
|
||||||
|
No mangas attributed to this author.
|
||||||
</p>
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#if total != null}
|
||||||
|
<p class="meta" data-testid="author-shown-of-total">
|
||||||
|
Showing {rangeStart}–{rangeEnd} of {total}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<ul class="manga-grid" data-testid="author-manga-list">
|
||||||
|
{#each mangas as m (m.id)}
|
||||||
|
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<Pager page={currentPage} {totalPages} onChange={goToPage} testid="author-pager" />
|
||||||
{/if}
|
{/if}
|
||||||
<ul class="manga-grid" data-testid="author-manga-list">
|
{:catch}
|
||||||
{#each mangas as m (m.id)}
|
<p class="status" role="alert" data-testid="author-error">
|
||||||
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
|
Could not load this author's mangas.
|
||||||
{/each}
|
</p>
|
||||||
</ul>
|
{/await}
|
||||||
<Pager
|
|
||||||
page={currentPage}
|
|
||||||
{totalPages}
|
|
||||||
onChange={goToPage}
|
|
||||||
testid="author-pager"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.back {
|
.back {
|
||||||
|
|||||||
@@ -11,28 +11,27 @@ export const load: PageLoad = async ({ params, url }) => {
|
|||||||
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||||
const currentPage =
|
const currentPage =
|
||||||
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
||||||
|
let author;
|
||||||
try {
|
try {
|
||||||
const [author, mangas] = await Promise.all([
|
// Awaited so a 404 surfaces as a real SvelteKit error (the framework
|
||||||
getAuthor(params.id),
|
// not-found page) rather than the happy-path markup with undefined
|
||||||
listAuthorMangas(params.id, {
|
// data. The author header is cheap, so blocking on it is fine.
|
||||||
limit: PAGE_SIZE,
|
author = await getAuthor(params.id);
|
||||||
offset: (currentPage - 1) * PAGE_SIZE
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
author,
|
|
||||||
mangas: mangas.items,
|
|
||||||
total: mangas.page.total,
|
|
||||||
currentPage,
|
|
||||||
pageSize: PAGE_SIZE
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 404 surfaces as a real SvelteKit error so the framework shell
|
|
||||||
// renders the standard not-found page instead of the route's
|
|
||||||
// happy-path markup with undefined data.
|
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
error(404, 'Author not found');
|
error(404, 'Author not found');
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
// Streamed (not awaited) so the grid shows a MangaGridSkeleton while it
|
||||||
|
// loads instead of the whole page blocking on it.
|
||||||
|
mangas: listAuthorMangas(params.id, {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (currentPage - 1) * PAGE_SIZE
|
||||||
|
}).then((m) => ({ items: m.items, total: m.page.total })),
|
||||||
|
currentPage,
|
||||||
|
pageSize: PAGE_SIZE
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||||
|
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const authenticated = $derived(data.authenticated);
|
|
||||||
const bookmarks = $derived(data.bookmarks);
|
|
||||||
const error = $derived(data.error);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Bookmarks</h1>
|
<h1>Bookmarks</h1>
|
||||||
|
|
||||||
{#if error}
|
{#await data.result}
|
||||||
<p class="error" role="alert" data-testid="bookmarks-error">
|
<ListRowSkeleton />
|
||||||
Couldn't load bookmarks: {error}
|
{:then r}
|
||||||
</p>
|
{#if r.error}
|
||||||
{:else if !authenticated}
|
<p class="error" role="alert" data-testid="bookmarks-error">
|
||||||
<p class="hint" data-testid="bookmarks-signin">
|
Couldn't load bookmarks: {r.error}
|
||||||
<a href="/login">Sign in</a> to see your bookmarks.
|
</p>
|
||||||
</p>
|
{:else if !r.authenticated}
|
||||||
{:else if bookmarks.length === 0}
|
<p class="hint" data-testid="bookmarks-signin">
|
||||||
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
<a href="/login">Sign in</a> to see your bookmarks.
|
||||||
{:else}
|
</p>
|
||||||
<BookmarkList {bookmarks} />
|
{:else if r.bookmarks.length === 0}
|
||||||
{/if}
|
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<BookmarkList bookmarks={r.bookmarks} />
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
@@ -5,21 +5,26 @@ import type { PageLoad } from './$types';
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
try {
|
// Streamed (the load itself doesn't await) so the list shows a skeleton
|
||||||
const page = await listMyBookmarks();
|
// while the single fetch — which is also the auth gate — is in flight.
|
||||||
return { bookmarks: page.items, authenticated: true, error: null };
|
return {
|
||||||
} catch (e) {
|
result: (async () => {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
try {
|
||||||
return { bookmarks: [], authenticated: false, error: null };
|
const page = await listMyBookmarks();
|
||||||
}
|
return { bookmarks: page.items, authenticated: true, error: null as string | null };
|
||||||
// Anything else (502 upstream_unavailable from a backend
|
} catch (e) {
|
||||||
// restart, 500 internal_error) is rendered inline rather than
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
// re-thrown — SvelteKit's generic error.html is not the right
|
return { bookmarks: [], authenticated: false, error: null as string | null };
|
||||||
// UX for a transient API blip and the user is already
|
}
|
||||||
// authenticated as far as we know.
|
// Anything else — an HTTP error (502 upstream_unavailable from
|
||||||
if (e instanceof ApiError) {
|
// a backend restart, 500 internal_error) or a raw network
|
||||||
return { bookmarks: [], authenticated: true, error: e.message };
|
// failure (a non-ApiError TypeError) — renders inline rather
|
||||||
}
|
// than re-thrown: SvelteKit's generic error.html is not the
|
||||||
throw e;
|
// right UX for a transient API blip and the user is already
|
||||||
}
|
// authenticated as far as we know.
|
||||||
|
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||||
|
return { bookmarks: [], authenticated: true, error: message };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||||
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const collections = $derived(data.collections);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Collections</h1>
|
<h1>Collections</h1>
|
||||||
|
|
||||||
{#if !data.authenticated}
|
{#await data.result}
|
||||||
<p class="status">
|
<MangaGridSkeleton count={6} />
|
||||||
<a href="/login">Sign in</a> to see and manage your collections.
|
{:then r}
|
||||||
</p>
|
{#if !r.authenticated}
|
||||||
{:else if data.error}
|
<p class="status">
|
||||||
<p class="error" role="alert">{data.error}</p>
|
<a href="/login">Sign in</a> to see and manage your collections.
|
||||||
{:else if collections.length === 0}
|
</p>
|
||||||
<p class="status" data-testid="collections-empty">
|
{:else if r.error}
|
||||||
You don't have any collections yet. Open any manga and use
|
<p class="error" role="alert">{r.error}</p>
|
||||||
<strong>Add to collection</strong> to start one.
|
{:else if r.collections.length === 0}
|
||||||
</p>
|
<p class="status" data-testid="collections-empty">
|
||||||
{:else}
|
You don't have any collections yet. Open any manga and use
|
||||||
<CollectionsGrid {collections} />
|
<strong>Add to collection</strong> to start one.
|
||||||
{/if}
|
</p>
|
||||||
|
{:else}
|
||||||
|
<CollectionsGrid collections={r.collections} />
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status {
|
.status {
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ import type { PageLoad } from './$types';
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
try {
|
// Streamed so the grid shows a skeleton while the single fetch (also the
|
||||||
const page = await listMyCollections({ limit: 200 });
|
// auth gate) is in flight.
|
||||||
return { collections: page.items, authenticated: true, error: null };
|
return {
|
||||||
} catch (e) {
|
result: (async () => {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
try {
|
||||||
return { collections: [], authenticated: false, error: null };
|
const page = await listMyCollections({ limit: 200 });
|
||||||
}
|
return { collections: page.items, authenticated: true, error: null as string | null };
|
||||||
if (e instanceof ApiError) {
|
} catch (e) {
|
||||||
return { collections: [], authenticated: true, error: e.message };
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
}
|
return { collections: [], authenticated: false, error: null as string | null };
|
||||||
throw e;
|
}
|
||||||
}
|
// An HTTP error or a raw network failure (a non-ApiError
|
||||||
|
// TypeError) renders inline rather than escaping to the
|
||||||
|
// framework error page for a transient API blip.
|
||||||
|
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||||
|
return { collections: [], authenticated: true, error: message };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import type { Manga } from '$lib/api/client';
|
import type { Manga } from '$lib/api/client';
|
||||||
import { fileUrl } from '$lib/api/client';
|
import { fileUrl } from '$lib/api/client';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
@@ -22,10 +23,39 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let collection = $state({ ...data.collection });
|
let collection = $state({ ...data.collection });
|
||||||
// svelte-ignore state_referenced_locally
|
// The manga/page lists are streamed and held as local mutable state so
|
||||||
let mangas = $state<Manga[]>([...data.mangas]);
|
// removals can be optimistic. They're seeded once the stream resolves; a
|
||||||
// svelte-ignore state_referenced_locally
|
// skeleton shows until then.
|
||||||
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
let mangas = $state<Manga[]>([]);
|
||||||
|
let pages = $state<CollectionPageItem[]>([]);
|
||||||
|
let contentLoading = $state(true);
|
||||||
|
// Set when the streamed content fails to load, so we surface an inline
|
||||||
|
// error instead of a misleading "this collection is empty".
|
||||||
|
let contentError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Seed (and re-seed on navigation) from the streamed content. A monotonic
|
||||||
|
// guard drops a stale resolution that lands after a newer navigation.
|
||||||
|
let contentSeq = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const content = data.content;
|
||||||
|
const seq = ++contentSeq;
|
||||||
|
contentLoading = true;
|
||||||
|
contentError = null;
|
||||||
|
content
|
||||||
|
.then((r) => {
|
||||||
|
if (seq !== contentSeq) return;
|
||||||
|
mangas = r.mangas;
|
||||||
|
pages = r.pages;
|
||||||
|
contentLoading = false;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (seq !== contentSeq) return;
|
||||||
|
mangas = [];
|
||||||
|
pages = [];
|
||||||
|
contentError = e instanceof Error ? e.message : 'Something went wrong.';
|
||||||
|
contentLoading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editName = $state('');
|
let editName = $state('');
|
||||||
@@ -178,13 +208,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if mangas.length === 0 && pages.length === 0}
|
{#if contentLoading}
|
||||||
|
<MangaGridSkeleton count={6} />
|
||||||
|
{:else if contentError}
|
||||||
|
<p class="error" role="alert" data-testid="collection-content-error">
|
||||||
|
Couldn't load this collection's contents: {contentError}
|
||||||
|
</p>
|
||||||
|
{:else if mangas.length === 0 && pages.length === 0}
|
||||||
<p class="status" data-testid="collection-empty">
|
<p class="status" data-testid="collection-empty">
|
||||||
This collection is empty.
|
This collection is empty.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mangas.length > 0}
|
{#if !contentLoading && mangas.length > 0}
|
||||||
<section aria-labelledby="mangas-heading">
|
<section aria-labelledby="mangas-heading">
|
||||||
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
||||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||||
@@ -207,7 +243,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pages.length > 0}
|
{#if !contentLoading && pages.length > 0}
|
||||||
<section aria-labelledby="pages-heading">
|
<section aria-labelledby="pages-heading">
|
||||||
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
||||||
<ul class="page-grid" data-testid="collection-page-list">
|
<ul class="page-grid" data-testid="collection-page-list">
|
||||||
|
|||||||
@@ -10,18 +10,11 @@ import type { PageLoad } from './$types';
|
|||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, url }) => {
|
export const load: PageLoad = async ({ params, url }) => {
|
||||||
|
let collection;
|
||||||
try {
|
try {
|
||||||
const [collection, mangas, pages] = await Promise.all([
|
// Awaited so the header renders immediately and 401/404 are handled by
|
||||||
getCollection(params.id),
|
// the framework (redirect / not-found) rather than the happy path.
|
||||||
listCollectionMangas(params.id, { limit: 200 }),
|
collection = await getCollection(params.id);
|
||||||
listCollectionPages(params.id, { limit: 200 })
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
collection,
|
|
||||||
mangas: mangas.items,
|
|
||||||
total: mangas.page.total,
|
|
||||||
pages: pages.items
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
@@ -34,4 +27,13 @@ export const load: PageLoad = async ({ params, url }) => {
|
|||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
collection,
|
||||||
|
// Streamed so the manga/page grids show a skeleton while they load
|
||||||
|
// instead of the whole page blocking on them.
|
||||||
|
content: Promise.all([
|
||||||
|
listCollectionMangas(params.id, { limit: 200 }),
|
||||||
|
listCollectionPages(params.id, { limit: 200 })
|
||||||
|
]).then(([mangas, pages]) => ({ mangas: mangas.items, pages: pages.items }))
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||||
import HistoryList from '$lib/components/HistoryList.svelte';
|
import HistoryList from '$lib/components/HistoryList.svelte';
|
||||||
|
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
|
||||||
|
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -66,37 +68,42 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !data.authenticated}
|
{#await data.result}
|
||||||
<p class="hint" data-testid="library-signin">
|
{#if activeTab === 'collections'}
|
||||||
<a href="/login?next=/library">Sign in</a> to see your library.
|
<MangaGridSkeleton count={6} />
|
||||||
</p>
|
|
||||||
{:else if data.error}
|
|
||||||
<p class="error" role="alert" data-testid="library-error">
|
|
||||||
Couldn't load library: {data.error}
|
|
||||||
</p>
|
|
||||||
{:else if activeTab === 'bookmarks'}
|
|
||||||
{#if data.bookmarks.length === 0}
|
|
||||||
<p class="hint" data-testid="library-bookmarks-empty">No bookmarks yet.</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<BookmarkList bookmarks={data.bookmarks} testid="library-bookmark-list" />
|
<ListRowSkeleton />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeTab === 'collections'}
|
{:then r}
|
||||||
{#if data.collections.length === 0}
|
{#if !r.authenticated}
|
||||||
<p class="hint" data-testid="library-collections-empty">
|
<p class="hint" data-testid="library-signin">
|
||||||
You don't have any collections yet. Open any manga and use
|
<a href="/login?next=/library">Sign in</a> to see your library.
|
||||||
<strong>Add to collection</strong> to start one.
|
|
||||||
</p>
|
</p>
|
||||||
|
{:else if r.error}
|
||||||
|
<p class="error" role="alert" data-testid="library-error">
|
||||||
|
Couldn't load library: {r.error}
|
||||||
|
</p>
|
||||||
|
{:else if activeTab === 'bookmarks'}
|
||||||
|
{#if r.bookmarks.length === 0}
|
||||||
|
<p class="hint" data-testid="library-bookmarks-empty">No bookmarks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<BookmarkList bookmarks={r.bookmarks} testid="library-bookmark-list" />
|
||||||
|
{/if}
|
||||||
|
{:else if activeTab === 'collections'}
|
||||||
|
{#if r.collections.length === 0}
|
||||||
|
<p class="hint" data-testid="library-collections-empty">
|
||||||
|
You don't have any collections yet. Open any manga and use
|
||||||
|
<strong>Add to collection</strong> to start one.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<CollectionsGrid collections={r.collections} />
|
||||||
|
{/if}
|
||||||
|
{:else if activeTab === 'page-tags'}
|
||||||
|
<PageTagsList initialItems={r.pageTags} initialDistinct={r.distinctPageTags} />
|
||||||
{:else}
|
{:else}
|
||||||
<CollectionsGrid collections={data.collections} />
|
<HistoryList entries={r.history} onClear={clearOne} testid="library-history" />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeTab === 'page-tags'}
|
{/await}
|
||||||
<PageTagsList
|
|
||||||
initialItems={data.pageTags}
|
|
||||||
initialDistinct={data.distinctPageTags}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<HistoryList entries={data.history} onClear={clearOne} testid="library-history" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.library-heading {
|
.library-heading {
|
||||||
|
|||||||
@@ -26,30 +26,37 @@ export const load: PageLoad = async () => {
|
|||||||
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
try {
|
// Streamed so the active sub-tab shows a skeleton while the one-shot fetch
|
||||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
// (also the auth gate) is in flight.
|
||||||
await Promise.all([
|
return {
|
||||||
listMyBookmarks(),
|
result: (async () => {
|
||||||
listMyCollections({ limit: 200 }),
|
try {
|
||||||
listMyReadProgress({ limit: 100 }),
|
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||||
listMyPageTags({ limit: 100 }),
|
await Promise.all([
|
||||||
listMyDistinctPageTags(undefined, 100)
|
listMyBookmarks(),
|
||||||
]);
|
listMyCollections({ limit: 200 }),
|
||||||
return {
|
listMyReadProgress({ limit: 100 }),
|
||||||
...empty,
|
listMyPageTags({ limit: 100 }),
|
||||||
bookmarks: bookmarks.items,
|
listMyDistinctPageTags(undefined, 100)
|
||||||
collections: collections.items,
|
]);
|
||||||
history: history.items,
|
return {
|
||||||
pageTags: pageTags.items,
|
...empty,
|
||||||
distinctPageTags
|
bookmarks: bookmarks.items,
|
||||||
};
|
collections: collections.items,
|
||||||
} catch (e) {
|
history: history.items,
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
pageTags: pageTags.items,
|
||||||
return { ...empty, authenticated: false };
|
distinctPageTags
|
||||||
}
|
};
|
||||||
if (e instanceof ApiError) {
|
} catch (e) {
|
||||||
return { ...empty, error: e.message };
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
}
|
return { ...empty, authenticated: false };
|
||||||
throw e;
|
}
|
||||||
}
|
// An HTTP error or a raw network failure (a non-ApiError
|
||||||
|
// TypeError) renders inline rather than escaping to the
|
||||||
|
// framework error page for a transient API blip.
|
||||||
|
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||||
|
return { ...empty, error: message };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,34 @@ import { listMyBookmarksOrEmpty } from '$lib/api/bookmarks';
|
|||||||
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
import { getMyReadProgressForManga } from '$lib/api/read_progress';
|
||||||
import { getMyReactionForManga } from '$lib/api/reactions';
|
import { getMyReactionForManga } from '$lib/api/reactions';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
import type { DetailData } from './types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = ({ params }) => {
|
||||||
const [manga, chapters, bookmarks, readProgress, reaction, similar] = await Promise.all([
|
// Streamed (the load doesn't await) so the page renders a
|
||||||
|
// MangaDetailSkeleton while the bundle loads instead of blocking
|
||||||
|
// navigation on all six calls. A getManga 404 / any hard failure rejects
|
||||||
|
// the bundle and is handled by the page's {:catch}.
|
||||||
|
const bundle: Promise<DetailData> = Promise.all([
|
||||||
getManga(params.id),
|
getManga(params.id),
|
||||||
listChapters(params.id),
|
listChapters(params.id),
|
||||||
listMyBookmarksOrEmpty(),
|
listMyBookmarksOrEmpty(),
|
||||||
// Null when guest or never-read — page handles both cases.
|
// Null when guest or never-read — the page handles both cases.
|
||||||
getMyReadProgressForManga(params.id),
|
getMyReadProgressForManga(params.id),
|
||||||
// Null when guest or not reacted — seeds the like/dislike toggle.
|
|
||||||
// Non-critical: any failure degrades to an unset toggle, never a
|
// Non-critical: any failure degrades to an unset toggle, never a
|
||||||
// broken page.
|
// broken page.
|
||||||
getMyReactionForManga(params.id).catch(() => null),
|
getMyReactionForManga(params.id).catch(() => null),
|
||||||
// Recommendations are non-critical: a failure here must not break
|
// Recommendations are non-critical: fall back to an empty list.
|
||||||
// the detail page, so fall back to an empty list.
|
|
||||||
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
|
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
|
||||||
]);
|
]).then(([manga, chapters, bookmarks, readProgress, reaction, similar]) => ({
|
||||||
return {
|
|
||||||
manga,
|
manga,
|
||||||
chapters: chapters.items,
|
chapters: chapters.items,
|
||||||
bookmarks: bookmarks.items,
|
bookmarks: bookmarks.items,
|
||||||
readProgress,
|
readProgress,
|
||||||
reaction,
|
reaction,
|
||||||
similar
|
similar
|
||||||
};
|
}));
|
||||||
|
|
||||||
|
return { bundle };
|
||||||
};
|
};
|
||||||
|
|||||||
1460
frontend/src/routes/manga/[id]/DetailView.svelte
Normal file
1460
frontend/src/routes/manga/[id]/DetailView.svelte
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/src/routes/manga/[id]/types.ts
Normal file
19
frontend/src/routes/manga/[id]/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MangaCard, MangaDetail } from '$lib/api/mangas';
|
||||||
|
import type { Chapter } from '$lib/api/chapters';
|
||||||
|
import type { Bookmark } from '$lib/api/bookmarks';
|
||||||
|
import type { ReadProgressForManga } from '$lib/api/read_progress';
|
||||||
|
import type { Reaction } from '$lib/api/reactions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manga detail bundle. Streamed from the loader (see +page.ts) and passed
|
||||||
|
* to DetailView once resolved, so the page can render a skeleton while it
|
||||||
|
* loads. Shape matches the loader's previous flat return.
|
||||||
|
*/
|
||||||
|
export type DetailData = {
|
||||||
|
manga: MangaDetail;
|
||||||
|
chapters: Chapter[];
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
readProgress: ReadProgressForManga | null;
|
||||||
|
reaction: Reaction | null;
|
||||||
|
similar: MangaCard[];
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
import TaggedPageRow from '$lib/components/TaggedPageRow.svelte';
|
||||||
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
import TaggedChapterRow from '$lib/components/TaggedChapterRow.svelte';
|
||||||
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
|
import TaggedMangaRow from '$lib/components/TaggedMangaRow.svelte';
|
||||||
|
import SearchResultsSkeleton from '$lib/components/SearchResultsSkeleton.svelte';
|
||||||
import { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags';
|
import { CONTENT_WARNINGS, type ContentWarning } from '$lib/api/page_tags';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
@@ -176,21 +177,29 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.contentSearch}
|
{#if data.contentSearch}
|
||||||
{#if data.results.length === 0}
|
{#await data.results}
|
||||||
<p class="hint" data-testid="search-content-empty">
|
<SearchResultsSkeleton />
|
||||||
No pages match this search.
|
{:then r}
|
||||||
</p>
|
{#if r.error}
|
||||||
{:else}
|
<p class="error" role="alert" data-testid="search-results-error">
|
||||||
<ul class="list" data-testid="search-content-list">
|
Couldn't load results: {r.error}
|
||||||
{#each data.results as p (p.page_id)}
|
</p>
|
||||||
<TaggedPageRow
|
{:else if r.results.length === 0}
|
||||||
item={{ ...p, tag: '', tagged_at: '' }}
|
<p class="hint" data-testid="search-content-empty">
|
||||||
showTagPill={false}
|
No pages match this search.
|
||||||
testid={`search-result-${p.page_id}`}
|
</p>
|
||||||
/>
|
{:else}
|
||||||
{/each}
|
<ul class="list" data-testid="search-content-list">
|
||||||
</ul>
|
{#each r.results as p (p.page_id)}
|
||||||
{/if}
|
<TaggedPageRow
|
||||||
|
item={{ ...p, tag: '', tagged_at: '' }}
|
||||||
|
showTagPill={false}
|
||||||
|
testid={`search-result-${p.page_id}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Tag filter. When a tag is selected it's shown as a chip with
|
<!-- Tag filter. When a tag is selected it's shown as a chip with
|
||||||
an x to clear; when none is, the input doubles as the entry
|
an x to clear; when none is, the input doubles as the entry
|
||||||
@@ -280,53 +289,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.view === 'pages'}
|
{#await data.results}
|
||||||
{#if data.pages.length === 0}
|
<SearchResultsSkeleton />
|
||||||
<p class="hint" data-testid="search-pages-empty">
|
{:then r}
|
||||||
No pages tagged with "{data.tag}".
|
{#if r.error}
|
||||||
|
<p class="error" role="alert" data-testid="search-results-error">
|
||||||
|
Couldn't load results: {r.error}
|
||||||
</p>
|
</p>
|
||||||
|
{:else if data.view === 'pages'}
|
||||||
|
{#if r.pages.length === 0}
|
||||||
|
<p class="hint" data-testid="search-pages-empty">
|
||||||
|
No pages tagged with "{data.tag}".
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list" data-testid="search-pages-list">
|
||||||
|
{#each r.pages as p (p.page_id)}
|
||||||
|
<TaggedPageRow
|
||||||
|
item={p}
|
||||||
|
showTagPill={false}
|
||||||
|
testid={`search-page-row-${p.page_id}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else if data.view === 'chapters'}
|
||||||
|
{#if r.chapters.length === 0}
|
||||||
|
<p class="hint" data-testid="search-chapters-empty">
|
||||||
|
No chapters contain pages tagged with "{data.tag}".
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list" data-testid="search-chapters-list">
|
||||||
|
{#each r.chapters as c (c.chapter_id)}
|
||||||
|
<TaggedChapterRow
|
||||||
|
item={c}
|
||||||
|
testid={`search-chapter-row-${c.chapter_id}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="list" data-testid="search-pages-list">
|
{#if r.mangas.length === 0}
|
||||||
{#each data.pages as p (p.page_id)}
|
<p class="hint" data-testid="search-mangas-empty">
|
||||||
<TaggedPageRow
|
No mangas contain pages tagged with "{data.tag}".
|
||||||
item={p}
|
</p>
|
||||||
showTagPill={false}
|
{:else}
|
||||||
testid={`search-page-row-${p.page_id}`}
|
<ul class="list" data-testid="search-mangas-list">
|
||||||
/>
|
{#each r.mangas as m (m.manga_id)}
|
||||||
{/each}
|
<TaggedMangaRow
|
||||||
</ul>
|
item={m}
|
||||||
|
testid={`search-manga-row-${m.manga_id}`}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if data.view === 'chapters'}
|
{/await}
|
||||||
{#if data.chapters.length === 0}
|
|
||||||
<p class="hint" data-testid="search-chapters-empty">
|
|
||||||
No chapters contain pages tagged with "{data.tag}".
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="list" data-testid="search-chapters-list">
|
|
||||||
{#each data.chapters as c (c.chapter_id)}
|
|
||||||
<TaggedChapterRow
|
|
||||||
item={c}
|
|
||||||
testid={`search-chapter-row-${c.chapter_id}`}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{#if data.mangas.length === 0}
|
|
||||||
<p class="hint" data-testid="search-mangas-empty">
|
|
||||||
No mangas contain pages tagged with "{data.tag}".
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="list" data-testid="search-mangas-list">
|
|
||||||
{#each data.mangas as m (m.manga_id)}
|
|
||||||
<TaggedMangaRow
|
|
||||||
item={m}
|
|
||||||
testid={`search-manga-row-${m.manga_id}`}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
// backend; cw_exclude alone can't drive a search.
|
// backend; cw_exclude alone can't drive a search.
|
||||||
const contentSearch = text !== '' || cwInclude.length > 0;
|
const contentSearch = text !== '' || cwInclude.length > 0;
|
||||||
|
|
||||||
const empty = {
|
const base = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
tag,
|
tag,
|
||||||
view,
|
view,
|
||||||
@@ -70,6 +70,10 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
cwExclude,
|
cwExclude,
|
||||||
contentSearch,
|
contentSearch,
|
||||||
distinct: [] as PageTagSummary[],
|
distinct: [] as PageTagSummary[],
|
||||||
|
error: null as string | null
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyResults = {
|
||||||
pages: [] as TaggedPageItem[],
|
pages: [] as TaggedPageItem[],
|
||||||
chapters: [] as TaggedChapterAggregate[],
|
chapters: [] as TaggedChapterAggregate[],
|
||||||
mangas: [] as TaggedMangaAggregate[],
|
mangas: [] as TaggedMangaAggregate[],
|
||||||
@@ -78,55 +82,54 @@ export const load: PageLoad = async ({ url }) => {
|
|||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The chip cloud + auth gate come from `distinct`, so it's awaited. A 401
|
||||||
|
// here means "not signed in"; any other error is surfaced inline. The
|
||||||
|
// view-specific results are streamed (see below).
|
||||||
|
let distinct: PageTagSummary[];
|
||||||
try {
|
try {
|
||||||
const distinct = await listMyDistinctPageTags(undefined, 100);
|
distinct = await listMyDistinctPageTags(undefined, 100);
|
||||||
|
|
||||||
// Content search takes precedence over tag browsing.
|
|
||||||
if (contentSearch) {
|
|
||||||
const r = await searchPages({
|
|
||||||
text: text || undefined,
|
|
||||||
cwInclude,
|
|
||||||
cwExclude,
|
|
||||||
limit: 100
|
|
||||||
});
|
|
||||||
return { ...empty, distinct, results: r.items, total: r.page.total ?? 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// No tag selected → just the chip cloud.
|
|
||||||
if (!tag) return { ...empty, distinct };
|
|
||||||
|
|
||||||
if (view === 'chapters') {
|
|
||||||
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
|
||||||
return {
|
|
||||||
...empty,
|
|
||||||
distinct,
|
|
||||||
chapters: r.items,
|
|
||||||
total: r.page.total ?? 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (view === 'mangas') {
|
|
||||||
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
|
||||||
return {
|
|
||||||
...empty,
|
|
||||||
distinct,
|
|
||||||
mangas: r.items,
|
|
||||||
total: r.page.total ?? 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const r = await listMyPageTags({ tag, limit: 100 });
|
|
||||||
return {
|
|
||||||
...empty,
|
|
||||||
distinct,
|
|
||||||
pages: r.items,
|
|
||||||
total: r.page.total ?? 0
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 401) {
|
if (e instanceof ApiError && e.status === 401) {
|
||||||
return { ...empty, authenticated: false };
|
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
|
||||||
}
|
}
|
||||||
if (e instanceof ApiError) {
|
// Any other HTTP error or a raw network failure (a non-ApiError
|
||||||
return { ...empty, error: e.message };
|
// TypeError) is surfaced inline rather than escaping to the framework
|
||||||
}
|
// error page.
|
||||||
throw e;
|
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||||
|
return { ...base, error: message, results: Promise.resolve(emptyResults) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streamed so the results list shows a skeleton while it (re-)queries on
|
||||||
|
// every tag / view / sort / text change instead of freezing the old list.
|
||||||
|
const results = (async () => {
|
||||||
|
try {
|
||||||
|
// Content search takes precedence over tag browsing.
|
||||||
|
if (contentSearch) {
|
||||||
|
const r = await searchPages({
|
||||||
|
text: text || undefined,
|
||||||
|
cwInclude,
|
||||||
|
cwExclude,
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
|
||||||
|
}
|
||||||
|
// No tag selected → just the chip cloud, no results.
|
||||||
|
if (!tag) return emptyResults;
|
||||||
|
if (view === 'chapters') {
|
||||||
|
const r = await listTaggedChapters({ tag, order, limit: 100 });
|
||||||
|
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
|
||||||
|
}
|
||||||
|
if (view === 'mangas') {
|
||||||
|
const r = await listTaggedMangas({ tag, order, limit: 100 });
|
||||||
|
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
|
||||||
|
}
|
||||||
|
const r = await listMyPageTags({ tag, limit: 100 });
|
||||||
|
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof ApiError ? e.message : 'Something went wrong.';
|
||||||
|
return { ...emptyResults, error: message };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { ...base, distinct, results };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user