Files
Calendar_Frontend/app/admin/layout.tsx
PaddyandClaude Opus 5.5 0cda40f85e Rewrite the calendar frontend in Next.js
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>
2026-09-28 22:15:59 +02:00

140 lines
5.7 KiB
TypeScript
Raw Permalink 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.
"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>
);
}