Event Detail page

This commit is contained in:
2026-09-25 10:57:10 +05:30
parent 1e5fb9031c
commit 8a75cff501
12 changed files with 550 additions and 35 deletions
+96 -11
View File
@@ -146,6 +146,14 @@ function CardActions({
); );
} }
function ViewLink({ href }: { href: string }) {
return (
<a href={href} target="_blank" className={btn}>
View page ↗
</a>
);
}
/** Local draft for one card. Resets automatically when the saved item changes (card remounts via key). */ /** Local draft for one card. Resets automatically when the saved item changes (card remounts via key). */
function useDraft<T>(item: T, id: string, onDirty: (id: string, dirty: boolean) => void) { function useDraft<T>(item: T, id: string, onDirty: (id: string, dirty: boolean) => void) {
const [draft, setDraftState] = useState<T>(item); const [draft, setDraftState] = useState<T>(item);
@@ -278,6 +286,16 @@ function MeetingCard({
<input value={draft.landmark ?? ""} placeholder="e.g. Near Mummy Hospital" onChange={(e) => setDraft({ landmark: e.target.value })} className={`${input} mt-1`} /> <input value={draft.landmark ?? ""} placeholder="e.g. Near Mummy Hospital" onChange={(e) => setDraft({ landmark: e.target.value })} className={`${input} mt-1`} />
</label> </label>
</div> </div>
<label className="block text-xs font-medium text-rotary-navy">
Detailed description (shown on the meeting&apos;s page)
<textarea
rows={5}
value={draft.details ?? ""}
placeholder="Agenda, speaker profile, notes… Leave a blank line between paragraphs."
onChange={(e) => setDraft({ details: e.target.value })}
className={`${input} mt-1`}
/>
</label>
<CardActions <CardActions
isNew={isNew} isNew={isNew}
dirty={dirty} dirty={dirty}
@@ -285,7 +303,9 @@ function MeetingCard({
onSave={() => onSave(draft)} onSave={() => onSave(draft)}
onCancel={() => (isNew ? onDiscard(item.id) : reset())} onCancel={() => (isNew ? onDiscard(item.id) : reset())}
onDelete={() => onDelete(item)} onDelete={() => onDelete(item)}
/> >
{!isNew && <ViewLink href={`/meetings/${item.id}`} />}
</CardActions>
</div> </div>
</div> </div>
); );
@@ -323,9 +343,19 @@ function EventCard({
</label> </label>
</div> </div>
<label className="block text-xs font-medium text-rotary-navy"> <label className="block text-xs font-medium text-rotary-navy">
Description Short description (shown on the card)
<textarea rows={2} value={draft.text} onChange={(e) => setDraft({ text: e.target.value })} className={`${input} mt-1`} /> <textarea rows={2} value={draft.text} onChange={(e) => setDraft({ text: e.target.value })} className={`${input} mt-1`} />
</label> </label>
<label className="block text-xs font-medium text-rotary-navy">
Detailed description (shown on the event&apos;s page)
<textarea
rows={6}
value={draft.details ?? ""}
placeholder="Full write-up of the event: what happened, who attended, impact… Leave a blank line between paragraphs."
onChange={(e) => setDraft({ details: e.target.value })}
className={`${input} mt-1`}
/>
</label>
<CardActions <CardActions
isNew={isNew} isNew={isNew}
dirty={dirty} dirty={dirty}
@@ -336,6 +366,7 @@ function EventCard({
> >
{!isNew && ( {!isNew && (
<> <>
<ViewLink href={`/events/${item.id}`} />
<button className={btn} disabled={!canUp || busy} onClick={() => onMove(-1)}> <button className={btn} disabled={!canUp || busy} onClick={() => onMove(-1)}>
↑ Up ↑ Up
</button> </button>
@@ -366,6 +397,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null); const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [pastOpen, setPastOpen] = useState(false);
const multiRef = useRef<HTMLInputElement>(null); const multiRef = useRef<HTMLInputElement>(null);
const unsaved = dirtyIds.size + pending.gallery.length + pending.meetings.length + pending.events.length; const unsaved = dirtyIds.size + pending.gallery.length + pending.meetings.length + pending.events.length;
@@ -489,16 +521,12 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
venue: latest?.venue ?? "", venue: latest?.venue ?? "",
landmark: latest?.landmark ?? "", landmark: latest?.landmark ?? "",
poster: "", poster: "",
details: "",
}, },
], ],
})); }));
} }
const TABS: { key: Tab; label: string }[] = [
{ key: "gallery", label: `Gallery (${saved.gallery.length})` },
{ key: "meetings", label: `Meetings (${saved.meetings.length})` },
{ key: "events", label: `Past Events (${saved.events.length})` },
];
// meeting groupings // meeting groupings
const today = todayIST(); const today = todayIST();
@@ -506,6 +534,19 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
const upcoming = saved.meetings.filter((m) => m.date >= today).sort((a, b) => a.date.localeCompare(b.date)); const upcoming = saved.meetings.filter((m) => m.date >= today).sort((a, b) => a.date.localeCompare(b.date));
const past = saved.meetings.filter((m) => m.date < today).sort((a, b) => b.date.localeCompare(a.date)); const past = saved.meetings.filter((m) => m.date < today).sort((a, b) => b.date.localeCompare(a.date));
const TABS: { key: Tab; label: string }[] = [
{ key: "gallery", label: `Gallery (${saved.gallery.length})` },
{ key: "meetings", label: `Meetings (${upcoming.length} upcoming)` },
{ key: "events", label: `Past Events (${saved.events.length + past.length})` },
];
/** Jump from the Past Events tab to a completed meeting's editor. */
const editPastMeeting = (id: string) => {
setTab("meetings");
setPastOpen(true);
setTimeout(() => document.getElementById(`meeting-${id}`)?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
};
const common = { onDirty } as const; const common = { onDirty } as const;
const key = (x: object) => JSON.stringify(x); const key = (x: object) => JSON.stringify(x);
@@ -677,12 +718,17 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
)} )}
{past.length > 0 && ( {past.length > 0 && (
<details className="mt-8"> <details
className="mt-8"
open={pastOpen}
onToggle={(e) => setPastOpen((e.currentTarget as HTMLDetailsElement).open)}
>
<summary className="cursor-pointer text-sm font-semibold uppercase tracking-wider text-rotary-ink/50"> <summary className="cursor-pointer text-sm font-semibold uppercase tracking-wider text-rotary-ink/50">
Past meetings ({past.length}) Past meetings ({past.length})
</summary> </summary>
<div className="mt-3 space-y-4"> <div className="mt-3 space-y-4">
{past.map((m) => ( {past.map((m) => (
<div key={key(m)} id={`meeting-${m.id}`} className="scroll-mt-32">
<MeetingCard <MeetingCard
key={key(m)} key={key(m)}
item={m} item={m}
@@ -695,6 +741,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
isNext={false} isNext={false}
isPast isPast
/> />
</div>
))} ))}
</div> </div>
</details> </details>
@@ -708,11 +755,14 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-xl font-semibold text-rotary-navy">Past events</h2> <h2 className="text-xl font-semibold text-rotary-navy">Past events</h2>
<p className="text-sm text-rotary-ink/60">The top event shows first on the website.</p> <p className="text-sm text-rotary-ink/60">
Meetings move to the website&apos;s Past Events automatically once their date has passed (edit them under
Meetings → Past meetings). Events you add here are shown alongside them, newest first.
</p>
</div> </div>
<button <button
onClick={() => onClick={() =>
setPending((p) => ({ ...p, events: [{ id: newId(), image: "", date: "", title: "", text: "" }, ...p.events] })) setPending((p) => ({ ...p, events: [{ id: newId(), image: "", date: "", title: "", text: "", details: "" }, ...p.events] }))
} }
className="rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white hover:bg-rotary-blue" className="rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white hover:bg-rotary-blue"
> >
@@ -738,7 +788,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
))} ))}
{saved.events.length === 0 && pending.events.length === 0 && ( {saved.events.length === 0 && pending.events.length === 0 && (
<p className="rounded-2xl border border-dashed border-rotary-navy/20 p-10 text-center text-sm text-rotary-ink/50"> <p className="rounded-2xl border border-dashed border-rotary-navy/20 p-10 text-center text-sm text-rotary-ink/50">
No events yet. Click “Add event” to create one. No events added by hand yet. Click “Add event” to create one.
</p> </p>
)} )}
{saved.events.map((ev, i) => ( {saved.events.map((ev, i) => (
@@ -757,6 +807,41 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
/> />
))} ))}
</div> </div>
<h3 className="mt-10 text-sm font-semibold uppercase tracking-wider text-rotary-azure">
Completed meetings ({past.length}) · added automatically
</h3>
{past.length === 0 ? (
<p className="mt-3 rounded-2xl border border-dashed border-rotary-navy/20 p-8 text-center text-sm text-rotary-ink/50">
Meetings appear here automatically the day after their date.
</p>
) : (
<div className="mt-3 grid gap-3">
{past.map((m) => (
<div
key={m.id}
className="flex items-center gap-4 rounded-2xl bg-white p-3 shadow-sm ring-1 ring-rotary-navy/[0.06]"
>
<div className="h-16 w-14 shrink-0 overflow-hidden rounded-lg bg-rotary-paper ring-1 ring-rotary-navy/10">
{m.poster && <img src={m.poster} alt="" className="h-full w-full object-cover object-top" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-rotary-navy">{m.topic || "Club meeting"}</p>
<p className="text-xs text-rotary-ink/60">
{formatMeetingDate(m.date)}
{m.venue && ` · ${m.venue}`}
</p>
</div>
<a href={`/meetings/${m.id}`} target="_blank" className={btn}>
View page ↗
</a>
<button className={btn} onClick={() => editPastMeeting(m.id)}>
Edit
</button>
</div>
))}
</div>
)}
</section> </section>
)} )}
</main> </main>
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import DetailShell, { Chip } from "@/components/DetailShell";
import RichText from "@/components/RichText";
import { getContent } from "@/lib/content";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ id: string }> };
async function findEvent(id: string) {
const { events } = await getContent();
return { event: events.find((e) => e.id === id), events };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { event } = await findEvent((await params).id);
if (!event) return { title: "Event not found · Rotary Club of Vellore" };
return {
title: `${event.title} · Rotary Club of Vellore`,
description: event.text || event.details.slice(0, 160),
};
}
export default async function EventPage({ params }: Props) {
const { event, events } = await findEvent((await params).id);
if (!event) notFound();
const others = events.filter((e) => e.id !== event.id).slice(0, 3);
return (
<DetailShell
eyebrow="Past event"
title={event.title}
backHref="/#events"
backLabel="All events"
badge={event.date ? <Chip tone="gold">{event.date}</Chip> : undefined}
>
<article className="overflow-hidden rounded-3xl bg-white shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-1 ring-rotary-navy/[0.06]">
{event.image && (
<div className="relative aspect-[16/9] w-full bg-rotary-navy/[0.04]">
<Image
src={event.image}
alt={event.title}
fill
priority
sizes="(min-width: 896px) 896px, 100vw"
unoptimized={event.image.startsWith("/uploads/")}
className="object-contain"
/>
</div>
)}
<div className="p-6 sm:p-10">
{event.text && (
<p className="font-serif text-lg leading-relaxed text-rotary-navy sm:text-xl">{event.text}</p>
)}
{event.details ? (
<RichText text={event.details} className={event.text ? "mt-6 border-t border-rotary-navy/[0.08] pt-6" : ""} />
) : (
!event.text && <p className="text-sm italic text-rotary-ink/50">More details will be shared soon.</p>
)}
</div>
</article>
{others.length > 0 && (
<section className="mt-14">
<h2 className="text-xl font-semibold text-rotary-navy">More events</h2>
<div className="mt-5 grid gap-5 sm:grid-cols-3">
{others.map((e) => (
<Link
key={e.id}
href={`/events/${e.id}`}
className="group overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-rotary-navy/[0.06] transition-all hover:-translate-y-1 hover:shadow-lg"
>
<div className="relative aspect-[16/10] bg-rotary-paper">
{e.image && (
<Image
src={e.image}
alt={e.title}
fill
sizes="(min-width: 640px) 280px, 100vw"
unoptimized={e.image.startsWith("/uploads/")}
className="object-cover object-top"
/>
)}
</div>
<div className="p-4">
{e.date && <p className="text-[11px] font-semibold uppercase tracking-wider text-rotary-azure">{e.date}</p>}
<p className="mt-0.5 font-semibold text-rotary-navy group-hover:text-rotary-blue">{e.title}</p>
</div>
</Link>
))}
</div>
</section>
)}
</DetailShell>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import DetailShell, { Chip } from "@/components/DetailShell";
import RichText from "@/components/RichText";
import { getContent } from "@/lib/content";
import { formatMeetingDate, getNextMeeting, todayIST } from "@/lib/meetings";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ id: string }> };
async function findMeeting(id: string) {
const { meetings } = await getContent();
return { meeting: meetings.find((m) => m.id === id), meetings };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { meeting } = await findMeeting((await params).id);
if (!meeting) return { title: "Meeting not found · Rotary Club of Vellore" };
const when = formatMeetingDate(meeting.date);
return {
title: `${meeting.topic || "Club meeting"} – ${when} · Rotary Club of Vellore`,
description: meeting.details.slice(0, 160) || `Rotary Club of Vellore meeting on ${when}.`,
};
}
const ICONS = {
date: <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" strokeLinecap="round" strokeLinejoin="round" />,
time: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" strokeLinecap="round" strokeLinejoin="round" /></>,
venue: <><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21Z" strokeLinecap="round" strokeLinejoin="round" /><circle cx="12" cy="9.5" r="2.5" /></>,
landmark: <path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" />,
};
export default async function MeetingPage({ params }: Props) {
const { meeting, meetings } = await findMeeting((await params).id);
if (!meeting) notFound();
const isPast = meeting.date < todayIST();
const isNext = getNextMeeting(meetings)?.id === meeting.id;
const facts = [
{ key: "date", label: "Date", value: formatMeetingDate(meeting.date) },
{ key: "time", label: "Time", value: meeting.time },
{ key: "venue", label: "Venue", value: meeting.venue },
{ key: "landmark", label: "Landmark", value: meeting.landmark },
].filter((f) => f.value) as { key: keyof typeof ICONS; label: string; value: string }[];
const mapQuery = [meeting.venue, "Vellore"].filter(Boolean).join(", ");
return (
<DetailShell
eyebrow="Club meeting"
title={meeting.topic || "Club meeting"}
backHref={isPast ? "/#events" : "/#event"}
backLabel={isPast ? "All past events" : "Back to home"}
badge={
<>
<Chip tone="gold">{formatMeetingDate(meeting.date)}</Chip>
{isNext && <Chip>Next meeting</Chip>}
{isPast && <Chip>Completed</Chip>}
</>
}
>
<div className={`grid gap-6 ${meeting.poster ? "lg:grid-cols-[1fr_320px]" : ""}`}>
<article className="rounded-3xl bg-white p-6 shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-1 ring-rotary-navy/[0.06] sm:p-8">
<ul className="grid gap-4 sm:grid-cols-2">
{facts.map((f) => (
<li key={f.key} className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rotary-blue/[0.08] text-rotary-blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4">
{ICONS[f.key]}
</svg>
</span>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-rotary-azure">{f.label}</p>
<p className="mt-0.5 text-sm text-rotary-navy">{f.value}</p>
</div>
</li>
))}
</ul>
{meeting.venue && !isPast && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapQuery)}`}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-rotary-blue"
>
Get directions ↗
</a>
)}
{meeting.details ? (
<RichText text={meeting.details} className="mt-8 border-t border-rotary-navy/[0.08] pt-6" />
) : (
<p className="mt-8 border-t border-rotary-navy/[0.08] pt-6 text-sm italic text-rotary-ink/50">
More details about this meeting will be shared soon.
</p>
)}
</article>
{meeting.poster && (
<a
href={meeting.poster}
target="_blank"
rel="noopener noreferrer"
className="relative block aspect-[4/5] overflow-hidden rounded-3xl bg-white shadow-[0_20px_50px_-24px_rgba(3,20,58,0.35)] ring-4 ring-white"
title="Open poster"
>
<Image
src={meeting.poster}
alt={meeting.topic || "Meeting poster"}
fill
priority
sizes="(min-width: 1024px) 320px, 100vw"
unoptimized={meeting.poster.startsWith("/uploads/")}
className="object-contain"
/>
</a>
)}
</div>
</DetailShell>
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ import CtaBand from "@/components/CtaBand";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Gallery from "@/components/Gallery"; import Gallery from "@/components/Gallery";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import { getNextMeeting } from "@/lib/meetings"; import { buildPastEvents, getNextMeeting } from "@/lib/meetings";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -20,7 +20,7 @@ export default async function Page() {
<Hero /> <Hero />
<Gallery photos={content.gallery} /> <Gallery photos={content.gallery} />
<EventDetails next={getNextMeeting(content.meetings)} /> <EventDetails next={getNextMeeting(content.meetings)} />
<PastEvents events={content.events} /> <PastEvents events={buildPastEvents(content.events, content.meetings)} />
<OfficeBearers /> <OfficeBearers />
<CtaBand /> <CtaBand />
<Footer /> <Footer />
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import Header from "./Header";
import Footer from "./Footer";
import GearMotif from "./GearMotif";
/** Page frame for event / meeting detail pages: header, navy title band, content, footer. */
export default function DetailShell({
eyebrow,
title,
badge,
backHref,
backLabel,
children,
}: {
eyebrow: string;
title: string;
badge?: React.ReactNode;
backHref: string;
backLabel: string;
children: React.ReactNode;
}) {
return (
<main className="min-h-screen bg-rotary-paper">
<Header showLogo />
<section className="relative overflow-hidden bg-gradient-to-b from-rotary-navy to-rotary-blue px-4 pb-14 pt-10 text-white sm:px-6 sm:pb-16 lg:px-8">
<GearMotif className="pointer-events-none absolute -right-20 -top-20 h-80 w-80 text-white/[0.05]" />
<div className="relative mx-auto max-w-5xl">
<Link
href={backHref}
className="inline-flex items-center gap-1.5 text-sm text-white/70 transition-colors hover:text-white"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{backLabel}
</Link>
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.28em] text-rotary-goldlight">{eyebrow}</p>
<h1 className="mt-2 font-serif text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">{title}</h1>
{badge && <div className="mt-4 flex flex-wrap gap-2">{badge}</div>}
</div>
</section>
<div className="relative z-10 mx-auto -mt-8 max-w-5xl px-4 pb-16 sm:px-6 lg:px-8">{children}</div>
<Footer />
</main>
);
}
export function Chip({ children, tone = "light" }: { children: React.ReactNode; tone?: "light" | "gold" }) {
return (
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
tone === "gold" ? "bg-rotary-gold text-rotary-navy" : "border border-white/20 bg-white/10 text-white"
}`}
>
{children}
</span>
);
}
+12
View File
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import Reveal from "./Reveal"; import Reveal from "./Reveal";
import type { MeetingItem } from "@/lib/content"; import type { MeetingItem } from "@/lib/content";
import { formatMeetingDate } from "@/lib/meetings"; import { formatMeetingDate } from "@/lib/meetings";
@@ -96,6 +97,17 @@ export default function EventDetails({ next }: { next?: MeetingItem }) {
</li> </li>
))} ))}
</ul> </ul>
{next && (
<Link
href={`/meetings/${next.id}`}
className="group mt-6 inline-flex w-fit items-center gap-2 rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-rotary-blue"
>
View meeting details
<svg className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
)}
</div> </div>
</Reveal> </Reveal>
+6 -6
View File
@@ -2,11 +2,11 @@ import Image from "next/image";
import GearMotif from "./GearMotif"; import GearMotif from "./GearMotif";
const LINKS = [ const LINKS = [
{ href: "#top", label: "Home" }, { href: "/#top", label: "Home" },
{ href: "#gallery", label: "Gallery" }, { href: "/#gallery", label: "Gallery" },
{ href: "#event", label: "Where We Meet" }, { href: "/#event", label: "Where We Meet" },
{ href: "#events", label: "Past Events" }, { href: "/#events", label: "Past Events" },
{ href: "#bearers", label: "Office Bearers" }, { href: "/#bearers", label: "Office Bearers" },
]; ];
export default function Footer({ hidden = [] }: { hidden?: string[] }) { export default function Footer({ hidden = [] }: { hidden?: string[] }) {
@@ -73,7 +73,7 @@ export default function Footer({ hidden = [] }: { hidden?: string[] }) {
</a> </a>
</p> </p>
{/* <a {/* <a
href="#top" href="/#top"
className="group flex items-center gap-1.5 text-white/60 transition-colors duration-200 hover:text-white" className="group flex items-center gap-1.5 text-white/60 transition-colors duration-200 hover:text-white"
> >
Back to top Back to top
+31 -9
View File
@@ -1,16 +1,19 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
const NAV_LINKS = [ const NAV_LINKS = [
{ href: "#top", label: "Home" }, { href: "/#top", label: "Home" },
{ href: "#gallery", label: "Gallery" }, { href: "/#gallery", label: "Gallery" },
{ href: "#event", label: "Where We Meet" }, { href: "/#event", label: "Where We Meet" },
{ href: "#events", label: "Past Events" }, { href: "/#events", label: "Past Events" },
{ href: "#bearers", label: "Office Bearers" }, { href: "/#bearers", label: "Office Bearers" },
]; ];
export default function Header({ hidden = [] }: { hidden?: string[] }) { /** `showLogo`: show the Rotary logo on the left (detail pages). The home page hero already has it. */
export default function Header({ hidden = [], showLogo = false }: { hidden?: string[]; showLogo?: boolean }) {
const links = NAV_LINKS.filter((l) => !hidden.includes(l.href)); const links = NAV_LINKS.filter((l) => !hidden.includes(l.href));
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -31,10 +34,29 @@ export default function Header({ hidden = [] }: { hidden?: string[] }) {
}`} }`}
> >
<div <div
className={`mx-auto flex max-w-7xl items-center justify-center px-4 transition-all duration-300 ${ className={`mx-auto flex max-w-7xl items-center px-4 transition-all duration-300 ${
scrolled ? "py-2.5" : "py-4" showLogo ? "justify-between" : "justify-center"
}`} } ${scrolled ? "py-2.5" : "py-4"}`}
> >
{showLogo && (
<Link href="/" aria-label="Rotary Club of Vellore — home" className="group flex items-center gap-3">
<span className="rounded-lg bg-white px-2.5 py-1.5 shadow-md shadow-black/20 transition-transform duration-300 group-hover:scale-[1.03]">
<Image
src="/images/rotary-logo.png"
alt="Rotary International — Create Lasting Impact"
width={180}
height={52}
className={`w-auto object-contain transition-all duration-300 ${scrolled ? "h-7" : "h-8 sm:h-9"}`}
priority
/>
</span>
<span className="hidden flex-col leading-tight sm:flex">
<span className="font-serif text-base font-semibold text-white">Rotary Club of Vellore</span>
<span className="text-[10px] uppercase tracking-[0.18em] text-rotary-goldlight/90">District 3231 · Est. 1948</span>
</span>
</Link>
)}
{/* Desktop nav */} {/* Desktop nav */}
<nav className="hidden items-center gap-7 md:flex"> <nav className="hidden items-center gap-7 md:flex">
{links.map((link) => ( {links.map((link) => (
+22 -7
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Reveal from "./Reveal"; import Reveal from "./Reveal";
import EmptyNote from "./EmptyNote"; import EmptyNote from "./EmptyNote";
import type { PastEvent } from "@/lib/content"; import type { PastEventCard } from "@/lib/meetings";
const AUTOPLAY_MS = 4500; const AUTOPLAY_MS = 4500;
@@ -20,7 +21,7 @@ function usePerView() {
return perView; return perView;
} }
export default function PastEvents({ events }: { events: PastEvent[] }) { export default function PastEvents({ events }: { events: PastEventCard[] }) {
const perView = usePerView(); const perView = usePerView();
const maxIndex = Math.max(0, events.length - perView); const maxIndex = Math.max(0, events.length - perView);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@@ -103,7 +104,10 @@ export default function PastEvents({ events }: { events: PastEvent[] }) {
> >
{events.map((e) => ( {events.map((e) => (
<div key={e.id} className="shrink-0 px-3 pb-4" style={{ width: `${100 / perView}%` }}> <div key={e.id} className="shrink-0 px-3 pb-4" style={{ width: `${100 / perView}%` }}>
<article className="group h-full overflow-hidden rounded-2xl border border-rotary-navy/[0.06] bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg hover:shadow-rotary-blue/10"> <Link
href={e.href}
draggable={false}
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-rotary-navy/[0.06] bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg hover:shadow-rotary-blue/10">
<div className="relative aspect-[16/10] overflow-hidden bg-rotary-paper"> <div className="relative aspect-[16/10] overflow-hidden bg-rotary-paper">
{e.image && ( {e.image && (
<Image <Image
@@ -115,17 +119,28 @@ export default function PastEvents({ events }: { events: PastEvent[] }) {
className="object-cover object-top transition-transform duration-700 group-hover:scale-105" className="object-cover object-top transition-transform duration-700 group-hover:scale-105"
/> />
)} )}
{e.kind === "meeting" && (
<span className="absolute right-3 top-3 rounded-full bg-rotary-navy/85 px-3 py-1 text-[11px] font-semibold text-white shadow backdrop-blur">
Club meeting
</span>
)}
{e.date && ( {e.date && (
<span className="absolute left-3 top-3 rounded-full bg-rotary-gold px-3 py-1 text-[11px] font-semibold text-rotary-navy shadow"> <span className="absolute left-3 top-3 rounded-full bg-rotary-gold px-3 py-1 text-[11px] font-semibold text-rotary-navy shadow">
{e.date} {e.date}
</span> </span>
)} )}
</div> </div>
<div className="p-5"> <div className="flex flex-1 flex-col p-5">
<h3 className="text-lg font-semibold text-rotary-navy">{e.title}</h3> <h3 className="text-lg font-semibold text-rotary-navy transition-colors group-hover:text-rotary-blue">{e.title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-rotary-ink/65">{e.text}</p> <p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-rotary-ink/65">{e.text}</p>
<span className="mt-auto inline-flex items-center gap-1.5 pt-4 text-sm font-semibold text-rotary-azure">
Read more
<svg className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div> </div>
</article> </Link>
</div> </div>
))} ))}
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
/** Plain text → paragraphs. A blank line starts a new paragraph; single line breaks are kept. */
export default function RichText({ text, className = "" }: { text: string; className?: string }) {
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
if (paragraphs.length === 0) return null;
return (
<div className={`space-y-4 text-[15px] leading-relaxed text-rotary-ink/80 sm:text-base ${className}`}>
{paragraphs.map((p, i) => (
<p key={i} className="whitespace-pre-line">
{p}
</p>
))}
</div>
);
}
+6
View File
@@ -21,6 +21,8 @@ export type MeetingItem = {
venue: string; venue: string;
landmark: string; landmark: string;
poster: string; poster: string;
/** Long description shown on the meeting's own page. */
details: string;
}; };
export type PastEvent = { export type PastEvent = {
@@ -29,6 +31,8 @@ export type PastEvent = {
date: string; date: string;
title: string; title: string;
text: string; text: string;
/** Long description shown on the event's own page. */
details: string;
}; };
export type SiteContent = { export type SiteContent = {
@@ -105,6 +109,7 @@ export function sanitizeContent(input: unknown): SiteContent {
venue: str(x.venue), venue: str(x.venue),
landmark: str(x.landmark), landmark: str(x.landmark),
poster: img(x.poster), poster: img(x.poster),
details: str(x.details, 10000),
})) }))
.filter((x) => x.date) .filter((x) => x.date)
.sort((a, b) => a.date.localeCompare(b.date)); .sort((a, b) => a.date.localeCompare(b.date));
@@ -116,6 +121,7 @@ export function sanitizeContent(input: unknown): SiteContent {
date: str(e.date, 60), date: str(e.date, 60),
title: str(e.title, 150), title: str(e.title, 150),
text: str(e.text, 600), text: str(e.text, 600),
details: str(e.details, 10000),
})) }))
.filter((e) => e.title); .filter((e) => e.title);
return { return {
+72
View File
@@ -22,3 +22,75 @@ export function formatMeetingDate(date: string) {
timeZone: "UTC", timeZone: "UTC",
}); });
} }
/** A card in the website's "Past Events" list: a manual event or a completed meeting. */
export type PastEventCard = {
id: string;
href: string;
image: string;
date: string;
title: string;
text: string;
kind: "event" | "meeting";
};
/** Best-effort sortable timestamp from free-text dates like "12 Aug 2026" or "Aug 2026". */
function toTime(date: string): number | null {
if (!date) return null;
const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T00:00:00Z` : date);
return Number.isNaN(t) ? null : t;
}
/**
* Website "Past Events" = events added by the admin + meetings whose date has passed.
* Newest first. Admin events without a readable date keep their admin order at the top.
*/
export function buildPastEvents(
events: { id: string; image: string; date: string; title: string; text: string }[],
meetings: (MeetingItem & { details?: string })[],
): PastEventCard[] {
const today = todayIST();
const cards: (PastEventCard & { t: number | null; i: number })[] = [
...events.map((e, i) => ({
id: e.id,
href: `/events/${e.id}`,
image: e.image,
date: e.date,
title: e.title,
text: e.text,
kind: "event" as const,
t: toTime(e.date),
i,
})),
...meetings
.filter((m) => m.date < today)
.map((m, i) => ({
id: `m-${m.id}`,
href: `/meetings/${m.id}`,
image: m.poster,
date: formatMeetingDate(m.date),
title: m.topic || "Club meeting",
text:
(m.details ?? "").replace(/\s+/g, " ").trim().slice(0, 160) ||
[m.venue && `Club meeting at ${m.venue}`, m.time].filter(Boolean).join(" · "),
kind: "meeting" as const,
t: toTime(m.date),
i: events.length + i,
})),
];
cards.sort((a, b) => {
if (a.t === null && b.t === null) return a.i - b.i;
if (a.t === null) return -1;
if (b.t === null) return 1;
return b.t - a.t || a.i - b.i;
});
return cards.map((c) => ({
id: c.id,
href: c.href,
image: c.image,
date: c.date,
title: c.title,
text: c.text,
kind: c.kind,
}));
}