Admin and Gallery and event change
This commit is contained in:
@@ -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
|
||||
@@ -39,3 +39,8 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# admin panel content + uploads (runtime data)
|
||||
/data/
|
||||
|
||||
!.env.example
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user