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
+40
View File
@@ -0,0 +1,40 @@
import { Route, Routes } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import HomePage from './pages/HomePage';
import PostsPage from './pages/PostsPage';
import PostPage from './pages/PostPage';
import AuthorsPage from './pages/AuthorsPage';
import AuthorPage from './pages/AuthorPage';
import TagsPage from './pages/TagsPage';
import TagPage from './pages/TagPage';
import ConfessionPage from './pages/ConfessionPage';
import AboutPage from './pages/AboutPage';
import ResourcesPage from './pages/ResourcesPage';
import SearchPage from './pages/SearchPage';
import NotFoundPage from './pages/NotFoundPage';
export default function App() {
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="container mx-auto grow px-4 py-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/posts" element={<PostsPage />} />
<Route path="/posts/:slug" element={<PostPage />} />
<Route path="/authors" element={<AuthorsPage />} />
<Route path="/authors/:name" element={<AuthorPage />} />
<Route path="/tags" element={<TagsPage />} />
<Route path="/tags/:tag" element={<TagPage />} />
<Route path="/confession" element={<ConfessionPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<Footer />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { createApi } from './lib/http';
import type {
AuthorPage, AuthorSummary, CommentView, MeInfo, PostDetail, PostSummary, TagCount,
} from './types';
const api = createApi();
// ---- auth (public site: only /api/admin/** requires a login) ----
export const getMe = () => api.get<MeInfo>('/me');
export const login = () => api.login();
export const logout = () => api.logout();
// ---- posts / tags / authors ----
export function getPosts(params: { tag?: string; author?: string } = {}): Promise<PostSummary[]> {
const qs = new URLSearchParams();
if (params.tag) qs.set('tag', params.tag);
if (params.author) qs.set('author', params.author);
const q = qs.toString();
return api.get<PostSummary[]>(`/posts${q ? `?${q}` : ''}`);
}
export const getPost = (slug: string) => api.get<PostDetail>(`/posts/${encodeURIComponent(slug)}`);
export const getTags = () => api.get<TagCount[]>('/tags');
export const getAuthors = () => api.get<AuthorSummary[]>('/authors');
export const getAuthor = (name: string) => api.get<AuthorPage>(`/authors/${encodeURIComponent(name)}`);
// ---- comments / subscriptions ----
export const getComments = (postId: string) =>
api.get<CommentView[]>(`/comments?postId=${encodeURIComponent(postId)}`);
export const postComment = (input: { postId: string; name: string; email: string; comment: string }) =>
api.post<CommentView>('/comments', input);
export const subscribe = (email: string) => api.post<void>('/subscriptions', { email });
@@ -0,0 +1,78 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { format } from 'date-fns';
import { getComments, postComment } from '../api';
import { useAsync } from '../lib/useAsync';
export default function CommentSection({ postId }: { postId: string }) {
const [reloadKey, setReloadKey] = useState(0);
const { data: comments, loading } = useAsync(() => getComments(postId), [postId, reloadKey]);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [comment, setComment] = useState('');
const [state, setState] = useState<'idle' | 'busy' | 'error'>('idle');
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (!name.trim() || !email.trim() || !comment.trim()) return;
setState('busy');
try {
await postComment({ postId, name: name.trim(), email: email.trim(), comment: comment.trim() });
setName(''); setEmail(''); setComment('');
setState('idle');
setReloadKey((k) => k + 1);
} catch {
setState('error');
}
}
const list = comments ?? [];
return (
<section className="mt-12">
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">
{list.length === 1 ? '1 Comment' : `${list.length} Comments`}
</h2>
{loading && <p className="text-primary-500">Loading comments</p>}
<ul className="mb-10 space-y-4">
{list.map((c) => (
<li key={c.id} className="card">
<div className="mb-1 flex items-center text-sm text-primary-500">
<span className="font-semibold text-primary-700">{c.name}</span>
{c.createdAt && (
<>
<span className="mx-2"></span>
<time dateTime={c.createdAt}>{format(new Date(c.createdAt), 'MMMM d, yyyy')}</time>
</>
)}
</div>
<p className="whitespace-pre-line text-primary-700">{c.body}</p>
</li>
))}
{!loading && list.length === 0 && (
<li className="text-primary-500">No comments yet be the first to share a thought.</li>
)}
</ul>
<div className="card">
<h3 className="mb-4 text-xl font-bold">Leave a comment</h3>
<form onSubmit={onSubmit} className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<input className="field" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<input className="field" type="email" placeholder="Email (not published)" value={email}
onChange={(e) => setEmail(e.target.value)} required />
</div>
<textarea className="field min-h-32" placeholder="Your comment" value={comment}
onChange={(e) => setComment(e.target.value)} required />
<button type="submit" className="button" disabled={state === 'busy'}>
{state === 'busy' ? 'Posting…' : 'Post Comment'}
</button>
{state === 'error' && <p className="text-sm text-red-700">Something went wrong. Please try again.</p>}
</form>
</div>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Link } from 'react-router-dom';
import SubscribeForm from './SubscribeForm';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="mt-16 bg-primary-800 text-white">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div>
<h3 className="mb-4 text-xl font-bold text-white">Confessions of Grace</h3>
<p className="text-primary-300">
A blog dedicated to exploring the doctrines of grace and Reformed theology.
</p>
</div>
<div>
<h3 className="mb-4 text-xl font-bold text-white">Navigation</h3>
<ul className="space-y-2">
<li><Link to="/" className="text-primary-300 hover:text-white">Home</Link></li>
<li><Link to="/about" className="text-primary-300 hover:text-white">About</Link></li>
<li><Link to="/posts" className="text-primary-300 hover:text-white">Archive</Link></li>
<li><Link to="/confession" className="text-primary-300 hover:text-white">1689 Confession</Link></li>
<li><Link to="/resources" className="text-primary-300 hover:text-white">Resources</Link></li>
<li>
<a
href="https://www.etsy.com/shop/ConfessionsOfGrace"
target="_blank"
rel="noreferrer"
className="text-primary-300 hover:text-white"
>
Shop
</a>
</li>
</ul>
</div>
<div>
<h3 className="mb-4 text-xl font-bold text-white">Subscribe</h3>
<p className="mb-4 text-primary-300">Stay updated with the latest posts.</p>
<SubscribeForm placeholder="Your email" buttonLabel="Subscribe" />
</div>
</div>
<div className="mt-8 border-t border-primary-700 pt-8 text-center text-primary-400">
<p>&copy; {currentYear} Confessions of Grace. All rights reserved.</p>
<p className="mt-2 text-sm">
&ldquo;For by grace you have been saved through faith. And this is not your own doing; it is the
gift of God.&rdquo; Ephesians 2:8
</p>
</div>
</div>
</footer>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
export default function Header() {
return (
<header className="border-b border-primary-200 bg-white shadow-sm">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col items-center justify-between md:flex-row">
<Link to="/" className="no-underline">
<div className="mb-4 flex items-center space-x-4 md:mb-0">
<img src="/assets/logo.svg" alt="Confessions of Grace" className="h-auto max-h-12 w-auto" />
<div>
<h1 className="mb-0 text-3xl font-bold text-primary-900">Confessions of Grace</h1>
<p className="text-sm italic text-primary-500">Confessing Christ. Rejoicing in Grace.</p>
</div>
</div>
</Link>
<nav className="flex flex-wrap justify-center gap-x-6 gap-y-2">
<Link to="/" className="text-primary-700 hover:text-accent-dark">Home</Link>
<Link to="/about" className="text-primary-700 hover:text-accent-dark">About</Link>
<Link to="/posts" className="text-primary-700 hover:text-accent-dark">Archive</Link>
<Link to="/confession" className="text-primary-700 hover:text-accent-dark">1689 Confession</Link>
<Link to="/resources" className="text-primary-700 hover:text-accent-dark">Resources</Link>
<a
href="https://www.etsy.com/shop/ConfessionsOfGrace"
target="_blank"
rel="noreferrer"
className="text-primary-700 hover:text-accent-dark"
>
Shop
</a>
</nav>
</div>
</div>
</header>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import type { PostSummary } from '../types';
export default function PostCard({ post }: { post: PostSummary }) {
return (
<article className="card transition-shadow duration-200 hover:shadow-md">
{post.coverImage && (
<div className="relative mb-4 h-48 w-full overflow-hidden rounded-md">
<img src={post.coverImage} alt={post.title} className="h-full w-full object-cover" />
</div>
)}
<h2 className="mb-2 text-xl font-bold">
<Link to={`/posts/${post.slug}`} className="hover:text-accent-dark">{post.title}</Link>
</h2>
<div className="mb-2 flex items-center text-sm text-primary-500">
<span>{post.author}</span>
<span className="mx-2"></span>
<time dateTime={post.date}>{format(new Date(post.date), 'MMMM d, yyyy')}</time>
</div>
<p className="mb-4 text-primary-600">{post.excerpt}</p>
<div className="mb-4 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag}
to={`/tags/${encodeURIComponent(tag)}`}
className="rounded-md bg-primary-100 px-2 py-1 text-xs text-primary-600 no-underline hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
<Link to={`/posts/${post.slug}`} className="inline-flex items-center text-accent-dark hover:text-accent">
Read more
<svg xmlns="http://www.w3.org/2000/svg" className="ml-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
</article>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import type { PostSummary, TagCount } from '../types';
import SubscribeForm from './SubscribeForm';
interface Props {
recentPosts: PostSummary[];
tags: TagCount[];
}
export default function Sidebar({ recentPosts, tags }: Props) {
return (
<aside className="flex flex-col gap-8">
<div className="card">
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Recent Posts</h3>
<ul className="space-y-3">
{recentPosts.map((post) => (
<li key={post.slug}>
<Link to={`/posts/${post.slug}`} className="hover:text-accent">{post.title}</Link>
<div className="text-sm text-primary-500">
{format(new Date(post.date), 'MMMM d, yyyy')}
</div>
</li>
))}
{recentPosts.length === 0 && <li className="text-primary-500">No posts yet.</li>}
</ul>
</div>
<div className="card">
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Tags</h3>
<div className="flex flex-wrap gap-2">
{tags.map((t) => (
<Link
key={t.tag}
to={`/tags/${encodeURIComponent(t.tag)}`}
className="rounded-md bg-primary-100 px-2 py-1 text-sm text-primary-600 no-underline hover:bg-primary-200"
>
{t.tag} <span className="text-primary-400">({t.count})</span>
</Link>
))}
{tags.length === 0 && <span className="text-primary-500">No tags yet.</span>}
</div>
</div>
<div className="card">
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Subscribe</h3>
<p className="mb-4 text-primary-600">Stay updated with the latest posts.</p>
<SubscribeForm />
</div>
</aside>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { subscribe } from '../api';
import { cn } from '../lib/utils';
interface Props {
placeholder?: string;
buttonLabel?: string;
className?: string;
}
export default function SubscribeForm({
placeholder = 'Your email',
buttonLabel = 'Subscribe',
className,
}: Props) {
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle');
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (!email.trim()) return;
setState('busy');
try {
await subscribe(email.trim());
setState('done');
setEmail('');
} catch {
setState('error');
}
}
if (state === 'done') {
return <p className="text-sm text-accent-light">Thank you you&apos;re subscribed.</p>;
}
return (
<form onSubmit={onSubmit} className={cn('flex flex-col space-y-2', className)}>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={placeholder}
aria-label="Email address"
className="field"
/>
<button type="submit" className="button" disabled={state === 'busy'}>
{state === 'busy' ? 'Subscribing…' : buttonLabel}
</button>
{state === 'error' && <p className="text-sm text-red-300">Something went wrong. Please try again.</p>}
</form>
);
}
+74
View File
@@ -0,0 +1,74 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* ---------------------------------------------------------------------------
Confessions of Grace brand — carried over verbatim from the original site so
the rebuild looks identical: serif type, warm tan accent, soft grey scale.
This file is the ONLY place the app's look is defined.
--------------------------------------------------------------------------- */
@theme {
--color-primary-50: #f8f9fa;
--color-primary-100: #e9ecef;
--color-primary-200: #dee2e6;
--color-primary-300: #ced4da;
--color-primary-400: #adb5bd;
--color-primary-500: #6c757d;
--color-primary-600: #495057;
--color-primary-700: #343a40;
--color-primary-800: #212529;
--color-primary-900: #121212;
--color-accent-light: #e2d8c6;
--color-accent: #9d8c70;
--color-accent-dark: #695c4a;
--font-serif: Baskerville, Georgia, "Times New Roman", serif;
--font-sans: "Helvetica Neue", Arial, sans-serif;
}
@layer base {
body {
@apply bg-primary-50 text-primary-800 font-serif;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-primary-900 mb-4 font-bold;
}
h1 { @apply text-3xl md:text-4xl; }
h2 { @apply text-2xl md:text-3xl; }
h3 { @apply text-xl md:text-2xl; }
a {
@apply text-accent-dark hover:text-accent transition-colors duration-200;
}
blockquote {
@apply border-l-4 border-accent pl-4 italic my-4;
}
}
@layer components {
.blog-post {
@apply prose prose-lg max-w-none;
}
.blog-post h1, .blog-post h2, .blog-post h3 {
@apply border-b border-primary-200 pb-2;
}
.button {
@apply inline-block px-4 py-2 rounded-md bg-accent text-white no-underline shadow-sm
hover:bg-accent-dark hover:text-white transition-colors duration-200;
}
.card {
@apply bg-white rounded-md shadow-sm p-6 border border-primary-200
hover:shadow-md transition-shadow duration-200;
}
.field {
@apply w-full rounded-md border border-primary-300 bg-white px-3 py-2 text-primary-800
focus:border-accent focus:outline-none;
}
}
+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));
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+84
View File
@@ -0,0 +1,84 @@
export default function AboutPage() {
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">About</h1>
<div className="mb-10 rounded-lg border border-primary-200 bg-white p-8 shadow-sm">
<h2 className="mb-4 text-2xl font-bold">Confessions of Grace</h2>
<p className="mb-6 text-primary-700">
Welcome to Confessions of Grace, a blog dedicated to exploring the riches of Reformed theology and the
doctrines of grace. Our aim is to articulate timeless biblical truths in a clear, accessible manner,
helping believers understand the depth and beauty of God&apos;s sovereign grace.
</p>
<h2 className="mb-4 text-2xl font-bold">What Do You Mean By &ldquo;Confessions of Grace&rdquo;?</h2>
<p className="mb-6 text-primary-700">
The name &ldquo;Confessions of Grace&rdquo; is a play on Augustine&apos;s &ldquo;Confessions&rdquo; and John
Bunyan&apos;s &ldquo;Grace Abounding to the Chief of Sinners.&rdquo; I originally wanted to call the blog
&ldquo;Confessions of the Chief of Sinners,&rdquo; but that felt a bit too long. The idea behind those books
is that we are all sinners saved by grace, needing to confess that grace to others. &ldquo;Confessions of
Grace&rdquo; seems to fit that idea well.
</p>
<h3 className="mb-3 text-xl font-bold">Our Vision</h3>
<p className="mb-6 text-primary-700">
In an age of theological confusion and spiritual relativism, we seek to provide content that is firmly
rooted in Scripture, historically informed, and pastorally sensitive. We believe that sound doctrine leads
to doxologythat theology, properly understood, results in worship and wonder at the character and works
of God.
</p>
<h3 className="mb-3 text-xl font-bold">What We Believe</h3>
<p className="mb-6 text-primary-700">
We stand in the tradition of the Protestant Reformation, affirming the five &ldquo;solas&rdquo;:
</p>
<ul className="mb-6 list-inside list-disc space-y-2 text-primary-700">
<li><span className="font-semibold italic">Sola Scriptura</span> Scripture Alone</li>
<li><span className="font-semibold italic">Sola Fide</span> Faith Alone</li>
<li><span className="font-semibold italic">Sola Gratia</span> Grace Alone</li>
<li><span className="font-semibold italic">Solus Christus</span> Christ Alone</li>
<li><span className="font-semibold italic">Soli Deo Gloria</span> Glory to God Alone</li>
</ul>
<p className="mb-6 text-primary-700">
As Reformed Baptists, we affirm the doctrines of grace as articulated in the 1689 London Baptist Confession
of Faith. This historic confession provides a robust and faithful summary of biblical doctrine, emphasizing
God&apos;s sovereignty in salvation and the centrality of Christ in all things.
</p>
<h3 className="mb-3 text-xl font-bold">Our Associations</h3>
<p className="mb-6 text-primary-700">
We are associated with the{' '}
<a href="https://reformedwitness.net" className="text-accent hover:text-accent-dark">
Reformed Witness Network (RWN)
</a>
, a group committed to the proclamation of the gospel and the advancement of Christ&apos;s kingdom. Through
RWN, we aim to foster fellowship among like-minded believers and support the spread of Reformed theology
globally.
</p>
<blockquote className="my-8 border-l-4 border-accent pl-4 italic text-primary-600">
&ldquo;For from him and through him and to him are all things. To him be glory forever. Amen.&rdquo;
<br /> Romans 11:36
</blockquote>
</div>
<div className="rounded-lg border border-primary-200 bg-white p-8 shadow-sm">
<h2 className="mb-4 text-2xl font-bold">About the Author</h2>
<div className="items-start gap-6 md:flex">
<div className="md:w-2/3">
<p className="mb-4 text-primary-700">
My wife and I are members of Covenant Community Church, where I have been blessed to grow in faith and
fellowship. My passion for theology and technology inspired me to start this blog as a way to share the
beauty of God&apos;s sovereign grace. I hope to one day reach unreached people groups and share the
gospel with them.
</p>
<p className="text-primary-700">
Feel free to connect with me on X @auggie2lbcf or email me at contact@confessionsofgrace.com. I would
love to hear from you and learn how I can serve you better.
</p>
</div>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, useParams } from 'react-router-dom';
import { getAuthor } from '../api';
import { useAsync } from '../lib/useAsync';
import PostCard from '../components/PostCard';
export default function AuthorPage() {
const { name = '' } = useParams();
const { data: author, loading, error } = useAsync(() => getAuthor(name), [name]);
if (loading) return <p className="text-primary-500">Loading</p>;
if (error || !author) {
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-4 text-3xl font-bold">Author not found</h1>
<p className="text-primary-700"><Link to="/authors">See all authors</Link>.</p>
</div>
);
}
return (
<div className="mx-auto max-w-5xl">
<div className="card mb-10">
<div className="flex items-center gap-6">
{author.pfpLink && (
<img src={author.pfpLink} alt={author.name} className="h-24 w-24 rounded-full object-cover" />
)}
<div>
<h1 className="mb-1 text-3xl font-bold">{author.name}</h1>
<p className="text-primary-500">
{author.postCount} {author.postCount === 1 ? 'post' : 'posts'}
</p>
</div>
</div>
{author.bio && <p className="mt-6 text-primary-700">{author.bio}</p>}
<div className="mt-4 flex gap-4">
{author.xLink && <a href={author.xLink} target="_blank" rel="noreferrer">X</a>}
{author.fbLink && <a href={author.fbLink} target="_blank" rel="noreferrer">Facebook</a>}
{author.instaLink && <a href={author.instaLink} target="_blank" rel="noreferrer">Instagram</a>}
</div>
</div>
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Posts</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{author.posts.map((post) => <PostCard key={post.slug} post={post} />)}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Link } from 'react-router-dom';
import { getAuthors } from '../api';
import { useAsync } from '../lib/useAsync';
export default function AuthorsPage() {
const { data: authors, loading } = useAsync(() => getAuthors(), []);
const list = authors ?? [];
return (
<div className="mx-auto max-w-4xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Authors</h1>
{loading && <p className="text-primary-500">Loading</p>}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{list.map((a) => (
<div key={a.name} className="card">
<div className="flex items-center gap-4">
{a.pfpLink && <img src={a.pfpLink} alt={a.name} className="h-16 w-16 rounded-full object-cover" />}
<div>
<h2 className="mb-1 text-xl font-bold">
<Link to={`/authors/${encodeURIComponent(a.name)}`} className="hover:text-accent-dark">{a.name}</Link>
</h2>
<p className="text-sm text-primary-500">
{a.postCount} {a.postCount === 1 ? 'post' : 'posts'}
</p>
</div>
</div>
{a.bio && <p className="mt-4 text-primary-700">{a.bio}</p>}
</div>
))}
</div>
{!loading && list.length === 0 && <p className="text-primary-500">No authors yet.</p>}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
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>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { Link } from 'react-router-dom';
import { getPosts, getTags } from '../api';
import { useAsync } from '../lib/useAsync';
import PostCard from '../components/PostCard';
import Sidebar from '../components/Sidebar';
export default function HomePage() {
const { data: posts, loading } = useAsync(() => getPosts(), []);
const { data: tags } = useAsync(() => getTags(), []);
const list = posts ?? [];
const featured = list[0];
return (
<div className="flex flex-col gap-8 md:flex-row">
<main className="md:w-2/3">
{featured && (
<div className="mb-12">
<div className="overflow-hidden rounded-lg bg-white shadow-md">
<div className="md:flex">
<div className="relative h-64 w-full bg-accent md:h-auto md:w-1/3 md:shrink-0">
{featured.coverImage ? (
<img src={featured.coverImage} alt={featured.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center bg-accent text-6xl font-bold text-white">
CG
</div>
)}
</div>
<div className="p-8">
<div className="text-sm font-semibold uppercase tracking-wide text-accent">Latest Post</div>
<Link
to={`/posts/${featured.slug}`}
className="mt-1 block text-2xl font-bold leading-tight text-primary-900 hover:text-accent-dark"
>
{featured.title}
</Link>
<p className="mt-2 text-primary-600">{featured.excerpt}</p>
<div className="mt-4">
<Link to={`/posts/${featured.slug}`} className="inline-flex items-center text-accent-dark hover:text-accent">
Read more
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
</div>
</div>
</div>
</div>
</div>
)}
<div className="mb-8">
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Recent Posts</h2>
{loading && <p className="text-primary-500">Loading</p>}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{list.slice(1).map((post) => <PostCard key={post.slug} post={post} />)}
</div>
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
</div>
<div className="mt-12 text-center">
<Link to="/posts" className="button">View All Posts</Link>
</div>
</main>
<div className="md:w-1/3">
<Sidebar recentPosts={list.slice(0, 5)} tags={tags ?? []} />
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="mx-auto max-w-3xl text-center">
<h1 className="mb-4 text-3xl font-bold md:text-4xl">Page not found</h1>
<p className="mb-8 text-primary-700">
We couldn&apos;t find that page. Perhaps start from the <Link to="/">home page</Link> or browse the{' '}
<Link to="/posts">archive</Link>.
</p>
<Link to="/" className="button">Back home</Link>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Link, useParams } from 'react-router-dom';
import { format } from 'date-fns';
import { getPost } from '../api';
import { useAsync } from '../lib/useAsync';
import CommentSection from '../components/CommentSection';
export default function PostPage() {
const { slug = '' } = useParams();
const { data: post, loading, error } = useAsync(() => getPost(slug), [slug]);
if (loading) return <p className="text-primary-500">Loading</p>;
if (error || !post) {
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-4 text-3xl font-bold">Post not found</h1>
<p className="text-primary-700">
That post doesn&apos;t exist. <Link to="/posts">Browse the archive</Link>.
</p>
</div>
);
}
return (
<article className="mx-auto max-w-3xl">
{post.coverImage && (
<div className="mb-8 h-72 w-full overflow-hidden rounded-md">
<img src={post.coverImage} alt={post.title} className="h-full w-full object-cover" />
</div>
)}
<h1 className="mb-3 text-3xl font-bold md:text-4xl">{post.title}</h1>
<div className="mb-6 flex flex-wrap items-center text-sm text-primary-500">
<Link to={`/authors/${encodeURIComponent(post.author)}`}>{post.author}</Link>
<span className="mx-2"></span>
<time dateTime={post.date}>{format(new Date(post.date), 'MMMM d, yyyy')}</time>
</div>
<div className="blog-post" dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{post.tags.length > 0 && (
<div className="mt-8 flex flex-wrap gap-2 border-t border-primary-200 pt-6">
{post.tags.map((tag) => (
<Link
key={tag}
to={`/tags/${encodeURIComponent(tag)}`}
className="rounded-md bg-primary-100 px-2 py-1 text-sm text-primary-600 no-underline hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
)}
<CommentSection postId={post.slug} />
</article>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getPosts } from '../api';
import { useAsync } from '../lib/useAsync';
import PostCard from '../components/PostCard';
export default function PostsPage() {
const { data: posts, loading } = useAsync(() => getPosts(), []);
const list = posts ?? [];
return (
<div className="mx-auto max-w-5xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Archive</h1>
<p className="mb-10 text-lg text-primary-700">Every post, newest first.</p>
{loading && <p className="text-primary-500">Loading</p>}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{list.map((post) => <PostCard key={post.slug} post={post} />)}
</div>
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
interface Resource {
title: string;
author: string;
description: string;
link?: string;
category: string;
}
const resources: Resource[] = [
{
title: 'The Second London Baptist Confession of Faith (1689)',
author: 'Particular Baptists',
description:
'A historic Reformed Baptist confession of faith that aligns closely with the Westminster Confession but reflects Baptist distinctives.',
link: '/confession',
category: 'Confessions',
},
{
title: 'The Heidelberg Catechism',
author: 'Zacharias Ursinus & Caspar Olevianus',
description: 'A warm, pastoral Reformed catechism organized around comfort in Christ.',
link: 'https://www.ligonier.org/learn/articles/heidelberg-catechism',
category: 'Confessions',
},
{
title: 'Ligonier Ministries',
author: 'Founded by R.C. Sproul',
description:
'A ministry dedicated to helping Christians know what they believe, why they believe it, how to live it, and how to share it.',
link: 'https://www.ligonier.org/',
category: 'Websites',
},
{
title: 'Monergism',
author: '',
description: 'A comprehensive resource for Reformed theology, including articles, books, and audio resources.',
link: 'https://www.monergism.com/',
category: 'Websites',
},
];
export default function ResourcesPage() {
const categories = Array.from(new Set(resources.map((r) => r.category)));
return (
<div className="mx-auto max-w-4xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Reformed Resources</h1>
<p className="mb-10 text-lg text-primary-700">
This is a curated collection of resources related to Reformed theology and the doctrines of grace. These
books, confessions, and websites have been formative in my own theological journey and are recommended for
those seeking to deepen their understanding of Reformed thought.
</p>
{categories.map((category) => (
<div key={category} className="mb-12">
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">{category}</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{resources
.filter((r) => r.category === category)
.map((r) => (
<div key={r.title} className="rounded-lg border border-primary-200 bg-white p-6 shadow-sm">
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
{r.author && <p className="mb-3 italic text-primary-500">by {r.author}</p>}
<p className="mb-4 text-primary-700">{r.description}</p>
{r.link && (
<a
href={r.link}
target={r.link.startsWith('http') ? '_blank' : undefined}
rel="noopener noreferrer"
className="inline-flex items-center text-accent-dark hover:text-accent"
>
Visit Resource
<svg xmlns="http://www.w3.org/2000/svg" className="ml-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getPosts } from '../api';
import { useAsync } from '../lib/useAsync';
import PostCard from '../components/PostCard';
export default function SearchPage() {
const [params, setParams] = useSearchParams();
const q = params.get('q') ?? '';
const [term, setTerm] = useState(q);
const { data: posts, loading } = useAsync(() => getPosts(), []);
const needle = q.trim().toLowerCase();
const results = (posts ?? []).filter((p) =>
!needle
|| p.title.toLowerCase().includes(needle)
|| p.excerpt.toLowerCase().includes(needle)
|| p.author.toLowerCase().includes(needle)
|| p.tags.some((t) => t.toLowerCase().includes(needle)));
return (
<div className="mx-auto max-w-5xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Search</h1>
<form
className="mb-10 flex gap-3"
onSubmit={(e) => { e.preventDefault(); setParams(term.trim() ? { q: term.trim() } : {}); }}
>
<input
className="field"
placeholder="Search posts…"
value={term}
onChange={(e) => setTerm(e.target.value)}
aria-label="Search posts"
/>
<button type="submit" className="button">Search</button>
</form>
{loading && <p className="text-primary-500">Loading</p>}
{!loading && needle && (
<p className="mb-6 text-primary-600">
{results.length} {results.length === 1 ? 'result' : 'results'} for &ldquo;{q}&rdquo;
</p>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{results.map((post) => <PostCard key={post.slug} post={post} />)}
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Link, useParams } from 'react-router-dom';
import { getPosts } from '../api';
import { useAsync } from '../lib/useAsync';
import PostCard from '../components/PostCard';
export default function TagPage() {
const { tag = '' } = useParams();
const { data: posts, loading } = useAsync(() => getPosts({ tag }), [tag]);
const list = posts ?? [];
return (
<div className="mx-auto max-w-5xl">
<h1 className="mb-2 text-3xl font-bold md:text-4xl">
Tagged &ldquo;{tag}&rdquo;
</h1>
<p className="mb-10 text-primary-600">
{list.length} {list.length === 1 ? 'post' : 'posts'} · <Link to="/tags">all tags</Link>
</p>
{loading && <p className="text-primary-500">Loading</p>}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{list.map((post) => <PostCard key={post.slug} post={post} />)}
</div>
{!loading && list.length === 0 && <p className="text-primary-500">Nothing tagged with that yet.</p>}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Link } from 'react-router-dom';
import { getTags } from '../api';
import { useAsync } from '../lib/useAsync';
export default function TagsPage() {
const { data: tags, loading } = useAsync(() => getTags(), []);
const list = tags ?? [];
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Tags</h1>
{loading && <p className="text-primary-500">Loading</p>}
<div className="flex flex-wrap gap-3">
{list.map((t) => (
<Link
key={t.tag}
to={`/tags/${encodeURIComponent(t.tag)}`}
className="rounded-md bg-primary-100 px-3 py-2 text-primary-600 no-underline hover:bg-primary-200"
>
{t.tag} <span className="text-primary-400">({t.count})</span>
</Link>
))}
</div>
{!loading && list.length === 0 && <p className="text-primary-500">No tags yet.</p>}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
export interface PostSummary {
slug: string;
title: string;
date: string;
excerpt: string;
author: string;
tags: string[];
coverImage: string | null;
}
export interface PostDetail extends PostSummary {
contentHtml: string;
}
export interface AuthorSummary {
name: string;
bio: string;
pfpLink: string | null;
postCount: number;
}
export interface AuthorPage {
name: string;
bio: string;
xLink: string | null;
fbLink: string | null;
instaLink: string | null;
pfpLink: string | null;
postCount: number;
posts: PostSummary[];
}
export interface TagCount {
tag: string;
count: number;
}
export interface CommentView {
id: number;
name: string;
body: string;
createdAt: string | null;
}
export interface MeInfo {
authenticated: boolean;
admin: boolean;
owner: string | null;
}