diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 2217f2b8..88819cca 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -26,6 +26,8 @@ Each book can also have an optional medium: Print, eBook, Audiobook, Comic / Gra ## Navigation - Switch between tabs using the bottom navigation bar on mobile or the sidebar on desktop +- Use the button under the logo to collapse the desktop sidebar to an icon-only rail; the choice is remembered in your browser +- The current page is highlighted in the sidebar and the mobile tab bar - Books are displayed as cards with cover images, titles, and authors - Click any book to open the detail view diff --git a/docs/releases.md b/docs/releases.md index 30112342..826e629d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -16,7 +16,7 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's | Version | Date | Type | |---|---|---| -| [v1.13.0](#v1-13-0-catppuccin-themes-and-theme-picker) | Unreleased | Feature release | +| [v1.13.0](#v1-13-0-catppuccin-themes-picker-and-collapsible-sidebar) | Unreleased | Feature release | | [v1.12.1](#v1-12-1-dst-chart-timezone-fixes) | 2026-11-03 | Maintenance | | [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | 2026-11-02 | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | @@ -40,13 +40,14 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's --- -## v1.13.0: Catppuccin Themes and Theme Picker +## v1.13.0: Catppuccin Themes, Picker and Collapsible Sidebar -**Summary:** The profile settings gain the four Catppuccin flavors: **Latte** (light), **Frappé**, **Macchiato**, and **Mocha** (dark), and the theme selector becomes a swatch grid with live color previews. The flavors are registered as regular DaisyUI themes from the official Catppuccin daisyUI port, so preview, per-user persistence, chart colors, and the favicon work exactly as they do for the built-in themes. The port's `secondary` color is remapped to Catppuccin's mauve so chart series and secondary buttons stay visible, and Latte gets a contrast-tuned label palette. Existing themes, stored preferences, and the database stay unchanged. +**Summary:** The profile settings gain the four Catppuccin flavors: **Latte** (light), **Frappé**, **Macchiato**, and **Mocha** (dark), and the theme selector becomes a swatch grid with live color previews. The flavors are registered as regular DaisyUI themes from the official Catppuccin daisyUI port, so preview, per-user persistence, chart colors, and the favicon work exactly as they do for the built-in themes. The port's `secondary` color is remapped to Catppuccin's mauve so chart series and secondary buttons stay visible, and Latte gets a contrast-tuned label palette. A new control under the logo also collapses the desktop sidebar to an icon rail and remembers the choice per browser. Existing themes, stored preferences, and the database stay unchanged. **Features** +- 🧭 **Collapsible sidebar.** A button under the logo shrinks the desktop sidebar from 224 px to an 80 px icon rail with tooltips, so small laptop screens gain content width. The current page stays highlighted in both states, and the choice is stored per browser - 🌿 **Four Catppuccin flavors.** Latte is the light flavor; Frappé, Macchiato, and Mocha are dark. They are generated at build time by `@catppuccin/daisyui` and registered as ordinary DaisyUI themes, so live preview, per-user persistence, chart colors, and the favicon work exactly as they do for the built-in themes - 🎨 **Swatch-based theme picker.** Themes are now shown as tiles with a live color preview instead of a dropdown. The four Catppuccin flavors are listed first under a "Catppuccin" heading and the built-in DaisyUI themes follow in their own group. Picking a tile applies the theme immediately - 🖌️ **Readable secondary color.** The port maps `secondary` to a surface tone, which blends into the page in LibrisLog. It is remapped to Catppuccin's mauve, so chart series, secondary buttons, and the add-book stripe stay visible diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts index 58e113fd..929ffbdb 100644 --- a/frontend/e2e/specs/02-dashboard.spec.ts +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -141,4 +141,43 @@ test.describe('Dashboard', () => { // Restore the default so later tests keep the section visible. await updateProfileSettings(page, { gamification_enabled: true }); }); + + test('2.12 sidebar collapses to an icon rail and remembers it', async ({ page }) => { + const sidebar = page.locator('#app-sidebar'); + const toggle = page.locator('#app-sidebar button[aria-controls="app-sidebar"]'); + + await expect(sidebar).toBeVisible(); + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(224); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#app-sidebar a[href="/dashboard"]')).toHaveAttribute('aria-current', 'page'); + + await toggle.click(); + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(80); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // Icon-only mode keeps an exact accessible name, without the tooltip text. + await expect(sidebar.locator('a[href="/library"]')).toHaveAccessibleName('Library'); + + // Navigating from the rail keeps it collapsed and moves the active marker. + await sidebar.locator('a[href="/library"]').click(); + await expect(page).toHaveURL('/library'); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(80); + await expect(page.locator('#app-sidebar a[href="/library"]')).toHaveAttribute('aria-current', 'page'); + + await page.reload(); + await page.waitForSelector('h1'); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(80); + + await page.locator('#app-sidebar button[aria-controls="app-sidebar"]').click(); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(224); + }); + + test('2.13 mobile keeps the bottom navigation with an active marker', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(500); + + await expect(page.locator('#app-sidebar')).toBeHidden(); + const mobileNav = page.locator('#mobile-nav'); + await expect(mobileNav).toBeVisible(); + await expect(mobileNav.locator('a[href="/dashboard"]')).toHaveAttribute('aria-current', 'page'); + }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 8fd660fa..09aee3b0 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -6,6 +6,11 @@ "language": "Sprache" }, "nav": { + "mainNavigation": "Hauptnavigation", + "collapseSidebar": "Seitenleiste einklappen", + "expandSidebar": "Seitenleiste ausklappen", + "collapse": "Einklappen", + "sidebar": "Seitenleiste", "dashboard": "Dashboard", "library": "Bibliothek", "timeline": "Zeitleiste", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 6714ca1f..6c24cee7 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -6,6 +6,11 @@ "language": "Language" }, "nav": { + "mainNavigation": "Main navigation", + "collapseSidebar": "Collapse sidebar", + "expandSidebar": "Expand sidebar", + "collapse": "Collapse", + "sidebar": "Sidebar", "dashboard": "Dashboard", "library": "Library", "timeline": "Timeline", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 350bf415..d0172393 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -6,6 +6,11 @@ "language": "Idioma" }, "nav": { + "mainNavigation": "Navegación principal", + "collapseSidebar": "Contraer barra lateral", + "expandSidebar": "Expandir barra lateral", + "collapse": "Contraer", + "sidebar": "Barra lateral", "dashboard": "Panel", "library": "Biblioteca", "timeline": "Cronología", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 2a49111a..a6b87c90 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -6,6 +6,11 @@ "language": "Langue" }, "nav": { + "mainNavigation": "Navigation principale", + "collapseSidebar": "Réduire la barre latérale", + "expandSidebar": "Développer la barre latérale", + "collapse": "Réduire", + "sidebar": "Barre latérale", "dashboard": "Tableau de bord", "library": "Bibliothèque", "timeline": "Chronologie", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 79f55be1..e865bc48 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -6,6 +6,11 @@ "language": "语言" }, "nav": { + "mainNavigation": "主导航", + "collapseSidebar": "收起侧边栏", + "expandSidebar": "展开侧边栏", + "collapse": "收起", + "sidebar": "侧边栏", "dashboard": "仪表盘", "library": "书库", "timeline": "时间线", diff --git a/frontend/src/lib/stores/sidebar.test.ts b/frontend/src/lib/stores/sidebar.test.ts new file mode 100644 index 00000000..08301ec4 --- /dev/null +++ b/frontend/src/lib/stores/sidebar.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { get } from 'svelte/store'; + +import { + SIDEBAR_COLLAPSED_KEY, + sidebarCollapsed, + loadSidebarFromStorage, + setSidebarCollapsed, + toggleSidebar +} from './sidebar'; + +describe('sidebar store', () => { + beforeEach(() => { + localStorage.clear(); + loadSidebarFromStorage(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to expanded', () => { + expect(get(sidebarCollapsed)).toBe(false); + }); + + it('toggles and persists the collapsed state', () => { + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(true); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('1'); + + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(false); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('0'); + }); + + it('persists an explicit state', () => { + setSidebarCollapsed(true); + expect(get(sidebarCollapsed)).toBe(true); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('1'); + }); + + it('restores the stored state', () => { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '1'); + loadSidebarFromStorage(); + expect(get(sidebarCollapsed)).toBe(true); + }); + + it('treats missing or unreadable values as expanded', () => { + for (const value of [null, '0', 'true', 'collapsed']) { + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(true); + if (value === null) { + localStorage.removeItem(SIDEBAR_COLLAPSED_KEY); + } else { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value); + } + loadSidebarFromStorage(); + expect(get(sidebarCollapsed)).toBe(false); + } + }); + + it('keeps the in-memory state when storage rejects writes', () => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage blocked'); + }); + + expect(() => setSidebarCollapsed(true)).not.toThrow(); + expect(get(sidebarCollapsed)).toBe(true); + }); + + it('keeps the in-memory state when storage rejects reads', () => { + setSidebarCollapsed(true); + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('storage blocked'); + }); + + expect(() => loadSidebarFromStorage()).not.toThrow(); + expect(get(sidebarCollapsed)).toBe(true); + }); +}); diff --git a/frontend/src/lib/stores/sidebar.ts b/frontend/src/lib/stores/sidebar.ts new file mode 100644 index 00000000..80f89b29 --- /dev/null +++ b/frontend/src/lib/stores/sidebar.ts @@ -0,0 +1,36 @@ +import { get, writable, type Readable } from 'svelte/store'; + +export const SIDEBAR_COLLAPSED_KEY = 'librislog_sidebar_collapsed'; + +const state = writable(false); + +/** Whether the desktop sidebar is collapsed to an icon rail. */ +export const sidebarCollapsed: Readable = { subscribe: state.subscribe }; + +function persist(value: boolean): void { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? '1' : '0'); + } catch { + // localStorage unavailable + } +} + +/** Restore the collapsed state saved for this browser. */ +export function loadSidebarFromStorage(): void { + try { + state.set(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'); + } catch { + // localStorage unavailable + } +} + +export function setSidebarCollapsed(value: boolean): void { + state.set(value); + persist(value); +} + +export function toggleSidebar(): void { + const next = !get(state); + state.set(next); + persist(next); +} diff --git a/frontend/src/lib/utils/nav.test.ts b/frontend/src/lib/utils/nav.test.ts new file mode 100644 index 00000000..537ac5ef --- /dev/null +++ b/frontend/src/lib/utils/nav.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { isActivePath } from './nav'; + +describe('isActivePath', () => { + it('matches the exact route', () => { + expect(isActivePath('/library', '/library')).toBe(true); + }); + + it('matches nested routes of the same section', () => { + expect(isActivePath('/library/42', '/library')).toBe(true); + expect(isActivePath('/library/42/edit', '/library')).toBe(true); + }); + + it('does not match other sections or prefix collisions', () => { + expect(isActivePath('/dashboard', '/library')).toBe(false); + expect(isActivePath('/libraryx', '/library')).toBe(false); + expect(isActivePath('/', '/library')).toBe(false); + }); +}); diff --git a/frontend/src/lib/utils/nav.ts b/frontend/src/lib/utils/nav.ts new file mode 100644 index 00000000..4fc2d22e --- /dev/null +++ b/frontend/src/lib/utils/nav.ts @@ -0,0 +1,4 @@ +/** Whether a nav href is the current page or a nested route below it. */ +export function isActivePath(pathname: string, href: string): boolean { + return pathname === href || pathname.startsWith(`${href}/`); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d02ba96d..93a7cb80 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,7 +12,9 @@ import { _, setupI18n } from '#lib/i18n/index.js'; import { setTimezone, setQuoteServiceEnabled } from '#lib/stores/timezone.js'; import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY, CUSTOM_THEME_KEY } from '#lib/stores/theme.js'; - import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, CloudDownload } from '@lucide/svelte'; + import { sidebarCollapsed, loadSidebarFromStorage, toggleSidebar, SIDEBAR_COLLAPSED_KEY } from '#lib/stores/sidebar.js'; + import { isActivePath } from '#lib/utils/nav.js'; + import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, PanelLeftClose, PanelLeftOpen } from '@lucide/svelte'; import { version } from '#lib/version.js'; import VersionLink from '#lib/components/VersionLink.svelte'; import { checkForUpdate, type UpdateInfo } from '#lib/stores/updateCheck.js'; @@ -28,6 +30,7 @@ if (typeof window !== 'undefined') { loadThemeFromStorage(); applyThemeToDocument(); + loadSidebarFromStorage(); } let addBookOpen = $state(false); @@ -62,6 +65,7 @@ } onMount(async () => { + window.addEventListener('storage', handleSidebarStorage); initAuthSync(() => { currentUser.set(null); csrfToken.set(null); @@ -183,7 +187,10 @@ }); }); - onDestroy(() => clearInterval(versionInterval)); + onDestroy(() => { + window.removeEventListener('storage', handleSidebarStorage); + clearInterval(versionInterval); + }); const NAV_ITEMS = $derived.by(() => { const items = [ @@ -198,6 +205,12 @@ return items; }); + function handleSidebarStorage(event: StorageEvent): void { + if (event.key === SIDEBAR_COLLAPSED_KEY) { + loadSidebarFromStorage(); + } + } + function pageTitle() { if (!i18nReady) return 'LibrisLog'; @@ -281,22 +294,52 @@ -