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>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,139 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||
|
||||
/**
|
||||
* This app has no login form: signing in happens once, on admin.nachklang.art,
|
||||
* for every Nachklang app. Copied from nachklang-feedback.
|
||||
*
|
||||
* The route exists as a forwarder so proxy.ts has a stable path on this origin
|
||||
* to send signed-out visitors to, rather than building an external URL inside
|
||||
* the proxy.
|
||||
*/
|
||||
export default function AdminLoginPage() {
|
||||
// Read from the URL directly rather than with useSearchParams(): this page is
|
||||
// prerendered, and useSearchParams() opts the whole route into client-side
|
||||
// rendering unless it sits inside a <Suspense> boundary.
|
||||
const [redirect] = useState(() =>
|
||||
typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("redirect"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Passed straight through; the admin app validates it against its own
|
||||
// origin allow-list, and a second check here could only drift from that.
|
||||
goToAdminLogin(redirect || "/admin");
|
||||
}, [redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Weiterleitung zur Nachklang-Anmeldung …</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, PlusIcon } from "lucide-react";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CalendarChips } from "@/components/admin/CalendarChips";
|
||||
import { EventList } from "@/components/admin/EventList";
|
||||
import { EventSheet, type SheetMode } from "@/components/admin/EventSheet";
|
||||
import { EventsToolbar } from "@/components/admin/EventsToolbar";
|
||||
import { PublishDialog } from "@/components/admin/PublishDialog";
|
||||
import { useEvents } from "@/components/admin/useEvents";
|
||||
import { useListState } from "@/components/admin/useListState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||
import { api } from "@/lib/api";
|
||||
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||
import { CALENDARS } from "@/lib/calendars";
|
||||
import { todayMarkerIndex } from "@/lib/event-display";
|
||||
import { payloadFromEvent } from "@/lib/event-form";
|
||||
import { filterEvents, matchesQuery, matchesRange, sortEvents } from "@/lib/filter";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
import { withRange } from "@/lib/url-state";
|
||||
|
||||
export default function AdminPage() {
|
||||
// useSearchParams() (in useListState) needs a Suspense boundary in a prerendered page.
|
||||
return (
|
||||
<Suspense>
|
||||
<EventsPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPage() {
|
||||
const [state, update] = useListState();
|
||||
const { events, loads, reload, refresh, upsertEvent, removeEvent } = useEvents();
|
||||
// Fixed per visit: "upcoming" should not reshuffle while someone is looking at it.
|
||||
const [now] = useState(() => new Date());
|
||||
const [publishingId, setPublishingId] = useState<number | null>(null);
|
||||
const [confirmPublish, setConfirmPublish] = useState<CalendarEvent | null>(null);
|
||||
// The sheet: `mode` outlives `open` by the closing animation; `sheetKey`
|
||||
// remounts it per opening so no state carries over between events.
|
||||
const [sheet, setSheet] = useState<{ open: boolean; mode: SheetMode | null; key: number }>({ open: false, mode: null, key: 0 });
|
||||
const openSheet = (mode: SheetMode) => setSheet((s) => ({ open: true, mode, key: s.key + 1 }));
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
sortEvents(
|
||||
filterEvents(events, { calendarIds: state.calendarIds, range: state.range, query: state.query }, now),
|
||||
state.sort,
|
||||
now,
|
||||
),
|
||||
[events, state, now],
|
||||
);
|
||||
|
||||
// Per-chip counts under the current range and search, whether the chip is on or not.
|
||||
const counts = useMemo(() => {
|
||||
const result: Record<number, number> = {};
|
||||
for (const e of events) {
|
||||
if (matchesRange(e, state.range, now) && matchesQuery(e, state.query)) {
|
||||
result[e.calendarId] = (result[e.calendarId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [events, state.range, state.query, now]);
|
||||
|
||||
const selectedLoading = state.calendarIds.some((id) => loads[id]?.status === "loading");
|
||||
|
||||
// "Alle" spans years; the "Heute" marker shows where now is, and opening the
|
||||
// range scrolls to it. Again after a change of calendars or sort, but not on
|
||||
// every keystroke in the search, which would make the page jump while typing.
|
||||
const todayMarker = state.range === "all" ? todayMarkerIndex(visible, state.sort, now) : null;
|
||||
const scrolledFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (state.range !== "all") {
|
||||
scrolledFor.current = null;
|
||||
return;
|
||||
}
|
||||
if (selectedLoading || todayMarker === null) return;
|
||||
const key = `${state.sort}|${state.calendarIds.join(",")}`;
|
||||
if (scrolledFor.current === key) return;
|
||||
scrolledFor.current = key;
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
document.getElementById("heute")?.scrollIntoView({ block: "center", behavior: reduceMotion ? "auto" : "smooth" });
|
||||
}, [state.range, state.sort, state.calendarIds, selectedLoading, todayMarker]);
|
||||
const failed = CALENDARS.filter((c) => state.calendarIds.includes(c.id) && loads[c.id]?.status === "error");
|
||||
|
||||
// A new event goes into the calendar being looked at - when that is unambiguous.
|
||||
const newEvent = () => openSheet({ kind: "new", calendarId: state.calendarIds.length === 1 ? state.calendarIds[0] : 0 });
|
||||
|
||||
const onSaved = (saved: CalendarEvent, previous: CalendarEvent | null) => {
|
||||
upsertEvent(saved);
|
||||
// Then fetch the server's view: uuid, timestamps, resolved author names.
|
||||
refresh(saved.calendarId);
|
||||
if (previous && previous.calendarId !== saved.calendarId) refresh(previous.calendarId);
|
||||
const shown = filterEvents([saved], { calendarIds: state.calendarIds, range: state.range, query: state.query }, now);
|
||||
if (shown.length === 0) {
|
||||
toast.info("Der Termin ist mit den aktuellen Filtern ausgeblendet.");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCalendar = (calendarId: number) =>
|
||||
update({
|
||||
calendarIds: state.calendarIds.includes(calendarId)
|
||||
? state.calendarIds.filter((id) => id !== calendarId)
|
||||
: [...state.calendarIds, calendarId],
|
||||
});
|
||||
|
||||
// Resends the whole event with only the status changed: PUT replaces every
|
||||
// field, so a partial body would drop the recurrence and anything else left out.
|
||||
const publish = async (event: CalendarEvent) => {
|
||||
setPublishingId(event.eventId);
|
||||
try {
|
||||
await api.updateEvent(event.eventId, payloadFromEvent(event, "PUBLIC"));
|
||||
upsertEvent({ ...event, status: "PUBLIC" });
|
||||
setConfirmPublish(null);
|
||||
toast.success(`„${event.name}“ ist veröffentlicht.`);
|
||||
refresh(event.calendarId);
|
||||
} catch (e) {
|
||||
if (isSessionExpired(e)) {
|
||||
toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …");
|
||||
goToAdminLogin();
|
||||
return;
|
||||
}
|
||||
toast.error("Veröffentlichen fehlgeschlagen", { description: describeApiError(e) });
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Termine</h1>
|
||||
{!selectedLoading && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{visible.length === 1 ? "1 Termin" : `${visible.length} Termine`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={newEvent} className="hidden sm:inline-flex">
|
||||
<PlusIcon />
|
||||
Neuer Termin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EventsToolbar
|
||||
query={state.query}
|
||||
range={state.range}
|
||||
sort={state.sort}
|
||||
loading={selectedLoading}
|
||||
onQueryChange={(query) => update({ query })}
|
||||
onRangeChange={(range) => update(withRange(state, range))}
|
||||
onSortChange={(sort) => update({ sort })}
|
||||
onReload={() => reload()}
|
||||
/>
|
||||
|
||||
<CalendarChips selected={state.calendarIds} counts={counts} loads={loads} onToggle={toggleCalendar} />
|
||||
|
||||
{failed.length > 0 && (
|
||||
<div role="alert" className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm sm:flex-row sm:items-center">
|
||||
<div className="flex flex-1 gap-2">
|
||||
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p>
|
||||
{failed.map((c) => c.label).join(", ")} {failed.length === 1 ? "konnte" : "konnten"} nicht geladen werden:{" "}
|
||||
{(loads[failed[0].id] as { message: string }).message}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => failed.forEach((c) => reload(c.id))}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EventList
|
||||
events={visible}
|
||||
now={now}
|
||||
sort={state.sort}
|
||||
range={state.range}
|
||||
query={state.query}
|
||||
loading={selectedLoading}
|
||||
noCalendars={state.calendarIds.length === 0}
|
||||
publishingId={publishingId}
|
||||
todayMarker={todayMarker}
|
||||
onOpen={(event) => openSheet({ kind: "edit", event })}
|
||||
onPublish={setConfirmPublish}
|
||||
onClearQuery={() => update({ query: "" })}
|
||||
/>
|
||||
|
||||
{/* Phones: a thumb-reach button instead of the header one. */}
|
||||
<Button
|
||||
onClick={newEvent}
|
||||
size="icon-lg"
|
||||
aria-label="Neuer Termin"
|
||||
className="fixed right-5 bottom-[calc(1.25rem+env(safe-area-inset-bottom))] z-20 size-14 rounded-full shadow-lg sm:hidden [&_svg:not([class*='size-'])]:size-6"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
|
||||
<EventSheet
|
||||
key={sheet.key}
|
||||
open={sheet.open}
|
||||
mode={sheet.mode}
|
||||
now={now}
|
||||
onClose={() => setSheet((s) => ({ ...s, open: false }))}
|
||||
onClosed={() => setSheet((s) => (s.open ? s : { ...s, mode: null }))}
|
||||
onSaved={onSaved}
|
||||
onDeleted={(event) => removeEvent(event.eventId)}
|
||||
onDuplicate={(event) => openSheet({ kind: "duplicate", event })}
|
||||
/>
|
||||
|
||||
<PublishDialog
|
||||
event={confirmPublish}
|
||||
now={now}
|
||||
publishing={publishingId !== null}
|
||||
onConfirm={publish}
|
||||
onCancel={() => setConfirmPublish(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 948 B |
+173
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
/*
|
||||
---break---
|
||||
*/
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* Design system: shadcn preset b7ClO5que (style: luma, base color: mist,
|
||||
* theme: indigo, radius: small). --primary/--accent/--muted-foreground etc.
|
||||
* below come from that preset — see https://ui.shadcn.com/create?preset=b7ClO5que
|
||||
* or run `npx shadcn@latest preset decode b7ClO5que`.
|
||||
*
|
||||
* A few tokens remain hand-tuned to nachklang.art's actual computed styles
|
||||
* (see lib/brand.ts and IMPLEMENTATION_PLAN.md §5.7 in nachklang-feedback) rather than coming from
|
||||
* the preset, since the preset has no equivalent for them: --surface-alt,
|
||||
* --radius-card/--radius-pill/--radius-small, and --shadow-card below.
|
||||
* Light-mode only for v1 — the source site has no dark mode either.
|
||||
*/
|
||||
:root {
|
||||
--surface-alt: #f7f7f7;
|
||||
--radius-pill: 25px;
|
||||
--radius-small: 4px;
|
||||
--radius-card: 10px;
|
||||
/* Matches nachklang.art's actual .card shadow (re-verified live). */
|
||||
--shadow-card: 0 2px 15px 0 rgba(0, 0, 0, 0.1);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.148 0.004 228.8);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.148 0.004 228.8);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.148 0.004 228.8);
|
||||
--primary: oklch(0.457 0.24 277.023);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.963 0.002 197.1);
|
||||
--muted-foreground: oklch(0.56 0.021 213.5);
|
||||
--accent: oklch(0.963 0.002 197.1);
|
||||
--accent-foreground: oklch(0.218 0.008 223.9);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.925 0.005 214.3);
|
||||
--input: oklch(0.925 0.005 214.3);
|
||||
--ring: oklch(0.723 0.014 214.4);
|
||||
--chart-1: oklch(0.872 0.007 219.6);
|
||||
--chart-2: oklch(0.56 0.021 213.5);
|
||||
--chart-3: oklch(0.45 0.017 213.2);
|
||||
--chart-4: oklch(0.378 0.015 216);
|
||||
--chart-5: oklch(0.275 0.011 216.9);
|
||||
--radius: 0.45rem;
|
||||
--sidebar: oklch(0.987 0.002 197.1);
|
||||
--sidebar-foreground: oklch(0.148 0.004 228.8);
|
||||
--sidebar-primary: oklch(0.511 0.262 276.966);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.963 0.002 197.1);
|
||||
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
|
||||
--sidebar-border: oklch(0.925 0.005 214.3);
|
||||
--sidebar-ring: oklch(0.723 0.014 214.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading), var(--font-sans), sans-serif;
|
||||
}
|
||||
|
||||
/* Plain box-shadow rather than Tailwind's shadow-lg utility, which composes
|
||||
via chained --tw-shadow custom properties that didn't resolve correctly
|
||||
in some environments during testing (rendered as no shadow at all). */
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
/*
|
||||
---break---
|
||||
*/
|
||||
.dark {
|
||||
--background: oklch(0.148 0.004 228.8);
|
||||
--foreground: oklch(0.987 0.002 197.1);
|
||||
--card: oklch(0.218 0.008 223.9);
|
||||
--card-foreground: oklch(0.987 0.002 197.1);
|
||||
--popover: oklch(0.218 0.008 223.9);
|
||||
--popover-foreground: oklch(0.987 0.002 197.1);
|
||||
--primary: oklch(0.398 0.195 277.366);
|
||||
--primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.275 0.011 216.9);
|
||||
--muted-foreground: oklch(0.723 0.014 214.4);
|
||||
--accent: oklch(0.275 0.011 216.9);
|
||||
--accent-foreground: oklch(0.987 0.002 197.1);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.56 0.021 213.5);
|
||||
--chart-1: oklch(0.872 0.007 219.6);
|
||||
--chart-2: oklch(0.56 0.021 213.5);
|
||||
--chart-3: oklch(0.45 0.017 213.2);
|
||||
--chart-4: oklch(0.378 0.015 216);
|
||||
--chart-5: oklch(0.275 0.011 216.9);
|
||||
--sidebar: oklch(0.218 0.008 223.9);
|
||||
--sidebar-foreground: oklch(0.987 0.002 197.1);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
|
||||
--sidebar-accent: oklch(0.275 0.011 216.9);
|
||||
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.56 0.021 213.5);
|
||||
}
|
||||
/*
|
||||
---break---
|
||||
*/
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Same design system as nachklang-feedback: shadcn preset b7ClO5que (style:
|
||||
// luma, base: mist, theme: indigo, font: inter). next/font self-hosts the font
|
||||
// at build time, so there is no runtime Google Fonts call.
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Nachklang e.V. – Kalender",
|
||||
description: "Veranstaltungskalender von Nachklang e.V.",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
className={cn("h-full", "antialiased", "font-sans", inter.variable)}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CopyLinkCard } from "@/components/landing/CopyLinkCard";
|
||||
import { dateBlock, describeWhen } from "@/lib/event-display";
|
||||
import { normaliseUrl } from "@/lib/event-form";
|
||||
import { API_BASE_URL } from "@/lib/env";
|
||||
import { fetchUpcomingPublicEvents, googleCalendarUrl, icalUrl, webcalUrl } from "@/lib/public-calendar";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Nachklang e.V. – Veranstaltungskalender",
|
||||
description: "Abonniere den Kalender von Nachklang e.V. und verpasse keine Veranstaltung mehr.",
|
||||
};
|
||||
|
||||
// Static, refreshed every five minutes - matches the fetch's own revalidate.
|
||||
export const revalidate = 300;
|
||||
|
||||
/**
|
||||
* Where the server fetches from. Optional and read at request time, like in
|
||||
* nachklang-feedback: it may point at an internal address. The links on the
|
||||
* page always use the public URL, since visitors' calendar apps fetch them.
|
||||
*/
|
||||
const serverApiBaseUrl = (): string => (process.env.API_BASE_URL || API_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
// Shared by the three subscribe options, so the two links and the copy button look alike.
|
||||
// A row on phones; icon above text in the three-column grid, where a row would wrap.
|
||||
const OPTION =
|
||||
"flex w-full items-center gap-3 rounded-xl border bg-card p-4 text-left transition-colors sm:flex-col sm:items-start " +
|
||||
"hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/30 focus-visible:outline-none";
|
||||
|
||||
/**
|
||||
* The public page: how to subscribe, and what is coming up. Same look as the
|
||||
* admin area (design system, light, Inter) - one product, one visual language.
|
||||
*/
|
||||
export default async function Home() {
|
||||
const upcoming = await fetchUpcomingPublicEvents(serverApiBaseUrl(), new Date(), 4);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="border-b pt-[env(safe-area-inset-top)]">
|
||||
<div className="mx-auto flex h-14 w-full max-w-2xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-semibold tracking-tight">Nachklang</span>
|
||||
<span className="text-sm text-muted-foreground">Kalender</span>
|
||||
</span>
|
||||
<Link href="/admin" className="text-sm text-muted-foreground transition-colors hover:text-foreground">
|
||||
Verwaltung
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-10 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Veranstaltungskalender</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Abonniere den Kalender von Nachklang e.V. – neue Termine erscheinen dann automatisch in deiner Kalender-App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="abonnieren" className="flex flex-col gap-3">
|
||||
<h2 id="abonnieren" className="text-sm font-semibold text-muted-foreground">
|
||||
Abonnieren
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<a href={webcalUrl(API_BASE_URL)} className={OPTION}>
|
||||
<AppleIcon />
|
||||
<OptionText label="Apple Kalender" sub="iPhone, iPad, Mac" />
|
||||
</a>
|
||||
<a href={googleCalendarUrl(API_BASE_URL)} target="_blank" rel="noopener noreferrer" className={OPTION}>
|
||||
<GoogleIcon />
|
||||
<OptionText label="Google Kalender" sub="Android, Web" />
|
||||
</a>
|
||||
<CopyLinkCard url={icalUrl(API_BASE_URL)} className={OPTION} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{upcoming && <UpcomingEvents events={upcoming} />}
|
||||
</main>
|
||||
|
||||
<footer className="border-t">
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] text-sm text-muted-foreground sm:px-6">
|
||||
Nachklang e.V.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionText({ label, sub }: { label: string; sub: string }) {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-sm text-muted-foreground">{sub}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The next few public events, in the admin list's style. Hidden entirely when
|
||||
* the API could not be reached, so the page never shows an error where people
|
||||
* come to subscribe.
|
||||
*/
|
||||
function UpcomingEvents({ events }: { events: CalendarEvent[] }) {
|
||||
const now = new Date();
|
||||
return (
|
||||
<section aria-labelledby="naechste-termine" className="flex flex-col gap-3">
|
||||
<h2 id="naechste-termine" className="text-sm font-semibold text-muted-foreground">
|
||||
Nächste Termine
|
||||
</h2>
|
||||
{events.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Gerade stehen keine öffentlichen Termine an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y overflow-hidden rounded-xl border bg-card">
|
||||
{events.map((event) => {
|
||||
const block = dateBlock(event.start);
|
||||
// Links come from the database, and rows older than this app's URL check were never
|
||||
// validated - only an http(s) URL becomes a link on this public page.
|
||||
const link = normaliseUrl(event.url);
|
||||
const href = link.ok && link.value !== "" ? link.value : null;
|
||||
const content = (
|
||||
<>
|
||||
<span className="flex w-11 shrink-0 flex-col items-center pt-0.5 text-center leading-tight" aria-hidden>
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase">{block.weekday}</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{block.day}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{block.month}</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="font-medium">{event.name}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground">
|
||||
<span>{describeWhen(event, now)}</span>
|
||||
{event.location && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<MapPinIcon aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{href && <ArrowUpRightIcon aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={event.eventId}>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex gap-3 px-3 py-3 transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none sm:gap-4 sm:px-4"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex gap-3 px-3 py-3 sm:gap-4 sm:px-4">{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppleIcon() {
|
||||
return (
|
||||
<span className="size-10 shrink-0 overflow-hidden rounded-lg ring-1 ring-border">
|
||||
<svg viewBox="0 0 48 48" fill="none" aria-hidden className="block size-full">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
<rect width="48" height="14" fill="#FF3B30" />
|
||||
<text x="24" y="41" textAnchor="middle" fontFamily="Helvetica Neue, Helvetica, Arial, sans-serif" fontSize="22" fontWeight="300" fill="#1c1c1e">
|
||||
31
|
||||
</text>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<span className="size-10 shrink-0 overflow-hidden rounded-lg ring-1 ring-border">
|
||||
<svg viewBox="0 0 48 48" fill="none" aria-hidden className="block size-full">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
<rect width="48" height="14" fill="#1a73e8" />
|
||||
<rect x="8" y="18" width="14" height="12" rx="1.5" fill="#4285f4" />
|
||||
<rect x="26" y="18" width="14" height="12" rx="1.5" fill="#ea4335" />
|
||||
<rect x="8" y="34" width="14" height="11" rx="1.5" fill="#34a853" />
|
||||
<rect x="26" y="34" width="14" height="11" rx="1.5" fill="#fbbc05" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user