Add admin UI + MinIO image uploads

- Admin API: list/edit posts (incl. drafts, raw markdown), moderate comments, subscribers,
  author bio/links, and presigned image upload to MinIO (bucket is public-read for covers).
- Admin UI at /admin: post editor with cover-image upload, comment moderation, subscriber list.
- Public security switched to authenticated-paths so static assets (/images, /data) stay public.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-22 20:50:45 -05:00
co-authored by Claude Opus 4.8
parent cbe8a764b0
commit eaad7b916e
82 changed files with 0 additions and 5492 deletions
-79
View File
@@ -1,79 +0,0 @@
import { useState } from 'react';
import { useAsync } from '../lib/useAsync';
import { cn } from '../lib/utils';
interface Chapter {
title: string;
paragraphs: Record<string, string>;
}
interface Confession {
title: string;
chapters: Record<string, Chapter>;
}
// Served from public/ rather than bundled — it's a large static document.
const loadConfession = async (): Promise<Confession> => {
const res = await fetch('/data/1689-confession.json');
if (!res.ok) throw new Error('could not load the confession');
return res.json();
};
export default function ConfessionPage() {
const { data, loading, error } = useAsync(loadConfession, []);
const [selected, setSelected] = useState<string | null>(null);
if (loading) return <p className="text-primary-500">Loading the confession</p>;
if (error || !data) return <p className="text-primary-700">The confession could not be loaded.</p>;
const numbers = Object.keys(data.chapters).sort((a, b) => Number(a) - Number(b));
const current = selected ?? numbers[0];
const chapter = data.chapters[current];
return (
<div className="mx-auto max-w-6xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">{data.title}</h1>
<div className="flex flex-col gap-8 md:flex-row">
<nav className="md:w-1/3">
<div className="card max-h-[70vh] overflow-y-auto">
<h2 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Chapters</h2>
<ol className="space-y-2">
{numbers.map((n) => (
<li key={n}>
<button
type="button"
onClick={() => setSelected(n)}
className={cn(
'w-full text-left text-sm transition-colors',
current === n ? 'font-bold text-accent-dark' : 'text-primary-700 hover:text-accent-dark',
)}
>
{n}. {data.chapters[n].title}
</button>
</li>
))}
</ol>
</div>
</nav>
<article className="md:w-2/3">
<div className="card">
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">
Chapter {current} {chapter.title}
</h2>
<ol className="space-y-5">
{Object.keys(chapter.paragraphs)
.sort((a, b) => Number(a) - Number(b))
.map((p) => (
<li key={p} className="leading-relaxed text-primary-700">
<span className="mr-2 font-bold text-accent-dark">{p}.</span>
{chapter.paragraphs[p]}
</li>
))}
</ol>
</div>
</article>
</div>
</div>
);
}