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
-55
View File
@@ -1,55 +0,0 @@
// Shared HTTP core (canonical copy: platform/frontend-template/src/lib/http.ts). Same-origin fetch to
// /api, CSRF from the XSRF-TOKEN cookie, and 401 -> Authentik login redirect (only admin routes are gated).
export interface HttpOptions {
loginUrl?: string;
base?: string;
}
function csrfHeader(): Record<string, string> {
const m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return m ? { 'X-XSRF-TOKEN': decodeURIComponent(m[1]) } : {};
}
export function createApi(opts: HttpOptions = {}) {
const loginUrl = opts.loginUrl ?? '/oauth2/authorization/authentik';
const base = opts.base ?? '/api';
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(base + path, {
...init,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...csrfHeader(), ...(init?.headers ?? {}) },
});
if (res.status === 401) {
window.location.href = loginUrl;
throw new Error('unauthenticated');
}
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
if (res.status === 204) return undefined as T;
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
return {
get: <T>(path: string) => req<T>(path),
post: <T>(path: string, body?: unknown) =>
req<T>(path, { method: 'POST', body: body != null ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
req<T>(path, { method: 'PUT', body: body != null ? JSON.stringify(body) : undefined }),
del: <T>(path: string) => req<T>(path, { method: 'DELETE' }),
login(): void {
window.location.href = loginUrl;
},
logout(): void {
// Full-page form POST so the browser follows the RP-initiated logout redirect chain.
const form = document.createElement('form');
form.method = 'POST';
form.action = '/logout';
document.body.appendChild(form);
form.submit();
},
};
}
export type Api = ReturnType<typeof createApi>;
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
/** Minimal data-loading helper: runs `fn` on mount / when `deps` change. */
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
fn()
.then((d) => { if (alive) { setData(d); setLoading(false); } })
.catch((e) => { if (alive) { setError(String(e)); setLoading(false); } });
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, loading, error };
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}