Organize navigation into a responsive grouped sidebar

This commit is contained in:
2026-10-05 18:55:48 -04:00
parent e9d0b2ee30
commit a5f9956615
4 changed files with 210 additions and 198 deletions
+52
View File
@@ -0,0 +1,52 @@
import type { UserRole } from './auth.js';
export interface NavigationItem { label: string; href: string; shortcut?: string; matches?: (path: string) => boolean }
export interface NavigationGroup { id: string; label: string; admin?: boolean; items: NavigationItem[] }
export const navigationGroups: NavigationGroup[] = [
{ id: 'fleet', label: 'Fleet', items: [
{ label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
{ label: 'Collections', href: '/collections' },
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) },
{ label: 'Capacity', href: '/capacity' }
] },
{ id: 'library', label: 'Library', items: [
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
{ label: 'Configs', href: '/configs', shortcut: 'c' },
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
{ label: 'Game Files', href: '/game-files' }
] },
{ id: 'operations', label: 'Operations', items: [
{ label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Betas', href: '/betas', shortcut: 'b' },
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) },
{ label: 'Host operation history', href: '/hosts/operations' }
] },
{ id: 'analytics', label: 'Analytics', items: [
{ label: 'Metrics', href: '/metrics', shortcut: 'm' },
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Match History', href: '/matches' }
] },
{ id: 'administration', label: 'Administration', admin: true, items: [
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Users', href: '/settings/users' }
] }
];
export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter(group => !group.admin || role === 'admin');
}
export function navigationItemActive(item: NavigationItem, path: string): boolean {
return item.matches ? item.matches(path) : path === item.href || path.startsWith(item.href + '/');
}
export function navigationLocation(path: string, role: UserRole) {
for (const group of visibleNavigation(role)) {
const item = group.items.find(item => navigationItemActive(item, path));
if (item) return { group, item };
}
return null;
}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import type { UserRole } from '$lib/auth.js';
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
let { role, open = $bindable(false) }: { role: UserRole; open?: boolean } = $props();
let collapsed = $state<Record<string, boolean>>({});
let drawer = $state<HTMLDialogElement>();
const groups = $derived(visibleNavigation(role));
const location = $derived(navigationLocation($page.url.pathname, role));
const storageKey = 'electric-cherry:navigation';
function save() {
try { localStorage.setItem(storageKey, JSON.stringify(collapsed)); } catch { /* Navigation works without storage. */ }
}
function toggle(id: string) { collapsed = { ...collapsed, [id]: !collapsed[id] }; save(); }
$effect(() => {
const path = $page.url.pathname;
const currentRole = role;
untrack(() => {
const current = navigationLocation(path, currentRole);
if (current) collapsed = { ...collapsed, [current.group.id]: false };
});
});
$effect(() => {
if (open && drawer && !drawer.open) drawer.showModal();
else if (!open && drawer?.open) drawer.close();
});
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
if (saved && typeof saved === 'object') collapsed = Object.fromEntries(groups.map(group => [group.id, saved[group.id] === true]));
} catch { /* Ignore invalid or unavailable preferences. */ }
if (location) collapsed = { ...collapsed, [location.group.id]: false };
const desktop = matchMedia('(min-width: 1000px)');
const resized = () => { if (desktop.matches) open = false; };
desktop.addEventListener('change', resized);
return () => desktop.removeEventListener('change', resized);
});
</script>
{#snippet links(prefix: string)}
<nav aria-label="Primary">
{#each groups as group (group.id)}
<section class:current={location?.group.id === group.id}>
<button class="group" aria-expanded={!collapsed[group.id]} aria-controls={`${prefix}-${group.id}`} onclick={() => toggle(group.id)}>
{group.label}<span aria-hidden="true">{collapsed[group.id] ? '+' : '−'}</span>
</button>
<div id={`${prefix}-${group.id}`} hidden={collapsed[group.id]}>
{#each group.items as item (item.href)}
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
{/each}
</div>
</section>
{/each}
</nav>
{/snippet}
<aside class="sidebar">{@render links('desktop-navigation')}</aside>
<dialog class="drawer" id="navigation-drawer" bind:this={drawer} aria-label="Navigation" onclose={() => open = false}
onclick={event => { if (event.target === drawer) { const box = drawer!.getBoundingClientRect(); if (event.clientX > box.right || event.clientX < box.left || event.clientY < box.top || event.clientY > box.bottom) open = false; } }}>
<div class="drawer-heading"><strong>Navigation</strong><button aria-label="Close navigation" onclick={() => open = false}>×</button></div>
{@render links('mobile-navigation')}
</dialog>
<style>
.sidebar { position: fixed; inset: 60px auto 0 0; width: 232px; overflow-y: auto; overscroll-behavior: contain; background: var(--surface); border-right: 1px solid var(--border-subtle); padding: 16px 12px 32px; z-index: 15; }
nav { display: grid; gap: 12px; }
.group { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 8px 12px; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-weight: 600; background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer; }
.current .group { color: var(--text-primary); }
a { display: block; padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-sm); line-height: 1.5; }
a:hover, .group:hover { background: var(--surface-raised); color: var(--text-primary); }
a[aria-current] { color: var(--accent-primary-hover); background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); box-shadow: inset 3px 0 var(--accent-primary); }
.drawer { position: fixed; inset: 0 auto 0 0; margin: 0; width: min(320px, 90vw); height: 100dvh; max-height: 100dvh; border: 0; border-right: 1px solid var(--border-strong); border-radius: 0; padding: max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom)); color: var(--text-primary); background: var(--surface); }
.drawer::backdrop { background: #0008; }
.drawer-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.drawer-heading button { width: 44px; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); color: inherit; font: inherit; font-size: 24px; cursor: pointer; }
.drawer a, .drawer .group { min-height: 44px; display: flex; align-items: center; }
@media (max-width: 999px) { .sidebar { display: none; } }
</style>