Event Detail page
This commit is contained in:
@@ -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). */
|
||||
function useDraft<T>(item: T, id: string, onDirty: (id: string, dirty: boolean) => void) {
|
||||
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`} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-xs font-medium text-rotary-navy">
|
||||
Detailed description (shown on the meeting'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
|
||||
isNew={isNew}
|
||||
dirty={dirty}
|
||||
@@ -285,7 +303,9 @@ function MeetingCard({
|
||||
onSave={() => onSave(draft)}
|
||||
onCancel={() => (isNew ? onDiscard(item.id) : reset())}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>
|
||||
>
|
||||
{!isNew && <ViewLink href={`/meetings/${item.id}`} />}
|
||||
</CardActions>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -323,9 +343,19 @@ function EventCard({
|
||||
</label>
|
||||
</div>
|
||||
<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`} />
|
||||
</label>
|
||||
<label className="block text-xs font-medium text-rotary-navy">
|
||||
Detailed description (shown on the event'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
|
||||
isNew={isNew}
|
||||
dirty={dirty}
|
||||
@@ -336,6 +366,7 @@ function EventCard({
|
||||
>
|
||||
{!isNew && (
|
||||
<>
|
||||
<ViewLink href={`/events/${item.id}`} />
|
||||
<button className={btn} disabled={!canUp || busy} onClick={() => onMove(-1)}>
|
||||
↑ Up
|
||||
</button>
|
||||
@@ -366,6 +397,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pastOpen, setPastOpen] = useState(false);
|
||||
const multiRef = useRef<HTMLInputElement>(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 && (
|
||||
<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">
|
||||
Past meetings ({past.length})
|
||||
</summary>
|
||||
<div className="mt-3 space-y-4">
|
||||
{past.map((m) => (
|
||||
<div key={key(m)} id={`meeting-${m.id}`} className="scroll-mt-32">
|
||||
<MeetingCard
|
||||
key={key(m)}
|
||||
item={m}
|
||||
@@ -695,6 +741,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
|
||||
isNext={false}
|
||||
isPast
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
@@ -708,11 +755,14 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<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'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>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
@@ -738,7 +788,7 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
|
||||
))}
|
||||
{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">
|
||||
No events yet. Click “Add event” to create one.
|
||||
No events added by hand yet. Click “Add event” to create one.
|
||||
</p>
|
||||
)}
|
||||
{saved.events.map((ev, i) => (
|
||||
@@ -757,6 +807,41 @@ export default function AdminDashboard({ initial }: { initial: SiteContent }) {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
<Hero />
|
||||
<Gallery photos={content.gallery} />
|
||||
<EventDetails next={getNextMeeting(content.meetings)} />
|
||||
<PastEvents events={content.events} />
|
||||
<PastEvents events={buildPastEvents(content.events, content.meetings)} />
|
||||
<OfficeBearers />
|
||||
<CtaBand />
|
||||
<Footer />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
</Reveal>
|
||||
|
||||
|
||||
@@ -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[] }) {
|
||||
</a>
|
||||
</p>
|
||||
{/* <a
|
||||
href="#top"
|
||||
href="/#top"
|
||||
className="group flex items-center gap-1.5 text-white/60 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
Back to top
|
||||
|
||||
@@ -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[] }) {
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto flex max-w-7xl items-center justify-center px-4 transition-all duration-300 ${
|
||||
scrolled ? "py-2.5" : "py-4"
|
||||
}`}
|
||||
className={`mx-auto flex max-w-7xl items-center px-4 transition-all duration-300 ${
|
||||
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 */}
|
||||
<nav className="hidden items-center gap-7 md:flex">
|
||||
{links.map((link) => (
|
||||
|
||||
@@ -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) => (
|
||||
<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">
|
||||
{e.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"
|
||||
/>
|
||||
)}
|
||||
{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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<h3 className="text-lg font-semibold text-rotary-navy">{e.title}</h3>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-rotary-ink/65">{e.text}</p>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<h3 className="text-lg font-semibold text-rotary-navy transition-colors group-hover:text-rotary-blue">{e.title}</h3>
|
||||
<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>
|
||||
</article>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user