Files
familienarchiv/frontend/src/routes/+layout.svelte
Marcel c59287fcfc
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
CI / Unit & Component Tests (push) Failing after 3m6s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / OCR Service Tests (push) Successful in 34s
fix(bulk-edit): cycle-3 polish — Felix C2/C3/C4/C5 + Sara coverage gaps
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future
@Size/etc. annotations on the record actually fire.

Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads
`bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on
route change, not on every checkbox toggle.

Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives
inside `onMount` (not at top-level script) so the SvelteMap mutation is
unambiguously tied to instance lifecycle, matching the pattern used by
`WhoWhenSection`/`DescriptionSection` after the cycle-2 fix.

Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in
`DocumentController` with a top-of-file import.

Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2
behaviours that were previously untested:
 - `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso /
   initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode
   branch
 - `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle /
   initialDocumentLocation; doesn't stomp parent-bound values; archive-box /
   archive-folder fields visible only in editMode
 - `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>`
   open and `aria-expanded` popover present
 - `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads
   "Massenbearbeitung" + "werden bearbeitet" in edit mode (not the
   upload-flavoured "hochladen"/"werden erstellt" copy)
 - `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars`
   — pins the @Size validator on archiveBox via the @Valid wiring

Refs #225, PR #331

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

133 lines
4.3 KiB
Svelte

<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { onMount, untrack } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { children, data } = $props();
// Provide the confirmation service to the entire component tree.
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService();
// Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
// when they wander back to /documents — Felix C4 on PR #331.
//
// `bulkSelectionStore.size` is read inside `untrack` so the effect only
// re-fires on route change, not on every checkbox toggle (Felix C3 cycle 3).
$effect(() => {
const path = page.url.pathname;
const inBulkContext =
path === '/documents' ||
path.startsWith('/documents/') ||
path === '/enrich' ||
path.startsWith('/enrich/');
if (!inBulkContext) {
untrack(() => {
if (bulkSelectionStore.size > 0) bulkSelectionStore.clear();
});
}
});
const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);
// Set after client-side hydration completes. Used by E2E tests to know the
// page is interactive (event handlers registered) before they interact with it.
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
const isAuthPage = $derived(
['/login', '/register', '/forgot-password', '/reset-password'].some((p) =>
page.url.pathname.startsWith(p)
)
);
const userInitials = $derived.by(() => {
const first = data?.user?.firstName?.[0];
const last = data?.user?.lastName?.[0];
if (first && last) return (first + last).toUpperCase();
return null;
});
</script>
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
{#if !isAuthPage}
<header class="sticky top-0 z-50 bg-header">
<div class="h-1 bg-accent"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<!-- Logo & Nav -->
<AppNav isAdmin={isAdmin} />
<!-- Right Side -->
<div class="flex items-center gap-3">
{#if data?.user}
<a
href="/documents/new"
aria-label={m.upload_action()}
class="inline-flex items-center gap-2 rounded-sm border border-white/25 px-3.5 py-1.5 font-sans text-[11px] font-bold tracking-[.12em] text-white uppercase transition-colors hover:bg-white/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="hidden xl:inline">{m.upload_action()}</span>
</a>
{/if}
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
<div
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
>
<LanguageSwitcher inverted />
</div>
<!-- Theme toggle -->
<ThemeToggle />
<!-- Notification bell (authenticated users only) -->
{#if data?.user}
<NotificationBell />
{/if}
<!-- User menu -->
<UserMenu userInitials={userInitials} />
</div>
</div>
</div>
</header>
{/if}
<main class={isAuthPage ? '' : 'py-6'}>
{@render children()}
</main>
<!-- Shared confirmation dialog — used by getConfirmService() throughout the app -->
<ConfirmDialog />
</div>