Files
familienarchiv/frontend/src/routes/admin/EntityNav.svelte
2026-04-15 13:54:42 +02:00

266 lines
7.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { tick } from 'svelte';
import { fly } from 'svelte/transition';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
import EntityNavSection from './EntityNavSection.svelte';
let {
userCount,
groupCount,
tagCount,
canManageUsers,
canManageTags,
canManagePermissions,
canRunMaintenance
}: {
userCount: number;
groupCount: number;
tagCount: number;
canManageUsers: boolean;
canManageTags: boolean;
canManagePermissions: boolean;
canRunMaintenance: boolean;
} = $props();
const currentPath = $derived(page.url.pathname);
const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`);
let flyoutOpen = $state(false);
let flyoutTriggerElement: HTMLButtonElement | null = null;
// All four section buttons open the same flyout that repeats the full nav.
// This is intentional: on tablet the flyout shows all sections as a wider navigation panel,
// not a context-specific panel for the clicked section.
async function openFlyout(event: MouseEvent) {
flyoutTriggerElement = event.currentTarget as HTMLButtonElement;
flyoutOpen = true;
await tick();
const firstLink = document.querySelector<HTMLAnchorElement>('[role="dialog"] a');
firstLink?.focus();
}
function closeFlyout() {
flyoutOpen = false;
flyoutTriggerElement?.focus();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && flyoutOpen) {
closeFlyout();
}
}
</script>
{#snippet usersIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
{/snippet}
{#snippet groupsIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
{/snippet}
{#snippet tagsIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
{/snippet}
{#snippet systemIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/40'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/snippet}
<svelte:document onkeydown={handleKeydown} />
<!--
Desktop (lg+): 120px with text labels
Tablet (mdlg): 48px icon-only strip with flyout panel
-->
<nav
class="flex flex-shrink-0 flex-col bg-brand-navy md:w-12 lg:w-30"
aria-label={m.admin_heading()}
>
<!-- Desktop-only heading -->
<div
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase lg:block"
>
{m.admin_heading()}
</div>
{#if canManageUsers}
<EntityNavSection
variant="sidebar"
href="/admin/users"
label={m.admin_tab_users()}
isActive={isActive('users')}
count={userCount}
onTabletTrigger={openFlyout}
icon={usersIcon}
/>
{/if}
{#if canManagePermissions}
<EntityNavSection
variant="sidebar"
href="/admin/groups"
label={m.admin_tab_groups()}
isActive={isActive('groups')}
count={groupCount}
onTabletTrigger={openFlyout}
icon={groupsIcon}
/>
{/if}
{#if canManageTags}
<EntityNavSection
variant="sidebar"
href="/admin/tags"
label={m.admin_tab_tags()}
isActive={isActive('tags')}
count={tagCount}
onTabletTrigger={openFlyout}
icon={tagsIcon}
/>
{/if}
<div class="flex-1"></div>
{#if canRunMaintenance}
<EntityNavSection
variant="sidebar"
href="/admin/system"
label={m.admin_tab_system()}
isActive={isActive('system')}
topBorder={true}
onTabletTrigger={openFlyout}
icon={systemIcon}
/>
{/if}
</nav>
{#if flyoutOpen}
<!-- Backdrop -->
<div
data-flyout-backdrop
role="none"
class="fixed inset-0 z-40 bg-black/40"
onclick={closeFlyout}
></div>
<!-- Flyout panel -->
<div
role="dialog"
aria-modal="true"
aria-label={m.admin_heading()}
class="fixed top-0 left-12 z-50 flex h-full w-40 flex-col bg-brand-navy shadow-xl"
transition:fly={{ x: -160, duration: 180 }}
>
<!-- Heading -->
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase">
{m.admin_heading()}
</div>
{#if canManageUsers}
<EntityNavSection
variant="flyout"
href="/admin/users"
label={m.admin_tab_users()}
isActive={isActive('users')}
count={userCount}
onFlyoutClick={closeFlyout}
icon={usersIcon}
/>
{/if}
{#if canManagePermissions}
<EntityNavSection
variant="flyout"
href="/admin/groups"
label={m.admin_tab_groups()}
isActive={isActive('groups')}
count={groupCount}
onFlyoutClick={closeFlyout}
icon={groupsIcon}
/>
{/if}
{#if canManageTags}
<EntityNavSection
variant="flyout"
href="/admin/tags"
label={m.admin_tab_tags()}
isActive={isActive('tags')}
count={tagCount}
onFlyoutClick={closeFlyout}
icon={tagsIcon}
/>
{/if}
<div class="flex-1"></div>
{#if canRunMaintenance}
<EntityNavSection
variant="flyout"
href="/admin/system"
label={m.admin_tab_system()}
isActive={isActive('system')}
topBorder={true}
onFlyoutClick={closeFlyout}
icon={systemIcon}
/>
{/if}
</div>
{/if}