Compare commits
20 Commits
main
...
366ccc1c4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366ccc1c4c | ||
|
|
491c0dacc9 | ||
|
|
ec5185593e | ||
|
|
b12e520c08 | ||
|
|
5794b5305f | ||
|
|
8e375d65b7 | ||
|
|
82c3de6ace | ||
|
|
5c37c5d8df | ||
|
|
50a8e96872 | ||
|
|
18e6b501e0 | ||
|
|
92db3ce324 | ||
|
|
67160fde47 | ||
|
|
9a16082015 | ||
|
|
b3ae90fa57 | ||
|
|
f83ac67676 | ||
|
|
79a1432db8 | ||
|
|
129cb0241d | ||
|
|
a08c49b708 | ||
|
|
68a58fba44 | ||
|
|
be493649af |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.88.0"
|
||||
version = "0.89.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.88.0"
|
||||
version = "0.89.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { SORT_FIELD_LABELS } from '../src/lib/mangaSort';
|
||||
|
||||
// These E2E tests run against the SvelteKit dev server, which proxies /api
|
||||
// to the backend. Playwright starts vite via `webServer` (see
|
||||
@@ -44,6 +45,35 @@ test('home page renders the Mangalord heading and search input', async ({ page }
|
||||
await expect(page.getByTestId('empty')).toContainText('No mangas yet');
|
||||
});
|
||||
|
||||
// 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
|
||||
// would silently diverge the two surfaces. Comparing the rendered options to
|
||||
// the single source of truth makes that divergence fail loudly.
|
||||
test('desktop sort options stay in sync with SORT_FIELD_LABELS', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.route('**/api/v1/mangas*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(emptyPage)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
const select = page.getByTestId('sort-select');
|
||||
await expect(select).toBeVisible();
|
||||
|
||||
const options = select.locator('option');
|
||||
const labels = (await options.allTextContents()).map((s) => s.trim());
|
||||
const values = await options.evaluateAll((opts) =>
|
||||
opts.map((o) => (o as HTMLOptionElement).value)
|
||||
);
|
||||
|
||||
expect(values).toEqual(Object.keys(SORT_FIELD_LABELS));
|
||||
expect(labels).toEqual(Object.values(SORT_FIELD_LABELS));
|
||||
});
|
||||
|
||||
test('search updates the manga list', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
let lastSearch: string | null = null;
|
||||
|
||||
@@ -11,7 +11,7 @@ const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
async function mockSession(
|
||||
page: Page,
|
||||
opts: { authed?: boolean; logoutCalls?: { count: number } } = {}
|
||||
opts: { authed?: boolean; admin?: boolean; logoutCalls?: { count: number } } = {}
|
||||
) {
|
||||
const authed = opts.authed ?? false;
|
||||
await page.route('**/api/v1/auth/config', (route) =>
|
||||
@@ -31,7 +31,7 @@ async function mockSession(
|
||||
id: 'u1',
|
||||
username: 'fabian',
|
||||
created_at: '2026-01-05T00:00:00Z',
|
||||
is_admin: false
|
||||
is_admin: opts.admin ?? false
|
||||
}
|
||||
})
|
||||
: JSON.stringify({
|
||||
@@ -175,11 +175,27 @@ test.describe('mobile account hub', () => {
|
||||
await expect(page.getByTestId('account-row-preferences')).toBeVisible();
|
||||
await expect(page.getByTestId('account-row-change-password')).toBeVisible();
|
||||
await expect(page.getByTestId('account-row-logout')).toBeVisible();
|
||||
// A non-admin never sees the Admin entry.
|
||||
await expect(page.getByTestId('account-row-admin')).toHaveCount(0);
|
||||
// Desktop card MUST not render — only the hub view is active
|
||||
// on mobile so we don't double-up the password form testids.
|
||||
await expect(page.getByTestId('account-desktop-card')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('phone viewport admin: the account hub offers an Admin row linking to /admin', async ({
|
||||
page
|
||||
}) => {
|
||||
// Admin is reachable from the desktop header but had no mobile entry
|
||||
// point — admins had to type the URL. The Account hub now carries it.
|
||||
await mockSession(page, { authed: true, admin: true });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/profile/account');
|
||||
|
||||
const adminRow = page.getByTestId('account-row-admin');
|
||||
await expect(adminRow).toBeVisible();
|
||||
await expect(adminRow).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
test('phone viewport authed: Change password row opens the bottom sheet', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
@@ -236,6 +236,51 @@ test.describe('mobile manga detail', () => {
|
||||
await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible();
|
||||
});
|
||||
|
||||
test('phone viewport: force resync from the overflow sheet surfaces a result message', async ({
|
||||
page
|
||||
}) => {
|
||||
await mockDetail(page, { authed: true });
|
||||
// Upgrade the session to admin so the Force resync action is offered.
|
||||
// Registered after mockDetail so this auth/me wins (Playwright routes
|
||||
// resolve most-recently-registered first).
|
||||
await page.route('**/api/v1/auth/me', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: {
|
||||
id: 'u1',
|
||||
username: 'admin',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/admin/mangas/${mangaId}/resync`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
manga: mangaFixture(),
|
||||
cover_fetched: true,
|
||||
metadata_status: 'updated'
|
||||
})
|
||||
})
|
||||
);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto(`/manga/${mangaId}`);
|
||||
|
||||
await page.getByTestId('detail-overflow').click();
|
||||
await page.getByTestId('overflow-force-resync').click();
|
||||
|
||||
// The result was previously display:none under 640px, leaving mobile
|
||||
// admins with no feedback. It must now be visible.
|
||||
const msg = page.getByTestId('force-resync-message');
|
||||
await expect(msg).toBeVisible();
|
||||
await expect(msg).toContainText(/Metadata updated/i);
|
||||
});
|
||||
|
||||
test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
@@ -373,5 +373,12 @@ test.describe('page action sheet (mobile long-press)', () => {
|
||||
await expect(page.getByTestId('page-action-add-tag')).toBeVisible();
|
||||
await expect(page.getByTestId('page-action-save-image')).toBeVisible();
|
||||
await expect(page.getByTestId('page-action-copy-link')).toBeVisible();
|
||||
|
||||
// The editor itself opens as a bottom Sheet on mobile (not a centered
|
||||
// Modal), so the action-sheet → editor flow stays one consistent
|
||||
// idiom. Sheet renders a `${testid}-scrim`; Modal a `${testid}-backdrop`.
|
||||
await page.getByTestId('page-action-add-tag').click();
|
||||
await expect(page.getByTestId('add-tags-modal-scrim')).toBeVisible();
|
||||
await expect(page.getByTestId('add-tags-modal-backdrop')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,33 @@ test('Profile link in nav for authed users; landing shows counts', async ({ page
|
||||
await expect(page.getByTestId('overview-collections')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Page tags tab reaches the page-tags list on desktop', async ({ page }) => {
|
||||
// Page tags are a first-class Library section on mobile; desktop reaches
|
||||
// them through this profile tab.
|
||||
await stubAuthenticated(page);
|
||||
await page.route('**/api/v1/me/page-tags?*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 100, offset: 0, total: 0 } })
|
||||
})
|
||||
);
|
||||
// Registered after the general route so distinct URLs resolve here.
|
||||
await page.route('**/api/v1/me/page-tags/distinct*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [] })
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/profile');
|
||||
await expect(page.getByTestId('tab-page-tags')).toBeVisible();
|
||||
await page.getByTestId('tab-page-tags').click();
|
||||
await expect(page).toHaveURL(/\/profile\/page-tags$/);
|
||||
await expect(page.getByTestId('library-page-tags-empty')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Account tab reaches the password form', async ({ page }) => {
|
||||
await stubAuthenticated(page);
|
||||
await page.goto('/profile');
|
||||
|
||||
@@ -66,6 +66,15 @@ async function mockReaderApis(page: Page) {
|
||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||
})
|
||||
);
|
||||
// Anonymous reader: read-progress reads/writes are unauthenticated. Without
|
||||
// this the GET/PUT fall through to the (absent) backend and stall the load.
|
||||
await page.route('**/api/v1/me/read-progress/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||
})
|
||||
);
|
||||
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -126,6 +135,7 @@ test.beforeEach(async ({ context }) => {
|
||||
try {
|
||||
localStorage.removeItem('mangalord-reader-mode');
|
||||
localStorage.removeItem('mangalord-reader-gap');
|
||||
localStorage.removeItem('mangalord-reader-brightness');
|
||||
} catch {
|
||||
// ignore — about:blank doesn't expose localStorage yet.
|
||||
}
|
||||
@@ -181,6 +191,64 @@ test('gap select updates the inline gap on the continuous container', async ({ p
|
||||
await expect(container).toHaveAttribute('style', /gap:\s*64px/);
|
||||
});
|
||||
|
||||
test('desktop reader nav exposes a brightness control that dims and undims the reader', async ({
|
||||
page
|
||||
}) => {
|
||||
// The brightness slider used to live only in the mobile settings sheet,
|
||||
// so desktop users couldn't dim — and could be stuck dimmed by a value a
|
||||
// phone had persisted. The desktop nav now carries its own slider.
|
||||
await mockReaderApis(page);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||
|
||||
const slider = page.getByTestId('reader-brightness-desktop');
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
const dim = () =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--reader-dim')
|
||||
);
|
||||
|
||||
// Default brightness is full → no dim overlay.
|
||||
await expect.poll(async () => Number(await dim())).toBe(0);
|
||||
|
||||
// Dimming raises the overlay opacity…
|
||||
await slider.fill('0.3');
|
||||
await expect.poll(async () => Number(await dim())).toBeGreaterThan(0);
|
||||
|
||||
// …and restoring it clears the dim, so a desktop user is never stuck.
|
||||
await slider.fill('1');
|
||||
await expect.poll(async () => Number(await dim())).toBe(0);
|
||||
});
|
||||
|
||||
test('a brightness value persisted by a phone is recoverable on desktop', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
// Seed a dimmed value as if set on mobile, then open the reader on a
|
||||
// desktop viewport: the dim applies and the desktop control reflects it.
|
||||
await context.addInitScript(() => {
|
||||
try {
|
||||
localStorage.setItem('mangalord-reader-brightness', '0.3');
|
||||
} catch {
|
||||
// about:blank — ignore
|
||||
}
|
||||
});
|
||||
await mockReaderApis(page);
|
||||
await page.goto(`/manga/${mangaId}/chapter/${chapterId}`);
|
||||
|
||||
const slider = page.getByTestId('reader-brightness-desktop');
|
||||
await expect(slider).toHaveValue('0.3');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Number(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--reader-dim')
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('reader-mode preference set on one page is honored when the reader opens', async ({
|
||||
page,
|
||||
context
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.88.0",
|
||||
"version": "0.89.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
53
frontend/src/lib/components/AdaptiveDialog.svelte
Normal file
53
frontend/src/lib/components/AdaptiveDialog.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import Sheet from './Sheet.svelte';
|
||||
|
||||
// One overlay that adapts to the form factor: a bottom Sheet on phones,
|
||||
// a centered Modal on desktop. Both share the same chrome contract
|
||||
// (open / title / onClose / children / testid), so callers stay agnostic
|
||||
// and the page-action editors present consistently with the rest of the
|
||||
// mobile UI (long-press → action sheet → editor sheet, no Modal hop).
|
||||
let {
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
testid
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: Snippet;
|
||||
/** Desktop modal width; ignored by the mobile sheet. */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
// Bottom sheet under the 640px breakpoint. Guarded on matchMedia rather
|
||||
// than $app/environment so it's SSR-safe (no window) and unit-testable
|
||||
// (stub matchMedia). First paint defaults to the modal; the effect
|
||||
// corrects to a sheet on phones before the dialog is opened.
|
||||
let isMobile = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mql = window.matchMedia('(max-width: 640px)');
|
||||
isMobile = mql.matches;
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
isMobile = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isMobile}
|
||||
<Sheet {open} {title} {onClose} {testid}>
|
||||
{@render children()}
|
||||
</Sheet>
|
||||
{:else}
|
||||
<Modal {open} {title} {onClose} {size} {testid}>
|
||||
{@render children()}
|
||||
</Modal>
|
||||
{/if}
|
||||
61
frontend/src/lib/components/AdaptiveDialog.svelte.test.ts
Normal file
61
frontend/src/lib/components/AdaptiveDialog.svelte.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import AdaptiveDialog from './AdaptiveDialog.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
// @ts-expect-error — restore between tests
|
||||
delete window.matchMedia;
|
||||
});
|
||||
|
||||
// jsdom has no matchMedia; stub it to drive the breakpoint branch.
|
||||
function stubMatchMedia(matches: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation((media: string) => ({
|
||||
matches,
|
||||
media,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
const body = createRawSnippet(() => ({
|
||||
render: () => `<p data-testid="dlg-body">hello</p>`
|
||||
}));
|
||||
|
||||
describe('AdaptiveDialog', () => {
|
||||
it('renders a centered Modal on desktop (the scrim is the Modal backdrop)', async () => {
|
||||
stubMatchMedia(false);
|
||||
render(AdaptiveDialog, {
|
||||
props: { open: true, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
|
||||
});
|
||||
// Modal exposes a `${testid}-backdrop`; Sheet exposes `${testid}-scrim`.
|
||||
expect(await screen.findByTestId('dlg-backdrop')).toBeTruthy();
|
||||
expect(screen.queryByTestId('dlg-scrim')).toBeNull();
|
||||
expect(screen.getByRole('dialog', { name: 'Tag this page' })).toBeTruthy();
|
||||
expect(screen.getByTestId('dlg-body')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a bottom Sheet on mobile', async () => {
|
||||
stubMatchMedia(true);
|
||||
render(AdaptiveDialog, {
|
||||
props: { open: true, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
|
||||
});
|
||||
// The effect flips to mobile after mount; poll for the Sheet scrim.
|
||||
expect(await screen.findByTestId('dlg-scrim')).toBeTruthy();
|
||||
expect(screen.queryByTestId('dlg-backdrop')).toBeNull();
|
||||
expect(screen.getByRole('dialog', { name: 'Tag this page' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
stubMatchMedia(false);
|
||||
render(AdaptiveDialog, {
|
||||
props: { open: false, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import AdaptiveDialog from './AdaptiveDialog.svelte';
|
||||
import {
|
||||
addMangaToCollection,
|
||||
createCollection,
|
||||
@@ -166,7 +166,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
|
||||
<AdaptiveDialog {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
|
||||
{#if loading}
|
||||
<p class="status">Loading your collections…</p>
|
||||
{:else if error}
|
||||
@@ -235,7 +235,7 @@
|
||||
<span>{creating ? 'Creating…' : 'Create + add'}</span>
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</AdaptiveDialog>
|
||||
|
||||
<style>
|
||||
.status {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||
import Modal from './Modal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
@@ -145,36 +146,31 @@
|
||||
from {p.file.name} · {formatBytes(p.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
<IconButton
|
||||
onclick={() => movePage(p.id, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move {pageLabel(i)} up"
|
||||
title="Move up"
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onclick={() => movePage(p.id, 1)}
|
||||
disabled={i === pages.length - 1}
|
||||
aria-label="Move {pageLabel(i)} down"
|
||||
title="Move down"
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
type="button"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onclick={() => removePage(p.id)}
|
||||
aria-label="Remove {pageLabel(i)}"
|
||||
title="Remove page"
|
||||
data-testid="{testidPrefix}-remove"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
{#if p.error}
|
||||
<span class="field-error" role="alert">{p.error}</span>
|
||||
{/if}
|
||||
@@ -297,28 +293,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -125,4 +125,24 @@
|
||||
background: var(--danger-soft-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* On touch the 16px glyph is far below the tap floor. Rather than grow
|
||||
the chip (which would inflate every tag row), overlay an invisible,
|
||||
centered hit area sized to --tap-min — the glyph stays compact while
|
||||
the tappable region reaches a comfortable size. */
|
||||
@media (max-width: 640px) {
|
||||
.chip-remove {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chip-remove::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
min-width: var(--tap-min);
|
||||
min-height: var(--tap-min);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
230
frontend/src/lib/components/HistoryList.svelte
Normal file
230
frontend/src/lib/components/HistoryList.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import type { ReadProgressSummary } from '$lib/api/read_progress';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
// Shared reading-history list, used by both the desktop /profile/history
|
||||
// page and the mobile Library "History" tab so the two can't drift in
|
||||
// layout, labels, or fallback handling (they previously did). The clear
|
||||
// affordance is opt-in: pass `onClear` to enable per-row removal — the
|
||||
// component owns the optimistic-removal UX (instant remove, rollback +
|
||||
// inline error on failure) so every caller gets identical behavior.
|
||||
let {
|
||||
entries,
|
||||
onClear,
|
||||
emptyText = 'Nothing here yet — open any manga and a row will land here once you turn a page.',
|
||||
testid = 'history'
|
||||
}: {
|
||||
entries: ReadProgressSummary[];
|
||||
onClear?: (p: ReadProgressSummary) => Promise<void>;
|
||||
emptyText?: string;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
// Internal working copy so optimistic removal can mutate without
|
||||
// reaching back into the caller's loaded data. Seeded from `entries`;
|
||||
// the caller's list is the initial truth, this owns it from then on.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rows = $state<ReadProgressSummary[]>([...entries]);
|
||||
let clearError = $state<string | null>(null);
|
||||
|
||||
async function clear(p: ReadProgressSummary) {
|
||||
if (!onClear) return;
|
||||
clearError = null;
|
||||
const snapshot = rows;
|
||||
rows = rows.filter((x) => x.manga_id !== p.manga_id);
|
||||
try {
|
||||
await onClear(p);
|
||||
} catch (e) {
|
||||
// Roll back the optimistic removal and surface inline rather than
|
||||
// via alert() — keeps the surface non-modal and unit-testable.
|
||||
rows = snapshot;
|
||||
clearError = `Couldn't clear "${p.manga_title}": ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Built in script rather than inline so the " — page N" suffix keeps its
|
||||
// surrounding spaces — Svelte trims whitespace at `{#if}` block edges,
|
||||
// which previously rendered "Chapter 3— page 7" with no space.
|
||||
function continueLabel(p: ReadProgressSummary): string {
|
||||
// Only rendered behind a `chapter_number != null` guard in the
|
||||
// template; narrow here so the helper doesn't depend on that.
|
||||
const label = p.chapter_number != null ? chapterLabel({ number: p.chapter_number, title: null }) : '';
|
||||
const base = `Continue ${label}`;
|
||||
return p.page > 1 ? `${base} — page ${p.page}` : base;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if clearError}
|
||||
<p class="error" role="alert" data-testid="{testid}-error">
|
||||
{clearError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if rows.length === 0}
|
||||
<p class="hint" data-testid="{testid}-empty">{emptyText}</p>
|
||||
{:else}
|
||||
<ul class="entry-list" data-testid="{testid}-list">
|
||||
{#each rows as p (p.manga_id)}
|
||||
<li class="entry">
|
||||
<a
|
||||
href={p.chapter_id != null
|
||||
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||
: `/manga/${p.manga_id}`}
|
||||
class="cover-link"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if p.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(p.manga_cover_image_path)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
<BookImage size={20} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a href="/manga/{p.manga_id}" class="title" data-testid="{testid}-title">
|
||||
{p.manga_title}
|
||||
</a>
|
||||
<span class="target">
|
||||
{#if p.chapter_id != null && p.chapter_number != null}
|
||||
<a href="/manga/{p.manga_id}/chapter/{p.chapter_id}">
|
||||
{continueLabel(p)}
|
||||
</a>
|
||||
{:else if p.chapter_id}
|
||||
<span class="muted">(chapter removed)</span>
|
||||
{:else}
|
||||
<span class="muted">Whole manga, page {p.page}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="when">Read {formatDate(p.updated_at)}</span>
|
||||
</div>
|
||||
{#if onClear}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
onclick={() => clear(p)}
|
||||
aria-label={`Clear ${p.manga_title} from history`}
|
||||
title="Clear from history"
|
||||
data-testid="{testid}-clear-{p.manga_id}"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.entry-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cover-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 56px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.when {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft-bg);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--danger);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/lib/components/HistoryList.svelte.test.ts
Normal file
103
frontend/src/lib/components/HistoryList.svelte.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import HistoryList from './HistoryList.svelte';
|
||||
import type { ReadProgressSummary } from '$lib/api/read_progress';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function entry(over: Partial<ReadProgressSummary> = {}): ReadProgressSummary {
|
||||
return {
|
||||
manga_id: 'm1',
|
||||
manga_title: 'Berserk',
|
||||
manga_cover_image_path: null,
|
||||
chapter_id: 'c1',
|
||||
chapter_number: 5,
|
||||
page: 1,
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('HistoryList', () => {
|
||||
it('renders one row per entry with a continue link to the chapter', () => {
|
||||
render(HistoryList, {
|
||||
props: { entries: [entry({ manga_id: 'm1', chapter_id: 'c9', chapter_number: 9 })] }
|
||||
});
|
||||
const cont = screen.getByRole('link', { name: /Continue Chapter 9/i });
|
||||
expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
|
||||
});
|
||||
|
||||
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.
|
||||
render(HistoryList, { props: { entries: [entry({ page: 7, chapter_number: 3 })] } });
|
||||
const link = screen.getByRole('link', { name: /Continue Chapter 3/i });
|
||||
expect(link.textContent?.replace(/\s+/g, ' ').trim()).toBe('Continue Chapter 3 — page 7');
|
||||
cleanup();
|
||||
render(HistoryList, { props: { entries: [entry({ page: 1, chapter_number: 3 })] } });
|
||||
expect(screen.queryByText(/page 1/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a "chapter removed" fallback when the chapter id survives but its number is gone', () => {
|
||||
render(HistoryList, {
|
||||
props: { entries: [entry({ chapter_id: 'c1', chapter_number: null })] }
|
||||
});
|
||||
expect(screen.getByText(/chapter removed/i)).toBeTruthy();
|
||||
expect(screen.queryByRole('link', { name: /Continue/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a "whole manga" fallback when there is no chapter', () => {
|
||||
render(HistoryList, {
|
||||
props: { entries: [entry({ chapter_id: null, chapter_number: null, page: 4 })] }
|
||||
});
|
||||
expect(screen.getByText(/Whole manga, page 4/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the empty state with the provided copy and testid when there are no entries', () => {
|
||||
render(HistoryList, {
|
||||
props: { entries: [], emptyText: 'Nothing here yet.', testid: 'library-history' }
|
||||
});
|
||||
const empty = screen.getByTestId('library-history-empty');
|
||||
expect(empty.textContent).toContain('Nothing here yet.');
|
||||
expect(screen.queryByTestId('library-history-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('omits clear buttons entirely when no onClear is supplied (read-only mode)', () => {
|
||||
render(HistoryList, { props: { entries: [entry()] } });
|
||||
expect(screen.queryByRole('button', { name: /Clear/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('optimistically removes a row and calls onClear when the clear button is pressed', async () => {
|
||||
const onClear = vi.fn().mockResolvedValue(undefined);
|
||||
render(HistoryList, {
|
||||
props: {
|
||||
entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
|
||||
onClear,
|
||||
testid: 'history-reading'
|
||||
}
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
|
||||
expect(onClear).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ manga_id: 'm1' })
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText('Berserk')).toBeNull());
|
||||
});
|
||||
|
||||
it('restores the row and surfaces an inline error when onClear rejects', async () => {
|
||||
const onClear = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
render(HistoryList, {
|
||||
props: {
|
||||
entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
|
||||
onClear,
|
||||
testid: 'history-reading'
|
||||
}
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
|
||||
await waitFor(() => {
|
||||
const err = screen.getByTestId('history-reading-error');
|
||||
expect(err.textContent).toMatch(/network down/i);
|
||||
});
|
||||
// Optimistic removal rolled back — the row is back.
|
||||
expect(screen.getByText('Berserk')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/components/IconButton.svelte
Normal file
80
frontend/src/lib/components/IconButton.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
// Shared square icon button — extracted from the near-identical
|
||||
// `.icon-btn` copies across the app so the size, hover, and variant
|
||||
// treatment live in one place. Callers pass the lucide icon as the child
|
||||
// and any button attributes (onclick, disabled, aria-label, title,
|
||||
// data-testid) straight through. `size`/`radius` cover the two larger
|
||||
// header/search buttons without forcing the 32px default everywhere.
|
||||
type Variant = 'plain' | 'primary' | 'danger';
|
||||
|
||||
let {
|
||||
variant = 'plain',
|
||||
size = 32,
|
||||
radius = 'sm',
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
variant?: Variant;
|
||||
size?: number;
|
||||
radius?: 'sm' | 'md';
|
||||
children: Snippet;
|
||||
} & HTMLButtonAttributes = $props();
|
||||
</script>
|
||||
|
||||
<!-- `type="button"` precedes the spread so a caller can still override it
|
||||
(e.g. a submit button) while the default never submits a form. -->
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn {variant} radius-{radius}"
|
||||
style:--ib-size="{size}px"
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--ib-size, 32px);
|
||||
height: var(--ib-size, 32px);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radius-sm {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.radius-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/lib/components/IconButton.svelte.test.ts
Normal file
79
frontend/src/lib/components/IconButton.svelte.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// A stand-in for the lucide icon callers pass as the child.
|
||||
const icon = createRawSnippet(() => ({
|
||||
render: () => `<svg data-testid="glyph"></svg>`
|
||||
}));
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders a button containing the icon child', () => {
|
||||
render(IconButton, { props: { children: icon } });
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.querySelector('[data-testid="glyph"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to type=button so it never submits a surrounding form by accident', () => {
|
||||
render(IconButton, { props: { children: icon } });
|
||||
expect(screen.getByRole('button').getAttribute('type')).toBe('button');
|
||||
});
|
||||
|
||||
it('lets a caller override the type (e.g. submit)', () => {
|
||||
render(IconButton, { props: { type: 'submit', children: icon } });
|
||||
expect(screen.getByRole('button').getAttribute('type')).toBe('submit');
|
||||
});
|
||||
|
||||
it('applies the variant as a class (plain by default)', () => {
|
||||
const { container } = render(IconButton, { props: { children: icon } });
|
||||
expect(container.querySelector('button.icon-btn.plain')).toBeTruthy();
|
||||
cleanup();
|
||||
const { container: c2 } = render(IconButton, {
|
||||
props: { variant: 'danger', children: icon }
|
||||
});
|
||||
expect(c2.querySelector('button.icon-btn.danger')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to a 32px square with the small radius', () => {
|
||||
const { container } = render(IconButton, { props: { children: icon } });
|
||||
const btn = container.querySelector('button.icon-btn') as HTMLButtonElement;
|
||||
expect(btn.classList.contains('radius-sm')).toBe(true);
|
||||
expect(btn.style.getPropertyValue('--ib-size')).toBe('32px');
|
||||
});
|
||||
|
||||
it('honours an explicit size and radius (the larger header/search buttons)', () => {
|
||||
const { container } = render(IconButton, {
|
||||
props: { size: 36, radius: 'md', children: icon }
|
||||
});
|
||||
const btn = container.querySelector('button.icon-btn') as HTMLButtonElement;
|
||||
expect(btn.style.getPropertyValue('--ib-size')).toBe('36px');
|
||||
expect(btn.classList.contains('radius-md')).toBe(true);
|
||||
expect(btn.classList.contains('radius-sm')).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards onclick', async () => {
|
||||
const onclick = vi.fn();
|
||||
render(IconButton, { props: { onclick, children: icon } });
|
||||
await fireEvent.click(screen.getByRole('button'));
|
||||
expect(onclick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('forwards arbitrary button attributes (aria-label, title, disabled, data-testid)', () => {
|
||||
render(IconButton, {
|
||||
props: {
|
||||
'aria-label': 'Delete collection',
|
||||
title: 'Delete',
|
||||
disabled: true,
|
||||
'data-testid': 'collection-delete',
|
||||
children: icon
|
||||
}
|
||||
});
|
||||
const btn = screen.getByTestId('collection-delete');
|
||||
expect(btn.getAttribute('aria-label')).toBe('Delete collection');
|
||||
expect(btn.getAttribute('title')).toBe('Delete');
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -126,4 +126,13 @@
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* This control doubles as the primary sort toggle (catalog) and the
|
||||
Library tab switcher on mobile, so its options must meet the touch
|
||||
floor on phones. */
|
||||
@media (max-width: 640px) {
|
||||
.seg {
|
||||
min-height: var(--tap-min);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,6 +78,12 @@
|
||||
--bp-md: 768px;
|
||||
--bp-lg: 1024px;
|
||||
|
||||
/* Comfortable touch-target floor for primary controls on phones.
|
||||
Controls compact to their desktop height by default and grow to
|
||||
this minimum under the 640px breakpoint. See touch-targets.test.ts
|
||||
for the shared-primitive contract. */
|
||||
--tap-min: 44px;
|
||||
|
||||
/* Safe-area helpers for notched / home-indicator devices. Resolve
|
||||
to 0 on devices without insets, so they're safe everywhere as
|
||||
long as <meta name="viewport" content="…, viewport-fit=cover">
|
||||
@@ -293,6 +299,23 @@ img {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Touch-target floor on phones. Form controls and submit buttons keep
|
||||
their compact 36px desktop height above the breakpoint but grow to a
|
||||
comfortable tap size here. `min-height` (not `height`) so the
|
||||
already-tall textarea is unaffected. Scoped to text fields, selects,
|
||||
and `type=submit` deliberately — bare `button` would also catch dense
|
||||
icon buttons (chip remove, segmented options, reader chrome) that meet
|
||||
the floor through their own hit-area rules instead. */
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
input[type='password'],
|
||||
input[type='number'],
|
||||
input[type='email'],
|
||||
select,
|
||||
button[type='submit'] {
|
||||
min-height: var(--tap-min);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
79
frontend/src/lib/styles/touch-targets.test.ts
Normal file
79
frontend/src/lib/styles/touch-targets.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Touch-target contract for the SHARED interactive primitives.
|
||||
//
|
||||
// WCAG / platform HIG put the comfortable touch floor at ~44px. jsdom can't
|
||||
// evaluate @media queries or compute layout, so we can't assert rendered box
|
||||
// sizes in a unit test. Instead we pin the CSS *contract*: each shared control
|
||||
// grows to the --tap-min floor inside the mobile breakpoint. This guards
|
||||
// against a future edit silently dropping the mobile bump (the regression the
|
||||
// desktop/mobile consistency review surfaced).
|
||||
//
|
||||
// Scope is deliberately the genuinely-shared primitives — the global
|
||||
// input/button rules in tokens.css, SegmentedControl, and Chip. The per-route
|
||||
// `.icon-btn` is copy-pasted across seven files and is a separate refactor.
|
||||
|
||||
// Resolved from the vitest cwd (the frontend root) — jsdom doesn't hand us a
|
||||
// file:// import.meta.url to anchor on.
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8');
|
||||
|
||||
const TOKENS = read('src/lib/styles/tokens.css');
|
||||
const SEGMENTED = read('src/lib/components/SegmentedControl.svelte');
|
||||
const CHIP = read('src/lib/components/Chip.svelte');
|
||||
|
||||
// Pull the text inside every `@media (max-width: 640px) { ... }` block,
|
||||
// brace-balanced so nested rules are captured. Returns the concatenation so a
|
||||
// `.includes` check only matches declarations that live under the mobile query.
|
||||
function mobileBlocks(css: string): string {
|
||||
const marker = '@media (max-width: 640px)';
|
||||
let out = '';
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const start = css.indexOf(marker, from);
|
||||
if (start === -1) break;
|
||||
const open = css.indexOf('{', start);
|
||||
if (open === -1) break;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < css.length; i++) {
|
||||
if (css[i] === '{') depth++;
|
||||
else if (css[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
out += css.slice(open + 1, i) + '\n';
|
||||
from = i + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Collapse whitespace so `min-height:\n var(--tap-min)` matches regardless of
|
||||
// the formatter's line breaks.
|
||||
const squish = (s: string) => s.replace(/\s+/g, ' ');
|
||||
|
||||
describe('shared touch-target contract', () => {
|
||||
it('defines a --tap-min token of 44px', () => {
|
||||
expect(squish(TOKENS)).toMatch(/--tap-min:\s*44px/);
|
||||
});
|
||||
|
||||
it('grows global inputs and buttons to the touch floor on mobile', () => {
|
||||
const mobile = squish(mobileBlocks(TOKENS));
|
||||
// input/select group and button rule both reach --tap-min under 640px.
|
||||
expect(mobile).toMatch(/input[^}]*min-height:\s*var\(--tap-min\)/);
|
||||
expect(mobile).toMatch(/button[^{]*\{[^}]*min-height:\s*var\(--tap-min\)/);
|
||||
});
|
||||
|
||||
it('grows SegmentedControl options to the touch floor on mobile', () => {
|
||||
const mobile = squish(mobileBlocks(SEGMENTED));
|
||||
expect(mobile).toMatch(/\.seg[^}]*min-height:\s*var\(--tap-min\)/);
|
||||
});
|
||||
|
||||
it("expands the chip remove button's hit area to the touch floor on mobile", () => {
|
||||
const mobile = squish(mobileBlocks(CHIP));
|
||||
// The visible glyph stays small; the tappable area reaches --tap-min.
|
||||
expect(mobile).toMatch(/\.chip-remove[^}]*min-(height|block-size):\s*var\(--tap-min\)/);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import { theme } from '$lib/theme.svelte';
|
||||
import AppBar from '$lib/components/AppBar.svelte';
|
||||
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
@@ -81,7 +82,9 @@
|
||||
label: 'Account',
|
||||
href: '/profile/account',
|
||||
icon: User,
|
||||
match: ['/profile', '/profile/preferences']
|
||||
// `/admin` keeps the Account tab highlighted while an admin is in
|
||||
// the admin area — its only mobile entry point is the Account hub.
|
||||
match: ['/profile', '/profile/preferences', '/admin']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -232,9 +235,9 @@
|
||||
<span data-testid="session-loading" aria-busy="true">…</span>
|
||||
{:else if session.user}
|
||||
<span class="username" data-testid="session-user">{session.user.username}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
<IconButton
|
||||
size={36}
|
||||
radius="md"
|
||||
onclick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
aria-label="Logout"
|
||||
@@ -245,7 +248,7 @@
|
||||
{:else}
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</IconButton>
|
||||
{:else}
|
||||
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
|
||||
{#if authConfig.self_register_enabled}
|
||||
@@ -348,24 +351,6 @@
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logging-out {
|
||||
font-size: var(--font-base);
|
||||
line-height: 1;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
@@ -467,9 +468,16 @@
|
||||
placeholder="Search by title or author"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<button class="icon-btn primary" type="submit" aria-label="Search" title="Search">
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size={36}
|
||||
radius="md"
|
||||
type="submit"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
<button
|
||||
type="button"
|
||||
class="filters-toggle"
|
||||
@@ -538,10 +546,11 @@
|
||||
<label class="sort">
|
||||
<span>Sort</span>
|
||||
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
|
||||
<option value="updated">Last updated</option>
|
||||
<option value="created">Date added</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="author">Author</option>
|
||||
<!-- Options derive from SORT_FIELD_LABELS — the same source the
|
||||
mobile sort sheet uses — so the two surfaces can't drift. -->
|
||||
{#each Object.entries(SORT_FIELD_LABELS) as [value, label] (value)}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="sort">
|
||||
@@ -922,26 +931,6 @@
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -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 IconButton from '$lib/components/IconButton.svelte';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
@@ -148,26 +149,23 @@
|
||||
{:else}
|
||||
<div class="title-row">
|
||||
<h1 data-testid="collection-name">{collection.name}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
<IconButton
|
||||
onclick={startEdit}
|
||||
aria-label="Edit collection"
|
||||
title="Edit"
|
||||
data-testid="collection-edit-open"
|
||||
>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onclick={onDeleteCollection}
|
||||
aria-label="Delete collection"
|
||||
title="Delete"
|
||||
data-testid="collection-delete"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
{#if collection.description}
|
||||
<p class="description" data-testid="collection-description">
|
||||
@@ -406,25 +404,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--danger);
|
||||
/* Touch has no hover, so the reveal-on-hover treatment leaves the
|
||||
remove buttons invisible and undiscoverable. Show them persistently
|
||||
and enlarge the hit area on phones. (Capped well below 44px because
|
||||
the button floats over a 4-up cover thumbnail — a full touch-floor
|
||||
square would swallow the cover.) */
|
||||
@media (max-width: 640px) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
49
frontend/src/routes/collections/[id]/remove-touch.test.ts
Normal file
49
frontend/src/routes/collections/[id]/remove-touch.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// The per-card "remove" buttons on a collection are revealed on hover /
|
||||
// focus-within (opacity 0 → 1), which leaves them invisible and
|
||||
// undiscoverable on touch. They must be shown persistently at the mobile
|
||||
// breakpoint. jsdom can't evaluate @media or layout, so this pins the CSS
|
||||
// contract: the 640px block makes `.remove` opaque. (Same approach as the
|
||||
// shared touch-target contract test.)
|
||||
|
||||
const SRC = readFileSync(
|
||||
resolve(process.cwd(), 'src/routes/collections/[id]/+page.svelte'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Brace-balanced extraction of every `@media (max-width: 640px) { ... }` body.
|
||||
function mobileBlocks(css: string): string {
|
||||
const marker = '@media (max-width: 640px)';
|
||||
let out = '';
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const start = css.indexOf(marker, from);
|
||||
if (start === -1) break;
|
||||
const open = css.indexOf('{', start);
|
||||
if (open === -1) break;
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < css.length; i++) {
|
||||
if (css[i] === '{') depth++;
|
||||
else if (css[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
out += css.slice(open + 1, i) + '\n';
|
||||
from = i + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const squish = (s: string) => s.replace(/\s+/g, ' ');
|
||||
|
||||
describe('collection remove buttons on touch', () => {
|
||||
it('reveals the remove buttons persistently at the mobile breakpoint', () => {
|
||||
const mobile = squish(mobileBlocks(SRC));
|
||||
expect(mobile).toMatch(/\.remove[^}]*opacity:\s*1/);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import BookmarkList from '$lib/components/BookmarkList.svelte';
|
||||
import CollectionsGrid from '$lib/components/CollectionsGrid.svelte';
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import HistoryList from '$lib/components/HistoryList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function clearOne(p: ReadProgressSummary): Promise<void> {
|
||||
return clearReadProgress(p.manga_id);
|
||||
}
|
||||
|
||||
type Tab = 'bookmarks' | 'collections' | 'page-tags' | 'history';
|
||||
const TABS: { label: string; value: Tab }[] = [
|
||||
{ label: 'Bookmarks', value: 'bookmarks' },
|
||||
@@ -91,53 +94,13 @@
|
||||
initialItems={data.pageTags}
|
||||
initialDistinct={data.distinctPageTags}
|
||||
/>
|
||||
{:else if data.history.length === 0}
|
||||
<p class="hint" data-testid="library-history-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn
|
||||
a page.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="history-list" data-testid="library-history-list">
|
||||
{#each data.history as p (p.manga_id)}
|
||||
<li class="history-row">
|
||||
<a
|
||||
href={p.chapter_id != null
|
||||
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||
: `/manga/${p.manga_id}`}
|
||||
class="cover-link"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if p.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(p.manga_cover_image_path)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
<BookImage size={18} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a href="/manga/{p.manga_id}" class="title">{p.manga_title}</a>
|
||||
{#if p.chapter_id != null && p.chapter_number != null}
|
||||
<a
|
||||
class="target"
|
||||
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
||||
>
|
||||
Continue {chapterLabel({
|
||||
number: p.chapter_number,
|
||||
title: null
|
||||
})}{#if p.page > 1} — page {p.page}{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<HistoryList
|
||||
entries={data.history}
|
||||
onClear={clearOne}
|
||||
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||
testid="library-history"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -165,65 +128,4 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 56px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1243,9 +1243,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resync-msg {
|
||||
display: none;
|
||||
}
|
||||
/* `.resync-msg` is a sibling of `.action-row`, not a child, so it
|
||||
survives the row being hidden. Keep it visible on mobile: admins
|
||||
trigger Force resync from the overflow sheet and need the result. */
|
||||
|
||||
.continue {
|
||||
display: none;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import Sheet from '$lib/components/Sheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import AdaptiveDialog from '$lib/components/AdaptiveDialog.svelte';
|
||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||
import TapZone from '$lib/components/TapZone.svelte';
|
||||
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
||||
@@ -1094,6 +1094,28 @@
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Desktop brightness. On mobile this control lives in the reader
|
||||
settings sheet; desktop has no sheet, so it sits inline here.
|
||||
Shares the `brightness` state and the same `--reader-dim`
|
||||
effect, so a value set on either form factor stays in sync. -->
|
||||
<label class="brightness-field desktop-control">
|
||||
<Sun size={16} aria-hidden="true" />
|
||||
<span class="visually-hidden">Brightness</span>
|
||||
<input
|
||||
type="range"
|
||||
class="brightness-slider"
|
||||
min="0.3"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={brightness}
|
||||
oninput={(e) => {
|
||||
brightness = Number((e.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
aria-label="Brightness"
|
||||
data-testid="reader-brightness-desktop"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="reader-settings-btn mobile-control"
|
||||
@@ -1555,7 +1577,7 @@
|
||||
if (activePageId) void loadPageSummary(activePageId);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
<AdaptiveDialog
|
||||
open={tagsModalOpen}
|
||||
title="Tag this page"
|
||||
onClose={() => (tagsModalOpen = false)}
|
||||
@@ -1568,7 +1590,7 @@
|
||||
activePageTags = tags;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</AdaptiveDialog>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -2181,6 +2203,20 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Inline desktop brightness control in the reader nav. The slider is
|
||||
full-width inside the mobile settings sheet; here it's capped so it
|
||||
sits neatly beside the mode toggle. */
|
||||
.brightness-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.brightness-field .brightness-slider {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Reader-nav sits at the viewport top — the global mobile
|
||||
chrome is hidden on this route so there's no app bar above
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { formatBytes, validateImageFile } from '$lib/upload-validation';
|
||||
import Chip from '$lib/components/Chip.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
@@ -190,16 +191,15 @@
|
||||
maxlength="200"
|
||||
data-testid="manga-author-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
<IconButton
|
||||
variant="primary"
|
||||
onclick={addAuthor}
|
||||
disabled={!authorDraft.trim()}
|
||||
aria-label="Add author"
|
||||
title="Add author"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,16 +240,15 @@
|
||||
maxlength="200"
|
||||
data-testid="manga-alt-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
<IconButton
|
||||
variant="primary"
|
||||
onclick={addAltTitle}
|
||||
disabled={!altTitleDraft.trim()}
|
||||
aria-label="Add alternative title"
|
||||
title="Add alternative title"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -270,16 +269,15 @@
|
||||
src={fileUrl(currentCoverPath)}
|
||||
alt="Current cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onclick={markCoverForRemoval}
|
||||
aria-label="Remove cover"
|
||||
title="Remove cover"
|
||||
data-testid="cover-remove"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
{:else if pendingCoverRemoval}
|
||||
<p class="hint" data-testid="cover-pending-removal">
|
||||
@@ -419,39 +417,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -27,6 +28,7 @@
|
||||
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
|
||||
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
|
||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false },
|
||||
{ href: '/profile/page-tags', label: 'Page tags', icon: Tag, testid: 'tab-page-tags', guestVisible: false },
|
||||
{ href: '/profile/history', label: 'History', icon: History, testid: 'tab-history', guestVisible: false }
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
let currentPassword = $state('');
|
||||
@@ -193,6 +194,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if session.user.is_admin}
|
||||
<!-- Admin is reachable from the desktop header; the mobile chrome
|
||||
has no Admin tab, so the Account hub is its entry point. -->
|
||||
<div class="row-group">
|
||||
<a class="row" href="/admin" data-testid="account-row-admin">
|
||||
<Shield size={18} aria-hidden="true" />
|
||||
<span class="row-label">Admin</span>
|
||||
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row-group">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,30 +2,16 @@
|
||||
import { fileUrl } from '$lib/api/client';
|
||||
import { chapterLabel } from '$lib/api/chapters';
|
||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||
import HistoryList from '$lib/components/HistoryList.svelte';
|
||||
import BookImage from '@lucide/svelte/icons/book-image';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let progress = $state<ReadProgressSummary[]>([...data.progress]);
|
||||
let clearError = $state<string | null>(null);
|
||||
const uploads = $derived(data.uploads);
|
||||
|
||||
async function clearOne(p: ReadProgressSummary) {
|
||||
clearError = null;
|
||||
const snapshot = progress;
|
||||
progress = progress.filter((x) => x.manga_id !== p.manga_id);
|
||||
try {
|
||||
await clearReadProgress(p.manga_id);
|
||||
} catch (e) {
|
||||
// Roll back optimistic removal and surface inline rather
|
||||
// than via alert() — keeps the page non-modal and
|
||||
// testable.
|
||||
progress = snapshot;
|
||||
clearError = `Couldn't clear "${p.manga_title}": ${(e as Error).message}`;
|
||||
}
|
||||
function clearOne(p: ReadProgressSummary): Promise<void> {
|
||||
return clearReadProgress(p.manga_id);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -47,77 +33,12 @@
|
||||
<Eye size={18} aria-hidden="true" />
|
||||
<span>Reading history</span>
|
||||
</h2>
|
||||
{#if clearError}
|
||||
<p class="error inline" role="alert" data-testid="history-clear-error">
|
||||
{clearError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if progress.length === 0}
|
||||
<p class="hint" data-testid="history-reading-empty">
|
||||
Nothing here yet — open any manga and a row will land here once you turn a page.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="entry-list" data-testid="history-reading-list">
|
||||
{#each progress as p (p.manga_id)}
|
||||
<li class="entry">
|
||||
<a
|
||||
href={p.chapter_id != null
|
||||
? `/manga/${p.manga_id}/chapter/${p.chapter_id}`
|
||||
: `/manga/${p.manga_id}`}
|
||||
class="cover-link"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if p.manga_cover_image_path}
|
||||
<img
|
||||
src={fileUrl(p.manga_cover_image_path)}
|
||||
alt=""
|
||||
class="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="cover cover-placeholder">
|
||||
<BookImage size={20} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a
|
||||
href="/manga/{p.manga_id}"
|
||||
class="title"
|
||||
data-testid="history-reading-title"
|
||||
>
|
||||
{p.manga_title}
|
||||
</a>
|
||||
<span class="target">
|
||||
{#if p.chapter_id != null && p.chapter_number != null}
|
||||
<a
|
||||
href="/manga/{p.manga_id}/chapter/{p.chapter_id}"
|
||||
>
|
||||
Continue Ch. {p.chapter_number}{#if p.page > 1} — page {p.page}{/if}
|
||||
</a>
|
||||
{:else if p.chapter_id}
|
||||
<span class="muted">(chapter removed)</span>
|
||||
{:else}
|
||||
<span class="muted">Whole manga, page {p.page}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="when">Read {formatDate(p.updated_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
onclick={() => clearOne(p)}
|
||||
aria-label={`Clear ${p.manga_title} from history`}
|
||||
title="Clear from history"
|
||||
data-testid={`history-clear-${p.manga_id}`}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<HistoryList
|
||||
entries={data.progress}
|
||||
onClear={clearOne}
|
||||
emptyText="Nothing here yet — open any manga and a row will land here once you turn a page."
|
||||
testid="history-reading"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="uploads-heading" class="uploads-section">
|
||||
@@ -285,31 +206,4 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error.inline {
|
||||
background: var(--danger-soft-bg);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--danger);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
</style>
|
||||
|
||||
31
frontend/src/routes/profile/page-tags/+page.svelte
Normal file
31
frontend/src/routes/profile/page-tags/+page.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import PageTagsList from '$lib/components/PageTagsList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mangalord | Page tags</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.error}
|
||||
<p class="error" role="alert" data-testid="profile-page-tags-error">
|
||||
Couldn't load page tags: {data.error}
|
||||
</p>
|
||||
{:else if !data.authenticated}
|
||||
<p class="hint" data-testid="profile-page-tags-signin">
|
||||
<a href="/login?next=/profile/page-tags">Sign in</a> to see your page tags.
|
||||
</p>
|
||||
{:else}
|
||||
<PageTagsList initialItems={data.pageTags} initialDistinct={data.distinctPageTags} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/routes/profile/page-tags/+page.ts
Normal file
32
frontend/src/routes/profile/page-tags/+page.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { listMyPageTags, listMyDistinctPageTags } from '$lib/api/page_tags';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
// Desktop home for the user's page tags — the parity counterpart to the
|
||||
// mobile Library "Page tags" tab. Loads the same data the library wrapper
|
||||
// feeds PageTagsList. 401 → unauthenticated path; the page shows a sign-in
|
||||
// prompt.
|
||||
export const load: PageLoad = async () => {
|
||||
try {
|
||||
const [pageTags, distinctPageTags] = await Promise.all([
|
||||
listMyPageTags({ limit: 100 }),
|
||||
listMyDistinctPageTags(undefined, 100)
|
||||
]);
|
||||
return {
|
||||
authenticated: true,
|
||||
pageTags: pageTags.items,
|
||||
distinctPageTags,
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
return { authenticated: false, pageTags: [], distinctPageTags: [], error: null };
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return { authenticated: true, pageTags: [], distinctPageTags: [], error: e.message };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
import ChapterPagesEditor, {
|
||||
type PendingPage
|
||||
} from '$lib/components/ChapterPagesEditor.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
@@ -236,16 +237,15 @@
|
||||
maxlength="200"
|
||||
data-testid="manga-author-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
<IconButton
|
||||
variant="primary"
|
||||
onclick={addAuthor}
|
||||
disabled={!authorDraft.trim()}
|
||||
aria-label="Add author"
|
||||
title="Add author"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,16 +286,15 @@
|
||||
maxlength="200"
|
||||
data-testid="manga-alt-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn primary"
|
||||
<IconButton
|
||||
variant="primary"
|
||||
onclick={addAltTitle}
|
||||
disabled={!altTitleDraft.trim()}
|
||||
aria-label="Add alternative title"
|
||||
title="Add alternative title"
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -378,16 +377,15 @@
|
||||
Failed
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onclick={() => removeChapter(c.id)}
|
||||
aria-label="Remove chapter"
|
||||
title="Remove chapter"
|
||||
data-testid="staged-chapter-remove"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
{#if c.error}
|
||||
<p class="field-error" role="alert">{c.error}</p>
|
||||
@@ -513,39 +511,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chapters-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user