feat(web): top-bar centered nav + Library tab page (replace sidebar)
test-web / test (push) Successful in 32s
test-web / test (push) Successful in 32s
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.
This commit is contained in:
@@ -1,141 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { X } from 'lucide-svelte';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { user, logout } from '$lib/auth/store.svelte';
|
|
||||||
import { mobileNav, closeMobileNav } from '$lib/stores/mobileNav.svelte';
|
|
||||||
|
|
||||||
let previouslyFocused: HTMLElement | null = null;
|
|
||||||
let drawerEl: HTMLElement | undefined = $state();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ href: '/', label: 'Home' },
|
|
||||||
{ href: '/library/artists', label: 'Artists' },
|
|
||||||
{ href: '/library/albums', label: 'Albums' },
|
|
||||||
{ href: '/library/liked', label: 'Liked' },
|
|
||||||
{ href: '/library/history', label: 'History' },
|
|
||||||
{ href: '/discover', label: 'Discover' },
|
|
||||||
{ href: '/playlists', label: 'Playlists' }
|
|
||||||
];
|
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
|
||||||
if (href === '/') return page.url.pathname === '/';
|
|
||||||
return page.url.pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
closeMobileNav();
|
|
||||||
await logout();
|
|
||||||
goto('/login', { replaceState: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLinkClick() {
|
|
||||||
closeMobileNav();
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (mobileNav.value) {
|
|
||||||
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
const first = drawerEl?.querySelector<HTMLAnchorElement>('nav a');
|
|
||||||
first?.focus();
|
|
||||||
});
|
|
||||||
} else if (previouslyFocused) {
|
|
||||||
previouslyFocused.focus();
|
|
||||||
previouslyFocused = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (mobileNav.value && e.key === 'Escape') {
|
|
||||||
closeMobileNav();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
{#if mobileNav.value}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Close navigation backdrop"
|
|
||||||
class="fixed inset-0 bg-obsidian/40 z-40 md:hidden"
|
|
||||||
onclick={() => closeMobileNav()}
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
bind:this={drawerEl}
|
|
||||||
aria-label="Main navigation"
|
|
||||||
aria-hidden={!mobileNav.value}
|
|
||||||
inert={!mobileNav.value}
|
|
||||||
class="fixed top-0 left-0 h-full w-[min(280px,80vw)] bg-surface z-50 md:hidden
|
|
||||||
transition-transform duration-200 flex flex-col
|
|
||||||
motion-reduce:transition-none
|
|
||||||
{mobileNav.value ? 'translate-x-0' : '-translate-x-full'}"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
||||||
<h2 class="text-lg font-semibold">Navigate</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Close navigation"
|
|
||||||
onclick={() => closeMobileNav()}
|
|
||||||
class="text-text-secondary hover:text-text-primary min-h-[44px] min-w-[44px]
|
|
||||||
flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex-1 overflow-y-auto p-2">
|
|
||||||
<ul>
|
|
||||||
{#each navItems as item (item.href)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
onclick={onLinkClick}
|
|
||||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
|
||||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr class="my-2 border-border" />
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/settings"
|
|
||||||
onclick={onLinkClick}
|
|
||||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{#if user.value?.is_admin}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/admin"
|
|
||||||
onclick={onLinkClick}
|
|
||||||
class="flex items-center min-h-[44px] rounded px-3 py-2 hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleLogout}
|
|
||||||
class="flex w-full items-center min-h-[44px] rounded px-3 py-2 text-left
|
|
||||||
hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { ChevronDown, Menu } from 'lucide-svelte';
|
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
|
||||||
import { user, logout } from '$lib/auth/store.svelte';
|
import { user, logout } from '$lib/auth/store.svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { player } from '$lib/player/store.svelte';
|
||||||
import { appName } from '$lib/branding';
|
import { appName } from '$lib/branding';
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import SearchInput from './SearchInput.svelte';
|
import SearchInput from './SearchInput.svelte';
|
||||||
import MobileNavDrawer from './MobileNavDrawer.svelte';
|
|
||||||
import { toggleMobileNav } from '$lib/stores/mobileNav.svelte';
|
|
||||||
|
|
||||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
|
|
||||||
@@ -43,39 +41,46 @@
|
|||||||
return page.url.pathname.startsWith(href);
|
return page.url.pathname.startsWith(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search reaches /search via the global SearchInput in the header (not the
|
// Primary nav. Sits centered in the top bar (replaces the prior left
|
||||||
// nav). Requests is a tab inside Discover. Settings + Admin live in the
|
// sidebar). Mirrors the Android top-app-bar treatment: three first-class
|
||||||
// username dropdown — they're per-operator chrome, not primary surfaces.
|
// destinations, plus Library acting as a parent for Artists / Albums /
|
||||||
|
// Liked / History / Playlists (tabs at /library/+layout.svelte).
|
||||||
|
// Search + user menu live on the right side of the header (unchanged).
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home', icon: House },
|
||||||
{ href: '/library/artists', label: 'Artists' },
|
{ href: '/library', label: 'Library', icon: LibraryBig },
|
||||||
{ href: '/library/albums', label: 'Albums' },
|
{ href: '/discover', label: 'Discover', icon: Compass }
|
||||||
{ href: '/library/liked', label: 'Liked' },
|
|
||||||
{ href: '/library/history', label: 'History' },
|
|
||||||
{ href: '/discover', label: 'Discover' },
|
|
||||||
{ href: '/playlists', label: 'Playlists' }
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||||
|
|
||||||
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto] bg-background text-text-primary">
|
<div class="grid h-screen grid-rows-[auto_1fr_auto] bg-background text-text-primary">
|
||||||
<header class="col-span-2 flex items-center gap-2 md:gap-4 border-b border-border bg-surface px-3 md:px-4 py-3">
|
<header class="flex items-center gap-3 md:gap-6 border-b border-border bg-surface px-3 md:px-4 py-2">
|
||||||
<button
|
<a href="/" class="font-semibold text-sm md:text-base whitespace-nowrap">{appName()}</a>
|
||||||
type="button"
|
|
||||||
aria-label="Open navigation"
|
<nav aria-label="Primary" class="flex flex-1 items-center justify-center gap-1 md:gap-2">
|
||||||
onclick={toggleMobileNav}
|
{#each navItems as item}
|
||||||
class="md:hidden flex items-center justify-center min-h-[44px] min-w-[44px] -ml-1
|
{@const active = isActive(item.href)}
|
||||||
rounded text-text-secondary hover:text-text-primary
|
<a
|
||||||
focus-visible:ring-2 focus-visible:ring-accent"
|
href={item.href}
|
||||||
>
|
class="group flex items-center gap-2 rounded px-2 md:px-3 py-2 min-h-[44px]
|
||||||
<Menu size={22} strokeWidth={1.5} />
|
text-text-secondary hover:text-text-primary hover:bg-surface-hover
|
||||||
</button>
|
focus-visible:ring-2 focus-visible:ring-accent
|
||||||
<div class="font-semibold text-sm md:text-base">{appName()}</div>
|
{active ? 'text-text-primary bg-surface-hover' : ''}"
|
||||||
<div class="flex-1 min-w-0">
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<item.icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="hidden sm:inline">{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
|
||||||
|
<div class="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
bind:this={menuButtonRef}
|
bind:this={menuButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -130,31 +135,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="hidden w-48 border-r border-border bg-surface md:block">
|
<main class="overflow-y-auto p-4">
|
||||||
<ul class="p-2">
|
|
||||||
{#each navItems as item}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
class="block rounded px-3 py-2 hover:bg-surface-hover"
|
|
||||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="overflow-y-auto p-4 md:col-start-2">
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if player.current}
|
{#if player.current}
|
||||||
<div class="col-span-2">
|
<PlayerBar />
|
||||||
<PlayerBar />
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileNavDrawer />
|
|
||||||
|
|||||||
@@ -39,30 +39,35 @@ describe('Shell', () => {
|
|||||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
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);
|
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) {
|
for (const label of labels) {
|
||||||
expect(screen.getByRole('link', { name: label })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: label })).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
|
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
|
||||||
expect(screen.getByRole('link', { name: 'Artists' })).toHaveAttribute('href', '/library/artists');
|
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/library');
|
||||||
expect(screen.getByRole('link', { name: 'Albums' })).toHaveAttribute('href', '/library/albums');
|
|
||||||
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
|
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 };
|
userState.current = { id: '1', username: 'alice', is_admin: true };
|
||||||
render(Shell);
|
render(Shell);
|
||||||
// Search reaches /search via the global SearchInput, Requests is a
|
// Library sub-pages reach their content via the /library tab bar
|
||||||
// tab inside Discover, Hidden lives under Settings, Settings + Admin
|
// (in routes/library/+layout.svelte), not via the global Shell nav.
|
||||||
// live in the username dropdown — none belong on the main 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
|
const navLinks = screen
|
||||||
.getAllByRole('link')
|
.getAllByRole('link')
|
||||||
.filter((el) => el.closest('nav') !== null)
|
.filter((el) => el.closest('nav[aria-label="Primary"]') !== null)
|
||||||
.map((el) => el.textContent?.trim());
|
.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);
|
expect(navLinks).not.toContain(label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
|
|
||||||
|
// Tab bar for the Library section. Mirrors Android's LibraryScreen
|
||||||
|
// (artists / albums / liked / history / playlists) — Playlists lives
|
||||||
|
// here so the operator can find their personal collection in one
|
||||||
|
// place rather than tracking a separate top-level route.
|
||||||
|
const tabs = [
|
||||||
|
{ href: '/library/artists', label: 'Artists' },
|
||||||
|
{ href: '/library/albums', label: 'Albums' },
|
||||||
|
{ href: '/library/liked', label: 'Liked' },
|
||||||
|
{ href: '/library/history', label: 'History' },
|
||||||
|
{ href: '/library/playlists', label: 'Playlists' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActiveTab(href: string): boolean {
|
||||||
|
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<nav aria-label="Library sections" class="border-b border-border">
|
||||||
|
<ul class="flex gap-1 overflow-x-auto">
|
||||||
|
{#each tabs as tab}
|
||||||
|
{@const active = isActiveTab(tab.href)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={tab.href}
|
||||||
|
class="block px-3 md:px-4 py-3 text-sm whitespace-nowrap border-b-2
|
||||||
|
{active
|
||||||
|
? 'border-accent text-text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
+8
-6
@@ -32,6 +32,8 @@
|
|||||||
try {
|
try {
|
||||||
const p = await createPlaylist({ name: newName.trim() });
|
const p = await createPlaylist({ name: newName.trim() });
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
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}`);
|
goto(`/playlists/${p.id}`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
createError = (e as { message?: string })?.message ?? 'Could not create.';
|
createError = (e as { message?: string })?.message ?? 'Could not create.';
|
||||||
@@ -43,11 +45,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Playlists')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Library · Playlists')}</title></svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl px-4 py-6">
|
<div class="space-y-4">
|
||||||
<header class="mb-6 flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-medium text-text-primary">Playlists</h1>
|
<h1 class="font-display text-2xl font-medium text-text-primary">Playlists</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (creating = true)}
|
onclick={() => (creating = true)}
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if creating}
|
{#if creating}
|
||||||
<div class="mb-6 rounded-md border border-border bg-surface p-4">
|
<div class="rounded-md border border-border bg-surface p-4">
|
||||||
<label class="block text-sm font-medium text-text-primary">
|
<label class="block text-sm font-medium text-text-primary">
|
||||||
Name
|
Name
|
||||||
<input
|
<input
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
{:else if playlistsQuery?.isError}
|
{:else if playlistsQuery?.isError}
|
||||||
<ApiErrorBanner error={playlistsQuery.error} onRetry={() => playlistsQuery.refetch()} />
|
<ApiErrorBanner error={playlistsQuery.error} onRetry={() => playlistsQuery.refetch()} />
|
||||||
{:else if playlistsQuery?.data}
|
{:else if playlistsQuery?.data}
|
||||||
<section class="mb-8">
|
<section>
|
||||||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
|
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
|
||||||
{#if playlistsQuery.data.owned.length === 0}
|
{#if playlistsQuery.data.owned.length === 0}
|
||||||
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
|
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { mockQuery } from '../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { Playlist } from '$lib/api/types';
|
import type { Playlist } from '$lib/api/types';
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => ({
|
vi.mock('$lib/api/playlists', () => ({
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// /playlists (list) moved to /library/playlists on 2026-06-01 as part of
|
||||||
|
// the nav restructure (Scribe 518). The detail route /playlists/[id]
|
||||||
|
// still lives here at /routes/playlists/[id]/+page.svelte — that URL
|
||||||
|
// matches the server API shape and is unchanged.
|
||||||
|
// 308 = permanent + preserve method; old bookmarks land on the new URL.
|
||||||
|
export const load = () => {
|
||||||
|
throw redirect(308, '/library/playlists');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user