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:
@@ -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');
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { qk } from '$lib/api/queries';
|
||||
|
||||
const playlistsStore = $derived(createPlaylistsQuery());
|
||||
const playlistsQuery = $derived($playlistsStore);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let creating = $state(false);
|
||||
let newName = $state('');
|
||||
let creatingBusy = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
inputEl?.focus();
|
||||
});
|
||||
|
||||
async function submitCreate() {
|
||||
if (!newName.trim()) {
|
||||
createError = 'Name is required';
|
||||
return;
|
||||
}
|
||||
creatingBusy = true;
|
||||
createError = null;
|
||||
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.';
|
||||
} finally {
|
||||
creatingBusy = false;
|
||||
creating = false;
|
||||
newName = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Library · Playlists')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Playlists</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (creating = true)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={1} />
|
||||
New playlist
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if creating}
|
||||
<div class="rounded-md border border-border bg-surface p-4">
|
||||
<label class="block text-sm font-medium text-text-primary">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value={newName}
|
||||
placeholder="Saturday morning"
|
||||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') submitCreate(); if (e.key === 'Escape') creating = false; }}
|
||||
/>
|
||||
</label>
|
||||
{#if createError}
|
||||
<p class="mt-2 text-xs text-action-destructive">{createError}</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
onclick={() => { creating = false; newName = ''; createError = null; }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={submitCreate}
|
||||
disabled={creatingBusy || !newName.trim()}
|
||||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playlistsQuery?.isPending}
|
||||
<p class="text-text-muted">Loading playlists…</p>
|
||||
{:else if playlistsQuery?.isError}
|
||||
<ApiErrorBanner error={playlistsQuery.error} onRetry={() => playlistsQuery.refetch()} />
|
||||
{:else if playlistsQuery?.data}
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
|
||||
{#if playlistsQuery.data.owned.length === 0}
|
||||
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{#each playlistsQuery.data.owned as p (p.id)}
|
||||
<PlaylistCard playlist={p} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if playlistsQuery.data.public.length > 0}
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">From other users</h2>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{#each playlistsQuery.data.public as p (p.id)}
|
||||
<PlaylistCard playlist={p} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { Playlist } from '$lib/api/types';
|
||||
|
||||
vi.mock('$lib/api/playlists', () => ({
|
||||
createPlaylistsQuery: vi.fn(),
|
||||
createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
import PlaylistsPage from './+page.svelte';
|
||||
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
|
||||
|
||||
const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType<typeof vi.fn>;
|
||||
const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
|
||||
|
||||
function p(over: Partial<Playlist>): Playlist {
|
||||
return {
|
||||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
|
||||
description: '', is_public: false, kind: 'user', system_variant: null, refreshable: false, seed_artist_id: null, cover_url: '', track_count: 0, duration_sec: 0,
|
||||
created_at: '', updated_at: '', ...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('Playlists index page', () => {
|
||||
test('renders own + public sections', () => {
|
||||
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
||||
data: {
|
||||
owned: [p({ id: 'p1', name: 'Mine' })],
|
||||
public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
|
||||
}
|
||||
}));
|
||||
render(PlaylistsPage);
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.getByText('Theirs')).toBeInTheDocument();
|
||||
expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/from other users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('empty-state copy when operator has no playlists', () => {
|
||||
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
||||
data: { owned: [], public: [] }
|
||||
}));
|
||||
render(PlaylistsPage);
|
||||
expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides "From other users" section when empty', () => {
|
||||
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
||||
data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] }
|
||||
}));
|
||||
render(PlaylistsPage);
|
||||
expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Create button reveals inline form; submit creates and navigates', async () => {
|
||||
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
|
||||
render(PlaylistsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
|
||||
const input = screen.getByPlaceholderText(/saturday morning/i);
|
||||
await fireEvent.input(input, { target: { value: 'Test' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user