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
*.tsbuildinfo
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
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)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+11
View File
@@ -10,6 +10,14 @@ services:
- "9022:3000"
environment:
- 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:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
@@ -34,3 +42,6 @@ services:
- .:/app
- /app/node_modules
- /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-ring-pulse: ring-pulse 2.4s ease-out infinite;
--animate-shine: shine 3.5s ease-in-out infinite;
--animate-marquee: marquee 40s linear infinite;
}
@keyframes spin-slow {
@@ -73,7 +74,27 @@
}
}
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.animate-marquee {
animation: none;
}
}
body {
background-color: var(--color-rotary-paper);
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 EventDetails from "@/components/EventDetails";
import Handover from "@/components/Handover";
import Guests from "@/components/Guests";
import PastEvents from "@/components/PastEvents";
import OfficeBearers from "@/components/OfficeBearers";
import CtaBand from "@/components/CtaBand";
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 (
<main className="bg-rotary-paper">
<Header />
<Hero />
<IncomingBoard />
<EventDetails />
<Handover />
<Guests />
<Gallery photos={content.gallery} />
<EventDetails next={getNextMeeting(content.meetings)} />
<PastEvents events={content.events} />
<OfficeBearers />
<CtaBand />
<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>
<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">
Dear Rotarians, your presence is most essential.
Together, we create lasting impact.
</p>
<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
family, for the Installation Ceremony on 3rd July 2026.
Every act of service, big or small, makes Vellore a better place.
Be part of the change with the Rotary Club of Vellore.
</p>
</div>
</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 Reveal from "./Reveal";
import type { MeetingItem } from "@/lib/content";
import { formatMeetingDate } from "@/lib/meetings";
const DETAILS = [
const DETAIL_META = [
{
key: "venue",
label: "Venue",
value: "Ladies Recreation Club, 19 Anna Salai, Bishop David Nagar, Kosapet, Vellore, Tamil Nadu 632001",
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" /></>
),
},
{
key: "landmark",
label: "Landmark",
value: "Near Mummy Hospital, easily spotted off Anna Salai",
icon: (
<path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" />
),
},
{
key: "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" /></>,
},
];
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 (
<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">
@@ -32,7 +56,7 @@ export default function EventDetails() {
Where we meet
</p>
<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>
</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">
<Image
src="/images/meeting.png"
alt="Rotary Club of Vellore weekly meeting"
alt="Rotary Club of Vellore meeting"
width={800}
height={500}
sizes="(min-width: 1024px) 560px, 100vw"
@@ -52,8 +76,13 @@ export default function EventDetails() {
/>
</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">
{DETAILS.map((d) => (
{details.map((d) => (
<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">
<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>
<p className="text-sm leading-relaxed text-rotary-ink/75">
<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>
</li>
))}
@@ -70,17 +99,41 @@ export default function EventDetails() {
</div>
</Reveal>
{/* Right: map */}
{/* Right: next meeting poster, next meeting card, or "no upcoming events" */}
<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]">
<iframe
title="Ladies Recreation Club location map"
src="https://www.google.com/maps?q=12.9102532,79.1310174&hl=en&z=16&output=embed"
className="h-full w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
{poster ? (
<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]">
<Image
src={poster}
unoptimized={poster.startsWith("/uploads/")}
alt={next?.topic || "Upcoming meeting poster"}
fill
sizes="(min-width: 1024px) 560px, 100vw"
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>
</div>
</div>
+8 -18
View File
@@ -2,18 +2,20 @@ import Image from "next/image";
import GearMotif from "./GearMotif";
const LINKS = [
{ href: "#event", label: "Weekly Meeting" },
{ href: "#handover", label: "Handover" },
{ href: "#guests", label: "Guests" },
{ 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() {
export default function Footer({ hidden = [] }: { hidden?: string[] }) {
const links = LINKS.filter((l) => !hidden.includes(l.href));
return (
<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]" />
<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 */}
<div>
<div className="flex items-center gap-3">
@@ -42,7 +44,7 @@ export default function Footer() {
Quick links
</p>
<ul className="mt-4 space-y-2.5">
{LINKS.map((l) => (
{links.map((l) => (
<li key={l.href}>
<a
href={l.href}
@@ -54,18 +56,6 @@ export default function Footer() {
))}
</ul>
</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 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";
import { useEffect, useState } from "react";
import Image from "next/image";
const NAV_LINKS = [
{ href: "#event", label: "The Ceremony" },
{ href: "#handover", label: "Handover" },
{ href: "#guests", label: "Guests" },
{ 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() {
export default function Header({ hidden = [] }: { hidden?: string[] }) {
const links = NAV_LINKS.filter((l) => !hidden.includes(l.href));
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
@@ -30,34 +31,13 @@ export default function Header() {
}`}
>
<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"
}`}
>
{/* Brand */}
<a href="#top" className="group flex items-center gap-3">
<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">
<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) => (
{/* Desktop nav */}
<nav className="hidden items-center gap-7 md:flex">
{links.map((link) => (
<a
key={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" />
</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>
{/* Mobile toggle (hidden) */}
{/* Mobile toggle */}
<button
onClick={() => setOpen((v) => !v)}
aria-label="Toggle menu"
aria-expanded={open}
className="hidden"
className="flex h-10 w-10 flex-col items-center justify-center gap-1.5 md:hidden"
>
<span
className={`h-0.5 w-6 bg-white transition-transform duration-300 ${
@@ -100,10 +74,10 @@ export default function Header() {
</button>
</div>
{/* Mobile menu (hidden) */}
<div className="hidden">
<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_LINKS.map((link) => (
{/* Mobile menu */}
<div className={`overflow-hidden transition-[max-height] duration-300 md:hidden ${open ? "max-h-96" : "max-h-0"}`}>
<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">
{links.map((link) => (
<a
key={link.href}
href={link.href}
@@ -113,13 +87,6 @@ export default function Header() {
{link.label}
</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>
</div>
</header>
+26 -27
View File
@@ -1,6 +1,5 @@
import Image from "next/image";
import GearMotif from "./GearMotif";
import Countdown from "./Countdown";
export default function Hero() {
return (
@@ -71,36 +70,36 @@ export default function Hero() {
</p>
<h1 className="mt-4 font-serif text-4xl font-semibold leading-[1.08] tracking-tight sm:text-5xl lg:text-6xl">
Installation
<span className="block text-rotary-goldlight">Ceremony 2026 – 27</span>
Serving Vellore
<span className="block text-rotary-goldlight">Since 1948</span>
</h1>
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-white/75 sm:text-base">
The President and Members cordially invite you, with family, to
witness the installation of the incoming Board and to
<span className="text-white"> Create Lasting Impact</span> together.
<p className="mt-5 max-w-lg text-[15px] leading-relaxed text-white/75 sm:text-base">
For over seven decades, the Rotary Club of Vellore has brought
together people of action to serve our community through health,
education and humanitarian projects — guided by
<span className="text-white"> Service Above Self</span>.
</p>
{/* date / venue chips */}
<div className="mt-7 flex flex-wrap 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">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" strokeLinecap="round" />
</svg>
3 July 2026 · Friday · 6:30 PM
</div>
<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">
<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" />
</svg>
Hotel Khanna, Laya Hall, Vellore
</div>
</div>
<div className="empty:mt-0 mt-8">
<Countdown />
{/* club highlights */}
<div className="mt-7 grid max-w-lg grid-cols-3 gap-3">
{[
{ value: "1948", label: "Chartered" },
{ value: "3231", label: "Rotary District" },
{ value: "78+", label: "Years of service" },
].map((s) => (
<div
key={s.label}
className="rounded-2xl border border-white/15 bg-white/5 px-3 py-4 text-center"
>
<p className="font-serif text-2xl font-semibold text-rotary-goldlight sm:text-3xl">
{s.value}
</p>
<p className="mt-1 text-[11px] uppercase tracking-wider text-white/60">
{s.label}
</p>
</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",
});
}