Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.7 KiB
TypeScript
140 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
import { LogOutIcon, RefreshCwIcon } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
import { useEffect, useState } from "react";
|
||
import { AdminSessionProvider } from "@/components/admin/AdminSession";
|
||
import { SubscriptionsDialog } from "@/components/admin/SubscriptionsDialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Toaster } from "@/components/ui/sonner";
|
||
import { goToAdminLogin, signOut } from "@/lib/admin-auth";
|
||
import { api, ApiError } from "@/lib/api";
|
||
import type { AdminUser } from "@/lib/types";
|
||
|
||
type Failure = "denied" | "unavailable";
|
||
|
||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||
const pathname = usePathname();
|
||
// /admin/login sits under this layout too, and must not be gated: it is the
|
||
// forwarder signed-out visitors are sent to. Gating it would hand the admin
|
||
// app the forwarder's own URL as the return target - so signing in would
|
||
// land back on the forwarder, which forwards again.
|
||
const isLoginPage = pathname === "/admin/login";
|
||
const [me, setMe] = useState<AdminUser | null>(null);
|
||
const [failure, setFailure] = useState<Failure | null>(null);
|
||
|
||
// The one call that decides whether the admin area renders. proxy.ts already
|
||
// bounced visitors with no cookie at all, but only the API can tell a valid
|
||
// session from a stale one, or a colleague without the calendar permission
|
||
// from one who has it.
|
||
//
|
||
// Exactly one status sends the user to the login page: 401. Anything else
|
||
// means they may well be signed in already, and bouncing them would sign them
|
||
// in, return them here and fail again - an endless loop with nothing on
|
||
// screen to explain it, triggered by exactly the partial outage most likely
|
||
// just after a deploy. 403 is the permission case; everything else (500, the
|
||
// API's 503 when the admin database is down, a network or CORS failure as
|
||
// status 0) is a fault worth showing.
|
||
useEffect(() => {
|
||
if (isLoginPage) return;
|
||
api
|
||
.me()
|
||
.then((user) => {
|
||
// Signed in, but not for this app. The API would refuse every write,
|
||
// so say so once instead of failing per action.
|
||
if (!user.apps.includes("calendar")) {
|
||
setFailure("denied");
|
||
return;
|
||
}
|
||
setMe(user);
|
||
})
|
||
.catch((e: unknown) => {
|
||
const status = e instanceof ApiError ? e.status : 0;
|
||
if (status === 401) {
|
||
goToAdminLogin();
|
||
return;
|
||
}
|
||
setFailure(status === 403 ? "denied" : "unavailable");
|
||
});
|
||
}, [isLoginPage]);
|
||
|
||
if (isLoginPage) {
|
||
return <>{children}</>;
|
||
}
|
||
|
||
if (failure) {
|
||
return <FailureScreen failure={failure} />;
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-dvh flex-col">
|
||
<header className="sticky top-0 z-30 border-b bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur supports-[backdrop-filter]:bg-background/75">
|
||
<div className="mx-auto flex h-14 w-full max-w-4xl items-center justify-between gap-3 px-4 sm:px-6">
|
||
<Link href="/admin" className="flex items-baseline gap-2">
|
||
<span className="font-semibold tracking-tight">Nachklang</span>
|
||
<span className="text-sm text-muted-foreground">Kalender</span>
|
||
</Link>
|
||
<div className="flex items-center gap-1 sm:gap-3">
|
||
{me ? (
|
||
<span className="hidden max-w-48 truncate text-sm text-muted-foreground sm:inline">{me.fullName}</span>
|
||
) : (
|
||
<Skeleton className="hidden h-4 w-24 sm:block" />
|
||
)}
|
||
{me && <SubscriptionsDialog />}
|
||
<Button variant="ghost" size="sm" onClick={() => void signOut()} aria-label="Abmelden">
|
||
<LogOutIcon />
|
||
<span className="hidden sm:inline">Abmelden</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto w-full max-w-4xl flex-1 px-4 pt-4 pb-[calc(6rem+env(safe-area-inset-bottom))] sm:px-6 sm:pt-6">
|
||
{me ? <AdminSessionProvider value={me}>{children}</AdminSessionProvider> : <LoadingShell />}
|
||
</main>
|
||
<Toaster position="top-center" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** What the page looks like while /me is in flight - the list's shape, not a blank page. */
|
||
function LoadingShell() {
|
||
return (
|
||
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Wird geladen">
|
||
<Skeleton className="h-9 w-full" />
|
||
<Skeleton className="h-5 w-32" />
|
||
{Array.from({ length: 4 }, (_, i) => (
|
||
<Skeleton key={i} className="h-20 w-full" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FailureScreen({ failure }: { failure: Failure }) {
|
||
const denied = failure === "denied";
|
||
return (
|
||
<div className="mx-auto flex min-h-dvh max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
|
||
<h1 className="text-lg font-semibold">{denied ? "Kein Zugriff auf den Kalender" : "Kalender nicht erreichbar"}</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
{denied
|
||
? "Dein Konto ist angemeldet, hat aber keine Berechtigung für den Kalender. Eine Administratorin oder ein Administrator kann sie dir in der Verwaltung geben – danach die Seite neu laden."
|
||
: "Die Verwaltung ist gerade nicht erreichbar. Bitte lade die Seite in einem Moment neu."}
|
||
</p>
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||
<RefreshCwIcon />
|
||
Neu laden
|
||
</Button>
|
||
{denied && (
|
||
<Button variant="ghost" onClick={() => void signOut()}>
|
||
<LogOutIcon />
|
||
Abmelden
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|