Compare commits

...

19 Commits

Author SHA1 Message Date
MechaCat02
366ccc1c4c Merge branch 'feat/desktop-profile-page-tags' into chore/reconcile-ui-review 2026-06-25 21:13:55 +02:00
MechaCat02
491c0dacc9 Merge branch 'feat/adaptive-overlay-sheets' into chore/reconcile-ui-review 2026-06-25 21:13:55 +02:00
MechaCat02
ec5185593e Merge branch 'feat/mobile-admin-entry' into chore/reconcile-ui-review 2026-06-25 21:13:55 +02:00
MechaCat02
b12e520c08 Merge branch 'feat/desktop-reader-brightness' into chore/reconcile-ui-review 2026-06-25 21:13:55 +02:00
MechaCat02
5794b5305f Merge branch 'feat/shared-history-list' into chore/reconcile-ui-review
# Conflicts:
#	backend/Cargo.lock
#	backend/Cargo.toml
#	frontend/package.json
2026-06-25 21:13:55 +02:00
MechaCat02
8e375d65b7 Merge branch 'refactor/icon-button-outliers' into chore/reconcile-ui-review
# Conflicts:
#	frontend/src/routes/collections/[id]/+page.svelte
2026-06-25 21:13:06 +02:00
MechaCat02
82c3de6ace Merge branch 'refactor/desktop-sort-options' into chore/reconcile-ui-review 2026-06-25 21:12:18 +02:00
MechaCat02
5c37c5d8df Merge branch 'fix/collection-remove-touch' into chore/reconcile-ui-review 2026-06-25 21:12:18 +02:00
MechaCat02
50a8e96872 Merge branch 'fix/mobile-resync-feedback' into chore/reconcile-ui-review 2026-06-25 21:12:18 +02:00
MechaCat02
18e6b501e0 feat(profile): add a desktop Page-tags section
All checks were successful
deploy / test-backend (pull_request) Successful in 27m40s
deploy / test-frontend (pull_request) Successful in 10m22s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
Page tags were a first-class section of the mobile Library tab but had no
home in the desktop /profile tabs, so desktop users couldn't browse their
tagged pages without going through /search. Add a /profile/page-tags route
that reuses PageTagsList (the same component the mobile Library renders) and
a "Page tags" tab between Collections and History.

The loader mirrors the library wrapper's page-tags fetch; 401 → sign-in
prompt, matching the other profile sub-routes.

e2e: the desktop Page-tags tab navigates to /profile/page-tags and renders
the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:31:07 +02:00
MechaCat02
92db3ce324 feat(ui): present page-action editors as sheets on mobile
All checks were successful
deploy / test-backend (pull_request) Successful in 28m26s
deploy / test-frontend (pull_request) Successful in 10m22s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:

- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
  detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
  its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.

Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.

AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:21:10 +02:00
MechaCat02
67160fde47 feat(nav): give admins a mobile entry point to the admin area
All checks were successful
deploy / test-backend (pull_request) Successful in 28m23s
deploy / test-frontend (pull_request) Successful in 10m19s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The admin area was reachable only from the desktop header's is_admin link;
on mobile there was no tab or row, so admins had to type the URL and no tab
highlighted. Add a conditional Admin row to the mobile Account hub and
`/admin` to the Account tab's match prefixes, so the tab stays highlighted
while an admin is in the admin area.

e2e: an admin session shows an Admin row linking to /admin; a non-admin
never sees it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:13:10 +02:00
MechaCat02
9a16082015 fix(collections): make per-card remove buttons usable on touch
All checks were successful
deploy / test-backend (pull_request) Successful in 28m14s
deploy / test-frontend (pull_request) Successful in 10m17s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The remove buttons on a collection's cards were revealed only on
hover / focus-within (opacity 0 → 1), so on touch they were invisible and
undiscoverable. Show them persistently at the 640px breakpoint and enlarge
the hit area from 24px to 32px (kept below the full 44px floor because the
button floats over a 4-up cover thumbnail).

