Rewrite as a Spring Boot app on the Bennett platform
build-and-publish / build (push) Successful in 1m38s

Restores the real backend lost with Supabase (posts, authors, comments, subscriptions, admin) in
Postgres, seeds the existing markdown posts, and rebuilds the site as a Vite/React SPA served by
Spring — keeping the original styling (serif + tan accent) and content.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-22 20:35:51 -05:00
co-authored by Claude Opus 4.8
parent ba054ab2eb
commit cbe8a764b0
128 changed files with 3997 additions and 7685 deletions
+55
View File
@@ -0,0 +1,55 @@
// 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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}