Admin and Gallery and event change

This commit is contained in:
2026-09-24 12:29:46 +05:30
parent a91b957579
commit aea69de410
27 changed files with 1815 additions and 126 deletions
+5
View File
@@ -0,0 +1,5 @@
# Admin panel login — open https://your-site/admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-password
# Long random string used to sign login sessions
ADMIN_SECRET=change-this-to-a-long-random-string
+5
View File
@@ -39,3 +39,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# admin panel content + uploads (runtime data)
/data/
!.env.example
+7 -1
View File
@@ -24,7 +24,13 @@ ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs \
&& mkdir -p /app/data/uploads \
&& chown -R nextjs:nodejs /app/data
# Admin-panel content (content.json + uploaded images) lives here — keep it on a volume.
ENV DATA_DIR=/app/data
VOLUME ["/app/data"]
# Standalone output — requires `output: "standalone"` in next.config.ts (see note below) # Standalone output — requires `output: "standalone"` in next.config.ts (see note below)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+11
View File
@@ -10,6 +10,14 @@ services:
- "9022:3000" - "9022:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATA_DIR=/app/data
# Admin panel login (/admin). Set these in a .env file next to this compose file.
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SECRET=${ADMIN_SECRET}
volumes:
# keeps gallery / meeting / events edits and uploaded photos across rebuilds
- rotary-data:/app/data
# healthcheck: hits the site root to confirm the container is actually serving # healthcheck: hits the site root to confirm the container is actually serving
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
@@ -34,3 +42,6 @@ services:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
- /app/.next - /app/.next
volumes:
rotary-data:
+765
View File
@@ -0,0 +1,765 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { GalleryPhoto, MeetingItem, PastEvent, SiteContent } from "@/lib/content";
import { formatMeetingDate, getNextMeeting, todayIST } from "@/lib/meetings";
type Tab = "gallery" | "meetings" | "events";
type Section = "gallery" | "meetings" | "events";
const newId = () => Math.random().toString(36).slice(2, 10);
const input =
"w-full rounded-xl border border-rotary-navy/15 bg-white px-3 py-2 text-sm outline-none focus:border-rotary-azure focus:ring-2 focus:ring-rotary-azure/20";
const btn =
"rounded-full border border-rotary-navy/15 bg-white px-3 py-1.5 text-xs font-medium text-rotary-navy transition-colors hover:bg-rotary-paper disabled:opacity-40";
const primary =
"rounded-full bg-rotary-gold px-4 py-1.5 text-xs font-semibold text-rotary-navy transition-colors hover:bg-rotary-goldlight disabled:opacity-40";
const danger = `${btn} text-red-600 hover:bg-red-50`;
async function uploadImage(file: File): Promise<string> {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Upload failed");
return data.url as string;
}
function move<T>(list: T[], i: number, dir: -1 | 1): T[] {
const j = i + dir;
if (j < 0 || j >= list.length) return list;
const copy = [...list];
[copy[i], copy[j]] = [copy[j], copy[i]];
return copy;
}
/* ---------- small building blocks ---------- */
function ImagePicker({
value,
onChange,
label,
aspect = "aspect-[16/10]",
removable = false,
}: {
value: string;
onChange: (url: string) => void;
label?: string;
aspect?: string;
removable?: boolean;
}) {
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
return (
<div>
{label && <p className="mb-1.5 text-sm font-medium text-rotary-navy">{label}</p>}
<div className={`relative overflow-hidden rounded-xl bg-rotary-paper ring-1 ring-rotary-navy/10 ${aspect}`}>
{value ? (
<img src={value} alt="" className="h-full w-full object-cover object-top" />
) : (
<div className="flex h-full items-center justify-center text-xs text-rotary-ink/40">No image</div>
)}
<button
type="button"
onClick={() => ref.current?.click()}
disabled={busy}
className="absolute bottom-2 right-2 rounded-full bg-rotary-navy/85 px-3 py-1.5 text-xs font-medium text-white backdrop-blur hover:bg-rotary-navy"
>
{busy ? "Uploading…" : value ? "Change" : "Upload"}
</button>
{removable && value && !busy && (
<button
type="button"
onClick={() => onChange("")}
className="absolute bottom-2 left-2 rounded-full bg-white/90 px-3 py-1.5 text-xs font-medium text-red-600 shadow hover:bg-white"
>
Remove
</button>
)}
</div>
<input
ref={ref}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (!f) return;
setBusy(true);
try {
onChange(await uploadImage(f));
} catch (err) {
alert((err as Error).message);
} finally {
setBusy(false);
}
}}
/>
</div>
);
}
/** Save / Cancel / Delete row shared by every card. */
function CardActions({
isNew,
dirty,
busy,
onSave,
onCancel,
onDelete,
children,
}: {
isNew: boolean;
dirty: boolean;
busy: boolean;
onSave: () => void;
onCancel: () => void;
onDelete: () => void;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-t border-rotary-navy/[0.06] pt-3">
{children}
<span className="ml-auto text-xs text-rotary-ink/50">
{busy ? "Saving…" : isNew ? "Not saved yet" : dirty ? "Unsaved changes" : ""}
</span>
{(dirty || isNew) && (
<>
<button className={btn} disabled={busy} onClick={onCancel}>
{isNew ? "Discard" : "Cancel"}
</button>
<button className={primary} disabled={busy} onClick={onSave}>
Save
</button>
</>
)}
{!isNew && (
<button className={danger} disabled={busy} onClick={onDelete}>
Delete
</button>
)}
</div>
);
}
/** 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);
const setDraft = (patch: Partial<T>) => {
const next = { ...draft, ...patch };
setDraftState(next);
onDirty(id, JSON.stringify(next) !== JSON.stringify(item));
};
const reset = () => {
setDraftState(item);
onDirty(id, false);
};
return { draft, setDraft, reset, dirty: JSON.stringify(draft) !== JSON.stringify(item) };
}
type CardProps<T> = {
item: T;
isNew: boolean;
busy: boolean;
onDirty: (id: string, dirty: boolean) => void;
onSave: (item: T) => void;
onDiscard: (id: string) => void;
onDelete: (item: T) => void;
};
/* ---------- cards ---------- */
function GalleryCard({
item,
isNew,
busy,
onDirty,
onSave,
onDiscard,
onDelete,
canUp,
canDown,
onMove,
}: CardProps<GalleryPhoto> & { canUp: boolean; canDown: boolean; onMove: (dir: -1 | 1) => void }) {
const { draft, setDraft, reset, dirty } = useDraft(item, item.id, onDirty);
return (
<div className={`grid gap-3 rounded-2xl bg-white p-3 shadow-sm ring-1 ${dirty ? "ring-rotary-gold" : "ring-rotary-navy/[0.06]"}`}>
<ImagePicker value={draft.src} onChange={(src) => setDraft({ src })} />
<input value={draft.alt} placeholder="Title (e.g. Blood donation camp)" onChange={(e) => setDraft({ alt: e.target.value })} className={input} />
<input value={draft.date} placeholder="Date (optional, e.g. Aug 2026)" onChange={(e) => setDraft({ date: e.target.value })} className={input} />
<textarea
rows={2}
value={draft.description}
placeholder="What is this photo about? (shown on the back of the card)"
onChange={(e) => setDraft({ description: e.target.value })}
className={input}
/>
<CardActions
isNew={isNew}
dirty={dirty}
busy={busy}
onSave={() => onSave(draft)}
onCancel={() => (isNew ? onDiscard(item.id) : reset())}
onDelete={() => onDelete(item)}
>
{!isNew && (
<>
<button className={btn} disabled={!canUp || busy} onClick={() => onMove(-1)} title="Move earlier">
←
</button>
<button className={btn} disabled={!canDown || busy} onClick={() => onMove(1)} title="Move later">
→
</button>
</>
)}
</CardActions>
</div>
);
}
function MeetingCard({
item,
isNew,
busy,
onDirty,
onSave,
onDiscard,
onDelete,
isNext,
isPast,
}: CardProps<MeetingItem> & { isNext: boolean; isPast: boolean }) {
const { draft, setDraft, reset, dirty } = useDraft(item, item.id, onDirty);
return (
<div
className={`grid gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 sm:grid-cols-[150px_1fr] ${
dirty || isNew ? "ring-2 ring-rotary-gold" : isNext ? "ring-2 ring-rotary-azure/60" : "ring-rotary-navy/[0.06]"
} ${isPast ? "opacity-75" : ""}`}
>
<ImagePicker value={draft.poster} removable aspect="aspect-[4/5]" onChange={(poster) => setDraft({ poster })} />
<div className="grid content-start gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-rotary-navy">{formatMeetingDate(draft.date)}</span>
{isNext && (
<span className="rounded-full bg-rotary-azure px-2.5 py-0.5 text-[11px] font-semibold text-white">
Next · shown on website
</span>
)}
{isPast && (
<span className="rounded-full bg-rotary-navy/10 px-2.5 py-0.5 text-[11px] font-semibold text-rotary-navy/70">
Completed
</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-[170px_140px_1fr]">
<label className="block text-xs font-medium text-rotary-navy">
Date
<input type="date" value={draft.date} onChange={(e) => e.target.value && setDraft({ date: e.target.value })} className={`${input} mt-1`} />
</label>
<label className="block text-xs font-medium text-rotary-navy">
Time
<input value={draft.time} placeholder="7:30 PM" onChange={(e) => setDraft({ time: e.target.value })} className={`${input} mt-1`} />
</label>
<label className="block text-xs font-medium text-rotary-navy">
Topic / speaker
<input value={draft.topic} placeholder="e.g. Talk on road safety" onChange={(e) => setDraft({ topic: e.target.value })} className={`${input} mt-1`} />
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block text-xs font-medium text-rotary-navy">
Venue
<input value={draft.venue} placeholder="e.g. Ladies Recreation Club, Anna Salai" onChange={(e) => setDraft({ venue: e.target.value })} className={`${input} mt-1`} />
</label>
<label className="block text-xs font-medium text-rotary-navy">
Landmark
<input value={draft.landmark ?? ""} placeholder="e.g. Near Mummy Hospital" onChange={(e) => setDraft({ landmark: e.target.value })} className={`${input} mt-1`} />
</label>
</div>
<CardActions
isNew={isNew}
dirty={dirty}
busy={busy}
onSave={() => onSave(draft)}
onCancel={() => (isNew ? onDiscard(item.id) : reset())}
onDelete={() => onDelete(item)}
/>
</div>
</div>
);
}
function EventCard({
item,
isNew,
busy,
onDirty,
onSave,
onDiscard,
onDelete,
canUp,
canDown,
onMove,
}: CardProps<PastEvent> & { canUp: boolean; canDown: boolean; onMove: (dir: -1 | 1) => void }) {
const { draft, setDraft, reset, dirty } = useDraft(item, item.id, onDirty);
return (
<div
className={`grid gap-5 rounded-2xl bg-white p-4 shadow-sm ring-1 sm:grid-cols-[240px_1fr] ${
dirty || isNew ? "ring-2 ring-rotary-gold" : "ring-rotary-navy/[0.06]"
}`}
>
<ImagePicker value={draft.image} onChange={(image) => setDraft({ image })} />
<div className="grid content-start gap-3">
<div className="grid gap-3 sm:grid-cols-[1fr_180px]">
<label className="block text-xs font-medium text-rotary-navy">
Title
<input value={draft.title} onChange={(e) => setDraft({ title: e.target.value })} className={`${input} mt-1`} />
</label>
<label className="block text-xs font-medium text-rotary-navy">
Date
<input value={draft.date} placeholder="e.g. 12 Aug 2026" onChange={(e) => setDraft({ date: e.target.value })} className={`${input} mt-1`} />
</label>
</div>
<label className="block text-xs font-medium text-rotary-navy">
Description
<textarea rows={2} value={draft.text} onChange={(e) => setDraft({ text: e.target.value })} className={`${input} mt-1`} />
</label>
<CardActions
isNew={isNew}
dirty={dirty}
busy={busy}
onSave={() => onSave(draft)}
onCancel={() => (isNew ? onDiscard(item.id) : reset())}
onDelete={() => onDelete(item)}
>
{!isNew && (
<>
<button className={btn} disabled={!canUp || busy} onClick={() => onMove(-1)}>
↑ Up
</button>
<button className={btn} disabled={!canDown || busy} onClick={() => onMove(1)}>
↓ Down
</button>
</>
)}
</CardActions>
</div>
</div>
);
}
/* ---------- dashboard ---------- */
export default function AdminDashboard({ initial }: { initial: SiteContent }) {
const router = useRouter();
const [tab, setTab] = useState<Tab>("gallery");
const [saved, setSaved] = useState<SiteContent>(initial);
// items added in the admin but not saved yet
const [pending, setPending] = useState<{ gallery: GalleryPhoto[]; meetings: MeetingItem[]; events: PastEvent[] }>({
gallery: [],
meetings: [],
events: [],
});
const [dirtyIds, setDirtyIds] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null);
const [uploading, setUploading] = useState(false);
const multiRef = useRef<HTMLInputElement>(null);
const unsaved = dirtyIds.size + pending.gallery.length + pending.meetings.length + pending.events.length;
useEffect(() => {
if (!unsaved) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [unsaved]);
const flash = (text: string, error = false) => {
setToast({ text, error });
setTimeout(() => setToast(null), 2500);
};
const onDirty = (id: string, dirty: boolean) =>
setDirtyIds((prev) => {
if (prev.has(id) === dirty) return prev;
const next = new Set(prev);
if (dirty) next.add(id);
else next.delete(id);
return next;
});
/** Save one whole section to the server. Returns true on success. */
async function persist(section: Section, items: unknown[], busy: string, message: string) {
setBusyId(busy);
try {
const res = await fetch("/api/admin/content", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section, items }),
});
if (res.status === 401) {
router.replace("/admin/login");
return false;
}
if (!res.ok) throw new Error();
setSaved((await res.json()) as SiteContent);
flash(message);
return true;
} catch {
flash("Could not save — please try again", true);
return false;
} finally {
setBusyId(null);
}
}
// generic save/delete helpers for one item in a section
function saveItem<T extends { id: string }>(section: Section, item: T, position: "start" | "end" = "end") {
const list = saved[section] as unknown as T[];
const exists = list.some((x) => x.id === item.id);
const items = exists
? list.map((x) => (x.id === item.id ? item : x))
: position === "start"
? [item, ...list]
: [...list, item];
persist(section, items, item.id, "Saved ✓").then((ok) => {
if (!ok) return;
onDirty(item.id, false);
setPending((p) => ({ ...p, [section]: (p[section] as unknown as T[]).filter((x) => x.id !== item.id) }));
});
}
function deleteItem<T extends { id: string }>(section: Section, item: T, label: string) {
if (!confirm(`Delete ${label}? This removes it from the website.`)) return;
const items = (saved[section] as unknown as T[]).filter((x) => x.id !== item.id);
persist(section, items, item.id, "Deleted ✓").then((ok) => ok && onDirty(item.id, false));
}
function discard(section: Section, id: string) {
setPending((p) => ({ ...p, [section]: (p[section] as { id: string }[]).filter((x) => x.id !== id) }));
onDirty(id, false);
}
function reorder<T extends { id: string }>(section: Section, i: number, dir: -1 | 1) {
persist(section, move(saved[section] as unknown as T[], i, dir), "reorder", "Order saved ✓");
}
async function logout() {
if (unsaved && !confirm("You have unsaved changes. Log out anyway?")) return;
await fetch("/api/admin/logout", { method: "POST" });
router.replace("/admin/login");
router.refresh();
}
async function addPhotos(files: FileList) {
setUploading(true);
const added: GalleryPhoto[] = [];
for (const f of Array.from(files)) {
try {
added.push({ id: newId(), src: await uploadImage(f), alt: "", description: "", date: "" });
} catch (err) {
alert(`${f.name}: ${(err as Error).message}`);
}
}
setUploading(false);
if (!added.length) return;
// photos go live straight away; details can be added and saved per photo
await persist("gallery", [...saved.gallery, ...added], "upload", `${added.length} photo(s) added ✓`);
}
function addMeeting() {
const today = todayIST();
const all = [...saved.meetings, ...pending.meetings].sort((a, b) => a.date.localeCompare(b.date));
const latest = all[all.length - 1];
const base = latest && latest.date >= today ? latest.date : today;
const d = new Date(`${base}T00:00:00Z`);
if (latest && latest.date >= today) d.setUTCDate(d.getUTCDate() + 7);
setPending((p) => ({
...p,
meetings: [
...p.meetings,
{
id: newId(),
date: d.toISOString().slice(0, 10),
time: latest?.time ?? "",
topic: "",
venue: latest?.venue ?? "",
landmark: latest?.landmark ?? "",
poster: "",
},
],
}));
}
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();
const next = getNextMeeting(saved.meetings);
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 common = { onDirty } as const;
const key = (x: object) => JSON.stringify(x);
return (
<div className="pb-20">
<header className="sticky top-0 z-40 bg-rotary-navy text-white shadow-lg shadow-rotary-navy/20">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-serif text-lg font-semibold leading-tight">Rotary Club of Vellore</p>
<p className="text-[11px] uppercase tracking-[0.18em] text-rotary-goldlight">Admin panel</p>
</div>
<div className="flex items-center gap-2">
{unsaved > 0 && (
<span className="hidden rounded-full bg-rotary-gold/20 px-3 py-1 text-xs text-rotary-goldlight sm:inline">
{unsaved} unsaved {unsaved === 1 ? "item" : "items"}
</span>
)}
<a href="/" target="_blank" className="rounded-full px-3 py-2 text-xs font-medium text-white/80 hover:text-white">
View site ↗
</a>
<button onClick={logout} className="rounded-full border border-white/20 px-3 py-2 text-xs font-medium hover:bg-white/10">
Log out
</button>
</div>
</div>
<nav className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
tab === t.key ? "border-rotary-gold text-white" : "border-transparent text-white/60 hover:text-white"
}`}
>
{t.label}
</button>
))}
</nav>
</header>
{toast && (
<div
className={`fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2.5 text-sm font-medium shadow-lg ${
toast.error ? "bg-red-600 text-white" : "bg-rotary-navy text-white"
}`}
>
{toast.text}
</div>
)}
<main className="mx-auto max-w-6xl px-4 pt-8">
<p className="mb-6 rounded-xl bg-rotary-azure/10 px-4 py-2.5 text-sm text-rotary-navy">
Each card has its own <b>Save</b> button. Deleting and reordering are saved straight away.
</p>
{/* GALLERY */}
{tab === "gallery" && (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-rotary-navy">Gallery photos</h2>
<p className="text-sm text-rotary-ink/60">
New photos go live immediately. Add a title and description — visitors see them on the back of the card.
</p>
</div>
<button
onClick={() => multiRef.current?.click()}
disabled={uploading || busyId === "upload"}
className="rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white hover:bg-rotary-blue disabled:opacity-60"
>
{uploading ? "Uploading…" : "+ Add photos"}
</button>
<input
ref={multiRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={(e) => {
if (e.target.files?.length) addPhotos(e.target.files);
e.target.value = "";
}}
/>
</div>
{saved.gallery.length === 0 && (
<p className="mt-8 rounded-2xl border border-dashed border-rotary-navy/20 p-10 text-center text-sm text-rotary-ink/50">
No photos yet. Click “Add photos” to upload.
</p>
)}
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{saved.gallery.map((g, i) => (
<GalleryCard
key={key(g)}
item={g}
isNew={false}
busy={busyId === g.id || busyId === "reorder"}
{...common}
onSave={(item) => saveItem("gallery", item)}
onDiscard={(id) => discard("gallery", id)}
onDelete={(item) => deleteItem("gallery", item, "this photo")}
canUp={i > 0}
canDown={i < saved.gallery.length - 1}
onMove={(dir) => reorder<GalleryPhoto>("gallery", i, dir)}
/>
))}
</div>
</section>
)}
{/* MEETINGS */}
{tab === "meetings" && (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-rotary-navy">Meetings</h2>
<p className="text-sm text-rotary-ink/60">
Meetings can be on any day. The website shows only the next upcoming one; with none scheduled it shows
“No upcoming meetings”.
</p>
</div>
<button onClick={addMeeting} className="rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white hover:bg-rotary-blue">
+ Add meeting
</button>
</div>
{pending.meetings.length > 0 && (
<div className="mt-6 space-y-4">
{pending.meetings.map((m) => (
<MeetingCard
key={m.id}
item={m}
isNew
busy={busyId === m.id}
{...common}
onSave={(item) => saveItem("meetings", item)}
onDiscard={(id) => discard("meetings", id)}
onDelete={() => {}}
isNext={false}
isPast={false}
/>
))}
</div>
)}
<h3 className="mt-6 text-sm font-semibold uppercase tracking-wider text-rotary-azure">Upcoming ({upcoming.length})</h3>
{upcoming.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">
No upcoming meetings. Click “Add meeting” to schedule one.
</p>
) : (
<div className="mt-3 space-y-4">
{upcoming.map((m) => (
<MeetingCard
key={key(m)}
item={m}
isNew={false}
busy={busyId === m.id}
{...common}
onSave={(item) => saveItem("meetings", item)}
onDiscard={() => {}}
onDelete={(item) => deleteItem("meetings", item, `the meeting on ${formatMeetingDate(item.date)}`)}
isNext={m.id === next?.id}
isPast={false}
/>
))}
</div>
)}
{past.length > 0 && (
<details className="mt-8">
<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) => (
<MeetingCard
key={key(m)}
item={m}
isNew={false}
busy={busyId === m.id}
{...common}
onSave={(item) => saveItem("meetings", item)}
onDiscard={() => {}}
onDelete={(item) => deleteItem("meetings", item, `the meeting on ${formatMeetingDate(item.date)}`)}
isNext={false}
isPast
/>
))}
</div>
</details>
)}
</section>
)}
{/* PAST EVENTS */}
{tab === "events" && (
<section>
<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>
</div>
<button
onClick={() =>
setPending((p) => ({ ...p, events: [{ id: newId(), image: "", date: "", title: "", text: "" }, ...p.events] }))
}
className="rounded-full bg-rotary-navy px-5 py-2.5 text-sm font-semibold text-white hover:bg-rotary-blue"
>
+ Add event
</button>
</div>
<div className="mt-6 space-y-5">
{pending.events.map((ev) => (
<EventCard
key={ev.id}
item={ev}
isNew
busy={busyId === ev.id}
{...common}
onSave={(item) => (item.title.trim() ? saveItem("events", item, "start") : alert("Please add a title"))}
onDiscard={(id) => discard("events", id)}
onDelete={() => {}}
canUp={false}
canDown={false}
onMove={() => {}}
/>
))}
{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.
</p>
)}
{saved.events.map((ev, i) => (
<EventCard
key={key(ev)}
item={ev}
isNew={false}
busy={busyId === ev.id || busyId === "reorder"}
{...common}
onSave={(item) => (item.title.trim() ? saveItem("events", item) : alert("Please add a title"))}
onDiscard={() => {}}
onDelete={(item) => deleteItem("events", item, `“${item.title}”`)}
canUp={i > 0}
canDown={i < saved.events.length - 1}
onMove={(dir) => reorder<PastEvent>("events", i, dir)}
/>
))}
</div>
</section>
)}
</main>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin · Rotary Club of Vellore",
robots: { index: false, follow: false },
};
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-rotary-paper text-rotary-ink">{children}</div>;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
setLoading(false);
if (res.ok) {
router.replace("/admin");
router.refresh();
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || "Login failed");
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-rotary-navy to-rotary-blue px-4">
<form
onSubmit={submit}
className="w-full max-w-sm rounded-3xl bg-white p-8 shadow-2xl shadow-black/30"
>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-azure">
Admin
</p>
<h1 className="mt-1 font-serif text-2xl font-semibold text-rotary-navy">
Rotary Club of Vellore
</h1>
<label className="mt-6 block text-sm font-medium text-rotary-navy">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
className="mt-1.5 w-full rounded-xl border border-rotary-navy/15 px-3.5 py-2.5 text-sm outline-none focus:border-rotary-azure focus:ring-2 focus:ring-rotary-azure/20"
/>
</label>
<label className="mt-4 block text-sm font-medium text-rotary-navy">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="mt-1.5 w-full rounded-xl border border-rotary-navy/15 px-3.5 py-2.5 text-sm outline-none focus:border-rotary-azure focus:ring-2 focus:ring-rotary-azure/20"
/>
</label>
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="mt-6 w-full rounded-full bg-rotary-gold px-5 py-3 text-sm font-semibold text-rotary-navy transition-colors hover:bg-rotary-goldlight disabled:opacity-60"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</main>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/auth";
import LoginForm from "./LoginForm";
export const dynamic = "force-dynamic";
export default async function LoginPage() {
if (await isAdmin()) redirect("/admin");
return <LoginForm />;
}
+12
View File
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/auth";
import { getContent } from "@/lib/content";
import AdminDashboard from "./AdminDashboard";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
if (!(await isAdmin())) redirect("/admin/login");
const content = await getContent();
return <AdminDashboard initial={content} />;
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { isAdmin } from "@/lib/auth";
import { getContent, sanitizeContent, saveContent, saveSection, type Section } from "@/lib/content";
export async function GET() {
if (!(await isAdmin())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json(await getContent());
}
export async function PUT(req: Request) {
if (!(await isAdmin())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid data" }, { status: 400 });
const content = sanitizeContent(body);
await saveContent(content);
revalidatePath("/");
return NextResponse.json(content);
}
const SECTIONS: Section[] = ["gallery", "meetings", "events"];
/** Save a single section: { section: "gallery" | "meetings" | "events", items: [...] } */
export async function PATCH(req: Request) {
if (!(await isAdmin())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body || !SECTIONS.includes(body.section) || !Array.isArray(body.items)) {
return NextResponse.json({ error: "Invalid data" }, { status: 400 });
}
const content = await saveSection(body.section, body.items);
revalidatePath("/");
return NextResponse.json(content);
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { checkCredentials, createSessionToken, SESSION_COOKIE } from "@/lib/auth";
const isHttps = (req: Request) =>
req.headers.get("x-forwarded-proto") === "https" || new URL(req.url).protocol === "https:";
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const username = typeof body.username === "string" ? body.username : "";
const password = typeof body.password === "string" ? body.password : "";
if (!checkCredentials(username, password)) {
await new Promise((r) => setTimeout(r, 800)); // slow down guessing
return NextResponse.json({ error: "Invalid username or password" }, { status: 401 });
}
const { token, maxAge } = createSessionToken();
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: isHttps(req),
path: "/",
maxAge,
});
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
import { isAdmin } from "@/lib/auth";
import { UPLOAD_DIR } from "@/lib/content";
const TYPES: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
};
const MAX_BYTES = 8 * 1024 * 1024;
export async function POST(req: Request) {
if (!(await isAdmin())) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData().catch(() => null);
const file = form?.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const ext = TYPES[file.type];
if (!ext) return NextResponse.json({ error: "Only JPG, PNG, WEBP or GIF images" }, { status: 400 });
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Image must be under 8 MB" }, { status: 400 });
const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${ext}`;
await fs.mkdir(UPLOAD_DIR, { recursive: true });
await fs.writeFile(path.join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));
return NextResponse.json({ url: `/uploads/${name}` });
}
+22 -1
View File
@@ -23,6 +23,7 @@
--animate-shimmer: shimmer 2.5s linear infinite; --animate-shimmer: shimmer 2.5s linear infinite;
--animate-ring-pulse: ring-pulse 2.4s ease-out infinite; --animate-ring-pulse: ring-pulse 2.4s ease-out infinite;
--animate-shine: shine 3.5s ease-in-out infinite; --animate-shine: shine 3.5s ease-in-out infinite;
--animate-marquee: marquee 40s linear infinite;
} }
@keyframes spin-slow { @keyframes spin-slow {
@@ -73,7 +74,27 @@
} }
} }
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.animate-marquee {
animation: none;
}
}
body { body {
background-color: var(--color-rotary-paper); background-color: var(--color-rotary-paper);
color: var(--color-rotary-ink); color: var(--color-rotary-ink);
} }
html {
scroll-behavior: smooth;
}
section[id] {
scroll-margin-top: 72px;
}
+14 -8
View File
@@ -1,20 +1,26 @@
import Header from "@/components/Header";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import EventDetails from "@/components/EventDetails"; import EventDetails from "@/components/EventDetails";
import Handover from "@/components/Handover"; import PastEvents from "@/components/PastEvents";
import Guests from "@/components/Guests";
import OfficeBearers from "@/components/OfficeBearers"; import OfficeBearers from "@/components/OfficeBearers";
import CtaBand from "@/components/CtaBand"; import CtaBand from "@/components/CtaBand";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import IncomingBoard from "@/components/IncomingBoard"; import Gallery from "@/components/Gallery";
import { getContent } from "@/lib/content";
import { getNextMeeting } from "@/lib/meetings";
export const dynamic = "force-dynamic";
export default async function Page() {
const content = await getContent();
export default function Page() {
return ( return (
<main className="bg-rotary-paper"> <main className="bg-rotary-paper">
<Header />
<Hero /> <Hero />
<IncomingBoard /> <Gallery photos={content.gallery} />
<EventDetails /> <EventDetails next={getNextMeeting(content.meetings)} />
<Handover /> <PastEvents events={content.events} />
<Guests />
<OfficeBearers /> <OfficeBearers />
<CtaBand /> <CtaBand />
<Footer /> <Footer />
+28
View File
@@ -0,0 +1,28 @@
import { promises as fs } from "fs";
import path from "path";
import { UPLOAD_DIR } from "@/lib/content";
const MIME: Record<string, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
};
// Serves images uploaded through the admin panel (stored outside /public so
// they work in the standalone Docker build and survive redeploys).
export async function GET(_req: Request, { params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
if (!/^[\w\-]+\.(jpg|png|webp|gif)$/.test(name)) return new Response("Not found", { status: 404 });
try {
const data = await fs.readFile(path.join(UPLOAD_DIR, name));
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": MIME[name.split(".").pop()!],
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return new Response("Not found", { status: 404 });
}
}
+3 -3
View File
@@ -6,11 +6,11 @@ export default function CtaBand() {
<Reveal> <Reveal>
<div className="relative mx-auto max-w-4xl overflow-hidden rounded-3xl bg-gradient-to-r from-rotary-gold via-rotary-goldlight to-rotary-gold bg-[length:200%_100%] p-8 text-center shadow-xl animate-shimmer sm:p-10"> <div className="relative mx-auto max-w-4xl overflow-hidden rounded-3xl bg-gradient-to-r from-rotary-gold via-rotary-goldlight to-rotary-gold bg-[length:200%_100%] p-8 text-center shadow-xl animate-shimmer sm:p-10">
<p className="font-serif text-xl font-semibold text-rotary-navy sm:text-2xl"> <p className="font-serif text-xl font-semibold text-rotary-navy sm:text-2xl">
Dear Rotarians, your presence is most essential. Together, we create lasting impact.
</p> </p>
<p className="mx-auto mt-2 max-w-md text-sm text-rotary-navy/70"> <p className="mx-auto mt-2 max-w-md text-sm text-rotary-navy/70">
Join the President and Members of Rotary Club of Vellore, with Every act of service, big or small, makes Vellore a better place.
family, for the Installation Ceremony on 3rd July 2026. Be part of the change with the Rotary Club of Vellore.
</p> </p>
</div> </div>
</Reveal> </Reveal>
+7
View File
@@ -0,0 +1,7 @@
export default function EmptyNote({ children }: { children: React.ReactNode }) {
return (
<div className="mt-8 rounded-2xl border border-dashed border-rotary-navy/15 bg-rotary-paper/60 px-6 py-10 text-center">
<p className="text-sm text-rotary-ink/55">{children}</p>
</div>
);
}
+72 -19
View File
@@ -1,29 +1,53 @@
import Image from "next/image"; import Image from "next/image";
import Reveal from "./Reveal"; import Reveal from "./Reveal";
import type { MeetingItem } from "@/lib/content";
import { formatMeetingDate } from "@/lib/meetings";
const DETAILS = [ const DETAIL_META = [
{ {
key: "venue",
label: "Venue", label: "Venue",
value: "Ladies Recreation Club, 19 Anna Salai, Bishop David Nagar, Kosapet, Vellore, Tamil Nadu 632001",
icon: ( icon: (
<><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" /></> <><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" /></>
), ),
}, },
{ {
key: "landmark",
label: "Landmark", label: "Landmark",
value: "Near Mummy Hospital, easily spotted off Anna Salai",
icon: ( icon: (
<path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" /> <path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" />
), ),
}, },
{ {
key: "time",
label: "Meeting Time", label: "Meeting Time",
value: "Every Friday, 7:30 PM onwards",
icon: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" strokeLinecap="round" strokeLinejoin="round" /></>, icon: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" strokeLinecap="round" strokeLinejoin="round" /></>,
}, },
]; ];
export default function EventDetails() { const CALENDAR_ICON = (
<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" />
);
export default function EventDetails({ next }: { next?: MeetingItem }) {
const values: Record<string, string> = {
venue: next?.venue ?? "",
landmark: next?.landmark ?? "",
time: next?.time ?? "",
};
const details = next
? [
{
key: "next",
label: "Next Meeting",
icon: CALENDAR_ICON,
value: [formatMeetingDate(next.date), next.topic].filter(Boolean).join(" · "),
},
...DETAIL_META.map((d) => ({ ...d, value: values[d.key] })),
]
: [];
const poster = next?.poster;
return ( return (
<section id="event" className="bg-rotary-paper px-4 py-6 sm:px-6 sm:py-8 lg:px-8"> <section id="event" className="bg-rotary-paper px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-7xl">
@@ -32,7 +56,7 @@ export default function EventDetails() {
Where we meet Where we meet
</p> </p>
<h2 className="mt-2 text-center text-3xl font-semibold text-rotary-navy sm:text-4xl"> <h2 className="mt-2 text-center text-3xl font-semibold text-rotary-navy sm:text-4xl">
Join us every week Join our next meeting
</h2> </h2>
</Reveal> </Reveal>
@@ -43,7 +67,7 @@ export default function EventDetails() {
<div className="relative w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white"> <div className="relative w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white">
<Image <Image
src="/images/meeting.png" src="/images/meeting.png"
alt="Rotary Club of Vellore weekly meeting" alt="Rotary Club of Vellore meeting"
width={800} width={800}
height={500} height={500}
sizes="(min-width: 1024px) 560px, 100vw" sizes="(min-width: 1024px) 560px, 100vw"
@@ -52,8 +76,13 @@ export default function EventDetails() {
/> />
</div> </div>
{!next && (
<p className="mt-6 text-sm italic text-rotary-ink/55">
No meeting is scheduled right now. Details of the next meeting will be updated soon.
</p>
)}
<ul className="mt-6 space-y-3"> <ul className="mt-6 space-y-3">
{DETAILS.map((d) => ( {details.map((d) => (
<li key={d.label} className="flex items-start gap-3"> <li key={d.label} className="flex items-start gap-3">
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-rotary-blue/[0.08] text-rotary-blue"> <span className="mt-0.5 flex h-7 w-7 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-3.5 w-3.5"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5">
@@ -62,7 +91,7 @@ export default function EventDetails() {
</span> </span>
<p className="text-sm leading-relaxed text-rotary-ink/75"> <p className="text-sm leading-relaxed text-rotary-ink/75">
<span className="font-semibold text-rotary-navy">{d.label}: </span> <span className="font-semibold text-rotary-navy">{d.label}: </span>
{d.value} {d.value || <span className="italic text-rotary-ink/45">To be updated soon</span>}
</p> </p>
</li> </li>
))} ))}
@@ -70,17 +99,41 @@ export default function EventDetails() {
</div> </div>
</Reveal> </Reveal>
{/* Right: map */} {/* Right: next meeting poster, next meeting card, or "no upcoming events" */}
<Reveal delay={120}> <Reveal delay={120}>
<div className="h-72 w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]"> {poster ? (
<iframe <div className="relative h-80 w-full overflow-hidden rounded-[1.5rem] bg-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]">
title="Ladies Recreation Club location map" <Image
src="https://www.google.com/maps?q=12.9102532,79.1310174&hl=en&z=16&output=embed" src={poster}
className="h-full w-full border-0" unoptimized={poster.startsWith("/uploads/")}
loading="lazy" alt={next?.topic || "Upcoming meeting poster"}
referrerPolicy="no-referrer-when-downgrade" fill
/> sizes="(min-width: 1024px) 560px, 100vw"
</div> className="object-contain"
/>
</div>
) : next ? (
<div className="flex h-80 w-full flex-col items-center justify-center rounded-[1.5rem] bg-gradient-to-br from-rotary-navy to-rotary-blue px-6 text-center text-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] sm:h-full sm:min-h-[22rem]">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-goldlight">
Next meeting
</p>
<p className="mt-3 font-serif text-3xl font-semibold">{formatMeetingDate(next.date)}</p>
{next.time && <p className="mt-1 text-white/80">{next.time}</p>}
{next.topic && <p className="mt-4 max-w-sm text-lg text-white/90">{next.topic}</p>}
</div>
) : (
<div className="flex h-80 w-full flex-col items-center justify-center rounded-[1.5rem] border-2 border-dashed border-rotary-navy/15 bg-white px-6 text-center sm:h-full sm:min-h-[22rem]">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-rotary-gold/15 text-rotary-gold">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
{CALENDAR_ICON}
</svg>
</span>
<p className="mt-4 font-serif text-xl font-semibold text-rotary-navy">No upcoming meetings</p>
<p className="mt-1 max-w-xs text-sm text-rotary-ink/55">
The next meeting will be announced here soon. Stay tuned!
</p>
</div>
)}
</Reveal> </Reveal>
</div> </div>
</div> </div>
+8 -18
View File
@@ -2,18 +2,20 @@ import Image from "next/image";
import GearMotif from "./GearMotif"; import GearMotif from "./GearMotif";
const LINKS = [ const LINKS = [
{ href: "#event", label: "Weekly Meeting" }, { href: "#top", label: "Home" },
{ href: "#handover", label: "Handover" }, { href: "#gallery", label: "Gallery" },
{ href: "#guests", label: "Guests" }, { href: "#event", label: "Where We Meet" },
{ href: "#events", label: "Past Events" },
{ href: "#bearers", label: "Office Bearers" }, { href: "#bearers", label: "Office Bearers" },
]; ];
export default function Footer() { export default function Footer({ hidden = [] }: { hidden?: string[] }) {
const links = LINKS.filter((l) => !hidden.includes(l.href));
return ( return (
<footer className="relative overflow-hidden bg-rotary-navy px-4 pb-8 pt-16 text-white sm:px-6 lg:px-8"> <footer className="relative overflow-hidden bg-rotary-navy px-4 pb-8 pt-16 text-white sm:px-6 lg:px-8">
<GearMotif className="pointer-events-none absolute -bottom-16 -right-16 h-64 w-64 text-white/[0.04]" /> <GearMotif className="pointer-events-none absolute -bottom-16 -right-16 h-64 w-64 text-white/[0.04]" />
<div className="relative mx-auto grid max-w-7xl gap-10 border-b border-white/10 pb-10 sm:grid-cols-3"> <div className="relative mx-auto grid max-w-7xl gap-10 border-b border-white/10 pb-10 sm:grid-cols-2">
{/* Brand */} {/* Brand */}
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -42,7 +44,7 @@ export default function Footer() {
Quick links Quick links
</p> </p>
<ul className="mt-4 space-y-2.5"> <ul className="mt-4 space-y-2.5">
{LINKS.map((l) => ( {links.map((l) => (
<li key={l.href}> <li key={l.href}>
<a <a
href={l.href} href={l.href}
@@ -54,18 +56,6 @@ export default function Footer() {
))} ))}
</ul> </ul>
</div> </div>
{/* Contact */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-rotary-goldlight">
The evening
</p>
<ul className="mt-4 space-y-2.5 text-sm text-white/65">
<li>3rd July 2026 · Friday · 6:30 PM</li>
<li>Hotel Khanna, Laya Hall</li>
<li>Anna Salai, Vellore</li>
</ul>
</div>
</div> </div>
<div className="relative mx-auto flex max-w-7xl flex-col-reverse items-center gap-3 pt-6 text-xs text-white/45 sm:flex-row sm:justify-between"> <div className="relative mx-auto flex max-w-7xl flex-col-reverse items-center gap-3 pt-6 text-xs text-white/45 sm:flex-row sm:justify-between">
+217
View File
@@ -0,0 +1,217 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import Reveal from "./Reveal";
import EmptyNote from "./EmptyNote";
import type { GalleryPhoto } from "@/lib/content";
const CARD =
"relative h-52 w-72 shrink-0 sm:h-64 sm:w-[25rem] [perspective:1200px] cursor-pointer text-left outline-none";
/** One photo card: photo on the front, details on the back (flips on hover). Click opens the viewer. */
function FlipCard({ photo, onOpen, hidden }: { photo: GalleryPhoto; onOpen: () => void; hidden?: boolean }) {
const hasDetails = Boolean(photo.alt || photo.description || photo.date);
return (
<button
type="button"
onClick={onOpen}
aria-hidden={hidden}
tabIndex={hidden ? -1 : 0}
aria-label={photo.alt ? `View photo: ${photo.alt}` : "View photo"}
className={`group/card ${CARD}`}
>
<div
className={`relative h-full w-full rounded-2xl shadow-[0_8px_24px_-12px_rgba(11,37,69,0.35)] transition-transform duration-700 [transform-style:preserve-3d] ${
hasDetails
? "[@media(hover:hover)]:group-hover/card:[transform:rotateY(180deg)] group-focus-visible/card:[transform:rotateY(180deg)]"
: ""
}`}
>
{/* front */}
<div className="absolute inset-0 overflow-hidden rounded-2xl bg-rotary-paper ring-1 ring-rotary-navy/[0.06] [backface-visibility:hidden]">
<Image
src={photo.src}
alt={hidden ? "" : photo.alt || "Rotary Club of Vellore photo"}
fill
sizes="(min-width: 640px) 400px, 288px"
unoptimized={photo.src.startsWith("/uploads/")}
className="object-cover object-top"
/>
{photo.alt && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-rotary-navy/85 to-transparent px-4 pb-3 pt-10 [@media(hover:hover)]:hidden">
<p className="truncate text-sm font-semibold text-white">{photo.alt}</p>
</div>
)}
</div>
{/* back */}
{hasDetails && (
<div className="absolute inset-0 flex flex-col justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-rotary-navy to-rotary-blue p-6 text-white [backface-visibility:hidden] [transform:rotateY(180deg)]">
{photo.date && (
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-rotary-goldlight">{photo.date}</p>
)}
{photo.alt && <p className="mt-1 font-serif text-xl font-semibold leading-snug sm:text-2xl">{photo.alt}</p>}
{photo.description && (
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-white/80">{photo.description}</p>
)}
<span className="mt-5 inline-flex items-center gap-1.5 text-xs font-semibold text-rotary-goldlight">
Click to view
<svg className="h-3.5 w-3.5" 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>
</button>
);
}
function Lightbox({
photos,
index,
onClose,
onGo,
}: {
photos: GalleryPhoto[];
index: number;
onClose: () => void;
onGo: (i: number) => void;
}) {
const photo = photos[index];
const many = photos.length > 1;
const go = useCallback(
(dir: number) => onGo((index + dir + photos.length) % photos.length),
[index, onGo, photos.length],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (many && e.key === "ArrowRight") go(1);
if (many && e.key === "ArrowLeft") go(-1);
};
window.addEventListener("keydown", onKey);
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = overflow;
};
}, [go, many, onClose]);
const arrow = (dir: number, label: string, d: string, pos: string) => (
<button
type="button"
aria-label={label}
onClick={(e) => {
e.stopPropagation();
go(dir);
}}
className={`absolute top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition-colors hover:bg-rotary-gold hover:text-rotary-navy ${pos}`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
return (
<div
role="dialog"
aria-modal="true"
aria-label={photo.alt || "Photo viewer"}
onClick={onClose}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-rotary-navy/95 p-4 backdrop-blur-sm animate-fade-up"
>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
{many && arrow(-1, "Previous photo", "M15 6l-6 6 6 6", "left-3 sm:left-6")}
{many && arrow(1, "Next photo", "M9 6l6 6-6 6", "right-3 sm:right-6")}
<figure className="flex max-h-full w-full max-w-5xl flex-col items-center" onClick={(e) => e.stopPropagation()}>
<img
key={photo.id}
src={photo.src}
alt={photo.alt || "Rotary Club of Vellore photo"}
className="max-h-[72vh] w-auto max-w-full rounded-xl object-contain shadow-2xl"
/>
{(photo.alt || photo.description || photo.date) && (
<figcaption className="mt-5 max-w-2xl text-center text-white">
{photo.date && (
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-rotary-goldlight">{photo.date}</p>
)}
{photo.alt && <p className="mt-1 font-serif text-xl font-semibold sm:text-2xl">{photo.alt}</p>}
{photo.description && <p className="mt-2 text-sm leading-relaxed text-white/75">{photo.description}</p>}
</figcaption>
)}
{many && (
<p className="mt-3 text-xs text-white/40">
{index + 1} / {photos.length}
</p>
)}
</figure>
</div>
);
}
export default function Gallery({ photos }: { photos: GalleryPhoto[] }) {
const [open, setOpen] = useState<number | null>(null);
// Up to 3 photos: a still, centered row. More than 3: auto-scroll.
const slide = photos.length > 3;
// Repeat short lists so the strip is always wider than the screen, then
// duplicate it so the loop is seamless (animation moves -50%).
const base = slide ? Array.from({ length: Math.ceil(8 / photos.length) }, () => photos).flat() : photos;
const loop = slide ? [...base, ...base] : base;
return (
<section id="gallery" className="overflow-hidden bg-white py-10 sm:py-14">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<Reveal>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-azure">Gallery</p>
<h2 className="mt-2 font-serif text-3xl font-semibold text-rotary-navy sm:text-4xl">Moments of Service</h2>
</Reveal>
{photos.length === 0 && <EmptyNote>Photos from our club activities will be shared here soon.</EmptyNote>}
</div>
{photos.length > 0 && !slide && (
<div className="mx-auto mt-8 flex max-w-7xl flex-wrap justify-center gap-6 px-4 sm:px-6 lg:px-8">
{photos.map((p, i) => (
<FlipCard key={p.id} photo={p} onOpen={() => setOpen(i)} />
))}
</div>
)}
{slide && (
<div className="group/strip relative mt-5 py-3 [mask-image:linear-gradient(to_right,transparent,black_6%,black_94%,transparent)]">
<div
className="flex w-max animate-marquee group-hover/strip:[animation-play-state:paused] group-focus-within/strip:[animation-play-state:paused]"
style={{ animationDuration: `${base.length * 5}s` }}
>
{loop.map((p, i) => (
<div key={`${p.id}-${i}`} className="mr-6">
<FlipCard photo={p} hidden={i >= photos.length} onOpen={() => setOpen(i % photos.length)} />
</div>
))}
</div>
</div>
)}
{open !== null && photos[open] && (
<Lightbox photos={photos} index={open} onClose={() => setOpen(null)} onGo={setOpen} />
)}
</section>
);
}
+16 -49
View File
@@ -1,16 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
const NAV_LINKS = [ const NAV_LINKS = [
{ href: "#event", label: "The Ceremony" }, { href: "#top", label: "Home" },
{ href: "#handover", label: "Handover" }, { href: "#gallery", label: "Gallery" },
{ href: "#guests", label: "Guests" }, { href: "#event", label: "Where We Meet" },
{ href: "#events", label: "Past Events" },
{ href: "#bearers", label: "Office Bearers" }, { href: "#bearers", label: "Office Bearers" },
]; ];
export default function Header() { export default function Header({ hidden = [] }: { hidden?: string[] }) {
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);
@@ -30,34 +31,13 @@ export default function Header() {
}`} }`}
> >
<div <div
className={`mx-auto flex max-w-7xl items-center justify-between px-4 transition-all duration-300 ${ className={`mx-auto flex max-w-7xl items-center justify-center px-4 transition-all duration-300 ${
scrolled ? "py-2.5" : "py-4" scrolled ? "py-2.5" : "py-4"
}`} }`}
> >
{/* Brand */} {/* Desktop nav */}
<a href="#top" className="group flex items-center gap-3"> <nav className="hidden items-center gap-7 md:flex">
<span className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rotary-gold/15 ring-1 ring-rotary-gold/40 transition-transform duration-300 group-hover:scale-105 sm:h-10 sm:w-10"> {links.map((link) => (
<Image
src="/images/rotary-logo.png"
alt="Rotary International District 3231"
width={28}
height={28}
className="h-6 w-6 object-contain sm:h-7 sm:w-7"
/>
</span>
<span className="flex flex-col leading-tight">
<span className="font-serif text-[15px] font-semibold tracking-tight text-white sm:text-base">
Rotary Club of Vellore
</span>
<span className="text-[11px] uppercase tracking-[0.18em] text-rotary-goldlight/90">
District 3231 · Est. 1948
</span>
</span>
</a>
{/* Desktop nav (hidden) */}
<nav className="hidden">
{NAV_LINKS.map((link) => (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
@@ -67,20 +47,14 @@ export default function Header() {
<span className="absolute -bottom-0.5 left-0 h-[2px] w-full origin-left scale-x-0 bg-rotary-gold transition-transform duration-300 ease-out group-hover:scale-x-100" /> <span className="absolute -bottom-0.5 left-0 h-[2px] w-full origin-left scale-x-0 bg-rotary-gold transition-transform duration-300 ease-out group-hover:scale-x-100" />
</a> </a>
))} ))}
<a
href="#event"
className="rounded-full bg-rotary-gold px-4 py-2 text-sm font-semibold text-rotary-navy shadow-sm transition-all duration-300 hover:-translate-y-0.5 hover:bg-rotary-goldlight hover:shadow-md active:translate-y-0"
>
RSVP
</a>
</nav> </nav>
{/* Mobile toggle (hidden) */} {/* Mobile toggle */}
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-label="Toggle menu" aria-label="Toggle menu"
aria-expanded={open} aria-expanded={open}
className="hidden" className="flex h-10 w-10 flex-col items-center justify-center gap-1.5 md:hidden"
> >
<span <span
className={`h-0.5 w-6 bg-white transition-transform duration-300 ${ className={`h-0.5 w-6 bg-white transition-transform duration-300 ${
@@ -100,10 +74,10 @@ export default function Header() {
</button> </button>
</div> </div>
{/* Mobile menu (hidden) */} {/* Mobile menu */}
<div className="hidden"> <div className={`overflow-hidden transition-[max-height] duration-300 md:hidden ${open ? "max-h-96" : "max-h-0"}`}>
<nav className="flex flex-col gap-1 border-t border-white/10 bg-rotary-navy px-4 pb-4 pt-2 sm:px-6"> <nav className="flex flex-col items-center gap-1 border-t border-white/10 bg-rotary-navy px-4 pb-4 pt-2 sm:px-6">
{NAV_LINKS.map((link) => ( {links.map((link) => (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
@@ -113,13 +87,6 @@ export default function Header() {
{link.label} {link.label}
</a> </a>
))} ))}
<a
href="#event"
onClick={() => setOpen(false)}
className="mt-2 rounded-full bg-rotary-gold px-4 py-2.5 text-center text-sm font-semibold text-rotary-navy"
>
RSVP
</a>
</nav> </nav>
</div> </div>
</header> </header>
+26 -27
View File
@@ -1,6 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import GearMotif from "./GearMotif"; import GearMotif from "./GearMotif";
import Countdown from "./Countdown";
export default function Hero() { export default function Hero() {
return ( return (
@@ -71,36 +70,36 @@ export default function Hero() {
</p> </p>
<h1 className="mt-4 font-serif text-4xl font-semibold leading-[1.08] tracking-tight sm:text-5xl lg:text-6xl"> <h1 className="mt-4 font-serif text-4xl font-semibold leading-[1.08] tracking-tight sm:text-5xl lg:text-6xl">
Installation Serving Vellore
<span className="block text-rotary-goldlight">Ceremony 2026 – 27</span> <span className="block text-rotary-goldlight">Since 1948</span>
</h1> </h1>
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-white/75 sm:text-base"> <p className="mt-5 max-w-lg text-[15px] leading-relaxed text-white/75 sm:text-base">
The President and Members cordially invite you, with family, to For over seven decades, the Rotary Club of Vellore has brought
witness the installation of the incoming Board and to together people of action to serve our community through health,
<span className="text-white"> Create Lasting Impact</span> together. education and humanitarian projects — guided by
<span className="text-white"> Service Above Self</span>.
</p> </p>
{/* date / venue chips */} {/* club highlights */}
<div className="mt-7 flex flex-wrap gap-3"> <div className="mt-7 grid max-w-lg grid-cols-3 gap-3">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm"> {[
<svg className="h-4 w-4 text-rotary-gold" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> { value: "1948", label: "Chartered" },
<rect x="3" y="5" width="18" height="16" rx="2" /> { value: "3231", label: "Rotary District" },
<path d="M16 3v4M8 3v4M3 10h18" strokeLinecap="round" /> { value: "78+", label: "Years of service" },
</svg> ].map((s) => (
3 July 2026 · Friday · 6:30 PM <div
</div> key={s.label}
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm"> className="rounded-2xl border border-white/15 bg-white/5 px-3 py-4 text-center"
<svg className="h-4 w-4 text-rotary-gold" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> >
<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" /> <p className="font-serif text-2xl font-semibold text-rotary-goldlight sm:text-3xl">
<circle cx="12" cy="9.5" r="2.5" /> {s.value}
</svg> </p>
Hotel Khanna, Laya Hall, Vellore <p className="mt-1 text-[11px] uppercase tracking-wider text-white/60">
</div> {s.label}
</div> </p>
</div>
<div className="empty:mt-0 mt-8"> ))}
<Countdown />
</div> </div>
</div> </div>
+153
View File
@@ -0,0 +1,153 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import Reveal from "./Reveal";
import EmptyNote from "./EmptyNote";
import type { PastEvent } from "@/lib/content";
const AUTOPLAY_MS = 4500;
function usePerView() {
const [perView, setPerView] = useState(1);
useEffect(() => {
const update = () =>
setPerView(window.innerWidth >= 1024 ? 3 : window.innerWidth >= 640 ? 2 : 1);
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return perView;
}
export default function PastEvents({ events }: { events: PastEvent[] }) {
const perView = usePerView();
const maxIndex = Math.max(0, events.length - perView);
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const touchX = useRef<number | null>(null);
const go = useCallback(
(dir: number) =>
setIndex((prev) => { const i = Math.min(prev, maxIndex); return (i + dir > maxIndex ? 0 : i + dir < 0 ? maxIndex : i + dir); }),
[maxIndex],
);
const current = Math.min(index, maxIndex);
useEffect(() => {
if (paused) return;
const t = setInterval(() => go(1), AUTOPLAY_MS);
return () => clearInterval(t);
}, [go, paused]);
const arrow = (dir: number, label: string, d: string) => (
<button
type="button"
aria-label={label}
onClick={() => go(dir)}
className="flex h-10 w-10 items-center justify-center rounded-full border border-rotary-navy/10 bg-white text-rotary-navy shadow-sm transition-colors duration-300 hover:border-rotary-gold hover:bg-rotary-gold"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
return (
<section id="events" className="bg-white px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
<div className="mx-auto max-w-7xl">
<Reveal>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-azure">
Past Events
</p>
<h2 className="mt-2 text-3xl font-semibold text-rotary-navy sm:text-4xl">
Service in action
</h2>
</div>
{events.length > perView && (
<div className="flex gap-2">
{arrow(-1, "Previous events", "M15 6l-6 6 6 6")}
{arrow(1, "Next events", "M9 6l6 6-6 6")}
</div>
)}
</div>
</Reveal>
{events.length === 0 ? (
<EmptyNote>Our past events and service projects will be updated here soon.</EmptyNote>
) : (
<Reveal delay={100}>
<div
className="-mx-3 mt-8 overflow-hidden"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={(e) => {
touchX.current = e.touches[0].clientX;
setPaused(true);
}}
onTouchEnd={(e) => {
if (touchX.current !== null) {
const dx = e.changedTouches[0].clientX - touchX.current;
if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
}
touchX.current = null;
setPaused(false);
}}
>
<div
className="flex transition-transform duration-700 ease-out"
style={{ transform: `translateX(-${(current * 100) / perView}%)` }}
>
{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">
<div className="relative aspect-[16/10] overflow-hidden bg-rotary-paper">
{e.image && (
<Image
src={e.image}
alt={e.title}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
unoptimized={e.image.startsWith("/uploads/")}
className="object-cover object-top transition-transform duration-700 group-hover:scale-105"
/>
)}
{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>
</article>
</div>
))}
</div>
</div>
{/* dots */}
<div className="mt-4 flex justify-center gap-2">
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
<button
key={i}
type="button"
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all duration-300 ${
i === current ? "w-6 bg-rotary-gold" : "w-2 bg-rotary-navy/20 hover:bg-rotary-navy/40"
}`}
/>
))}
</div>
</Reveal>
)}
</div>
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import crypto from "crypto";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "rcv_admin";
const SESSION_HOURS = 12;
const secret = () =>
process.env.ADMIN_SECRET || `${process.env.ADMIN_PASSWORD || "dev"}-rcv-session`;
const sign = (value: string) =>
crypto.createHmac("sha256", secret()).update(value).digest("hex");
const safeEqual = (a: string, b: string) => {
const ha = crypto.createHash("sha256").update(a).digest();
const hb = crypto.createHash("sha256").update(b).digest();
return crypto.timingSafeEqual(ha, hb);
};
export function checkCredentials(username: string, password: string) {
const u = process.env.ADMIN_USERNAME;
const p = process.env.ADMIN_PASSWORD;
if (!u || !p) return false; // admin disabled until credentials are configured
return safeEqual(username, u) && safeEqual(password, p);
}
export function createSessionToken() {
const expires = Date.now() + SESSION_HOURS * 3600 * 1000;
return { token: `${expires}.${sign(String(expires))}`, maxAge: SESSION_HOURS * 3600 };
}
export function verifyToken(token: string | undefined) {
if (!token) return false;
const [exp, sig] = token.split(".");
if (!exp || !sig || Number(exp) < Date.now()) return false;
return safeEqual(sig, sign(exp));
}
export async function isAdmin() {
const store = await cookies();
return verifyToken(store.get(SESSION_COOKIE)?.value);
}
+180
View File
@@ -0,0 +1,180 @@
import { promises as fs } from "fs";
import path from "path";
/** `alt` doubles as the photo title shown on the card's back and in the viewer. */
export type GalleryPhoto = { id: string; src: string; alt: string; description: string; date: string };
export type Meeting = {
venue: string;
landmark: string;
time: string;
photo: string;
poster: string;
};
/** One scheduled meeting (any day of the week). `date` is YYYY-MM-DD. */
export type MeetingItem = {
id: string;
date: string;
time: string;
topic: string;
venue: string;
landmark: string;
poster: string;
};
export type PastEvent = {
id: string;
image: string;
date: string;
title: string;
text: string;
};
export type SiteContent = {
gallery: GalleryPhoto[];
meeting: Meeting;
meetings: MeetingItem[];
events: PastEvent[];
};
// Where content.json and uploaded images live. Mount this folder as a
// Docker volume so edits survive rebuilds.
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
export const UPLOAD_DIR = path.join(DATA_DIR, "uploads");
const CONTENT_FILE = path.join(DATA_DIR, "content.json");
export const DEFAULT_CONTENT: SiteContent = {
gallery: [],
meeting: {
venue:
"Ladies Recreation Club, 19 Anna Salai, Bishop David Nagar, Kosapet, Vellore, Tamil Nadu 632001",
landmark: "Near Mummy Hospital, easily spotted off Anna Salai",
time: "Every Friday, 7:30 PM onwards",
photo: "/images/meeting.png",
poster: "",
},
meetings: [],
events: [],
};
export async function getContent(): Promise<SiteContent> {
try {
const raw = await fs.readFile(CONTENT_FILE, "utf-8");
const data = JSON.parse(raw) as Partial<SiteContent>;
// normalise so older saved files pick up newly added fields
return sanitizeContent({
gallery: Array.isArray(data.gallery) ? data.gallery : DEFAULT_CONTENT.gallery,
meeting: { ...DEFAULT_CONTENT.meeting, ...(data.meeting ?? {}) },
meetings: Array.isArray(data.meetings) ? data.meetings : [],
events: Array.isArray(data.events) ? data.events : DEFAULT_CONTENT.events,
});
} catch {
return DEFAULT_CONTENT;
}
}
const str = (v: unknown, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const img = (v: unknown) => {
const s = str(v, 300);
return /^\/(images|uploads)\/[\w.\-]+$/.test(s) ? s : "";
};
const id = (v: unknown) => str(v, 40) || Math.random().toString(36).slice(2, 10);
/** Validate + normalise untrusted input from the admin panel. */
export function sanitizeContent(input: unknown): SiteContent {
const d = (input ?? {}) as Record<string, unknown>;
const m = (d.meeting ?? {}) as Record<string, unknown>;
const gallery = (Array.isArray(d.gallery) ? d.gallery : [])
.slice(0, 100)
.map((g: Record<string, unknown>) => ({
id: id(g.id),
src: img(g.src),
alt: str(g.alt, 200),
description: str(g.description, 600),
date: str(g.date, 60),
}))
.filter((g) => g.src);
const meetings = (Array.isArray(d.meetings) ? d.meetings : [])
.slice(0, 300)
.map((x: Record<string, unknown>) => ({
id: id(x.id),
date: /^\d{4}-\d{2}-\d{2}$/.test(str(x.date, 10)) ? str(x.date, 10) : "",
time: str(x.time, 40),
topic: str(x.topic, 200),
venue: str(x.venue),
landmark: str(x.landmark),
poster: img(x.poster),
}))
.filter((x) => x.date)
.sort((a, b) => a.date.localeCompare(b.date));
const events = (Array.isArray(d.events) ? d.events : [])
.slice(0, 100)
.map((e: Record<string, unknown>) => ({
id: id(e.id),
image: img(e.image),
date: str(e.date, 60),
title: str(e.title, 150),
text: str(e.text, 600),
}))
.filter((e) => e.title);
return {
gallery,
meeting: {
venue: str(m.venue),
landmark: str(m.landmark),
time: str(m.time, 200),
photo: img(m.photo),
poster: img(m.poster),
},
meetings,
events,
};
}
export async function saveContent(content: SiteContent) {
await fs.mkdir(DATA_DIR, { recursive: true });
const tmp = `${CONTENT_FILE}.${Date.now()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(content, null, 2), "utf-8");
await fs.rename(tmp, CONTENT_FILE);
await removeUnusedUploads(content);
}
export type Section = "gallery" | "meetings" | "events";
/** Replace one section only, keeping everything else as it is on disk. */
export async function saveSection(section: Section, items: unknown) {
const current = await getContent();
const merged = sanitizeContent({ ...current, [section]: items });
const next = { ...current, [section]: merged[section] };
await saveContent(next);
return next;
}
/**
* Delete uploaded files that no saved content points to any more.
* Files younger than 1 hour are kept, so an image uploaded but not yet
* saved is never removed from under the admin.
*/
async function removeUnusedUploads(content: SiteContent) {
const used = new Set(
JSON.stringify(content).match(/\/uploads\/[\w.\-]+/g)?.map((u) => u.slice("/uploads/".length)) ?? [],
);
let files: string[] = [];
try {
files = await fs.readdir(UPLOAD_DIR);
} catch {
return;
}
const cutoff = Date.now() - 60 * 60 * 1000;
await Promise.all(
files
.filter((f) => !used.has(f))
.map(async (f) => {
const full = path.join(UPLOAD_DIR, f);
const stat = await fs.stat(full).catch(() => null);
if (stat?.isFile() && stat.mtimeMs < cutoff) await fs.unlink(full).catch(() => {});
}),
);
}
+24
View File
@@ -0,0 +1,24 @@
// Date helpers shared by the site and the admin panel (no server-only imports).
import type { MeetingItem } from "./content";
/** Today's date in India as YYYY-MM-DD. */
export function todayIST() {
return new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Kolkata" });
}
/** The next meeting on or after today, if any. */
export function getNextMeeting(list: MeetingItem[]): MeetingItem | undefined {
const today = todayIST();
return [...list].filter((m) => m.date >= today).sort((a, b) => a.date.localeCompare(b.date))[0];
}
export function formatMeetingDate(date: string) {
const [y, m, d] = date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-IN", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
timeZone: "UTC",
});
}