feat: paginate list views, fix stale page titles, tidy admin filter bar
Bundle of small UI/UX fixes plus a build hygiene tweak.
* List pagination — Home (`/`) and `/authors/[id]` silently capped at
the backend default of 50 with no UI to advance. New reusable
`Pager.svelte` (Prev/Next + numbered with ellipsis), URL-synced
`?page=N`, and filter/search/sort reset to page 1 so users aren't
stranded on an out-of-range page. Count label now shows a range
("Showing 51–100 of 237").
* Stale page title — Pages without a `<svelte:head><title>` left the
document title at whatever the last manga / author / collection page
set it to. Move static-route titles into a route-id → title map in
the root layout and invert every dynamic title to brand-first
(`Mangalord | {X}`) for consistency.
* Admin filter bar — `/admin/mangas` search input had `flex: 1` and
ballooned across the row, shoving the sync-state select + Search
button to the far right. Cap at 24rem, vertical-align the row, and
promote the previously aria-only "Sync state" label to visible text.
* Build hygiene — `backend/target` had grown to 68 GiB. Cleaned and
added `[profile.dev] debug = "line-tables-only"` (and `[profile.test]`
too) to cut future dev builds by ~50–70% while keeping line numbers
in backtraces.
Also: configure vitest to resolve Svelte's browser entry so
`@testing-library/svelte` can mount components in jsdom — needed for
the new `Pager.svelte.test.ts`.
Bump 0.48.0 -> 0.49.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.48.0"
|
version = "0.49.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.48.0"
|
version = "0.49.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
@@ -57,3 +57,13 @@ http-body-util = "0.1"
|
|||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tokio = { version = "1", features = ["test-util"] }
|
tokio = { version = "1", features = ["test-util"] }
|
||||||
|
|
||||||
|
# Trim debug builds: keep line numbers in panics / backtraces but drop the
|
||||||
|
# full DWARF info (variable-level inspection in gdb/lldb). With a sqlx +
|
||||||
|
# axum + tokio dep tree the default ("full") leaves backend/target on the
|
||||||
|
# order of tens of GiB; this typically cuts ~50–70% off that.
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||||
|
|
||||||
async function mockAnonymous(page: Page) {
|
async function mockAnonymous(page: Page) {
|
||||||
|
// Force public mode so the root +layout.ts doesn't bounce us to /login
|
||||||
|
// (a dev backend with PRIVATE_MODE=true must not leak into E2E runs).
|
||||||
|
await page.route('**/api/v1/auth/config', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route('**/api/v1/auth/me', async (route) => {
|
await page.route('**/api/v1/auth/me', async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -69,3 +78,53 @@ test('search updates the manga list', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
|
await expect(page.getByTestId('manga-list')).toContainText('Berserk');
|
||||||
expect(lastSearch).toBe('berserk');
|
expect(lastSearch).toBe('berserk');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking Next paginates to page 2 and updates the URL', async ({ page }) => {
|
||||||
|
await mockAnonymous(page);
|
||||||
|
|
||||||
|
// Fake a catalogue of 75 mangas; page 1 is ids 1..50, page 2 is ids 51..75.
|
||||||
|
const TOTAL = 75;
|
||||||
|
function mangaAt(i: number) {
|
||||||
|
return {
|
||||||
|
id: `m${i}`,
|
||||||
|
title: `Manga ${i}`,
|
||||||
|
author: 'Test',
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
authors: [],
|
||||||
|
genres: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route('**/api/v1/mangas*', async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const limit = Number(url.searchParams.get('limit') ?? '50');
|
||||||
|
const offset = Number(url.searchParams.get('offset') ?? '0');
|
||||||
|
const items: ReturnType<typeof mangaAt>[] = [];
|
||||||
|
for (let i = offset + 1; i <= Math.min(offset + limit, TOTAL); i++) {
|
||||||
|
items.push(mangaAt(i));
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items,
|
||||||
|
page: { limit, offset, total: TOTAL }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('manga-total')).toContainText('Showing 1–50 of 75');
|
||||||
|
await expect(page.getByTestId('manga-list')).toContainText('Manga 1');
|
||||||
|
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 75');
|
||||||
|
|
||||||
|
await page.getByTestId('manga-pager').getByRole('button', { name: /next/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/[?&]page=2(&|$)/);
|
||||||
|
await expect(page.getByTestId('manga-total')).toContainText('Showing 51–75 of 75');
|
||||||
|
await expect(page.getByTestId('manga-list')).toContainText('Manga 75');
|
||||||
|
await expect(page.getByTestId('manga-list')).not.toContainText('Manga 1');
|
||||||
|
});
|
||||||
|
|||||||
67
frontend/e2e/page-title.spec.ts
Normal file
67
frontend/e2e/page-title.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Guards the title-on-nav behavior: without this, a stale title from
|
||||||
|
// the last manga / author page lingers when the user navigates to a
|
||||||
|
// generic page like /upload.
|
||||||
|
|
||||||
|
async function mockAnonymous(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/auth/me', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/mangas*', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('static route titles use the brand-first layout map', async ({ page }) => {
|
||||||
|
await mockAnonymous(page);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle('Mangalord');
|
||||||
|
|
||||||
|
await page.goto('/upload');
|
||||||
|
await expect(page).toHaveTitle('Mangalord | Upload');
|
||||||
|
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page).toHaveTitle('Mangalord | Login');
|
||||||
|
|
||||||
|
await page.goto('/bookmarks');
|
||||||
|
await expect(page).toHaveTitle('Mangalord | Bookmarks');
|
||||||
|
|
||||||
|
await page.goto('/collections');
|
||||||
|
await expect(page).toHaveTitle('Mangalord | Collections');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title updates when navigating away from a content page', async ({ page }) => {
|
||||||
|
await mockAnonymous(page);
|
||||||
|
|
||||||
|
// Pretend we just left a manga detail page — the document title
|
||||||
|
// would have been overridden to "Mangalord | Berserk". Use evaluate
|
||||||
|
// to set it synthetically so we can assert the regression cleanly
|
||||||
|
// even though the dynamic page itself isn't mocked here.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.title = 'Mangalord | Berserk';
|
||||||
|
});
|
||||||
|
expect(await page.title()).toBe('Mangalord | Berserk');
|
||||||
|
|
||||||
|
// Client-side nav to /upload — the root layout must reassert its
|
||||||
|
// mapped title or the stale "Berserk" lingers.
|
||||||
|
await page.goto('/upload');
|
||||||
|
await expect(page).toHaveTitle('Mangalord | Upload');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.48.0",
|
"version": "0.49.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
128
frontend/src/lib/components/Pager.svelte
Normal file
128
frontend/src/lib/components/Pager.svelte
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onChange: (page: number) => void;
|
||||||
|
testid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { page, totalPages, onChange, testid }: Props = $props();
|
||||||
|
|
||||||
|
type Slot = number | 'ellipsis';
|
||||||
|
|
||||||
|
// Compact layout: always show first + last, surround the current page with
|
||||||
|
// its direct neighbours, and use "…" to elide the rest. Keeps the bar to
|
||||||
|
// at most 7 buttons regardless of totalPages.
|
||||||
|
function buildSlots(p: number, total: number): Slot[] {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const out: Slot[] = [1];
|
||||||
|
if (p <= 4) {
|
||||||
|
for (let i = 2; i <= 5; i++) out.push(i);
|
||||||
|
out.push('ellipsis');
|
||||||
|
out.push(total);
|
||||||
|
} else if (p >= total - 3) {
|
||||||
|
out.push('ellipsis');
|
||||||
|
for (let i = total - 4; i <= total; i++) out.push(i);
|
||||||
|
} else {
|
||||||
|
out.push('ellipsis');
|
||||||
|
out.push(p - 1);
|
||||||
|
out.push(p);
|
||||||
|
out.push(p + 1);
|
||||||
|
out.push('ellipsis');
|
||||||
|
out.push(total);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = $derived(buildSlots(page, totalPages));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<nav class="pager" aria-label="Pagination" data-testid={testid}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onclick={() => onChange(page - 1)}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each slots as slot, i (i)}
|
||||||
|
{#if slot === 'ellipsis'}
|
||||||
|
<span class="ellipsis" aria-hidden="true">…</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="num"
|
||||||
|
class:active={slot === page}
|
||||||
|
aria-current={slot === page ? 'page' : undefined}
|
||||||
|
aria-label={`Go to page ${slot}`}
|
||||||
|
onclick={() => onChange(slot)}
|
||||||
|
>
|
||||||
|
{slot}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="step"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onclick={() => onChange(page + 1)}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step,
|
||||||
|
.num {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:hover:not(:disabled),
|
||||||
|
.num:hover:not(.active) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
border-color: var(--primary);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
77
frontend/src/lib/components/Pager.svelte.test.ts
Normal file
77
frontend/src/lib/components/Pager.svelte.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||||
|
import Pager from './Pager.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('Pager', () => {
|
||||||
|
it('renders nothing when totalPages <= 1', () => {
|
||||||
|
const { container } = render(Pager, { props: { page: 1, totalPages: 1, onChange: () => {} } });
|
||||||
|
expect(container.querySelector('nav')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Prev on the first page and Next on the last', () => {
|
||||||
|
const { rerender } = render(Pager, {
|
||||||
|
props: { page: 1, totalPages: 5, onChange: () => {} }
|
||||||
|
});
|
||||||
|
expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
rerender({ page: 5, totalPages: 5, onChange: () => {} });
|
||||||
|
expect((screen.getByRole('button', { name: /prev/i }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((screen.getByRole('button', { name: /next/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the current page button as aria-current', () => {
|
||||||
|
render(Pager, { props: { page: 3, totalPages: 5, onChange: () => {} } });
|
||||||
|
const current = screen.getByRole('button', { name: /go to page 3/i });
|
||||||
|
expect(current.getAttribute('aria-current')).toBe('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onChange with the clicked page number', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(Pager, { props: { page: 1, totalPages: 5, onChange } });
|
||||||
|
screen.getByRole('button', { name: /go to page 3/i }).click();
|
||||||
|
expect(onChange).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Prev decrements and Next increments via onChange', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(Pager, { props: { page: 3, totalPages: 5, onChange } });
|
||||||
|
screen.getByRole('button', { name: /prev/i }).click();
|
||||||
|
screen.getByRole('button', { name: /next/i }).click();
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, 2);
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(2, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows every page button when totalPages <= 7', () => {
|
||||||
|
render(Pager, { props: { page: 4, totalPages: 7, onChange: () => {} } });
|
||||||
|
for (let n = 1; n <= 7; n++) {
|
||||||
|
expect(screen.getByRole('button', { name: new RegExp(`go to page ${n}$`, 'i') })).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses middle pages with ellipsis when totalPages > 7 and current is in the middle', () => {
|
||||||
|
render(Pager, { props: { page: 10, totalPages: 24, onChange: () => {} } });
|
||||||
|
// First and last are always shown
|
||||||
|
expect(screen.getByRole('button', { name: /go to page 1$/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /go to page 24$/i })).toBeTruthy();
|
||||||
|
// Current and direct neighbours are shown
|
||||||
|
expect(screen.getByRole('button', { name: /go to page 9$/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /go to page 10$/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /go to page 11$/i })).toBeTruthy();
|
||||||
|
// Distant pages are NOT rendered as buttons
|
||||||
|
expect(screen.queryByRole('button', { name: /go to page 2$/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: /go to page 23$/i })).toBeNull();
|
||||||
|
// Ellipsis appears on both sides
|
||||||
|
const ellipses = screen.getAllByText('…');
|
||||||
|
expect(ellipses.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate boundary buttons when current is near the edge', () => {
|
||||||
|
render(Pager, { props: { page: 2, totalPages: 20, onChange: () => {} } });
|
||||||
|
// Each page button rendered should be unique — no duplicate "go to page 1"
|
||||||
|
const first = screen.getAllByRole('button', { name: /go to page 1$/i });
|
||||||
|
expect(first.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { logout } from '$lib/api/auth';
|
import { logout } from '$lib/api/auth';
|
||||||
import { authConfig } from '$lib/auth-config.svelte';
|
import { authConfig } from '$lib/auth-config.svelte';
|
||||||
import { preferences } from '$lib/preferences.svelte';
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
@@ -18,6 +19,32 @@
|
|||||||
let loggingOut = $state(false);
|
let loggingOut = $state(false);
|
||||||
let headerEl: HTMLElement | undefined = $state();
|
let headerEl: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// Static-route title map. Dynamic pages (manga / author / collection /
|
||||||
|
// chapter) override this via their own <svelte:head><title>, since the
|
||||||
|
// title depends on data the layout doesn't have. Routes omitted here
|
||||||
|
// (notably the dynamic ones) fall through to the bare brand and rely
|
||||||
|
// on the page to set the descriptive form.
|
||||||
|
const STATIC_TITLES: Record<string, string> = {
|
||||||
|
'/': 'Mangalord',
|
||||||
|
'/login': 'Mangalord | Login',
|
||||||
|
'/register': 'Mangalord | Register',
|
||||||
|
'/upload': 'Mangalord | Upload',
|
||||||
|
'/bookmarks': 'Mangalord | Bookmarks',
|
||||||
|
'/collections': 'Mangalord | Collections',
|
||||||
|
'/profile': 'Mangalord | Profile',
|
||||||
|
'/profile/account': 'Mangalord | Account',
|
||||||
|
'/profile/bookmarks': 'Mangalord | Bookmarks',
|
||||||
|
'/profile/collections': 'Mangalord | Collections',
|
||||||
|
'/profile/history': 'Mangalord | Reading history',
|
||||||
|
'/profile/preferences': 'Mangalord | Preferences',
|
||||||
|
'/admin': 'Mangalord | Admin',
|
||||||
|
'/admin/mangas': 'Mangalord | Admin · Mangas',
|
||||||
|
'/admin/users': 'Mangalord | Admin · Users',
|
||||||
|
'/admin/system': 'Mangalord | Admin · System'
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
|
||||||
|
|
||||||
// Seed authConfig from the universal layout load. $effect keeps
|
// Seed authConfig from the universal layout load. $effect keeps
|
||||||
// the store in sync if `data` is replaced by a subsequent layout
|
// the store in sync if `data` is replaced by a subsequent layout
|
||||||
// load (client-side nav). The first run also covers initial
|
// load (client-side nav). The first run also covers initial
|
||||||
@@ -78,6 +105,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{layoutTitle}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<header bind:this={headerEl}>
|
<header bind:this={headerEl}>
|
||||||
<nav aria-label="primary">
|
<nav aria-label="primary">
|
||||||
<a class="brand" href="/">Mangalord</a>
|
<a class="brand" href="/">Mangalord</a>
|
||||||
|
|||||||
@@ -13,10 +13,13 @@
|
|||||||
import { listTags, type Tag } from '$lib/api/tags';
|
import { listTags, type Tag } from '$lib/api/tags';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
let mangas: MangaCardData[] = $state([]);
|
let mangas: MangaCardData[] = $state([]);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let sort: MangaSort = $state('recent');
|
let sort: MangaSort = $state('recent');
|
||||||
@@ -36,11 +39,21 @@
|
|||||||
let total: number | null = $state(null);
|
let total: number | null = $state(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
|
||||||
const activeFilterCount = $derived(
|
const activeFilterCount = $derived(
|
||||||
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
|
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totalPages = $derived(
|
||||||
|
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1-indexed range like "51–100 of 237", clamped to the actual loaded set
|
||||||
|
// in case the last page is short.
|
||||||
|
const rangeStart = $derived(mangas.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1);
|
||||||
|
const rangeEnd = $derived((currentPage - 1) * PAGE_SIZE + mangas.length);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -50,7 +63,9 @@
|
|||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
genreIds: selectedGenres.map((g) => g.id),
|
genreIds: selectedGenres.map((g) => g.id),
|
||||||
tagIds: selectedTags.map((t) => t.id),
|
tagIds: selectedTags.map((t) => t.id),
|
||||||
sort
|
sort,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (currentPage - 1) * PAGE_SIZE
|
||||||
});
|
});
|
||||||
mangas = result.items;
|
mangas = result.items;
|
||||||
total = result.page.total;
|
total = result.page.total;
|
||||||
@@ -71,11 +86,29 @@
|
|||||||
params.set('genres', selectedGenres.map((g) => g.id).join(','));
|
params.set('genres', selectedGenres.map((g) => g.id).join(','));
|
||||||
if (selectedTags.length)
|
if (selectedTags.length)
|
||||||
params.set('tags', selectedTags.map((t) => t.id).join(','));
|
params.set('tags', selectedTags.map((t) => t.id).join(','));
|
||||||
|
if (currentPage > 1) params.set('page', String(currentPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = qs ? `/?${qs}` : '/';
|
const url = qs ? `/?${qs}` : '/';
|
||||||
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
|
goto(url, { replaceState: true, keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter / search / sort changes invalidate the current page — drop back
|
||||||
|
// to page 1 so the user isn't stranded on an out-of-range page when the
|
||||||
|
// result set shrinks. Direct page navigation calls `goToPage()` instead.
|
||||||
|
function resetAndReload() {
|
||||||
|
currentPage = 1;
|
||||||
|
syncUrl();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
if (p === currentPage) return;
|
||||||
|
currentPage = p;
|
||||||
|
syncUrl();
|
||||||
|
load();
|
||||||
|
if (browser) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
async function hydrateFromUrl() {
|
async function hydrateFromUrl() {
|
||||||
// Parse the query and resolve the supplied ids back to full Tag /
|
// Parse the query and resolve the supplied ids back to full Tag /
|
||||||
// Genre objects so the chip rows render real labels.
|
// Genre objects so the chip rows render real labels.
|
||||||
@@ -100,6 +133,8 @@
|
|||||||
const tags = await listTags({ limit: 50 });
|
const tags = await listTags({ limit: 50 });
|
||||||
selectedTags = tags.filter((t) => tagIds.includes(t.id));
|
selectedTags = tags.filter((t) => tagIds.includes(t.id));
|
||||||
}
|
}
|
||||||
|
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||||
|
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
|
||||||
// Open the filters panel if anything is active so the user can see why.
|
// Open the filters panel if anything is active so the user can see why.
|
||||||
if (statusFilter || selectedGenres.length || selectedTags.length) {
|
if (statusFilter || selectedGenres.length || selectedTags.length) {
|
||||||
filtersOpen = true;
|
filtersOpen = true;
|
||||||
@@ -108,32 +143,27 @@
|
|||||||
|
|
||||||
async function onSubmit(e: SubmitEvent) {
|
async function onSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
await load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSortChange() {
|
function onSortChange() {
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatusChange() {
|
function onStatusChange() {
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGenre(g: Genre) {
|
function toggleGenre(g: Genre) {
|
||||||
selectedGenres = selectedGenres.some((x) => x.id === g.id)
|
selectedGenres = selectedGenres.some((x) => x.id === g.id)
|
||||||
? selectedGenres.filter((x) => x.id !== g.id)
|
? selectedGenres.filter((x) => x.id !== g.id)
|
||||||
: [...selectedGenres, g];
|
: [...selectedGenres, g];
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(t: Tag) {
|
function removeTag(t: Tag) {
|
||||||
selectedTags = selectedTags.filter((x) => x.id !== t.id);
|
selectedTags = selectedTags.filter((x) => x.id !== t.id);
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTag(t: Tag) {
|
function pickTag(t: Tag) {
|
||||||
@@ -143,8 +173,7 @@
|
|||||||
tagDraft = '';
|
tagDraft = '';
|
||||||
tagSuggestions = [];
|
tagSuggestions = [];
|
||||||
tagSuggestHighlight = -1;
|
tagSuggestHighlight = -1;
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTagDraftInput() {
|
function onTagDraftInput() {
|
||||||
@@ -192,8 +221,7 @@
|
|||||||
statusFilter = '';
|
statusFilter = '';
|
||||||
selectedGenres = [];
|
selectedGenres = [];
|
||||||
selectedTags = [];
|
selectedTags = [];
|
||||||
syncUrl();
|
resetAndReload();
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -383,7 +411,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#if total !== null}
|
{#if total !== null}
|
||||||
<p class="count" data-testid="manga-total">
|
<p class="count" data-testid="manga-total">
|
||||||
Showing {mangas.length} of {total}
|
Showing {rangeStart}–{rangeEnd} of {total}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<ul class="manga-grid" data-testid="manga-list">
|
<ul class="manga-grid" data-testid="manga-list">
|
||||||
@@ -391,6 +419,12 @@
|
|||||||
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
|
<MangaCard manga={m} authors={m.authors} genres={m.genres} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
<Pager
|
||||||
|
page={currentPage}
|
||||||
|
{totalPages}
|
||||||
|
onChange={goToPage}
|
||||||
|
testid="manga-pager"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -71,16 +71,19 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="search by title"
|
placeholder="Search by title"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
data-testid="admin-mangas-search"
|
data-testid="admin-mangas-search"
|
||||||
/>
|
/>
|
||||||
<select bind:value={syncFilter} aria-label="sync state">
|
<label class="sync-label">
|
||||||
<option value="">all states</option>
|
<span>Sync state</span>
|
||||||
<option value="in_progress">in progress</option>
|
<select bind:value={syncFilter} aria-label="sync state">
|
||||||
<option value="dropped">dropped</option>
|
<option value="">All</option>
|
||||||
<option value="synced">synced</option>
|
<option value="in_progress">In progress</option>
|
||||||
</select>
|
<option value="dropped">Dropped</option>
|
||||||
|
<option value="synced">Synced</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -173,17 +176,28 @@
|
|||||||
}
|
}
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 24rem;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.sync-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MangaCard from '$lib/components/MangaCard.svelte';
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
||||||
|
import Pager from '$lib/components/Pager.svelte';
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const author = $derived(data.author);
|
const author = $derived(data.author);
|
||||||
const mangas = $derived(data.mangas);
|
const mangas = $derived(data.mangas);
|
||||||
const total = $derived(data.total);
|
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;
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (p === 1) url.searchParams.delete('page');
|
||||||
|
else url.searchParams.set('page', String(p));
|
||||||
|
goto(url.pathname + url.search, { noScroll: false });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{author.name} — Mangalord</title>
|
<title>Mangalord | {author.name}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="back">
|
<nav class="back">
|
||||||
@@ -34,7 +52,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#if total != null}
|
{#if total != null}
|
||||||
<p class="meta" data-testid="author-shown-of-total">
|
<p class="meta" data-testid="author-shown-of-total">
|
||||||
Showing {mangas.length} of {total}
|
Showing {rangeStart}–{rangeEnd} of {total}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<ul class="manga-grid" data-testid="author-manga-list">
|
<ul class="manga-grid" data-testid="author-manga-list">
|
||||||
@@ -42,6 +60,12 @@
|
|||||||
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
|
<MangaCard manga={m} testid={`author-manga-${m.id}`} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
<Pager
|
||||||
|
page={currentPage}
|
||||||
|
{totalPages}
|
||||||
|
onChange={goToPage}
|
||||||
|
testid="author-pager"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -5,13 +5,27 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
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;
|
||||||
try {
|
try {
|
||||||
const [author, mangas] = await Promise.all([
|
const [author, mangas] = await Promise.all([
|
||||||
getAuthor(params.id),
|
getAuthor(params.id),
|
||||||
listAuthorMangas(params.id, { limit: 50 })
|
listAuthorMangas(params.id, {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (currentPage - 1) * PAGE_SIZE
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
return { author, mangas: mangas.items, total: mangas.page.total };
|
return {
|
||||||
|
author,
|
||||||
|
mangas: mangas.items,
|
||||||
|
total: mangas.page.total,
|
||||||
|
currentPage,
|
||||||
|
pageSize: PAGE_SIZE
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 404 surfaces as a real SvelteKit error so the framework shell
|
// 404 surfaces as a real SvelteKit error so the framework shell
|
||||||
// renders the standard not-found page instead of the route's
|
// renders the standard not-found page instead of the route's
|
||||||
|
|||||||
@@ -7,10 +7,6 @@
|
|||||||
const error = $derived(data.error);
|
const error = $derived(data.error);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Bookmarks — Mangalord</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Bookmarks</h1>
|
<h1>Bookmarks</h1>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|||||||
@@ -5,10 +5,6 @@
|
|||||||
const collections = $derived(data.collections);
|
const collections = $derived(data.collections);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Collections — Mangalord</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Collections</h1>
|
<h1>Collections</h1>
|
||||||
|
|
||||||
{#if !data.authenticated}
|
{#if !data.authenticated}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{collection.name} — Mangalord</title>
|
<title>Mangalord | {collection.name}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="back">
|
<nav class="back">
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{manga.title} — Mangalord</title>
|
<title>Mangalord | {manga.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
|
|
||||||
const pageTitle = $derived(
|
const pageTitle = $derived(
|
||||||
chapter.title
|
chapter.title
|
||||||
? `${manga.title} — Ch. ${chapter.number}: ${chapter.title}`
|
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
|
||||||
: `${manga.title} — Ch. ${chapter.number}`
|
: `Mangalord | ${manga.title} · Ch. ${chapter.number}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prev/next chapter computed from the chapter list. listChapters
|
// Prev/next chapter computed from the chapter list. listChapters
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Edit {manga.title} — Mangalord</title>
|
<title>Mangalord | Edit · {manga.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Edit manga</h1>
|
<h1>Edit manga</h1>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Upload chapter — {manga.title} — Mangalord</title>
|
<title>Mangalord | Upload chapter · {manga.title}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<nav class="back">
|
<nav class="back">
|
||||||
|
|||||||
@@ -35,10 +35,6 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Profile — Mangalord</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<header class="profile-header">
|
<header class="profile-header">
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
{#if !session.loaded}
|
{#if !session.loaded}
|
||||||
|
|||||||
@@ -184,10 +184,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Upload — Mangalord</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1>Create manga</h1>
|
<h1>Create manga</h1>
|
||||||
|
|
||||||
{#if !session.loaded}
|
{#if !session.loaded}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
globals: false
|
globals: false
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
// Use Svelte's browser entry under vitest so component tests can
|
||||||
|
// mount with @testing-library/svelte. The default (server entry)
|
||||||
|
// throws lifecycle_function_unavailable on mount().
|
||||||
|
conditions: mode === 'test' ? ['browser'] : []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user