Files
familienarchiv/frontend/src/routes/admin/EntityNav.svelte
Marcel daea748a20
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 32s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m47s
CI / Unit & Component Tests (pull_request) Failing after 2m29s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
feat(frontend): invite-based registration UI
- Add /register route with invite code prefill, password show/hide
- Add /login?registered=1 success banner
- Add /admin/invites page: list, create, revoke, copy link
- Add Einladungen nav section to admin sidebar (ADMIN_USER perm)
- Add invite error codes to errors.ts
- Add 48 i18n keys across de/en/es
- Update hooks.server.ts to allow public access to invite/register API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:01:19 +02:00

348 lines
9.3 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,
inviteCount,
canManageUsers,
canManageTags,
canManagePermissions,
canRunMaintenance
}: {
userCount: number;
groupCount: number;
tagCount: number;
inviteCount: 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 invitesIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('invites') ? '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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</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}
{#snippet ocrIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('ocr') ? '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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</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 canManageUsers}
<EntityNavSection
variant="sidebar"
href="/admin/invites"
label={m.admin_tab_invites()}
isActive={isActive('invites')}
count={inviteCount}
onTabletTrigger={openFlyout}
icon={invitesIcon}
/>
{/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}
{#if canRunMaintenance}
<EntityNavSection
variant="sidebar"
href="/admin/ocr"
label={m.admin_tab_ocr()}
isActive={isActive('ocr')}
onTabletTrigger={openFlyout}
icon={ocrIcon}
/>
{/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 canManageUsers}
<EntityNavSection
variant="flyout"
href="/admin/invites"
label={m.admin_tab_invites()}
isActive={isActive('invites')}
count={inviteCount}
onFlyoutClick={closeFlyout}
icon={invitesIcon}
/>
{/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}
{#if canRunMaintenance}
<EntityNavSection
variant="flyout"
href="/admin/ocr"
label={m.admin_tab_ocr()}
isActive={isActive('ocr')}
onFlyoutClick={closeFlyout}
icon={ocrIcon}
/>
{/if}
</div>
{/if}