Rewrite as a Spring Boot app on the Bennett platform

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.
This commit is contained in:
2026-07-22 20:35:51 -05:00
parent 10cd973e35
commit 0c5bbaaa01
128 changed files with 3997 additions and 7685 deletions
+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>
);
}