diff --git a/src/app/admin/AdminDashboard.tsx b/src/app/admin/AdminDashboard.tsx
index d572e96..9a35bc9 100644
--- a/src/app/admin/AdminDashboard.tsx
+++ b/src/app/admin/AdminDashboard.tsx
@@ -146,6 +146,14 @@ function CardActions({
);
}
+function ViewLink({ href }: { href: string }) {
+ return (
+
+ View page ↗
+
+ );
+}
+
/** Local draft for one card. Resets automatically when the saved item changes (card remounts via key). */
function useDraft(item: T, id: string, onDirty: (id: string, dirty: boolean) => void) {
const [draft, setDraftState] = useState(item);
@@ -278,6 +286,16 @@ function MeetingCard({
setDraft({ landmark: e.target.value })} className={`${input} mt-1`} />
+
+ Detailed description (shown on the meeting's page)
+
onSave(draft)}
onCancel={() => (isNew ? onDiscard(item.id) : reset())}
onDelete={() => onDelete(item)}
- />
+ >
+ {!isNew && }
+
);
@@ -323,9 +343,19 @@ function EventCard({
- Description
+ Short description (shown on the card)
+
+ Detailed description (shown on the event's page)
+
{!isNew && (
<>
+
onMove(-1)}>
↑ Up
@@ -366,6 +397,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
const [busyId, setBusyId] = useState(null);
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
const [uploading, setUploading] = useState(false);
+ const [pastOpen, setPastOpen] = useState(false);
const multiRef = useRef(null);
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 ?? "",
landmark: latest?.landmark ?? "",
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
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 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 key = (x: object) => JSON.stringify(x);
@@ -677,12 +718,17 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
)}
{past.length > 0 && (
-
+ setPastOpen((e.currentTarget as HTMLDetailsElement).open)}
+ >
Past meetings ({past.length})
{past.map((m) => (
+
+
))}
@@ -708,11 +755,14 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
Past events
-
The top event shows first on the website.
+
+ Meetings move to the website'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.
+
- 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"
>
@@ -738,7 +788,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
))}
{saved.events.length === 0 && pending.events.length === 0 && (
- No events yet. Click “Add event” to create one.
+ No events added by hand yet. Click “Add event” to create one.
)}
{saved.events.map((ev, i) => (
@@ -757,6 +807,41 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
/>
))}
+
+
+ Completed meetings ({past.length}) · added automatically
+
+ {past.length === 0 ? (
+
+ Meetings appear here automatically the day after their date.
+
+ ) : (
+
+ {past.map((m) => (
+
+
+ {m.poster &&
}
+
+
+
{m.topic || "Club meeting"}
+
+ {formatMeetingDate(m.date)}
+ {m.venue && ` · ${m.venue}`}
+
+
+
+ View page ↗
+
+
editPastMeeting(m.id)}>
+ Edit
+
+
+ ))}
+
+ )}
)}
diff --git a/src/app/events/[id]/page.tsx b/src/app/events/[id]/page.tsx
new file mode 100644
index 0000000..f188387
--- /dev/null
+++ b/src/app/events/[id]/page.tsx
@@ -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 {
+ 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 (
+ {event.date} : undefined}
+ >
+
+ {event.image && (
+
+
+
+ )}
+
+ {event.text && (
+
{event.text}
+ )}
+ {event.details ? (
+
+ ) : (
+ !event.text &&
More details will be shared soon.
+ )}
+
+
+
+ {others.length > 0 && (
+
+ More events
+
+ {others.map((e) => (
+
+
+ {e.image && (
+
+ )}
+
+
+ {e.date &&
{e.date}
}
+
{e.title}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/meetings/[id]/page.tsx b/src/app/meetings/[id]/page.tsx
new file mode 100644
index 0000000..f28ccd5
--- /dev/null
+++ b/src/app/meetings/[id]/page.tsx
@@ -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 {
+ 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: ,
+ time: <> >,
+ venue: <> >,
+ landmark: ,
+};
+
+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 (
+
+ {formatMeetingDate(meeting.date)}
+ {isNext && Next meeting }
+ {isPast && Completed }
+ >
+ }
+ >
+
+
+
+ {facts.map((f) => (
+
+
+
+ {ICONS[f.key]}
+
+
+
+
{f.label}
+
{f.value}
+
+
+ ))}
+
+
+ {meeting.venue && !isPast && (
+
+ Get directions ↗
+
+ )}
+
+ {meeting.details ? (
+
+ ) : (
+
+ More details about this meeting will be shared soon.
+
+ )}
+
+
+ {meeting.poster && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 90013f6..64e1685 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -7,7 +7,7 @@ import CtaBand from "@/components/CtaBand";
import Footer from "@/components/Footer";
import Gallery from "@/components/Gallery";
import { getContent } from "@/lib/content";
-import { getNextMeeting } from "@/lib/meetings";
+import { buildPastEvents, getNextMeeting } from "@/lib/meetings";
export const dynamic = "force-dynamic";
@@ -20,7 +20,7 @@ export default async function Page() {
-
+
diff --git a/src/components/DetailShell.tsx b/src/components/DetailShell.tsx
new file mode 100644
index 0000000..884b98b
--- /dev/null
+++ b/src/components/DetailShell.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ {backLabel}
+
+
{eyebrow}
+
{title}
+ {badge &&
{badge}
}
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function Chip({ children, tone = "light" }: { children: React.ReactNode; tone?: "light" | "gold" }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/EventDetails.tsx b/src/components/EventDetails.tsx
index ee9ce83..d03d15c 100644
--- a/src/components/EventDetails.tsx
+++ b/src/components/EventDetails.tsx
@@ -1,4 +1,5 @@
import Image from "next/image";
+import Link from "next/link";
import Reveal from "./Reveal";
import type { MeetingItem } from "@/lib/content";
import { formatMeetingDate } from "@/lib/meetings";
@@ -96,6 +97,17 @@ export default function EventDetails({ next }: { next?: MeetingItem }) {
))}
+ {next && (
+
+ View meeting details
+
+
+
+
+ )}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 6ea90c6..e36b125 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -2,11 +2,11 @@ import Image from "next/image";
import GearMotif from "./GearMotif";
const LINKS = [
- { href: "#top", label: "Home" },
- { href: "#gallery", label: "Gallery" },
- { href: "#event", label: "Where We Meet" },
- { href: "#events", label: "Past Events" },
- { href: "#bearers", label: "Office Bearers" },
+ { href: "/#top", label: "Home" },
+ { href: "/#gallery", label: "Gallery" },
+ { href: "/#event", label: "Where We Meet" },
+ { href: "/#events", label: "Past Events" },
+ { href: "/#bearers", label: "Office Bearers" },
];
export default function Footer({ hidden = [] }: { hidden?: string[] }) {
@@ -73,7 +73,7 @@ export default function Footer({ hidden = [] }: { hidden?: string[] }) {
{/*
Back to top
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index bde7039..3ecaa80 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,16 +1,19 @@
"use client";
import { useEffect, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
const NAV_LINKS = [
- { href: "#top", label: "Home" },
- { href: "#gallery", label: "Gallery" },
- { href: "#event", label: "Where We Meet" },
- { href: "#events", label: "Past Events" },
- { href: "#bearers", label: "Office Bearers" },
+ { href: "/#top", label: "Home" },
+ { href: "/#gallery", label: "Gallery" },
+ { href: "/#event", label: "Where We Meet" },
+ { href: "/#events", label: "Past Events" },
+ { 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 [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
@@ -31,10 +34,29 @@ export default function Header({ hidden = [] }: { hidden?: string[] }) {
}`}
>
+ {showLogo && (
+
+
+
+
+
+ Rotary Club of Vellore
+ District 3231 · Est. 1948
+
+
+ )}
+
{/* Desktop nav */}
{links.map((link) => (
diff --git a/src/components/PastEvents.tsx b/src/components/PastEvents.tsx
index cfa2c2e..dfe505f 100644
--- a/src/components/PastEvents.tsx
+++ b/src/components/PastEvents.tsx
@@ -1,10 +1,11 @@
"use client";
import Image from "next/image";
+import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import Reveal from "./Reveal";
import EmptyNote from "./EmptyNote";
-import type { PastEvent } from "@/lib/content";
+import type { PastEventCard } from "@/lib/meetings";
const AUTOPLAY_MS = 4500;
@@ -20,7 +21,7 @@ function usePerView() {
return perView;
}
-export default function PastEvents({ events }: { events: PastEvent[] }) {
+export default function PastEvents({ events }: { events: PastEventCard[] }) {
const perView = usePerView();
const maxIndex = Math.max(0, events.length - perView);
const [index, setIndex] = useState(0);
@@ -103,7 +104,10 @@ export default function PastEvents({ events }: { events: PastEvent[] }) {
>
{events.map((e) => (
-
+
{e.image && (
)}
+ {e.kind === "meeting" && (
+
+ Club meeting
+
+ )}
{e.date && (
{e.date}
)}
-
-
{e.title}
-
{e.text}
+
+
{e.title}
+
{e.text}
+
+ Read more
+
+
+
+
-
+
))}
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
new file mode 100644
index 0000000..1a25ca4
--- /dev/null
+++ b/src/components/RichText.tsx
@@ -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 (
+
+ {paragraphs.map((p, i) => (
+
+ {p}
+
+ ))}
+
+ );
+}
diff --git a/src/lib/content.ts b/src/lib/content.ts
index ab32593..59158b8 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -21,6 +21,8 @@ export type MeetingItem = {
venue: string;
landmark: string;
poster: string;
+ /** Long description shown on the meeting's own page. */
+ details: string;
};
export type PastEvent = {
@@ -29,6 +31,8 @@ export type PastEvent = {
date: string;
title: string;
text: string;
+ /** Long description shown on the event's own page. */
+ details: string;
};
export type SiteContent = {
@@ -105,6 +109,7 @@ export function sanitizeContent(input: unknown): SiteContent {
venue: str(x.venue),
landmark: str(x.landmark),
poster: img(x.poster),
+ details: str(x.details, 10000),
}))
.filter((x) => x.date)
.sort((a, b) => a.date.localeCompare(b.date));
@@ -116,6 +121,7 @@ export function sanitizeContent(input: unknown): SiteContent {
date: str(e.date, 60),
title: str(e.title, 150),
text: str(e.text, 600),
+ details: str(e.details, 10000),
}))
.filter((e) => e.title);
return {
diff --git a/src/lib/meetings.ts b/src/lib/meetings.ts
index 0424856..6cee391 100644
--- a/src/lib/meetings.ts
+++ b/src/lib/meetings.ts
@@ -22,3 +22,75 @@ export function formatMeetingDate(date: string) {
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,
+ }));
+}