Files
ApiCore/ApiCore-Frontend/src/app/(app)/layout.tsx
T
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00

157 lines
6.2 KiB
TypeScript

"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { NotificationBell } from "@/components/notification-bell";
import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
/** Id of the page content, the target of the skip link. */
const MAIN_CONTENT_ID = "main-content";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, isLoading, sessionMode } = useAuth();
const [isLogoutOpen, setIsLogoutOpen] = useState(false);
// Focus lands here when the offline queue sheet closes and nothing else is left.
const pageContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isLoading && !user) {
router.replace("/login");
}
}, [isLoading, user, router]);
// Photos of form drafts nobody can restore any more (older than 3 days)
// leave the device. Housekeeping only: if it fails, the next start tries again.
useEffect(() => {
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
}, []);
// Online saves whose photos could not be tied to their inspection at once:
// finished for the signed-in user, even offline - the photos then wait as
// entries of their own. Every sync run of that user tries again too.
const userId = user?.id;
useEffect(() => {
if (userId) {
void finishHandOvers(userId).catch(() => undefined);
}
}, [userId]);
if (isLoading || !user) {
return null;
}
// The phone's quick-add button floats 16 px above the bottom nav and is
// 48 px high: without room under the page it would cover the last buttons.
const contentPaddingClass = hasQuickAddButton(pathname, user.permissions)
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
: "pb-(--bottom-nav-height)";
// The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
return (
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" });
}}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none"
>
Zum Inhalt springen
</a>
<Sidebar user={user} />
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center gap-1.5 font-heading font-semibold sm:hidden"
>
<span
className="flex size-7 items-center justify-center rounded-lg"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4 fill-white/25 text-white" strokeWidth={2.25} />
</span>
ApiCore
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
>
<PageTransition>{children}</PageTransition>
</div>
</div>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
);
}