From 6e6fbc7856f3f3e47453c590bba1ad2a9db1bbd9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 10:07:02 -0400 Subject: [PATCH] feat(web): top-bar centered nav + Library tab page (replace sidebar) Operator 2026-06-01: "navigation layout and library sections are what I'd like to have implemented as it seems better than our current navbar solution. I think I'd like to have these nav options moved into the top bar centered." Top-bar restructure: - Centered nav (replaces the 192dp left sidebar): Home / Library / Discover, with icons + labels. Labels collapse below sm breakpoint so the bar stays icon-only on small viewports. - Right side (search input + user dropdown) unchanged. - Hamburger button + MobileNavDrawer + the mobileNav store all removed - the centered nav lives at all viewport sizes. Library page restructure (mirrors Android LibraryScreen): - New routes/library/+layout.svelte renders a tab bar across the five Library sub-pages: Artists / Albums / Liked / History / Playlists. Active tab gets an accent underline + onSurface text. - routes/library/+page.server.ts redirects bare /library to /library/artists (Android default tab). - /playlists (list) moved to /library/playlists; old URL gets a 308 redirect (routes/playlists/+page.server.ts) so existing bookmarks land on the new location. /playlists/[id] (detail) is unchanged - matches the server API URL shape. Deleted: Shell's sidebar markup, MobileNavDrawer.{svelte,test.ts}, the mobileNav store, the old routes/playlists/+page.svelte. Shell test rewritten to assert the new 3-item centered nav; playlists test moved next to its new +page.svelte and its test-utils import path updated. --- web/src/lib/components/MobileNavDrawer.svelte | 141 ------------------ .../lib/components/MobileNavDrawer.test.ts | 65 -------- web/src/lib/components/Shell.svelte | 85 +++++------ web/src/lib/components/Shell.test.ts | 27 ++-- web/src/lib/stores/mobileNav.svelte.ts | 22 --- web/src/routes/library/+layout.svelte | 45 ++++++ web/src/routes/library/+page.server.ts | 9 ++ .../{ => library}/playlists/+page.svelte | 14 +- .../{ => library}/playlists/playlists.test.ts | 2 +- web/src/routes/playlists/+page.server.ts | 10 ++ 10 files changed, 124 insertions(+), 296 deletions(-) delete mode 100644 web/src/lib/components/MobileNavDrawer.svelte delete mode 100644 web/src/lib/components/MobileNavDrawer.test.ts delete mode 100644 web/src/lib/stores/mobileNav.svelte.ts create mode 100644 web/src/routes/library/+layout.svelte create mode 100644 web/src/routes/library/+page.server.ts rename web/src/routes/{ => library}/playlists/+page.svelte (90%) rename web/src/routes/{ => library}/playlists/playlists.test.ts (98%) create mode 100644 web/src/routes/playlists/+page.server.ts diff --git a/web/src/lib/components/MobileNavDrawer.svelte b/web/src/lib/components/MobileNavDrawer.svelte deleted file mode 100644 index 5fa68d13..00000000 --- a/web/src/lib/components/MobileNavDrawer.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - - - -{#if mobileNav.value} - -{/if} - - diff --git a/web/src/lib/components/MobileNavDrawer.test.ts b/web/src/lib/components/MobileNavDrawer.test.ts deleted file mode 100644 index 8786f89a..00000000 --- a/web/src/lib/components/MobileNavDrawer.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; - -// SvelteKit's $app/state and $app/navigation can't be imported in vitest -// without bootstrapping the runtime; stub them. Mirrors the pattern used -// by Shell.test.ts and other route tests. -vi.mock('$app/state', () => ({ - page: { url: new URL('http://localhost/') } -})); - -vi.mock('$app/navigation', () => ({ - goto: vi.fn() -})); - -// auth/store reads $app — stub the bits MobileNavDrawer touches. -vi.mock('$lib/auth/store.svelte', () => ({ - user: { value: { username: 'alice', is_admin: false } }, - logout: vi.fn().mockResolvedValue(undefined) -})); - -import MobileNavDrawer from './MobileNavDrawer.svelte'; -import { mobileNav, openMobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte'; - -describe('MobileNavDrawer', () => { - beforeEach(() => { - closeMobileNav(); - }); - - it('renders inert + aria-hidden when closed', () => { - render(MobileNavDrawer); - const aside = screen.getByLabelText('Main navigation'); - expect(aside.getAttribute('aria-hidden')).toBe('true'); - // Svelte 5 + jsdom binds `inert` as a property, not always as an - // attribute. Mirror the pattern used in QueueDrawer.test.ts. - const inertProp = (aside as unknown as { inert?: boolean }).inert === true; - const inertAttr = aside.hasAttribute('inert'); - expect(inertProp || inertAttr).toBe(true); - }); - - it('flips to visible when mobileNav opens', async () => { - render(MobileNavDrawer); - openMobileNav(); - await Promise.resolve(); - const aside = screen.getByLabelText('Main navigation'); - expect(aside.getAttribute('aria-hidden')).toBe('false'); - expect(aside.hasAttribute('inert')).toBe(false); - }); - - it('renders all nav items + Settings + Log out', () => { - openMobileNav(); - render(MobileNavDrawer); - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('Artists')).toBeInTheDocument(); - expect(screen.getByText('Settings')).toBeInTheDocument(); - expect(screen.getByText('Log out')).toBeInTheDocument(); - }); - - it('Escape key closes the drawer', () => { - openMobileNav(); - render(MobileNavDrawer); - expect(mobileNav.value).toBe(true); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - expect(mobileNav.value).toBe(false); - }); -}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 09d4f7f8..c3de4804 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,14 +1,12 @@ e.key === 'Escape' && (menuOpen = false)} /> -
-
- -
{appName()}
-
+
+
+ {appName()} + + + + -
+ +
- - -
+
{@render children?.()}
{#if player.current} -
- -
+ {/if}
- - diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index 5771f11b..ad3ac561 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -39,30 +39,35 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('main nav renders the six primary surfaces in order', () => { + test('main nav renders the three primary surfaces in order', () => { render(Shell); - const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Discover', 'Playlists']; + // Library is a parent for Artists / Albums / Liked / History / + // Playlists (tabs under /library); only the parent appears here. + const labels = ['Home', 'Library', 'Discover']; for (const label of labels) { expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); } expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/'); - expect(screen.getByRole('link', { name: 'Artists' })).toHaveAttribute('href', '/library/artists'); - expect(screen.getByRole('link', { name: 'Albums' })).toHaveAttribute('href', '/library/albums'); + expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/library'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); - expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); - test('main nav omits Search, Requests, Hidden, Settings, and Admin', () => { + test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => { userState.current = { id: '1', username: 'alice', is_admin: true }; render(Shell); - // Search reaches /search via the global SearchInput, Requests is a - // tab inside Discover, Hidden lives under Settings, Settings + Admin - // live in the username dropdown — none belong on the main nav. + // Library sub-pages reach their content via the /library tab bar + // (in routes/library/+layout.svelte), not via the global Shell nav. + // Search reaches /search via the global SearchInput on the right. + // Settings + Admin live in the username dropdown — they're per- + // operator chrome, not primary surfaces. const navLinks = screen .getAllByRole('link') - .filter((el) => el.closest('nav') !== null) + .filter((el) => el.closest('nav[aria-label="Primary"]') !== null) .map((el) => el.textContent?.trim()); - for (const label of ['Search', 'Requests', 'Hidden', 'Settings', 'Admin']) { + for (const label of [ + 'Artists', 'Albums', 'Liked', 'History', 'Playlists', + 'Search', 'Requests', 'Hidden', 'Settings', 'Admin' + ]) { expect(navLinks).not.toContain(label); } }); diff --git a/web/src/lib/stores/mobileNav.svelte.ts b/web/src/lib/stores/mobileNav.svelte.ts deleted file mode 100644 index 641750bc..00000000 --- a/web/src/lib/stores/mobileNav.svelte.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Open/close state for the off-canvas mobile navigation drawer. -// Mounted once from Shell.svelte; toggled by the hamburger button. - -let _open = $state(false); - -export const mobileNav = { - get value(): boolean { - return _open; - } -}; - -export function openMobileNav(): void { - _open = true; -} - -export function closeMobileNav(): void { - _open = false; -} - -export function toggleMobileNav(): void { - _open = !_open; -} diff --git a/web/src/routes/library/+layout.svelte b/web/src/routes/library/+layout.svelte new file mode 100644 index 00000000..b231858a --- /dev/null +++ b/web/src/routes/library/+layout.svelte @@ -0,0 +1,45 @@ + + +
+ + + {@render children?.()} +
diff --git a/web/src/routes/library/+page.server.ts b/web/src/routes/library/+page.server.ts new file mode 100644 index 00000000..11a5e992 --- /dev/null +++ b/web/src/routes/library/+page.server.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; + +// /library has no content of its own — the tab bar (in +layout.svelte) +// always renders the active sub-page. Bare hits go to the default tab +// (Artists, matching Android's LibraryScreen default). 308 = permanent +// + preserve method, so SPA navigations and direct loads behave the same. +export const load = () => { + throw redirect(308, '/library/artists'); +}; diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/library/playlists/+page.svelte similarity index 90% rename from web/src/routes/playlists/+page.svelte rename to web/src/routes/library/playlists/+page.svelte index 32f02663..3f46edd5 100644 --- a/web/src/routes/playlists/+page.svelte +++ b/web/src/routes/library/playlists/+page.svelte @@ -32,6 +32,8 @@ try { const p = await createPlaylist({ name: newName.trim() }); await queryClient.invalidateQueries({ queryKey: qk.playlists() }); + // Detail route stays at /playlists/[id] (matches the API URL); + // only the LIST view moved under /library. goto(`/playlists/${p.id}`); } catch (e: unknown) { createError = (e as { message?: string })?.message ?? 'Could not create.'; @@ -43,11 +45,11 @@ } -{pageTitle('Playlists')} +{pageTitle('Library · Playlists')} -
-
-

Playlists

+
+
+

Playlists

{#if creating} -
+