Admin and Gallery and event change
This commit is contained in:
@@ -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(() => {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user