diff --git a/web/src/lib/components/DiscoverTabs.svelte b/web/src/lib/components/DiscoverTabs.svelte new file mode 100644 index 00000000..300a97ea --- /dev/null +++ b/web/src/lib/components/DiscoverTabs.svelte @@ -0,0 +1,36 @@ + + + + + {#each items as item (item.href)} + + + {item.label} + + + {/each} + + diff --git a/web/src/lib/components/DiscoverTabs.test.ts b/web/src/lib/components/DiscoverTabs.test.ts new file mode 100644 index 00000000..ebcc9e54 --- /dev/null +++ b/web/src/lib/components/DiscoverTabs.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/discover') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +import DiscoverTabs from './DiscoverTabs.svelte'; + +describe('DiscoverTabs', () => { + test('Discover tab is current on /discover', () => { + state.pageUrl = new URL('http://localhost/discover'); + render(DiscoverTabs); + expect(screen.getByRole('link', { name: /discover/i })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: /requests/i })).not.toHaveAttribute('aria-current'); + }); + + test('Requests tab is current on /requests', () => { + state.pageUrl = new URL('http://localhost/requests'); + render(DiscoverTabs); + expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: /discover/i })).not.toHaveAttribute('aria-current'); + }); + + test('renders both tabs in order', () => { + state.pageUrl = new URL('http://localhost/discover'); + render(DiscoverTabs); + const labels = screen.getAllByRole('link').map((l) => l.textContent?.trim()); + expect(labels).toEqual(['Discover', 'Requests']); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9d121751..ddfeb032 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -21,24 +21,17 @@ return page.url.pathname.startsWith(href); } + // Search reaches /search via the global SearchInput in the header (not the + // nav). Requests is a tab inside Discover. Settings + Admin live in the + // username dropdown — they're per-operator chrome, not primary surfaces. const navItems = [ { href: '/', label: 'Home' }, { href: '/library/artists', label: 'Artists' }, { href: '/library/albums', label: 'Albums' }, { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, { href: '/discover', label: 'Discover' }, - { href: '/requests', label: 'Requests' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } + { href: '/playlists', label: 'Playlists' } ]; - - // Admin link sits between Playlists and Settings, only visible to admins. - const visibleNavItems = $derived( - user.value?.is_admin - ? [...navItems.slice(0, -1), { href: '/admin', label: 'Admin' }, navItems[navItems.length - 1]] - : navItems - ); (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> @@ -59,15 +52,34 @@ {#if menuOpen} e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="menu" tabindex="-1" > + (menuOpen = false)} + > + Settings + + {#if user.value?.is_admin} + (menuOpen = false)} + > + Admin + + {/if} Log out @@ -79,7 +91,7 @@ - {#each visibleNavItems as item} + {#each navItems as item} { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders nav items in expected order', () => { + test('main nav renders the six primary surfaces in order', () => { render(Shell); - const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Search', - 'Discover', 'Requests', 'Playlists', 'Settings']; + const labels = ['Home', 'Artists', 'Albums', 'Liked', 'Discover', 'Playlists']; 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: 'Search' })).toHaveAttribute('href', '/search'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); - expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests'); expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); - test('Hidden link is not in the main nav', () => { - render(Shell); - expect(screen.queryByRole('link', { name: 'Hidden' })).not.toBeInTheDocument(); - }); - - test('non-admin users do not see the Admin nav link', () => { - userState.current = { id: '1', username: 'alice', is_admin: false }; - render(Shell); - expect(screen.queryByRole('link', { name: 'Admin' })).not.toBeInTheDocument(); - }); - - test('admin users see the Admin nav link between Playlists and Settings', () => { + test('main nav omits Search, Requests, Hidden, Settings, and Admin', () => { userState.current = { id: '1', username: 'alice', is_admin: true }; render(Shell); - const link = screen.getByRole('link', { name: 'Admin' }); - expect(link).toHaveAttribute('href', '/admin'); - // Order check: Playlists then Admin then Settings. - const labels = screen + // 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. + const navLinks = screen .getAllByRole('link') - .map((el) => el.textContent?.trim()) - .filter(Boolean); - const idxPlaylists = labels.indexOf('Playlists'); - const idxAdmin = labels.indexOf('Admin'); - const idxSettings = labels.indexOf('Settings'); - expect(idxPlaylists).toBeLessThan(idxAdmin); - expect(idxAdmin).toBeLessThan(idxSettings); + .filter((el) => el.closest('nav') !== null) + .map((el) => el.textContent?.trim()); + for (const label of ['Search', 'Requests', 'Hidden', 'Settings', 'Admin']) { + expect(navLinks).not.toContain(label); + } + }); + + test('user-menu shows Settings + Log out for non-admin users', async () => { + userState.current = { id: '1', username: 'alice', is_admin: false }; + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + expect(screen.getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings'); + expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Admin' })).not.toBeInTheDocument(); + }); + + test('user-menu adds Admin between Settings and Log out for admin users', async () => { + userState.current = { id: '1', username: 'alice', is_admin: true }; + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + const items = screen.getAllByRole('menuitem').map((el) => el.textContent?.trim()); + expect(items).toEqual(['Settings', 'Admin', 'Log out']); + expect(screen.getByRole('menuitem', { name: 'Admin' })).toHaveAttribute('href', '/admin'); }); test('user-menu "Log out" calls logout() and navigates to /login', async () => { render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); - await fireEvent.click(screen.getByRole('button', { name: /log out/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i })); expect(logout).toHaveBeenCalledTimes(1); expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); }); diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index dac4c2b5..d00badf6 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -2,6 +2,7 @@ import { createLidarrSearchQuery } from '$lib/api/lidarr'; import { createRequest } from '$lib/api/requests'; import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte'; + import DiscoverTabs from '$lib/components/DiscoverTabs.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import SuggestionFeed from '$lib/components/SuggestionFeed.svelte'; import type { @@ -114,7 +115,9 @@ ]; - + + + - + + + Your requests