Files
Calendar_Frontend/app/page.tsx
T
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

196 lines
8.5 KiB
TypeScript
Raw 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.
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>
);
}