diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8242594 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 5ef6a52..5642af0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# admin panel content + uploads (runtime data) +/data/ + +!.env.example diff --git a/Dockerfile b/Dockerfile index 8006912..3738a2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,13 @@ ENV PORT=3000 ENV HOSTNAME=0.0.0.0 RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs + && adduser --system --uid 1001 nextjs \ + && mkdir -p /app/data/uploads \ + && chown -R nextjs:nodejs /app/data + +# Admin-panel content (content.json + uploaded images) lives here — keep it on a volume. +ENV DATA_DIR=/app/data +VOLUME ["/app/data"] # Standalone output — requires `output: "standalone"` in next.config.ts (see note below) COPY --from=builder --chown=nextjs:nodejs /app/public ./public diff --git a/docker-compose.yml b/docker-compose.yml index 051fb99..1d0fab2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,14 @@ services: - "9022:3000" environment: - NODE_ENV=production + - DATA_DIR=/app/data + # Admin panel login (/admin). Set these in a .env file next to this compose file. + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_SECRET=${ADMIN_SECRET} + volumes: + # keeps gallery / meeting / events edits and uploaded photos across rebuilds + - rotary-data:/app/data # healthcheck: hits the site root to confirm the container is actually serving healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"] @@ -34,3 +42,6 @@ services: - .:/app - /app/node_modules - /app/.next + +volumes: + rotary-data: diff --git a/src/app/admin/AdminDashboard.tsx b/src/app/admin/AdminDashboard.tsx new file mode 100644 index 0000000..d572e96 --- /dev/null +++ b/src/app/admin/AdminDashboard.tsx @@ -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 { + 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(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(null); + const [busy, setBusy] = useState(false); + return ( +
+ {label &&

{label}

} +
+ {value ? ( + + ) : ( +
No image
+ )} + + {removable && value && !busy && ( + + )} +
+ { + 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); + } + }} + /> +
+ ); +} + +/** 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 ( +
+ {children} + + {busy ? "Saving…" : isNew ? "Not saved yet" : dirty ? "Unsaved changes" : ""} + + {(dirty || isNew) && ( + <> + + + + )} + {!isNew && ( + + )} +
+ ); +} + +/** Local draft for one card. Resets automatically when the saved item changes (card remounts via key). */ +function useDraft(item: T, id: string, onDirty: (id: string, dirty: boolean) => void) { + const [draft, setDraftState] = useState(item); + const setDraft = (patch: Partial) => { + 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 = { + 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 & { canUp: boolean; canDown: boolean; onMove: (dir: -1 | 1) => void }) { + const { draft, setDraft, reset, dirty } = useDraft(item, item.id, onDirty); + return ( +
+ setDraft({ src })} /> + setDraft({ alt: e.target.value })} className={input} /> + setDraft({ date: e.target.value })} className={input} /> +