Files
minstrel/web/src/lib/components/Shell.svelte
T
bvandeusenandClaude Opus 5.5 358849de77
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:14:33 -04:00

181 lines
7.1 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, Disc3, House, LibraryBig } from 'lucide-svelte';
import { user, logout } from '#lib/auth/store.svelte.js';
import { player } from '#lib/player/store.svelte.js';
import { appName } from '#lib/branding.js';
import MinstrelMark from './MinstrelMark.svelte';
import NotificationBell from './NotificationBell.svelte';
import PlayerBar from './PlayerBar.svelte';
import SearchInput from './SearchInput.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
let menuOpen = $state(false);
let menuRef: HTMLElement | undefined = $state();
let menuButtonRef: HTMLElement | undefined = $state();
// Close the dropdown when the click was outside both the button and the menu.
// The previous implementation used onclick={(e) => e.stopPropagation()} on the
// menu container, which kept the window-level close-on-outside-click handler
// from firing — but it also blocked SvelteKit's document-level link-click
// interceptor, so clicking Admin/Settings inside the dropdown fell through
// to the browser's native navigation (a full-page reload). On a hard reload,
// /admin/+layout.ts's load function ran before bootstrap() settled the user
// store, so the admin guard saw user.value === null and redirected to /.
function handleWindowClick(e: MouseEvent) {
if (!menuOpen) return;
const target = e.target as Node | null;
if (!target) return;
if (menuRef?.contains(target) || menuButtonRef?.contains(target)) return;
menuOpen = false;
}
async function handleLogout() {
menuOpen = false;
await logout();
goto('/login', { replace: true });
}
function isActive(prefix: string): boolean {
if (prefix === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(prefix);
}
// Primary nav. Sits centered in the top bar (replaces the prior left
// sidebar). Mirrors the Android top-app-bar treatment: three first-class
// 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).
// Library targets the Artists tab directly so SPA navigation doesn't
// bounce through the /library 308-redirect. isActive('/library')
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it, shaped like Lidarr's.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' },
...(user.value?.is_admin
? [{ href: '/collection', label: 'Collection', icon: Disc3, matchPrefix: '/collection' }]
: [])
]);
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
<div class="grid h-screen grid-rows-[auto_1fr_auto] bg-background text-text-primary">
<!-- 3-col grid so the middle column (nav) is centered on the page
regardless of how wide the side columns are. With the prior
flex+justify-center layout the nav's "available space" shifted
whenever search or the user menu grew, so the nav drifted off
window-center. Grid pins each column to a fixed lane. -->
<header class="grid grid-cols-3 items-center border-b border-border bg-surface px-3 md:px-4 py-2 gap-3 md:gap-6">
<a
href="/"
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
>
<MinstrelMark size={28} class="shrink-0" />
{appName()}
</a>
<nav aria-label="Primary" class="flex items-center justify-center gap-1 md:gap-2 justify-self-center">
{#each navItems as item}
{@const active = isActive(item.matchPrefix)}
<a
href={item.href}
class="group flex items-center gap-2 rounded px-2 md:px-3 py-2 min-h-[44px]
text-text-secondary hover:text-text-primary hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent
{active ? 'text-text-primary bg-surface-hover' : ''}"
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="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
<div class="hidden md:block min-w-0 max-w-xs shrink">
<SearchInput />
</div>
<NotificationBell />
<div class="relative shrink-0">
<button
bind:this={menuButtonRef}
type="button"
class="flex items-center gap-1.5 rounded px-2 md:px-3 py-1 hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent min-h-[44px]"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<span class="hidden sm:inline max-w-[8ch] truncate">{user.value?.username ?? ''}</span>
<ChevronDown
size={16}
strokeWidth={1}
class="transition-transform {menuOpen ? 'rotate-180' : ''}"
aria-hidden="true"
/>
</button>
{#if menuOpen}
<div
bind:this={menuRef}
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow-sm"
role="menu"
tabindex="-1"
>
<a
href="/settings"
role="menuitem"
class="block px-3 py-2 text-sm hover:bg-surface-hover"
onclick={() => (menuOpen = false)}
>
Settings
</a>
{#if user.value?.is_admin}
<a
href="/admin"
role="menuitem"
class="block px-3 py-2 text-sm hover:bg-surface-hover"
onclick={() => (menuOpen = false)}
>
Admin
</a>
{/if}
<button
type="button"
role="menuitem"
class="block w-full border-t border-border px-3 py-2 text-left text-sm hover:bg-surface-hover"
onclick={handleLogout}
>
Log out
</button>
</div>
{/if}
</div>
</div>
</header>
<!-- Subtle 1200x600 radial highlight at the top of the scroll
area fades the slate tone (fs-iron with alpha) into the
background. Adds depth so the page doesn't read as a flat
slab. color-mix lets the gradient theme-track in case the
palette token ever shifts. -->
<main
class="overflow-y-auto p-4"
style="background-image: radial-gradient(ellipse 1200px 600px at 50% -100px,
color-mix(in srgb, var(--fs-iron) 60%, transparent), transparent 70%);"
>
{@render children?.()}
</main>
{#if player.current}
<PlayerBar />
{/if}
</div>