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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 nextjs
|
&& adduser --system --uid 1001 nextjs \
|
||||||
|
&& mkdir -p /app/data/uploads \
|
||||||
|
&& chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
# Admin-panel content (content.json + uploaded images) lives here — keep it on a volume.
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
# Standalone output — requires `output: "standalone"` in next.config.ts (see note below)
|
# Standalone output — requires `output: "standalone"` in next.config.ts (see note below)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ services:
|
|||||||
- "9022:3000"
|
- "9022:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
# Admin panel login (/admin). Set these in a .env file next to this compose file.
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- ADMIN_SECRET=${ADMIN_SECRET}
|
||||||
|
volumes:
|
||||||
|
# keeps gallery / meeting / events edits and uploaded photos across rebuilds
|
||||||
|
- rotary-data:/app/data
|
||||||
# healthcheck: hits the site root to confirm the container is actually serving
|
# healthcheck: hits the site root to confirm the container is actually serving
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
||||||
@@ -34,3 +42,6 @@ services:
|
|||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- /app/.next
|
- /app/.next
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rotary-data:
|
||||||
|
|||||||
@@ -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-shimmer: shimmer 2.5s linear infinite;
|
||||||
--animate-ring-pulse: ring-pulse 2.4s ease-out infinite;
|
--animate-ring-pulse: ring-pulse 2.4s ease-out infinite;
|
||||||
--animate-shine: shine 3.5s ease-in-out infinite;
|
--animate-shine: shine 3.5s ease-in-out infinite;
|
||||||
|
--animate-marquee: marquee 40s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin-slow {
|
@keyframes spin-slow {
|
||||||
@@ -73,7 +74,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-marquee {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-rotary-paper);
|
background-color: var(--color-rotary-paper);
|
||||||
color: var(--color-rotary-ink);
|
color: var(--color-rotary-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
section[id] {
|
||||||
|
scroll-margin-top: 72px;
|
||||||
|
}
|
||||||
|
|||||||
+14
-8
@@ -1,20 +1,26 @@
|
|||||||
|
import Header from "@/components/Header";
|
||||||
import Hero from "@/components/Hero";
|
import Hero from "@/components/Hero";
|
||||||
import EventDetails from "@/components/EventDetails";
|
import EventDetails from "@/components/EventDetails";
|
||||||
import Handover from "@/components/Handover";
|
import PastEvents from "@/components/PastEvents";
|
||||||
import Guests from "@/components/Guests";
|
|
||||||
import OfficeBearers from "@/components/OfficeBearers";
|
import OfficeBearers from "@/components/OfficeBearers";
|
||||||
import CtaBand from "@/components/CtaBand";
|
import CtaBand from "@/components/CtaBand";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import IncomingBoard from "@/components/IncomingBoard";
|
import Gallery from "@/components/Gallery";
|
||||||
|
import { getContent } from "@/lib/content";
|
||||||
|
import { getNextMeeting } from "@/lib/meetings";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const content = await getContent();
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-rotary-paper">
|
<main className="bg-rotary-paper">
|
||||||
|
<Header />
|
||||||
<Hero />
|
<Hero />
|
||||||
<IncomingBoard />
|
<Gallery photos={content.gallery} />
|
||||||
<EventDetails />
|
<EventDetails next={getNextMeeting(content.meetings)} />
|
||||||
<Handover />
|
<PastEvents events={content.events} />
|
||||||
<Guests />
|
|
||||||
<OfficeBearers />
|
<OfficeBearers />
|
||||||
<CtaBand />
|
<CtaBand />
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -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>
|
<Reveal>
|
||||||
<div className="relative mx-auto max-w-4xl overflow-hidden rounded-3xl bg-gradient-to-r from-rotary-gold via-rotary-goldlight to-rotary-gold bg-[length:200%_100%] p-8 text-center shadow-xl animate-shimmer sm:p-10">
|
<div className="relative mx-auto max-w-4xl overflow-hidden rounded-3xl bg-gradient-to-r from-rotary-gold via-rotary-goldlight to-rotary-gold bg-[length:200%_100%] p-8 text-center shadow-xl animate-shimmer sm:p-10">
|
||||||
<p className="font-serif text-xl font-semibold text-rotary-navy sm:text-2xl">
|
<p className="font-serif text-xl font-semibold text-rotary-navy sm:text-2xl">
|
||||||
Dear Rotarians, your presence is most essential.
|
Together, we create lasting impact.
|
||||||
</p>
|
</p>
|
||||||
<p className="mx-auto mt-2 max-w-md text-sm text-rotary-navy/70">
|
<p className="mx-auto mt-2 max-w-md text-sm text-rotary-navy/70">
|
||||||
Join the President and Members of Rotary Club of Vellore, with
|
Every act of service, big or small, makes Vellore a better place.
|
||||||
family, for the Installation Ceremony on 3rd July 2026.
|
Be part of the change with the Rotary Club of Vellore.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -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 Image from "next/image";
|
||||||
import Reveal from "./Reveal";
|
import Reveal from "./Reveal";
|
||||||
|
import type { MeetingItem } from "@/lib/content";
|
||||||
|
import { formatMeetingDate } from "@/lib/meetings";
|
||||||
|
|
||||||
const DETAILS = [
|
const DETAIL_META = [
|
||||||
{
|
{
|
||||||
|
key: "venue",
|
||||||
label: "Venue",
|
label: "Venue",
|
||||||
value: "Ladies Recreation Club, 19 Anna Salai, Bishop David Nagar, Kosapet, Vellore, Tamil Nadu 632001",
|
|
||||||
icon: (
|
icon: (
|
||||||
<><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21Z" strokeLinecap="round" strokeLinejoin="round" /><circle cx="12" cy="9.5" r="2.5" /></>
|
<><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21Z" strokeLinecap="round" strokeLinejoin="round" /><circle cx="12" cy="9.5" r="2.5" /></>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "landmark",
|
||||||
label: "Landmark",
|
label: "Landmark",
|
||||||
value: "Near Mummy Hospital, easily spotted off Anna Salai",
|
|
||||||
icon: (
|
icon: (
|
||||||
<path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M3 21h18M5 21V8l7-5 7 5v13M9 21v-6h6v6M9 12h.01M15 12h.01" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "time",
|
||||||
label: "Meeting Time",
|
label: "Meeting Time",
|
||||||
value: "Every Friday, 7:30 PM onwards",
|
|
||||||
icon: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" strokeLinecap="round" strokeLinejoin="round" /></>,
|
icon: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" strokeLinecap="round" strokeLinejoin="round" /></>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EventDetails() {
|
const CALENDAR_ICON = (
|
||||||
|
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function EventDetails({ next }: { next?: MeetingItem }) {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
venue: next?.venue ?? "",
|
||||||
|
landmark: next?.landmark ?? "",
|
||||||
|
time: next?.time ?? "",
|
||||||
|
};
|
||||||
|
const details = next
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "next",
|
||||||
|
label: "Next Meeting",
|
||||||
|
icon: CALENDAR_ICON,
|
||||||
|
value: [formatMeetingDate(next.date), next.topic].filter(Boolean).join(" · "),
|
||||||
|
},
|
||||||
|
...DETAIL_META.map((d) => ({ ...d, value: values[d.key] })),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const poster = next?.poster;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="event" className="bg-rotary-paper px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
<section id="event" className="bg-rotary-paper px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
@@ -32,7 +56,7 @@ export default function EventDetails() {
|
|||||||
Where we meet
|
Where we meet
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-center text-3xl font-semibold text-rotary-navy sm:text-4xl">
|
<h2 className="mt-2 text-center text-3xl font-semibold text-rotary-navy sm:text-4xl">
|
||||||
Join us every week
|
Join our next meeting
|
||||||
</h2>
|
</h2>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -43,7 +67,7 @@ export default function EventDetails() {
|
|||||||
<div className="relative w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white">
|
<div className="relative w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white">
|
||||||
<Image
|
<Image
|
||||||
src="/images/meeting.png"
|
src="/images/meeting.png"
|
||||||
alt="Rotary Club of Vellore weekly meeting"
|
alt="Rotary Club of Vellore meeting"
|
||||||
width={800}
|
width={800}
|
||||||
height={500}
|
height={500}
|
||||||
sizes="(min-width: 1024px) 560px, 100vw"
|
sizes="(min-width: 1024px) 560px, 100vw"
|
||||||
@@ -52,8 +76,13 @@ export default function EventDetails() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!next && (
|
||||||
|
<p className="mt-6 text-sm italic text-rotary-ink/55">
|
||||||
|
No meeting is scheduled right now. Details of the next meeting will be updated soon.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<ul className="mt-6 space-y-3">
|
<ul className="mt-6 space-y-3">
|
||||||
{DETAILS.map((d) => (
|
{details.map((d) => (
|
||||||
<li key={d.label} className="flex items-start gap-3">
|
<li key={d.label} className="flex items-start gap-3">
|
||||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-rotary-blue/[0.08] text-rotary-blue">
|
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-rotary-blue/[0.08] text-rotary-blue">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5">
|
||||||
@@ -62,7 +91,7 @@ export default function EventDetails() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="text-sm leading-relaxed text-rotary-ink/75">
|
<p className="text-sm leading-relaxed text-rotary-ink/75">
|
||||||
<span className="font-semibold text-rotary-navy">{d.label}: </span>
|
<span className="font-semibold text-rotary-navy">{d.label}: </span>
|
||||||
{d.value}
|
{d.value || <span className="italic text-rotary-ink/45">To be updated soon</span>}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -70,17 +99,41 @@ export default function EventDetails() {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Right: map */}
|
{/* Right: next meeting poster, next meeting card, or "no upcoming events" */}
|
||||||
<Reveal delay={120}>
|
<Reveal delay={120}>
|
||||||
<div className="h-72 w-full overflow-hidden rounded-[1.5rem] shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]">
|
{poster ? (
|
||||||
<iframe
|
<div className="relative h-80 w-full overflow-hidden rounded-[1.5rem] bg-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] ring-4 ring-white sm:h-full sm:min-h-[22rem]">
|
||||||
title="Ladies Recreation Club location map"
|
<Image
|
||||||
src="https://www.google.com/maps?q=12.9102532,79.1310174&hl=en&z=16&output=embed"
|
src={poster}
|
||||||
className="h-full w-full border-0"
|
unoptimized={poster.startsWith("/uploads/")}
|
||||||
loading="lazy"
|
alt={next?.topic || "Upcoming meeting poster"}
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
fill
|
||||||
/>
|
sizes="(min-width: 1024px) 560px, 100vw"
|
||||||
</div>
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : next ? (
|
||||||
|
<div className="flex h-80 w-full flex-col items-center justify-center rounded-[1.5rem] bg-gradient-to-br from-rotary-navy to-rotary-blue px-6 text-center text-white shadow-[0_20px_50px_-20px_rgba(3,20,58,0.35)] sm:h-full sm:min-h-[22rem]">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-rotary-goldlight">
|
||||||
|
Next meeting
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 font-serif text-3xl font-semibold">{formatMeetingDate(next.date)}</p>
|
||||||
|
{next.time && <p className="mt-1 text-white/80">{next.time}</p>}
|
||||||
|
{next.topic && <p className="mt-4 max-w-sm text-lg text-white/90">{next.topic}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-80 w-full flex-col items-center justify-center rounded-[1.5rem] border-2 border-dashed border-rotary-navy/15 bg-white px-6 text-center sm:h-full sm:min-h-[22rem]">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-rotary-gold/15 text-rotary-gold">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
|
||||||
|
{CALENDAR_ICON}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<p className="mt-4 font-serif text-xl font-semibold text-rotary-navy">No upcoming meetings</p>
|
||||||
|
<p className="mt-1 max-w-xs text-sm text-rotary-ink/55">
|
||||||
|
The next meeting will be announced here soon. Stay tuned!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import Image from "next/image";
|
|||||||
import GearMotif from "./GearMotif";
|
import GearMotif from "./GearMotif";
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: "#event", label: "Weekly Meeting" },
|
{ href: "#top", label: "Home" },
|
||||||
{ href: "#handover", label: "Handover" },
|
{ href: "#gallery", label: "Gallery" },
|
||||||
{ href: "#guests", label: "Guests" },
|
{ href: "#event", label: "Where We Meet" },
|
||||||
|
{ href: "#events", label: "Past Events" },
|
||||||
{ href: "#bearers", label: "Office Bearers" },
|
{ href: "#bearers", label: "Office Bearers" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer({ hidden = [] }: { hidden?: string[] }) {
|
||||||
|
const links = LINKS.filter((l) => !hidden.includes(l.href));
|
||||||
return (
|
return (
|
||||||
<footer className="relative overflow-hidden bg-rotary-navy px-4 pb-8 pt-16 text-white sm:px-6 lg:px-8">
|
<footer className="relative overflow-hidden bg-rotary-navy px-4 pb-8 pt-16 text-white sm:px-6 lg:px-8">
|
||||||
<GearMotif className="pointer-events-none absolute -bottom-16 -right-16 h-64 w-64 text-white/[0.04]" />
|
<GearMotif className="pointer-events-none absolute -bottom-16 -right-16 h-64 w-64 text-white/[0.04]" />
|
||||||
|
|
||||||
<div className="relative mx-auto grid max-w-7xl gap-10 border-b border-white/10 pb-10 sm:grid-cols-3">
|
<div className="relative mx-auto grid max-w-7xl gap-10 border-b border-white/10 pb-10 sm:grid-cols-2">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -42,7 +44,7 @@ export default function Footer() {
|
|||||||
Quick links
|
Quick links
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-4 space-y-2.5">
|
<ul className="mt-4 space-y-2.5">
|
||||||
{LINKS.map((l) => (
|
{links.map((l) => (
|
||||||
<li key={l.href}>
|
<li key={l.href}>
|
||||||
<a
|
<a
|
||||||
href={l.href}
|
href={l.href}
|
||||||
@@ -54,18 +56,6 @@ export default function Footer() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-rotary-goldlight">
|
|
||||||
The evening
|
|
||||||
</p>
|
|
||||||
<ul className="mt-4 space-y-2.5 text-sm text-white/65">
|
|
||||||
<li>3rd July 2026 · Friday · 6:30 PM</li>
|
|
||||||
<li>Hotel Khanna, Laya Hall</li>
|
|
||||||
<li>Anna Salai, Vellore</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto flex max-w-7xl flex-col-reverse items-center gap-3 pt-6 text-xs text-white/45 sm:flex-row sm:justify-between">
|
<div className="relative mx-auto flex max-w-7xl flex-col-reverse items-center gap-3 pt-6 text-xs text-white/45 sm:flex-row sm:justify-between">
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: "#event", label: "The Ceremony" },
|
{ href: "#top", label: "Home" },
|
||||||
{ href: "#handover", label: "Handover" },
|
{ href: "#gallery", label: "Gallery" },
|
||||||
{ href: "#guests", label: "Guests" },
|
{ href: "#event", label: "Where We Meet" },
|
||||||
|
{ href: "#events", label: "Past Events" },
|
||||||
{ href: "#bearers", label: "Office Bearers" },
|
{ href: "#bearers", label: "Office Bearers" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header({ hidden = [] }: { hidden?: string[] }) {
|
||||||
|
const links = NAV_LINKS.filter((l) => !hidden.includes(l.href));
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -30,34 +31,13 @@ export default function Header() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`mx-auto flex max-w-7xl items-center justify-between px-4 transition-all duration-300 ${
|
className={`mx-auto flex max-w-7xl items-center justify-center px-4 transition-all duration-300 ${
|
||||||
scrolled ? "py-2.5" : "py-4"
|
scrolled ? "py-2.5" : "py-4"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Brand */}
|
{/* Desktop nav */}
|
||||||
<a href="#top" className="group flex items-center gap-3">
|
<nav className="hidden items-center gap-7 md:flex">
|
||||||
<span className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rotary-gold/15 ring-1 ring-rotary-gold/40 transition-transform duration-300 group-hover:scale-105 sm:h-10 sm:w-10">
|
{links.map((link) => (
|
||||||
<Image
|
|
||||||
src="/images/rotary-logo.png"
|
|
||||||
alt="Rotary International District 3231"
|
|
||||||
width={28}
|
|
||||||
height={28}
|
|
||||||
className="h-6 w-6 object-contain sm:h-7 sm:w-7"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-col leading-tight">
|
|
||||||
<span className="font-serif text-[15px] font-semibold tracking-tight text-white sm:text-base">
|
|
||||||
Rotary Club of Vellore
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] uppercase tracking-[0.18em] text-rotary-goldlight/90">
|
|
||||||
District 3231 · Est. 1948
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Desktop nav (hidden) */}
|
|
||||||
<nav className="hidden">
|
|
||||||
{NAV_LINKS.map((link) => (
|
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -67,20 +47,14 @@ export default function Header() {
|
|||||||
<span className="absolute -bottom-0.5 left-0 h-[2px] w-full origin-left scale-x-0 bg-rotary-gold transition-transform duration-300 ease-out group-hover:scale-x-100" />
|
<span className="absolute -bottom-0.5 left-0 h-[2px] w-full origin-left scale-x-0 bg-rotary-gold transition-transform duration-300 ease-out group-hover:scale-x-100" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a
|
|
||||||
href="#event"
|
|
||||||
className="rounded-full bg-rotary-gold px-4 py-2 text-sm font-semibold text-rotary-navy shadow-sm transition-all duration-300 hover:-translate-y-0.5 hover:bg-rotary-goldlight hover:shadow-md active:translate-y-0"
|
|
||||||
>
|
|
||||||
RSVP
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile toggle (hidden) */}
|
{/* Mobile toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="hidden"
|
className="flex h-10 w-10 flex-col items-center justify-center gap-1.5 md:hidden"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-0.5 w-6 bg-white transition-transform duration-300 ${
|
className={`h-0.5 w-6 bg-white transition-transform duration-300 ${
|
||||||
@@ -100,10 +74,10 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu (hidden) */}
|
{/* Mobile menu */}
|
||||||
<div className="hidden">
|
<div className={`overflow-hidden transition-[max-height] duration-300 md:hidden ${open ? "max-h-96" : "max-h-0"}`}>
|
||||||
<nav className="flex flex-col gap-1 border-t border-white/10 bg-rotary-navy px-4 pb-4 pt-2 sm:px-6">
|
<nav className="flex flex-col items-center gap-1 border-t border-white/10 bg-rotary-navy px-4 pb-4 pt-2 sm:px-6">
|
||||||
{NAV_LINKS.map((link) => (
|
{links.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -113,13 +87,6 @@ export default function Header() {
|
|||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a
|
|
||||||
href="#event"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="mt-2 rounded-full bg-rotary-gold px-4 py-2.5 text-center text-sm font-semibold text-rotary-navy"
|
|
||||||
>
|
|
||||||
RSVP
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+26
-27
@@ -1,6 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import GearMotif from "./GearMotif";
|
import GearMotif from "./GearMotif";
|
||||||
import Countdown from "./Countdown";
|
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
return (
|
return (
|
||||||
@@ -71,36 +70,36 @@ export default function Hero() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 className="mt-4 font-serif text-4xl font-semibold leading-[1.08] tracking-tight sm:text-5xl lg:text-6xl">
|
<h1 className="mt-4 font-serif text-4xl font-semibold leading-[1.08] tracking-tight sm:text-5xl lg:text-6xl">
|
||||||
Installation
|
Serving Vellore
|
||||||
<span className="block text-rotary-goldlight">Ceremony 2026 – 27</span>
|
<span className="block text-rotary-goldlight">Since 1948</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-white/75 sm:text-base">
|
<p className="mt-5 max-w-lg text-[15px] leading-relaxed text-white/75 sm:text-base">
|
||||||
The President and Members cordially invite you, with family, to
|
For over seven decades, the Rotary Club of Vellore has brought
|
||||||
witness the installation of the incoming Board and to
|
together people of action to serve our community through health,
|
||||||
<span className="text-white"> Create Lasting Impact</span> together.
|
education and humanitarian projects — guided by
|
||||||
|
<span className="text-white"> Service Above Self</span>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* date / venue chips */}
|
{/* club highlights */}
|
||||||
<div className="mt-7 flex flex-wrap gap-3">
|
<div className="mt-7 grid max-w-lg grid-cols-3 gap-3">
|
||||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm">
|
{[
|
||||||
<svg className="h-4 w-4 text-rotary-gold" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
{ value: "1948", label: "Chartered" },
|
||||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
{ value: "3231", label: "Rotary District" },
|
||||||
<path d="M16 3v4M8 3v4M3 10h18" strokeLinecap="round" />
|
{ value: "78+", label: "Years of service" },
|
||||||
</svg>
|
].map((s) => (
|
||||||
3 July 2026 · Friday · 6:30 PM
|
<div
|
||||||
</div>
|
key={s.label}
|
||||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm">
|
className="rounded-2xl border border-white/15 bg-white/5 px-3 py-4 text-center"
|
||||||
<svg className="h-4 w-4 text-rotary-gold" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
>
|
||||||
<path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21Z" strokeLinecap="round" strokeLinejoin="round" />
|
<p className="font-serif text-2xl font-semibold text-rotary-goldlight sm:text-3xl">
|
||||||
<circle cx="12" cy="9.5" r="2.5" />
|
{s.value}
|
||||||
</svg>
|
</p>
|
||||||
Hotel Khanna, Laya Hall, Vellore
|
<p className="mt-1 text-[11px] uppercase tracking-wider text-white/60">
|
||||||
</div>
|
{s.label}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
<div className="empty:mt-0 mt-8">
|
))}
|
||||||
<Countdown />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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