Test pins the CSS contract (jsdom can't evaluate @media): the mobile block
makes `.remove` opaque.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:08:16 +02:00
MechaCat02
b3ae90fa57 fix(manga): show force-resync result on mobile, not just desktop
All checks were successful
deploy / test-backend (pull_request) Successful in 27m59s
deploy / test-frontend (pull_request) Successful in 10m19s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
`.resync-msg` (the admin Force-resync result) was display:none under 640px,
so mobile admins — who trigger resync from the overflow sheet — got no
success or error feedback. The element is already a sibling of `.action-row`
(not a child), so it survives the row being hidden; the only thing hiding it
was an explicit mobile rule. Drop that rule.

e2e: a mobile admin triggers Force resync from the overflow sheet and now
sees the "Metadata updated" result (written test-first against the hidden
element, then unhidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:05:42 +02:00
MechaCat02
f83ac67676 feat(reader): surface brightness on desktop, not just the mobile sheet
All checks were successful
deploy / test-backend (pull_request) Successful in 27m40s
deploy / test-frontend (pull_request) Successful in 10m19s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The brightness slider lived only in the mobile reader settings sheet, so
desktop users couldn't dim — and could be left stuck dimmed by a value a
phone had persisted (brightness is shared via localStorage and the
--reader-dim effect is form-factor agnostic). Add an inline brightness
slider to the desktop reader nav, marked `desktop-control` alongside the
existing mode toggle and gap select. It binds the same `brightness` state,
so a value set on either form factor stays in sync.

Also mocks `/me/read-progress` in the reader-mode e2e setup — without it the
GET/PUT fell through to the (absent) backend and stalled the reader load,
which is why those specs couldn't run headless. The spec is now hermetic and
covers the new desktop control (it dims/undims, and a phone-persisted value
is recoverable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:01:05 +02:00
MechaCat02
79a1432db8 refactor(ui): fold the 36px header/search buttons into IconButton
Completes the IconButton extraction by adding `size` and `radius` props and
converting the two remaining outliers that were left out of the first pass:
the desktop header logout button (+layout) and the home search submit button
(+page), both 36px with the medium radius.

IconButton now drives every icon button in the app (except profile/history's,
which the shared-HistoryList change removes separately). Defaults stay 32px /
small radius, so the four already-converted sites are untouched. Rendered
size, radius, and variant match the old markup — no behaviour change, no
version bump.

This also sets up applying the --tap-min touch floor in one place once the
touch-target change lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:46:54 +02:00
MechaCat02
129cb0241d refactor(ui): extract shared IconButton from duplicated .icon-btn copies
All checks were successful
deploy / test-frontend (pull_request) Successful in 10m21s
deploy / test-backend (pull_request) Successful in 28m26s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
Five files hand-rolled a near-identical 32px `.icon-btn` (same size, hover,
and primary/danger variants). Extract a single IconButton.svelte component
so the treatment lives in one place. Converts the four sites with the
standard 32px form: collections detail, manga edit, upload, and the
chapter-pages editor.

The component takes a `variant` (plain/primary/danger) and spreads any
button attributes (onclick, disabled, aria-label, title, data-testid)
straight through; `type="button"` defaults but a caller can override. The
rendered button keeps the same class, styles, and DOM position, so layout
and behaviour are unchanged — no version bump.

Three icon-button sites are intentionally left out:
- The header (+layout) and home search button are 36px / different radius —
  size outliers that need a size/radius prop before folding in.
- profile/history's copy is being removed in the shared-HistoryList change;
  touching it here would just conflict.

A component (not a global class) avoids colliding with those remaining
local `.icon-btn` definitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:37:08 +02:00
MechaCat02
a08c49b708 refactor(catalog): derive desktop sort options from SORT_FIELD_LABELS
All checks were successful
deploy / test-backend (pull_request) Successful in 27m32s
deploy / test-frontend (pull_request) Successful in 10m21s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The desktop sort <select> hard-coded its four <option>s while the mobile
sort sheet rendered them from SORT_FIELD_LABELS. The two happened to match,
but a label rename in mangaSort.ts would have silently diverged the desktop
control. Render the desktop options from the same map so they can't.

No behaviour change (the rendered options are identical), so no version
bump. Adds an e2e guard asserting the desktop select's option values and
labels equal SORT_FIELD_LABELS in order — verified red against the old
hard-coded markup (with a label renamed) and green after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:22:45 +02:00
MechaCat02
68a58fba44 feat(history): share one HistoryList across desktop and mobile
All checks were successful
deploy / test-backend (pull_request) Successful in 29m1s
deploy / test-frontend (pull_request) Successful in 10m20s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
The desktop /profile/history page and the mobile Library "History" tab
each hand-rendered reading history with separate markup, which had drifted:
the Library tab was a read-only 2-column list with no date and no
removed-chapter / whole-manga fallbacks, while /profile/history was a
3-column list with a per-row clear button. Extract a shared HistoryList
component so the two can't diverge again.

- New HistoryList.svelte owns row rendering, the optimistic-removal UX
  (instant remove, rollback + inline error on failure), and the empty
  state. Clear is opt-in via an `onClear` prop so a caller can render
  read-only, but both surfaces now pass it.
- The mobile Library History tab gains the clear action, the "Read {date}"
  line, and the (chapter removed) / whole-manga fallbacks it was missing.
- Continue label is built in script (was inline) so the " — page N" suffix
  keeps its spaces — Svelte trimmed them at the {#if} edge, rendering
  "Chapter 3— page 7". Both surfaces now read "Continue Chapter N".

Net -204 lines across the two pages. Uploads parity on the mobile tab is
left as a follow-up (it needs the library loader to fetch uploads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:05:51 +02:00
31 changed files with 1065 additions and 440 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.88.1"
version = "0.89.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.88.1"
version = "0.89.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -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;

View File

@@ -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
}) => {

View File

@@ -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
}) => {

View File

@@ -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);
});
});

View File

@@ -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');

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.88.1",
"version": "0.89.0",
"private": true,
"type": "module",
"scripts": {

View 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}

View 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();
});
});

View File

@@ -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 {

View File

@@ -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);

View 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>

View 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();
});
});

View 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>

View 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);
});
});

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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>

View 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/);
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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 }
];

View File

@@ -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"

View File

@@ -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>

View 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>

View 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;
}
};

View File

@@ -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;