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]]
|
||||
name = "mangalord"
|
||||
version = "0.109.1"
|
||||
version = "0.122.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.109.1"
|
||||
version = "0.122.1"
|
||||
edition = "2021"
|
||||
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');
|
||||
});
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
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",
|
||||
"version": "0.109.1",
|
||||
"version": "0.122.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
alt=""
|
||||
class="collage-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover cover-placeholder">
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
|
||||
@@ -29,6 +29,16 @@ describe('HistoryList', () => {
|
||||
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', () => {
|
||||
// The page suffix is a separate text node from "Continue Chapter N",
|
||||
// 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=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
@@ -89,17 +90,8 @@
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
/* `.manga-card` layout lives in tokens.css so the catalog cards and the
|
||||
loading skeleton share one definition. */
|
||||
|
||||
.cover-link {
|
||||
display: block;
|
||||
|
||||
@@ -49,6 +49,16 @@ describe('MangaCard badges & overlays', () => {
|
||||
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', () => {
|
||||
const { rerender } = render(MangaCard, {
|
||||
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}>
|
||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if primaryCover}
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="cover cover-placeholder"></div>
|
||||
{/if}
|
||||
@@ -46,7 +46,7 @@
|
||||
{#if item.sample_storage_keys.length > 1}
|
||||
<div class="samples">
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<li class="row" data-testid={testid}>
|
||||
<a {href} class="cover-link" aria-hidden="true" tabindex="-1">
|
||||
{#if primaryCover}
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" />
|
||||
<img src={fileUrl(primaryCover)} alt="" class="cover" loading="lazy" decoding="async" />
|
||||
{:else}
|
||||
<div class="cover cover-placeholder"></div>
|
||||
{/if}
|
||||
@@ -50,7 +50,7 @@
|
||||
{#if sampleStrip.length > 0}
|
||||
<div class="samples">
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
<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-soft-bg: #fff5f5;
|
||||
--success: #0a7d2c;
|
||||
--success-soft-bg: #eaf7ee;
|
||||
--warning-soft-bg: #fff5d6;
|
||||
--warning-border: #d6a800;
|
||||
--focus-ring: #2563eb;
|
||||
@@ -28,6 +29,9 @@
|
||||
|
||||
--font-xs: 0.75rem;
|
||||
--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-lg: 1.125rem;
|
||||
--font-xl: 1.5rem;
|
||||
@@ -95,6 +99,9 @@
|
||||
|
||||
--z-dropdown: 10;
|
||||
--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-toast: 1000;
|
||||
}
|
||||
@@ -114,6 +121,7 @@
|
||||
--danger: #f87171;
|
||||
--danger-soft-bg: #3a1620;
|
||||
--success: #4ade80;
|
||||
--success-soft-bg: #12301c;
|
||||
--warning-soft-bg: #3a2e10;
|
||||
--warning-border: #a37800;
|
||||
--focus-ring: #60a5fa;
|
||||
@@ -341,6 +349,20 @@ img {
|
||||
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) {
|
||||
.manga-grid {
|
||||
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 AppBar from '$lib/components/AppBar.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 Upload from '@lucide/svelte/icons/upload';
|
||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||
@@ -198,6 +200,8 @@
|
||||
<title>{layoutTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<NavProgress />
|
||||
|
||||
{#if showMobileChrome}
|
||||
<div class="mobile-app-bar-wrap" bind:this={appBarEl}>
|
||||
<AppBar title="Mangalord" testid="mobile-app-bar" />
|
||||
@@ -268,6 +272,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<style>
|
||||
header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
import Chip from '$lib/components/Chip.svelte';
|
||||
import ContinueReadingShelf from '$lib/components/ContinueReadingShelf.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 MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
@@ -47,6 +50,10 @@
|
||||
let mangas: MangaCardData[] = $state([]);
|
||||
let continueEntries = $state<ReadProgressSummary[]>([]);
|
||||
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 sort = $state<MangaSort>(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.
|
||||
}
|
||||
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();
|
||||
// Fetch the personal shelves after the catalogue so the public browse
|
||||
// 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;
|
||||
}
|
||||
await shelves;
|
||||
});
|
||||
|
||||
// Track viewport in a separate $effect so the listener cleans up on
|
||||
@@ -484,12 +496,18 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if continueEntries.length > 0}
|
||||
<ContinueReadingShelf entries={continueEntries} />
|
||||
{/if}
|
||||
{#if shelvesLoading && session.user}
|
||||
<!-- Reserve the shelf area for logged-in users so the grid below doesn't
|
||||
jump when the real shelves resolve. -->
|
||||
<ShelfSkeleton testid="shelf-skeleton" />
|
||||
{:else}
|
||||
{#if continueEntries.length > 0}
|
||||
<ContinueReadingShelf entries={continueEntries} />
|
||||
{/if}
|
||||
|
||||
{#if recommendations.length > 0}
|
||||
<RecommendationShelf mangas={recommendations} />
|
||||
{#if recommendations.length > 0}
|
||||
<RecommendationShelf mangas={recommendations} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<form
|
||||
@@ -659,7 +677,10 @@
|
||||
</Sheet>
|
||||
|
||||
{#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}
|
||||
<p class="error" data-testid="error" role="alert">{error}</p>
|
||||
{:else if mangas.length === 0}
|
||||
@@ -972,6 +993,18 @@
|
||||
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 {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -7,15 +8,8 @@
|
||||
|
||||
let { data } = $props();
|
||||
const author = $derived(data.author);
|
||||
const mangas = $derived(data.mangas);
|
||||
const total = $derived(data.total);
|
||||
const currentPage = $derived(data.currentPage);
|
||||
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) {
|
||||
if (p === currentPage) return;
|
||||
@@ -45,28 +39,36 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if mangas.length === 0}
|
||||
<p class="status" data-testid="author-no-mangas">
|
||||
No mangas attributed to this author.
|
||||
</p>
|
||||
{:else}
|
||||
{#if total != null}
|
||||
<p class="meta" data-testid="author-shown-of-total">
|
||||
Showing {rangeStart}–{rangeEnd} of {total}
|
||||
{#await data.mangas}
|
||||
<MangaGridSkeleton />
|
||||
{:then result}
|
||||
{@const mangas = result.items}
|
||||
{@const total = result.total}
|
||||
{@const totalPages = total != null && total > 0 ? Math.ceil(total / pageSize) : 1}
|
||||
{@const rangeStart = mangas.length === 0 ? 0 : (currentPage - 1) * pageSize + 1}
|
||||
{@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>
|
||||
{: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}
|
||||
<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}
|
||||
{:catch}
|
||||
<p class="status" role="alert" data-testid="author-error">
|
||||
Could not load this author's mangas.
|
||||
</p>
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.back {
|
||||
|
||||
@@ -11,28 +11,27 @@ export const load: PageLoad = async ({ params, url }) => {
|
||||
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||
const currentPage =
|
||||
Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
||||
let author;
|
||||
try {
|
||||
const [author, mangas] = await Promise.all([
|
||||
getAuthor(params.id),
|
||||
listAuthorMangas(params.id, {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (currentPage - 1) * PAGE_SIZE
|
||||
})
|
||||
]);
|
||||
return {
|
||||
author,
|
||||
mangas: mangas.items,
|
||||
total: mangas.page.total,
|
||||
currentPage,
|
||||
pageSize: PAGE_SIZE
|
||||
};
|
||||
// Awaited so a 404 surfaces as a real SvelteKit error (the framework
|
||||
// not-found page) rather than the happy-path markup with undefined
|
||||
// data. The author header is cheap, so blocking on it is fine.
|
||||
author = await getAuthor(params.id);
|
||||
} 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) {
|
||||
error(404, 'Author not found');
|
||||
}
|
||||
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">
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import ListRowSkeleton from '$lib/components/ListRowSkeleton.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const authenticated = $derived(data.authenticated);
|
||||
const bookmarks = $derived(data.bookmarks);
|
||||
const error = $derived(data.error);
|
||||
</script>
|
||||
|
||||
<h1>Bookmarks</h1>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert" data-testid="bookmarks-error">
|
||||
Couldn't load bookmarks: {error}
|
||||
</p>
|
||||
{:else if !authenticated}
|
||||
<p class="hint" data-testid="bookmarks-signin">
|
||||
<a href="/login">Sign in</a> to see your bookmarks.
|
||||
</p>
|
||||
{:else if bookmarks.length === 0}
|
||||
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||
{:else}
|
||||
<BookmarkList {bookmarks} />
|
||||
{/if}
|
||||
{#await data.result}
|
||||
<ListRowSkeleton />
|
||||
{:then r}
|
||||
{#if r.error}
|
||||
<p class="error" role="alert" data-testid="bookmarks-error">
|
||||
Couldn't load bookmarks: {r.error}
|
||||
</p>
|
||||
{:else if !r.authenticated}
|
||||
<p class="hint" data-testid="bookmarks-signin">
|
||||
<a href="/login">Sign in</a> to see your bookmarks.
|
||||
</p>
|
||||
{:else if r.bookmarks.length === 0}
|
||||
<p class="hint" data-testid="bookmarks-empty">No bookmarks yet.</p>
|
||||
{:else}
|
||||
<BookmarkList bookmarks={r.bookmarks} />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.error {
|
||||
|
||||
@@ -5,21 +5,26 @@ import type { PageLoad } from './$types';
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
try {
|
||||
const page = await listMyBookmarks();
|
||||
return { bookmarks: page.items, authenticated: true, error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { bookmarks: [], authenticated: false, error: null };
|
||||
}
|
||||
// Anything else (502 upstream_unavailable from a backend
|
||||
// restart, 500 internal_error) is rendered inline rather than
|
||||
// re-thrown — SvelteKit's generic error.html is not the right
|
||||
// UX for a transient API blip and the user is already
|
||||
// authenticated as far as we know.
|
||||
if (e instanceof ApiError) {
|
||||
return { bookmarks: [], authenticated: true, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// Streamed (the load itself doesn't await) so the list shows a skeleton
|
||||
// while the single fetch — which is also the auth gate — is in flight.
|
||||
return {
|
||||
result: (async () => {
|
||||
try {
|
||||
const page = await listMyBookmarks();
|
||||
return { bookmarks: page.items, authenticated: true, error: null as string | null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { bookmarks: [], authenticated: false, error: null as string | null };
|
||||
}
|
||||
// Anything else — an HTTP error (502 upstream_unavailable from
|
||||
// a backend restart, 500 internal_error) or a raw network
|
||||
// failure (a non-ApiError TypeError) — renders inline rather
|
||||
// than re-thrown: SvelteKit's generic error.html is not the
|
||||
// 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">
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const collections = $derived(data.collections);
|
||||
</script>
|
||||
|
||||
<h1>Collections</h1>
|
||||
|
||||
{#if !data.authenticated}
|
||||
<p class="status">
|
||||
<a href="/login">Sign in</a> to see and manage your collections.
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<p class="error" role="alert">{data.error}</p>
|
||||
{:else if collections.length === 0}
|
||||
<p class="status" data-testid="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} />
|
||||
{/if}
|
||||
{#await data.result}
|
||||
<MangaGridSkeleton count={6} />
|
||||
{:then r}
|
||||
{#if !r.authenticated}
|
||||
<p class="status">
|
||||
<a href="/login">Sign in</a> to see and manage your collections.
|
||||
</p>
|
||||
{:else if r.error}
|
||||
<p class="error" role="alert">{r.error}</p>
|
||||
{:else if r.collections.length === 0}
|
||||
<p class="status" data-testid="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}
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.status {
|
||||
|
||||
@@ -5,16 +5,23 @@ import type { PageLoad } from './$types';
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
try {
|
||||
const page = await listMyCollections({ limit: 200 });
|
||||
return { collections: page.items, authenticated: true, error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { collections: [], authenticated: false, error: null };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { collections: [], authenticated: true, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// Streamed so the grid shows a skeleton while the single fetch (also the
|
||||
// auth gate) is in flight.
|
||||
return {
|
||||
result: (async () => {
|
||||
try {
|
||||
const page = await listMyCollections({ limit: 200 });
|
||||
return { collections: page.items, authenticated: true, error: null as string | null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { collections: [], authenticated: false, error: null as string | null };
|
||||
}
|
||||
// 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 { fileUrl } from '$lib/api/client';
|
||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||
import MangaGridSkeleton from '$lib/components/MangaGridSkeleton.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
@@ -22,10 +23,39 @@
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collection = $state({ ...data.collection });
|
||||
// svelte-ignore state_referenced_locally
|
||||
let mangas = $state<Manga[]>([...data.mangas]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
||||
// The manga/page lists are streamed and held as local mutable state so
|
||||
// removals can be optimistic. They're seeded once the stream resolves; a
|
||||
// skeleton shows until then.
|
||||
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 editName = $state('');
|
||||
@@ -178,13 +208,19 @@
|
||||
{/if}
|
||||
</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">
|
||||
This collection is empty.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mangas.length > 0}
|
||||
{#if !contentLoading && mangas.length > 0}
|
||||
<section aria-labelledby="mangas-heading">
|
||||
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
||||
<ul class="manga-grid" data-testid="collection-manga-list">
|
||||
@@ -207,7 +243,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if pages.length > 0}
|
||||
{#if !contentLoading && pages.length > 0}
|
||||
<section aria-labelledby="pages-heading">
|
||||
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
||||
<ul class="page-grid" data-testid="collection-page-list">
|
||||
|
||||
@@ -10,18 +10,11 @@ import type { PageLoad } from './$types';
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params, url }) => {
|
||||
let collection;
|
||||
try {
|
||||
const [collection, mangas, pages] = await Promise.all([
|
||||
getCollection(params.id),
|
||||
listCollectionMangas(params.id, { limit: 200 }),
|
||||
listCollectionPages(params.id, { limit: 200 })
|
||||
]);
|
||||
return {
|
||||
collection,
|
||||
mangas: mangas.items,
|
||||
total: mangas.page.total,
|
||||
pages: pages.items
|
||||
};
|
||||
// Awaited so the header renders immediately and 401/404 are handled by
|
||||
// the framework (redirect / not-found) rather than the happy path.
|
||||
collection = await getCollection(params.id);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 401) {
|
||||
@@ -34,4 +27,13 @@ export const load: PageLoad = async ({ params, url }) => {
|
||||
}
|
||||
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 PageTagsList from '$lib/components/PageTagsList.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();
|
||||
|
||||
@@ -66,37 +68,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !data.authenticated}
|
||||
<p class="hint" data-testid="library-signin">
|
||||
<a href="/login?next=/library">Sign in</a> to see your library.
|
||||
</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>
|
||||
{#await data.result}
|
||||
{#if activeTab === 'collections'}
|
||||
<MangaGridSkeleton count={6} />
|
||||
{:else}
|
||||
<BookmarkList bookmarks={data.bookmarks} testid="library-bookmark-list" />
|
||||
<ListRowSkeleton />
|
||||
{/if}
|
||||
{:else if activeTab === 'collections'}
|
||||
{#if data.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.
|
||||
{:then r}
|
||||
{#if !r.authenticated}
|
||||
<p class="hint" data-testid="library-signin">
|
||||
<a href="/login?next=/library">Sign in</a> to see your library.
|
||||
</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}
|
||||
<CollectionsGrid collections={data.collections} />
|
||||
<HistoryList entries={r.history} onClear={clearOne} testid="library-history" />
|
||||
{/if}
|
||||
{:else if activeTab === 'page-tags'}
|
||||
<PageTagsList
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else}
|
||||
<HistoryList entries={data.history} onClear={clearOne} testid="library-history" />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<style>
|
||||
.library-heading {
|
||||
|
||||
@@ -26,30 +26,37 @@ export const load: PageLoad = async () => {
|
||||
distinctPageTags: [] as Awaited<ReturnType<typeof listMyDistinctPageTags>>,
|
||||
error: null as string | null
|
||||
};
|
||||
try {
|
||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||
await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
...empty,
|
||||
bookmarks: bookmarks.items,
|
||||
collections: collections.items,
|
||||
history: history.items,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...empty, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// Streamed so the active sub-tab shows a skeleton while the one-shot fetch
|
||||
// (also the auth gate) is in flight.
|
||||
return {
|
||||
result: (async () => {
|
||||
try {
|
||||
const [bookmarks, collections, history, pageTags, distinctPageTags] =
|
||||
await Promise.all([
|
||||
listMyBookmarks(),
|
||||
listMyCollections({ limit: 200 }),
|
||||
listMyReadProgress({ limit: 100 }),
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
...empty,
|
||||
bookmarks: bookmarks.items,
|
||||
collections: collections.items,
|
||||
history: history.items,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
}
|
||||
// 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 { getMyReactionForManga } from '$lib/api/reactions';
|
||||
import type { PageLoad } from './$types';
|
||||
import type { DetailData } from './types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const [manga, chapters, bookmarks, readProgress, reaction, similar] = await Promise.all([
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
// 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),
|
||||
listChapters(params.id),
|
||||
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),
|
||||
// Null when guest or not reacted — seeds the like/dislike toggle.
|
||||
// Non-critical: any failure degrades to an unset toggle, never a
|
||||
// broken page.
|
||||
getMyReactionForManga(params.id).catch(() => null),
|
||||
// Recommendations are non-critical: a failure here must not break
|
||||
// the detail page, so fall back to an empty list.
|
||||
// Recommendations are non-critical: fall back to an empty list.
|
||||
getSimilarMangas(params.id).catch(() => [] as MangaCard[])
|
||||
]);
|
||||
return {
|
||||
]).then(([manga, chapters, bookmarks, readProgress, reaction, similar]) => ({
|
||||
manga,
|
||||
chapters: chapters.items,
|
||||
bookmarks: bookmarks.items,
|
||||
readProgress,
|
||||
reaction,
|
||||
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 TaggedChapterRow from '$lib/components/TaggedChapterRow.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 X from '@lucide/svelte/icons/x';
|
||||
|
||||
@@ -176,21 +177,29 @@
|
||||
</section>
|
||||
|
||||
{#if data.contentSearch}
|
||||
{#if data.results.length === 0}
|
||||
<p class="hint" data-testid="search-content-empty">
|
||||
No pages match this search.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-content-list">
|
||||
{#each data.results as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={{ ...p, tag: '', tagged_at: '' }}
|
||||
showTagPill={false}
|
||||
testid={`search-result-${p.page_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#await data.results}
|
||||
<SearchResultsSkeleton />
|
||||
{:then r}
|
||||
{#if r.error}
|
||||
<p class="error" role="alert" data-testid="search-results-error">
|
||||
Couldn't load results: {r.error}
|
||||
</p>
|
||||
{:else if r.results.length === 0}
|
||||
<p class="hint" data-testid="search-content-empty">
|
||||
No pages match this search.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="list" data-testid="search-content-list">
|
||||
{#each r.results as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={{ ...p, tag: '', tagged_at: '' }}
|
||||
showTagPill={false}
|
||||
testid={`search-result-${p.page_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<!-- 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
|
||||
@@ -280,53 +289,61 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.view === 'pages'}
|
||||
{#if data.pages.length === 0}
|
||||
<p class="hint" data-testid="search-pages-empty">
|
||||
No pages tagged with "{data.tag}".
|
||||
{#await data.results}
|
||||
<SearchResultsSkeleton />
|
||||
{:then r}
|
||||
{#if r.error}
|
||||
<p class="error" role="alert" data-testid="search-results-error">
|
||||
Couldn't load results: {r.error}
|
||||
</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}
|
||||
<ul class="list" data-testid="search-pages-list">
|
||||
{#each data.pages as p (p.page_id)}
|
||||
<TaggedPageRow
|
||||
item={p}
|
||||
showTagPill={false}
|
||||
testid={`search-page-row-${p.page_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if r.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 r.mangas as m (m.manga_id)}
|
||||
<TaggedMangaRow
|
||||
item={m}
|
||||
testid={`search-manga-row-${m.manga_id}`}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if data.view === 'chapters'}
|
||||
{#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}
|
||||
{/await}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const load: PageLoad = async ({ url }) => {
|
||||
// backend; cw_exclude alone can't drive a search.
|
||||
const contentSearch = text !== '' || cwInclude.length > 0;
|
||||
|
||||
const empty = {
|
||||
const base = {
|
||||
authenticated: true,
|
||||
tag,
|
||||
view,
|
||||
@@ -70,6 +70,10 @@ export const load: PageLoad = async ({ url }) => {
|
||||
cwExclude,
|
||||
contentSearch,
|
||||
distinct: [] as PageTagSummary[],
|
||||
error: null as string | null
|
||||
};
|
||||
|
||||
const emptyResults = {
|
||||
pages: [] as TaggedPageItem[],
|
||||
chapters: [] as TaggedChapterAggregate[],
|
||||
mangas: [] as TaggedMangaAggregate[],
|
||||
@@ -78,55 +82,54 @@ export const load: PageLoad = async ({ url }) => {
|
||||
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 {
|
||||
const 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
|
||||
};
|
||||
distinct = await listMyDistinctPageTags(undefined, 100);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { ...empty, authenticated: false };
|
||||
return { ...base, authenticated: false, results: Promise.resolve(emptyResults) };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { ...empty, error: e.message };
|
||||
}
|
||||
throw e;
|
||||
// Any other HTTP error or a raw network failure (a non-ApiError
|
||||
// TypeError) is surfaced inline rather than escaping to the framework
|
||||
// error page.
|
||||
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