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]>
157 lines
6.2 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|