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
+8 -9
View File
@@ -1,9 +1,8 @@
node_modules
.next
.git
.env
.env.local
Dockerfile
.dockerignore
README.md
node-*
target/
frontend/node_modules/
frontend/dist/
_content/
.git/
.idea/
*.iml
.vscode/
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>gitea</id>
<username>${env.MAVEN_USER}</username>
<password>${env.MAVEN_TOKEN}</password>
</server>
</servers>
<profiles>
<profile>
<id>gitea</id>
<repositories>
<repository>
<id>gitea</id>
<url>https://git.thebennett.net/api/packages/austin/maven</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles><activeProfile>gitea</activeProfile></activeProfiles>
</settings>
+43
View File
@@ -0,0 +1,43 @@
name: build-and-publish
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# --no-cache: the platform is a 0.1.0-SNAPSHOT dependency, so a stale cached maven layer could bake
# in an old (e.g. unpatched) version. Always resolve fresh for a correct, scanned image.
- name: Build image
env:
DOCKER_BUILDKIT: "1"
MAVEN_USER: ${{ secrets.REGISTRY_USER }}
MAVEN_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
docker build --no-cache \
--secret id=maven_user,env=MAVEN_USER \
--secret id=maven_token,env=MAVEN_TOKEN \
--build-arg GIT_SHA=${{ github.sha }} \
-t git.thebennett.net/reformedwitness/confessions-of-grace:latest \
-t git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }} .
- name: Scan image (Trivy)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
git.thebennett.net/reformedwitness/confessions-of-grace:latest || true
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
--pkg-types library --exit-code 1 --no-progress \
git.thebennett.net/reformedwitness/confessions-of-grace:latest
- name: Push image
run: |
docker push git.thebennett.net/reformedwitness/confessions-of-grace:latest
docker push git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }}
-16
View File
@@ -1,16 +0,0 @@
name: build-and-publish
on:
push:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build + push
run: |
docker build -t git.thebennett.net/reformedwitness/confessions-of-grace:latest -t git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }} .
docker push git.thebennett.net/reformedwitness/confessions-of-grace:latest
docker push git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }}
+6 -41
View File
@@ -1,42 +1,7 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
target/
frontend/node_modules/
frontend/dist/
.idea/
*.iml
.vscode/
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# open-next
/.open-next/
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-131
View File
@@ -1,131 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6e8c8ce2-6687-44ce-91df-7f4086b97c6c" name="Changes" comment="speed insights and analytics">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/layout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/layout.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="GitHubPullRequestSearchHistory">{
&quot;lastFilter&quot;: {
&quot;state&quot;: &quot;OPEN&quot;,
&quot;assignee&quot;: &quot;auggie2lbcf&quot;
}
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/auggie2lbcf/confessions-of-grace.git&quot;,
&quot;accountId&quot;: &quot;65e4dc35-d9be-4a76-bcb9-5976d828ef36&quot;
}
}</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 8
}</component>
<component name="ProjectId" id="31VaqrjxhpcgG7G4IRxcONRXwmS" />
<component name="ProjectLevelVcsManager">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
"git-widget-placeholder": "main",
"js.debugger.nextJs.config.created.client": "true",
"js.debugger.nextJs.config.created.server": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.lookFeel",
"ts.external.directory.path": "C:\\Users\\adbennett\\WebstormProjects\\confessions-of-grace\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" />
</configuration>
<configuration name="Next.js: server-side" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-e03c56caf84a-JavaScript-WS-252.23892.411" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task id="LOCAL-00001" summary="speed insights and analytics">
<option name="closed" value="true" />
<created>1755611816528</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1755611816528</updated>
</task>
<task active="true" id="Default" summary="Default task">
<changelist id="6e8c8ce2-6687-44ce-91df-7f4086b97c6c" name="Changes" comment="speed insights and analytics" />
<created>1755611892495</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1755611892495</updated>
<workItem from="1755611894091" duration="542000" />
<workItem from="1755612460551" duration="568000" />
</task>
<task id="LOCAL-00002" summary="speed insights and analytics">
<option name="closed" value="true" />
<created>1755612832865</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1755612832865</updated>
</task>
<option name="localTasksCounter" value="3" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="speed insights and analytics" />
<option name="LAST_COMMIT_MESSAGE" value="speed insights and analytics" />
</component>
</project>
+32 -19
View File
@@ -1,24 +1,37 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# ---------- build ----------
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /src
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Resolve the platform from the Gitea Maven registry (creds via BuildKit secrets); the Maven build also
# runs the Vite/React SPA build (frontend-maven-plugin) and folds it into the jar. Tests are skipped here
# because Testcontainers needs a Docker daemon — they run via `mvn verify`, not in the image build.
RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
MAVEN_USER="$(cat /run/secrets/maven_user)" MAVEN_TOKEN="$(cat /run/secrets/maven_token)" \
mvn -B -ntp -s .gitea/ci-settings.xml -DskipTests package \
&& cp "$(ls target/confessions-of-grace-*.jar | grep -v original | head -1)" app.jar \
&& java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM node:22-alpine AS runner
# ---------- runtime ----------
FROM eclipse-temurin:25-jre-alpine AS runtime
RUN apk -U upgrade --no-cache && apk add --no-cache curl
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder /app/data ./data
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
COPY --from=build --chown=spring:spring /src/extracted/dependencies/ ./
COPY --from=build --chown=spring:spring /src/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=spring:spring /src/extracted/application/ ./
USER spring
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="confessions-of-grace" \
org.opencontainers.image.source="https://git.thebennett.net/reformedwitness/confessions-of-grace" \
org.opencontainers.image.revision="${GIT_SHA}"
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
-107
View File
@@ -1,107 +0,0 @@
import React from 'react';
import { createPageMetadata } from '@/components/Metadata';
export const metadata = createPageMetadata({
title: "About",
description: "Learn about Confessions of Grace, our mission, and Reformed theology.",
url: "https://confessionsofgrace.com/about"
});
export default function About() {
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">About</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
<h2 className="text-2xl font-bold mb-4">Confessions of Grace</h2>
<p className="text-primary-700 mb-6">
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's sovereign grace.
</p>
<h2 className="text-2xl font-bold mb-4">What Do You Mean By "Confessions of Grace"?</h2>
<p className="text-primary-700 mb-6">
The name "Confessions of Grace" is a play on Augustine's "Confessions" and John Bunyan's "Grace
Abounding to the Chief of Sinners."
I originally wanted to call the blog "Confessions of the Chief of Sinners," 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.
"Confessions of Grace" seems to fit that idea well.
</p>
<h3 className="text-xl font-bold mb-3">Our Vision</h3>
<p className="text-primary-700 mb-6">
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 doxology—that theology, properly understood,
results in worship and wonder at the character and works of God.
</p>
<h3 className="text-xl font-bold mb-3">What We Believe</h3>
<p className="text-primary-700 mb-6">
We stand in the tradition of the Protestant Reformation, affirming the five "solas":
</p>
<ul className="list-disc list-inside mb-6 text-primary-700 space-y-2">
<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="text-primary-700 mb-6">
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's sovereignty in salvation and the centrality of Christ
in all things.
</p>
<h3 className="text-xl font-bold mb-3">Our Associations</h3>
<p className="text-primary-700 mb-6">
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's kingdom.
Through RWN, we aim to foster fellowship among like-minded believers and support the spread of
Reformed theology globally.
</p>
<blockquote className="border-l-4 border-accent pl-4 italic my-8 text-primary-600">
"For from him and through him and to him are all things. To him be glory forever. Amen."
<br/>— Romans 11:36
</blockquote>
</div>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<h2 className="text-2xl font-bold mb-4">About the Author</h2>
<div className="md:flex items-start gap-6">
{/* <div className="md:w-1/3 mb-4 md:mb-0">
<div className="bg-primary-200 h-64 w-full rounded-md mb-4 relative h-48 w-full overflow-hidden rounded-md">
<Image
src="/images/me.jpeg"
alt="Me"
fill
className="object-cover"
/>
</div>
</div> */}
<div className="md:w-2/3">
<p className="text-primary-700 mb-4">
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'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>
);
};
-27
View File
@@ -1,27 +0,0 @@
import React from 'react';
import { PostMetadata } from '@/types';
interface AuthorProfileProps {
author: string;
posts: PostMetadata[];
}
// Static author profile. No bio/social data is available in the markdown
// source, so we render the author's name and a placeholder avatar only.
export default function AuthorProfile({ author }: AuthorProfileProps) {
return (
<div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6">
{/* Placeholder avatar */}
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-500">
{author.charAt(0).toUpperCase()}
</div>
<div>
{/* Author Name */}
<h1 className="text-3xl md:text-4xl font-bold mb-2">
{author}
</h1>
</div>
</div>
);
}
-63
View File
@@ -1,63 +0,0 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import { getPostsByAuthor } from '@/lib/posts';
import { getAllAuthors } from '@/lib/authors';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
import AuthorProfile from './AuthorProfile';
interface PageProps {
params: Promise<{ author: string }>;
}
export async function generateStaticParams() {
const authors = await getAllAuthors();
return authors.map((author) => ({
author: author.slug,
}));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { author } = await params;
const decodedAuthor = decodeURIComponent(author);
return createMetadata({
title: `${decodedAuthor} | Author`,
description: `Posts authored by ${decodedAuthor} on Confessions of Grace.`,
url: `https://confessionsofgrace.com/authors/${author}`,
type: 'website'
});
}
async function getPostsByAuthorData(author: string): Promise<PostMetadata[]> {
return await getPostsByAuthor(author);
}
export default async function AuthorPage({ params }: PageProps) {
const { author } = await params;
const decodedAuthor = decodeURIComponent(author);
const posts = await getPostsByAuthorData(decodedAuthor);
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<AuthorProfile author={decodedAuthor} posts={posts} />
{/* Posts */}
<p className="text-lg text-primary-600 mb-6">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by &quot;{decodedAuthor}&quot;
</p>
{posts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-xl text-primary-500">No posts found for this author.</p>
</div>
)}
</div>
);
}
-48
View File
@@ -1,48 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { getAllAuthors } from '@/lib/authors';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: 'Authors',
description: 'Meet the authors of Confessions of Grace and learn about their backgrounds in Reformed theology.',
url: 'https://confessionsofgrace.com/authors',
type: 'website'
});
}
export default async function AuthorsPage() {
const authors = await getAllAuthors();
return (
<div className="max-w-5xl mx-auto px-4">
<h1 className="text-4xl font-bold mb-10 text-center">Meet the Authors</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{authors.map((author) => (
<Link
key={author.name}
href={`/authors/${encodeURIComponent(author.slug)}`}
className="bg-white rounded-lg shadow-md p-5 flex flex-col items-center hover:shadow-lg transition-shadow"
>
<div className="w-24 h-24 mb-4 rounded-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-500">
{author.name.charAt(0).toUpperCase()}
</div>
<h2 className="text-lg font-semibold">{author.name}</h2>
<p className="text-sm text-primary-500 mt-1">
{author.postCount} {author.postCount === 1 ? 'post' : 'posts'}
</p>
</Link>
))}
</div>
<div className="mt-12 text-center">
<Link href="/posts" className="button">
View All Posts
</Link>
</div>
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
import React from 'react';
import {createPageMetadata} from "@/components/Metadata";
export const metadata = createPageMetadata({
title: "RB Church Finder",
description: "",
url: "https://confessionsofgrace.com/church-finder"
});
export default function ChurchFinder() {
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8">Find a Reformed Church Near You</h1>
<div className="bg-white rounded-lg shadow-sm p-6 border border-primary-200 mb-8">
<p className="text-primary-700 mb-4">
Use the map below to search for Reformed Baptist churches in your area. You can zoom in, search by
city, or drag the map to explore different regions.
</p>
<div className="w-full aspect-video rounded-md overflow-hidden border border-primary-200 shadow-sm">
<iframe
title="Reformed Church Finder"
src="https://www.google.com/maps/d/embed?mid=1lxWCjrZza0cQ8NIen0PMof9r1kg6zsI&ehbc=2E312F"
width="100%"
height="100%"
style={{border: 0}}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
></iframe>
</div>
</div>
<div className="text-center text-primary-500 text-sm">
<p>
Want your church listed? <a href="https://reformedbaptistchurches.com/form"
className="text-accent-dark hover:text-accent">Contact us</a> with your
church's info!
</p>
</div>
</div>
);
};
-175
View File
@@ -1,175 +0,0 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
interface ConfessionViewerProps {
confession: {
title: string;
chapters: {
[key: string]: {
title: string;
paragraphs: {
[key: string]: string;
};
};
};
};
verses: {
[key: string]: string;
};
}
export default function ConfessionViewer({ confession, verses }: ConfessionViewerProps) {
const [activeChapter, setActiveChapter] = useState<string>("1");
const [showScripture, setShowScripture] = useState<boolean>(true);
// Get sorted chapter numbers
const chapterNumbers = Object.keys(confession.chapters).sort((a, b) =>
parseInt(a) - parseInt(b)
);
// Function to render Bible references as links with tooltips
const renderTextWithReferences = (text: string) => {
const regex = /\b(?:\d\s+)?[A-Za-z]+\s+\d+:\d+(?:-\d+)?(?:,\s+\d+(?:-\d+)?)*\b/g;
const parts = text.split(regex);
const matches = text.match(regex) || [];
if (matches.length === 0) return text;
return parts.reduce((result: React.ReactNode[], part, i) => {
result.push(part);
if (i < matches.length) {
const reference = matches[i];
const verseText = verses[reference];
if (verseText && showScripture) {
result.push(
<span key={i} className="relative group">
<span className="text-accent-dark cursor-pointer underline">
{reference}
</span>
<span className="absolute left-0 -bottom-2 transform translate-y-full bg-white border border-primary-200 p-3 rounded-md shadow-lg w-64 hidden group-hover:block z-10 text-sm text-primary-700">
<strong>{reference}</strong>: {verseText}
</span>
</span>
);
} else {
result.push(
<span key={i} className="text-accent-dark">
{reference}
</span>
);
}
}
return result;
}, []);
};
return (
<>
{/* Uncomment this section if you want the introduction and controls */}
{/* <div className="mb-8 bg-white rounded-lg shadow-sm p-6 border border-primary-200">
<p className="text-primary-700 mb-4">
The 1689 Baptist Confession of Faith, also called the Second London Baptist Confession, was written by Particular Baptists in England who were concerned that their Calvinistic theological positions would be misunderstood.
</p>
<p className="text-primary-700 mb-4">
This confession follows the Westminster Confession of Faith and the Savoy Declaration in its doctrine, but with Baptist distinctives related to baptism and church polity.
</p>
<div className="flex mb-2 mt-6">
<button
onClick={() => setShowScripture(!showScripture)}
className="button text-sm"
>
{showScripture ? 'Hide Scripture Tooltips' : 'Show Scripture Tooltips'}
</button>
<p className="ml-3 text-sm text-primary-500 self-center">
{showScripture ? 'Hover over references to see Scripture text' : 'Scripture tooltips are hidden'}
</p>
</div>
</div> */}
<div className="flex flex-col md:flex-row gap-8">
{/* Sidebar Navigation */}
<div className="md:w-1/4">
<div className="bg-white rounded-lg shadow-sm border border-primary-200 sticky top-4">
<h2 className="text-xl font-bold p-4 border-b border-primary-200">Chapters</h2>
<div className="overflow-y-auto max-h-[70vh] p-2">
{chapterNumbers.map(chapterNum => (
<button
key={chapterNum}
onClick={() => setActiveChapter(chapterNum)}
className={`w-full text-left p-3 rounded-md my-1 transition-colors ${
activeChapter === chapterNum
? 'bg-accent text-white'
: 'text-primary-700 hover:bg-primary-50'
}`}
>
<span className="font-bold">Chapter {chapterNum}</span>
<br />
<span className="text-sm">
{confession.chapters[chapterNum].title}
</span>
</button>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="md:w-3/4">
<div className="bg-white rounded-lg shadow-sm p-6 border border-primary-200">
<h2 className="text-2xl font-bold mb-6 pb-2 border-b border-primary-200">
Chapter {activeChapter}: {confession.chapters[activeChapter].title}
</h2>
{Object.keys(confession.chapters[activeChapter].paragraphs).map(paraNum => (
<div key={paraNum} className="mb-8 last:mb-0" id={`${activeChapter}-${paraNum}`}>
<h3 className="font-bold text-lg mb-3 flex items-baseline">
<span className="bg-accent text-white w-8 h-8 inline-flex items-center justify-center rounded-full mr-2 text-sm flex-shrink-0">
{paraNum}
</span>
<Link
href={`#${activeChapter}-${paraNum}`}
className="hover:text-accent-dark"
>
Paragraph {paraNum}
</Link>
</h3>
<p className="text-primary-700 leading-relaxed">
{renderTextWithReferences(confession.chapters[activeChapter].paragraphs[paraNum])}
</p>
</div>
))}
</div>
<div className="flex justify-between mt-6">
{parseInt(activeChapter) > 1 && (
<button
onClick={() => setActiveChapter((parseInt(activeChapter) - 1).toString())}
className="bg-white text-primary-700 border border-primary-200 px-4 py-2 rounded-md hover:bg-primary-50 flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous Chapter
</button>
)}
{parseInt(activeChapter) < chapterNumbers.length && (
<button
onClick={() => setActiveChapter((parseInt(activeChapter) + 1).toString())}
className="bg-white text-primary-700 border border-primary-200 px-4 py-2 rounded-md hover:bg-primary-50 flex items-center ml-auto"
>
Next Chapter
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
</>
);
}
-48
View File
@@ -1,48 +0,0 @@
import React from 'react';
import confessionData from '@/data/1689-confession.json';
import verseReferences from '@/data/1689-verses.json';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
import ConfessionViewer from './ConfessionViewer';
interface ConfessionData {
title: string;
chapters: {
[key: string]: {
title: string;
paragraphs: {
[key: string]: string;
};
};
};
}
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: '1689 Baptist Confession of Faith',
description: 'Read the complete 1689 Baptist Confession of Faith, also known as the Second London Baptist Confession, with Scripture references.',
url: 'https://confessionsofgrace.com/1689-confession',
type: 'website'
});
}
async function getConfessionData(): Promise<{
confession: ConfessionData;
verses: { [key: string]: string };
}> {
return {
confession: confessionData,
verses: verseReferences,
};
}
export default async function ConfessionPage() {
const { confession, verses } = await getConfessionData();
return (
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">The Baptist Confession of Faith of 1689</h1>
<ConfessionViewer confession={confession} verses={verses} />
</div>
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-44
View File
@@ -1,44 +0,0 @@
import type {Metadata} from "next";
import {Geist, Geist_Mono} from "next/font/google";
import "./globals.css";
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import {defaultMetadata} from '@/components/Metadata';
import {Analytics} from "@vercel/analytics/next"
import {SpeedInsights} from "@vercel/speed-insights/next"
export const metadata = defaultMetadata;
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<div className="flex flex-col min-h-screen">
<Header/>
<main className="flex-grow container mx-auto px-4 py-8">
{children}
</main>
<Footer/>
</div>
<Analytics/>
<SpeedInsights/>
</body>
</html>
);
}
-116
View File
@@ -1,116 +0,0 @@
export const dynamic = 'force-dynamic';
import React from 'react';
import PostCard from '@/components/PostCard';
import Sidebar from '@/components/Sidebar';
import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import Image from 'next/image';
// This function runs on the server during build time
async function getHomeData() {
const posts = await getSortedPostsData();
// Create tag data
const allTags = posts.flatMap(post => post.tags);
const tagCounts: Record<string, number> = {};
allTags.forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
const tags = Object.entries(tagCounts).map(([tag, count]) => ({
tag,
count
}));
return {
posts,
recentPosts: posts.slice(0, 5).map(post => ({
id: post.id,
title: post.title,
date: post.date
})),
tags
};
}
// Make the component async
export default async function Home() {
// Fetch data directly in the component
const { posts, recentPosts, tags } = await getHomeData();
return (
<div className="flex flex-col md:flex-row gap-8">
<main className="md:w-2/3">
{posts.length > 0 && (
<div className="mb-12">
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="md:flex">
<div className="md:flex-shrink-0 bg-accent relative w-full md:w-1/3 h-64 md:h-auto">
{posts[0].coverImage ? (
<Image
src={posts[0].coverImage}
alt={posts[0].title}
className="h-full w-full object-cover"
width={1000}
height={1000}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-accent text-white text-6xl font-bold">
CG
</div>
)}
</div>
<div className="p-8">
<div className="uppercase tracking-wide text-sm text-accent font-semibold">
Latest Post
</div>
<a
href={`/posts/${posts[0].id}`}
className="block mt-1 text-2xl leading-tight font-bold text-primary-900 hover:text-accent-dark"
>
{posts[0].title}
</a>
<p className="mt-2 text-primary-600">
{posts[0].excerpt}
</p>
<div className="mt-4">
<a
href={`/posts/${posts[0].id}`}
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>
</a>
</div>
</div>
</div>
</div>
</div>
)}
<div className="mb-8">
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">Recent Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{posts.slice(1).map((post) => (
<PostCard key={post.id} post={post}/>
))}
</div>
</div>
<div className="text-center mt-12">
<a href="/posts" className="button">
View All Posts
</a>
</div>
</main>
<div className="md:w-1/3">
<Sidebar recentPosts={recentPosts} tags={tags}/>
</div>
</div>
);
}
-161
View File
@@ -1,161 +0,0 @@
export const dynamic = "force-dynamic";
import ShareButtons from "@/components/ShareButtons";
import { getPostData, getSortedPostsData } from "@/lib/posts";
import { PostData, PostMetadata } from "@/types";
import { format } from "date-fns";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { generateMetadata as createMetadata } from "@/components/Metadata";
import type { Metadata } from "next";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { id } = await params;
const post = await getPostData(id);
const postUrl = `https://confessionsofgrace.com/posts/${post.id}`;
return createMetadata({
title: post.title,
description: post.excerpt,
keywords: post.tags.join(", "),
image: post.coverImage,
url: postUrl,
type: "article",
});
}
async function getPostAndMorePosts(
id: string,
): Promise<{ post: PostData; morePosts: PostMetadata[] }> {
const post = await getPostData(id);
const allPosts = await getSortedPostsData();
// Filter out the current post and get a few related posts (by tags)
const otherPosts = allPosts.filter((p) => p.id !== post.id);
// Find posts with matching tags
const relatedPosts = otherPosts
.filter((p) => p.tags.some((tag) => post.tags.includes(tag)))
.slice(0, 2);
// If we don't have enough related posts, add recent posts
const morePosts =
relatedPosts.length < 2
? [
...relatedPosts,
...otherPosts.filter((p) => !relatedPosts.includes(p)),
].slice(0, 2)
: relatedPosts;
return { post, morePosts };
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params;
const { post, morePosts } = await getPostAndMorePosts(id);
const postUrl = `https://confessionsofgrace.com/posts/${post.id}`;
return (
<article className="max-w-3xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold mb-2">{post.title}</h1>
<div className="flex items-center text-primary-500 mb-6">
<span>{post.author}</span>
<span className="mx-2"></span>
<time dateTime={post.date}>
{format(new Date(post.date), "MMMM d, yyyy")}
</time>
</div>
{post.coverImage && (
<div className="relative h-64 md:h-96 w-full mb-8 rounded-lg overflow-hidden">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
<div className="flex flex-wrap gap-2 mb-6">
{post.tags.map((tag) => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-sm bg-primary-100 text-primary-600 px-3 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
</header>
<div
className="blog-post prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<div className="mt-8 pt-6 border-t border-primary-200">
<ShareButtons
url={postUrl}
title={post.title}
description={post.excerpt}
/>
</div>
<div className="mt-12 pt-6 border-t border-primary-200">
<h2 className="text-2xl font-bold mb-6">More Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{morePosts.slice(0, 2).map((post) => (
<div
key={post.id}
className="bg-white p-6 rounded-md border border-primary-200 shadow-sm"
>
<h3 className="font-bold mb-2">
<Link
href={`/posts/${post.id}`}
className="hover:text-accent-dark"
>
{post.title}
</Link>
</h3>
<p className="text-primary-600 text-sm mb-2">
{format(new Date(post.date), "MMMM d, yyyy")}
</p>
<p className="text-primary-700">{post.excerpt}</p>
</div>
))}
</div>
</div>
<div className="mt-12 pt-6 border-t border-primary-200">
<Link
href="/public"
className="text-accent-dark hover:text-accent inline-flex items-center"
>
<svg
className="mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to all posts
</Link>
</div>
</article>
);
}
-86
View File
@@ -1,86 +0,0 @@
export const dynamic = 'force-dynamic';
import React from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: 'Posts Archive',
description: 'Browse all posts from Confessions of Grace, organized by year.',
url: 'https://confessionsofgrace.com/posts',
type: 'website'
});
}
async function getPostsAndYears(): Promise<{ posts: PostMetadata[]; years: number[] }> {
const posts = await getSortedPostsData();
// Extract unique years from post dates
const years = Array.from(new Set(
posts.map(post => new Date(post.date).getFullYear())
)).sort((a, b) => b - a); // Sort years in descending order
return { posts, years };
}
export default async function PostsPage() {
const { posts, years } = await getPostsAndYears();
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8">Archive</h1>
{years.map(year => (
<div key={year} className="mb-12">
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{year}</h2>
<ul className="space-y-6">
{posts
.filter(post => new Date(post.date).getFullYear() === year)
.map(post => (
<li key={post.id} className="bg-white p-6 rounded-md shadow-sm border border-primary-200">
<div className="md:flex md:justify-between md:items-center">
<div>
<h3 className="text-xl font-bold mb-2">
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
{post.title}
</Link>
</h3>
<div className="flex items-center mb-2 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="text-primary-600">{post.excerpt}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4 items-center">
{post.tags.map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-sm bg-primary-100 text-primary-600 px-3 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
<div className="mt-4 md:mt-0 md:self-end md:ml-auto flex justify-end">
<Link href={`/posts/${post.id}`} className="text-sm bg-accent text-white px-3 py-1 rounded-md hover:bg-accent-dark">
Read post
</Link>
</div>
</div>
</li>
))}
</ul>
</div>
))}
</div>
);
}
-114
View File
@@ -1,114 +0,0 @@
import React from 'react';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
interface Resource {
title: string;
author: string;
description: string;
link?: string;
category: string;
}
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: 'Reformed Resources',
description: 'A curated collection of Reformed theology resources including confessions, books, and websites for deepening understanding of the doctrines of grace.',
url: 'https://confessionsofgrace.com/resources',
type: 'website'
});
}
export default function ResourcesPage() {
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"
},
{
title: "Church Finder",
author: "",
description: "Find a Reformed Baptist church near you using our interactive map.",
link: "/church-finder",
category: "Websites"
}
];
const categories = Array.from(new Set(resources.map(resource => resource.category)));
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Reformed Resources</h1>
<p className="text-lg text-primary-700 mb-10">
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="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{category}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{resources
.filter(resource => resource.category === category)
.map((resource, index) => (
<div key={index}
className="bg-white rounded-lg shadow-sm p-6 border border-primary-200">
<h3 className="text-xl font-bold mb-2">{resource.title}</h3>
{resource.author && (
<p className="text-primary-500 italic mb-3">by {resource.author}</p>
)}
<p className="text-primary-700 mb-4">{resource.description}</p>
{resource.link && (
<a
href={resource.link}
target="_blank"
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="h-4 w-4 ml-1"
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>
);
}
-77
View File
@@ -1,77 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import PostCard from '@/components/PostCard';
import SearchBar from '@/components/SearchBar';
import { PostMetadata } from '@/types';
interface SearchClientProps {
allPosts: PostMetadata[];
}
export default function SearchClient({ allPosts }: SearchClientProps) {
const searchParams = useSearchParams();
const query = searchParams.get('q') || '';
const [searchResults, setSearchResults] = useState<PostMetadata[]>([]);
useEffect(() => {
if (!query.trim()) {
setSearchResults([]);
return;
}
const searchTerms = query.toLowerCase().trim().split(/\s+/);
const filteredPosts = allPosts.filter(post => {
const titleMatch = searchTerms.some(term =>
post.title.toLowerCase().includes(term)
);
const excerptMatch = searchTerms.some(term =>
post.excerpt.toLowerCase().includes(term)
);
const tagMatch = post.tags.some(tag =>
searchTerms.some(term => tag.toLowerCase().includes(term))
);
return titleMatch || excerptMatch || tagMatch;
});
setSearchResults(filteredPosts);
}, [query, allPosts]);
return (
<>
<div className="mb-8">
<SearchBar />
</div>
{query.trim() ? (
<>
<p className="mb-8 text-primary-600">
{searchResults.length} {searchResults.length === 1 ? 'result' : 'results'} for "{query}"
</p>
{searchResults.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{searchResults.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 text-center">
<p className="text-xl text-primary-600 mb-4">No results found for "{query}"</p>
<p className="text-primary-500">Try using different keywords or browse all posts.</p>
</div>
)}
</>
) : (
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<p className="text-xl text-primary-600 mb-4 text-center">Enter a search term to find posts</p>
</div>
)}
</>
);
}
-34
View File
@@ -1,34 +0,0 @@
export const dynamic = 'force-dynamic';
import React, {Suspense} from 'react';
import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
import SearchClient from './SearchClient';
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: 'Search Posts',
description: 'Search through all posts on Confessions of Grace by title, content, or tags.',
url: 'https://confessionsofgrace.com/search',
type: 'website'
});
}
async function getAllPosts(): Promise<PostMetadata[]> {
return getSortedPostsData();
}
export default async function SearchPage() {
const allPosts = await getAllPosts();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Search Results</h1>
<Suspense fallback={<div>Loading search...</div>}>
<SearchClient allPosts={allPosts} />
</Suspense>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import { getPostsByTag } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ tag: string }>;
}
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
return createMetadata({
title: `Posts tagged "${decodedTag}"`,
description: `Browse all posts tagged with "${decodedTag}" on Confessions of Grace.`,
url: `https://confessionsofgrace.com/tags/${tag}`,
type: 'website'
});
}
async function getPostsByTagData(tag: string): Promise<PostMetadata[]> {
const decodedTag = decodeURIComponent(tag);
return await getPostsByTag(decodedTag);
}
export default async function TagPage({ params }: PageProps) {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
const posts = await getPostsByTagData(tag);
return (
<div className="max-w-4xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold mb-4">Tag: {decodedTag}</h1>
<p className="text-lg text-primary-600">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} tagged with "{decodedTag}"
</p>
</div>
{posts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-xl text-primary-500">No posts found with this tag.</p>
</div>
)}
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
export const dynamic = 'force-dynamic';
import React from 'react';
import Link from 'next/link';
import { getSortedPostsData } from '@/lib/posts';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
return createMetadata({
title: 'Tags',
description: 'Browse all tags used on Confessions of Grace to find posts by topic.',
url: 'https://confessionsofgrace.com/tags',
type: 'website'
});
}
async function getAllTags(): Promise<{ tag: string; count: number }[]> {
try {
const posts = await getSortedPostsData();
const tagCount: { [key: string]: number } = {};
// Count occurrences of each tag
posts.forEach(post => {
post.tags.forEach(tag => {
if (tag && tag.trim() !== '') {
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
});
});
// Convert to array and sort by count (descending) then by name
return Object.entries(tagCount)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.tag.localeCompare(b.tag);
});
} catch (error) {
console.error('Error getting tags:', error);
return [];
}
}
export default async function TagsPage() {
const tags = await getAllTags();
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Browse by Tag</h1>
<p className="text-lg text-primary-700 mb-8">
Explore posts organized by topic. Click on any tag to see all posts with that tag.
</p>
{tags.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{tags.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${encodeURIComponent(tag)}`}
className="bg-white border border-primary-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-center">
<span className="font-medium text-primary-700">{tag}</span>
<span className="bg-primary-100 text-primary-600 px-2 py-1 rounded-full text-sm">
{count} {count === 1 ? 'post' : 'posts'}
</span>
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-xl text-primary-500">No tags found.</p>
</div>
)}
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { format, parseISO } from 'date-fns';
import React from 'react';
interface DateFormatterProps {
dateString: string;
formatString?: string;
className?: string;
}
const DateFormatter: React.FC<DateFormatterProps> = ({
dateString,
formatString = 'MMMM d, yyyy',
className = ''
}) => {
const date = parseISO(dateString);
return (
<time dateTime={dateString} className={className}>
{format(date, formatString)}
</time>
);
};
export default DateFormatter;
-76
View File
@@ -1,76 +0,0 @@
import React from 'react';
import Link from 'next/link';
import SubscribeForm from './SubscribeForm'; // Import the reusable SubscribeForm
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-primary-800 text-white mt-16">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 className="text-xl font-bold mb-4 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="text-xl font-bold mb-4 text-white">Navigation</h3>
<ul className="space-y-2">
<li>
<Link href="/" className="text-primary-300 hover:text-white">
Home
</Link>
</li>
<li>
<Link href="/about" className="text-primary-300 hover:text-white">
About
</Link>
</li>
<li>
<Link href="/posts" className="text-primary-300 hover:text-white">
Archive
</Link>
</li>
<li>
<Link href="/confession" className="text-primary-300 hover:text-white">
1689 Confession
</Link>
</li>
<li>
<Link href="/resources" className="text-primary-300 hover:text-white">
Resources
</Link>
</li>
<li>
<Link href="https://www.etsy.com/shop/ConfessionsOfGrace" className="text-primary-700 hover:text-accent-dark">
Shop
</Link>
</li>
</ul>
</div>
<div>
<h3 className="text-xl font-bold mb-4 text-white">Subscribe</h3>
<p className="text-primary-300 mb-4">
Stay updated with the latest posts.
</p>
<SubscribeForm
placeholder="Your email"
buttonLabel="Subscribe"
className="flex flex-col space-y-2"
/> {/* Use the common SubscribeForm */}
</div>
</div>
<div className="border-t border-primary-700 mt-8 pt-8 text-center text-primary-400">
<p>&copy; {currentYear} Confessions of Grace. All rights reserved.</p>
<p className="mt-2 text-sm">
"For by grace you have been saved through faith. And this is not your own doing; it is the gift of God." Ephesians 2:8
</p>
</div>
</div>
</footer>
);
};
export default Footer;
-55
View File
@@ -1,55 +0,0 @@
import React from 'react';
import Link from 'next/link';
import Image from "next/image";
const Header: React.FC = () => {
return (
<header className="bg-white shadow-sm border-b border-primary-200">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col md:flex-row justify-between items-center">
<Link href="/" className="no-underline">
<div className="mb-4 md:mb-0 flex items-center space-x-4">
<Image
src="/assets/logo.svg"
alt="Logo"
width={100}
height={100}
className="h-auto w-auto max-h-12"
/>
<div>
<h1 className="text-3xl font-bold text-primary-900 mb-0">Confessions of Grace</h1>
<p className="text-primary-500 italic text-sm">Confessing Christ. Rejoicing in Grace.</p>
</div>
</div>
</Link>
<nav className="flex space-x-6">
<Link href="/" className="text-primary-700 hover:text-accent-dark">
Home
</Link>
<Link href="/about" className="text-primary-700 hover:text-accent-dark">
About
</Link>
<Link href="/posts" className="text-primary-700 hover:text-accent-dark">
Archive
</Link>
{/*<Link href="/confession" className="text-primary-700 hover:text-accent-dark">*/}
{/* 1689 Confession*/}
{/*</Link>*/}
<Link href="/resources" className="text-primary-700 hover:text-accent-dark">
Resources
</Link>
<Link href="https://www.etsy.com/shop/ConfessionsOfGrace" className="text-primary-700 hover:text-accent-dark">
Shop
</Link>
</nav>
</div>
</div>
</header>
);
};
export default Header;
-65
View File
@@ -1,65 +0,0 @@
import type { Metadata } from "next";
interface MetaProps {
title?: string;
description?: string;
keywords?: string;
image?: string;
url?: string;
type?: string;
}
export function generateMetadata({
title = "Confessions of Grace",
description = "A blog dedicated to exploring the doctrines of grace and Reformed theology.",
keywords = "reformed theology, doctrines of grace, christianity, calvinism, theology",
image = "/images/og-default.jpg",
url = "https://confessionsofgrace.com",
type = "website",
}: MetaProps = {}): Metadata {
const siteTitle =
title === "Confessions of Grace"
? "Confessions of Grace | Confessing Christ. Rejoicing in Grace."
: `${title} | Confessions of Grace`;
const fullImageUrl = image.startsWith("http")
? image
: `https://confessionsofgrace.com${image}`;
return {
title: siteTitle,
description,
keywords,
openGraph: {
type: type as any,
url,
title: siteTitle,
description,
images: [
{
url: fullImageUrl,
alt: title || "Confessions of Grace",
},
],
locale: "en_US",
siteName: "Confessions of Grace",
},
twitter: {
card: "summary_large_image",
title: siteTitle,
description,
images: [fullImageUrl],
},
alternates: {
canonical: url,
},
};
}
// Default metadata for the site
export const defaultMetadata: Metadata = generateMetadata();
// Helper function for pages that need custom metadata
export function createPageMetadata(props: MetaProps): Metadata {
return generateMetadata(props);
}
-113
View File
@@ -1,113 +0,0 @@
import React from 'react';
import Link from 'next/link';
interface PaginationProps {
currentPage: number;
totalPages: number;
basePath: string;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
basePath
}) => {
// Generate page numbers to display
const getPageNumbers = () => {
// Always show first and last page
// Show 2 pages before and after current page
const pageNumbers = new Set<number>();
// Add current page
pageNumbers.add(currentPage);
// Add adjacent pages
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) {
pageNumbers.add(i);
}
// Always add first and last page
pageNumbers.add(1);
pageNumbers.add(totalPages);
// Convert to array and sort
return Array.from(pageNumbers).sort((a, b) => a - b);
};
const pageNumbers = getPageNumbers();
// Generate link for a page number
const getPageLink = (pageNum: number) => {
if (pageNum === 1) {
return basePath;
}
return `${basePath}/page/${pageNum}`;
};
return (
<nav className="flex justify-center my-8">
<ul className="flex items-center space-x-1">
{/* Previous page button */}
{currentPage > 1 && (
<li>
<Link
href={getPageLink(currentPage - 1)}
className="flex items-center justify-center w-10 h-10 rounded-md border border-primary-200 bg-white text-primary-700 hover:bg-primary-50"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
</li>
)}
{/* Page numbers */}
{pageNumbers.map((pageNum, index) => {
// Add ellipsis if there's a gap
const showEllipsisBefore = index > 0 && pageNum > pageNumbers[index - 1] + 1;
return (
<React.Fragment key={pageNum}>
{showEllipsisBefore && (
<li className="flex items-center justify-center w-10 h-10 text-primary-500">
...
</li>
)}
<li>
<Link
href={getPageLink(pageNum)}
className={`flex items-center justify-center w-10 h-10 rounded-md border ${
pageNum === currentPage
? 'bg-accent text-white border-accent font-bold'
: 'bg-white text-primary-700 border-primary-200 hover:bg-primary-50'
}`}
aria-current={pageNum === currentPage ? 'page' : undefined}
>
{pageNum}
</Link>
</li>
</React.Fragment>
);
})}
{/* Next page button */}
{currentPage < totalPages && (
<li>
<Link
href={getPageLink(currentPage + 1)}
className="flex items-center justify-center w-10 h-10 rounded-md border border-primary-200 bg-white text-primary-700 hover:bg-primary-50"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</li>
)}
</ul>
</nav>
);
};
export default Pagination;
-69
View File
@@ -1,69 +0,0 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { format } from 'date-fns';
import { PostMetadata } from '@/types';
interface PostCardProps {
post: PostMetadata;
}
const PostCard: React.FC<PostCardProps> = ({ post }) => {
return (
<article className="card hover:shadow-md transition-shadow duration-200">
{post.coverImage && (
<div className="mb-4 relative h-48 w-full overflow-hidden rounded-md">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
/>
</div>
)}
<h2 className="text-xl font-bold mb-2">
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
{post.title}
</Link>
</h2>
<div className="flex items-center mb-2 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="text-primary-600 mb-4">{post.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-xs bg-primary-100 text-primary-600 px-2 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
<Link href={`/posts/${post.id}`} className="inline-flex items-center text-accent-dark hover:text-accent">
Read more
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 ml-1"
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>
);
};
export default PostCard;
-48
View File
@@ -1,48 +0,0 @@
import { useRouter } from 'next/router';
import React, { useState } from 'react';
const SearchBar: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
}
};
return (
<form onSubmit={handleSubmit} className="relative">
<input
type="text"
placeholder="Search posts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 rounded-md border border-primary-200 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button
type="submit"
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-primary-400"
aria-label="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
</form>
);
};
export default SearchBar;
-118
View File
@@ -1,118 +0,0 @@
import React from 'react';
interface ShareButtonsProps {
url: string;
title: string;
description?: string;
}
const ShareButtons: React.FC<ShareButtonsProps> = ({ url, title, description = '' }) => {
// Encode parameters for sharing URLs
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedDescription = encodeURIComponent(description);
// Generate sharing URLs
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
const emailUrl = `mailto:?subject=${encodedTitle}&body=${encodedDescription}%0A%0A${encodedUrl}`;
return (
<div className="flex items-center space-x-3">
<span className="text-primary-600 font-medium">Share:</span>
{/* Facebook */}
<a
href={facebookUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on Facebook"
title="Share on Facebook"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
clipRule="evenodd"
/>
</svg>
</a>
{/* Twitter */}
<a
href={twitterUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on Twitter"
title="Share on Twitter"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
/>
</svg>
</a>
{/* LinkedIn */}
<a
href={linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on LinkedIn"
title="Share on LinkedIn"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
clipRule="evenodd"
/>
</svg>
</a>
{/* Email */}
<a
href={emailUrl}
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share via Email"
title="Share via Email"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</a>
</div>
);
};
export default ShareButtons;
-82
View File
@@ -1,82 +0,0 @@
import React from 'react';
import Link from 'next/link';
import TagList from './TagList';
import SubscribeForm from "@/components/SubscribeForm";
interface SidebarProps {
recentPosts: Array<{
id: string;
title: string;
date: string;
}>;
tags?: Array<{
tag: string;
count: number;
}>;
}
const Sidebar: React.FC<SidebarProps> = ({ recentPosts, tags = [] }) => {
return (
<aside className="space-y-8">
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">About</h2>
<p className="text-primary-700 mb-4">
Confessions of Grace explores the riches of Reformed theology and the doctrines of grace.
</p>
<Link
href="/about"
className="text-accent-dark hover:text-accent inline-flex items-center"
>
Learn more
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 ml-1"
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 className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Recent Posts</h2>
<ul className="space-y-3">
{recentPosts.map(post => (
<li key={post.id}>
<Link
href={`/posts/${post.id}`}
className="hover:text-accent-dark block"
>
<h3 className="font-medium">{post.title}</h3>
<p className="text-sm text-primary-500">
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</Link>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Popular Tags</h2>
<TagList limit={15} tags={tags} />
</div>
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Subscribe</h2>
<p className="text-primary-700 mb-4">
Stay updated with the latest posts and resources.
</p>
<SubscribeForm /> {/* Use the common SubscribeForm */}
</div>
</aside>
);
};
export default Sidebar;
-49
View File
@@ -1,49 +0,0 @@
"use client"
import React, { useState } from 'react';
interface SubscribeFormProps {
placeholder?: string;
buttonLabel?: string;
className?: string;
}
// Email subscriptions require a backend which has been removed for the static
// build. This form is a no-op: it never calls any API and simply informs the
// user that subscriptions are unavailable.
const SubscribeForm: React.FC<SubscribeFormProps> = ({
placeholder = 'Your email',
buttonLabel = 'Subscribe',
className = '',
}) => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault();
setMessage('Subscriptions are currently unavailable.');
};
return (
<form onSubmit={handleSubscribe} className={`space-y-3 ${className}`}>
<div>
<input
type="email"
placeholder={placeholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
</div>
<button
type="submit"
className="button w-full"
>
{buttonLabel}
</button>
{message && <p className="text-sm text-primary-700">{message}</p>}
</form>
);
};
export default SubscribeForm;
-38
View File
@@ -1,38 +0,0 @@
import React from 'react';
import Link from 'next/link';
interface TagListProps {
className?: string;
limit?: number;
tags?: Array<{
tag: string;
count: number;
}>;
}
const TagList: React.FC<TagListProps> = ({ className = '', limit, tags = [] }) => {
// Sort tags by count (descending)
const sortedTags = [...tags].sort((a, b) => b.count - a.count);
// Apply limit if provided
const tagsToShow = limit ? sortedTags.slice(0, limit) : sortedTags;
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{tagsToShow.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${tag}`}
className="bg-primary-100 text-primary-700 px-3 py-1 rounded-md hover:bg-primary-200 text-sm transition-colors duration-200 flex items-center"
>
{tag}
<span className="ml-1 text-xs bg-primary-200 rounded-full px-2 py-0.5">
{count}
</span>
</Link>
))}
</div>
);
};
export default TagList;
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/assets/logo.svg">
<title>Confessions of Grace</title>
<meta name="description" content="Confessing Christ. Rejoicing in Grace. A blog exploring the doctrines of grace and Reformed theology.">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1524
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "confessions-of-grace-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"clsx": "2.1.1",
"date-fns": "4.4.0",
"lucide-react": "1.25.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "4.3.3",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"tailwindcss": "4.3.3",
"typescript": "^5.9.0",
"vite": "^8.1.3"
}
}

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

+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>
);
}
+19 -27
View File
@@ -1,6 +1,11 @@
@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;
@@ -17,20 +22,8 @@
--color-accent: #9d8c70;
--color-accent-dark: #695c4a;
--font-family-serif: Baskerville, Georgia, "Times New Roman", serif;
--font-family-sans: "Helvetica Neue", Arial, sans-serif;
--color-background: #ffffff;
--color-foreground: #171717;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
@theme {
--color-background: #0a0a0a;
--color-foreground: #ededed;
}
--font-serif: Baskerville, Georgia, "Times New Roman", serif;
--font-sans: "Helvetica Neue", Arial, sans-serif;
}
@layer base {
@@ -42,17 +35,9 @@
@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;
}
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;
@@ -73,10 +58,17 @@
}
.button {
@apply px-4 py-2 rounded-md bg-accent text-white shadow-sm hover:bg-accent-dark transition-colors duration-200;
@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;
@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;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// dist/ is copied into the Spring jar's static/ by platform-parent's managed build.
export default defineConfig({
base: '/',
plugins: [react(), tailwindcss()],
build: { outDir: 'dist', emptyOutDir: true },
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8080',
'/oauth2': 'http://127.0.0.1:8080',
'/login': 'http://127.0.0.1:8080',
'/logout': 'http://127.0.0.1:8080',
},
},
});
-57
View File
@@ -1,57 +0,0 @@
import { getSortedPostsData, getPostsByAuthor } from "@/lib/posts";
import { PostMetadata } from "@/types";
export interface AuthorSummary {
name: string;
slug: string;
postCount: number;
// No bio data is available in the static build; these remain optional.
bio?: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
export interface AuthorWithPosts extends AuthorSummary {
posts: PostMetadata[];
}
/**
* Derive the list of authors from the posts' frontmatter.
* The author `slug` is the author name itself, which is what
* `getPostsByAuthor` filters on and what the `[author]` route uses.
*/
export async function getAllAuthors(): Promise<AuthorSummary[]> {
const posts = await getSortedPostsData();
const counts = new Map<string, number>();
for (const post of posts) {
if (!post.author) continue;
counts.set(post.author, (counts.get(post.author) || 0) + 1);
}
return Array.from(counts.entries())
.map(([name, postCount]) => ({
name,
slug: name,
postCount,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getAuthor(
name: string,
): Promise<AuthorWithPosts | null> {
const posts = await getPostsByAuthor(name);
if (posts.length === 0) {
return null;
}
return {
name,
slug: name,
postCount: posts.length,
posts,
};
}
-94
View File
@@ -1,94 +0,0 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import html from "remark-html";
import { PostData, PostMetadata } from "@/types";
const postsDirectory = path.join(process.cwd(), "data/posts");
interface PostFrontmatter {
title: string;
date: string;
author: string;
excerpt: string;
tags?: string[];
coverImage?: string;
}
function getPostIds(): string[] {
if (!fs.existsSync(postsDirectory)) {
return [];
}
return fs
.readdirSync(postsDirectory)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => fileName.replace(/\.md$/, ""));
}
function readPostMetadata(id: string): PostMetadata {
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data } = matter(fileContents);
const frontmatter = data as PostFrontmatter;
return {
id,
title: frontmatter.title,
date: frontmatter.date,
excerpt: frontmatter.excerpt,
author: frontmatter.author,
tags: frontmatter.tags || [],
coverImage: frontmatter.coverImage || undefined,
};
}
export async function getSortedPostsData(): Promise<PostMetadata[]> {
const posts = getPostIds().map((id) => readPostMetadata(id));
return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}
export async function getAllPostIds(): Promise<{ params: { id: string } }[]> {
return getPostIds().map((id) => ({
params: { id },
}));
}
export async function getPostData(id: string): Promise<PostData> {
const fullPath = path.join(postsDirectory, `${id}.md`);
if (!fs.existsSync(fullPath)) {
throw new Error(`Post not found: ${id}`);
}
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const frontmatter = data as PostFrontmatter;
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
id,
title: frontmatter.title,
date: frontmatter.date,
excerpt: frontmatter.excerpt,
content: contentHtml,
author: frontmatter.author,
tags: frontmatter.tags || [],
coverImage: frontmatter.coverImage || undefined,
};
}
export async function getPostsByTag(tag: string): Promise<PostMetadata[]> {
const posts = await getSortedPostsData();
return posts.filter((post) => post.tags.includes(tag));
}
export async function getPostsByAuthor(
author: string,
): Promise<PostMetadata[]> {
const posts = await getSortedPostsData();
return posts.filter((post) => post.author === author);
}
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
-4443
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -1,39 +0,0 @@
{
"name": "confessions-of-grace",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"migrate-posts": "npx tsx scripts/migrate-posts.ts"
},
"dependencies": {
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.55.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"autoprefixer": "^10.4.21",
"date-fns": "^4.1.0",
"gray-matter": "^4.0.3",
"next": "^16.1.6",
"postcss": "^8.5.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"supabase": "^2.76.11",
"tailwindcss": "^4.1.12",
"typescript": "^5",
"wrangler": "^4.31.0"
}
}
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath/>
</parent>
<groupId>net.reformedwitness</groupId>
<artifactId>confessions-of-grace</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Confessions of Grace</name>
<description>Reformed devotional site — posts, authors, comments, subscriptions — on the Bennett platform.</description>
<properties>
<commonmark.version>0.29.0</commonmark.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-bom</artifactId>
<version>0.1.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.21.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-web</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-data</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-storage</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-security</artifactId>
</dependency>
<!-- Markdown → HTML with YAML front-matter parsing (posts). -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-yaml-front-matter</artifactId>
<version>${commonmark.version}</version>
</dependency>
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
-5
View File
@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1,12 @@
package net.reformedwitness.cog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ConfessionsApplication {
public static void main(String[] args) {
SpringApplication.run(ConfessionsApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package net.reformedwitness.cog.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
@Entity
@Table(name = "author")
public class Author extends BaseEntity {
@Column(nullable = false, unique = true)
private String name;
@Column(columnDefinition = "text")
private String bio = "";
@Column(name = "x_link")
private String xLink;
@Column(name = "fb_link")
private String fbLink;
@Column(name = "insta_link")
private String instaLink;
@Column(name = "pfp_link")
private String pfpLink;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public String getXLink() { return xLink; }
public void setXLink(String xLink) { this.xLink = xLink; }
public String getFbLink() { return fbLink; }
public void setFbLink(String fbLink) { this.fbLink = fbLink; }
public String getInstaLink() { return instaLink; }
public void setInstaLink(String instaLink) { this.instaLink = instaLink; }
public String getPfpLink() { return pfpLink; }
public void setPfpLink(String pfpLink) { this.pfpLink = pfpLink; }
}
@@ -0,0 +1,33 @@
package net.reformedwitness.cog.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
@Entity
@Table(name = "comment")
public class Comment extends BaseEntity {
@Column(name = "post_slug", nullable = false)
private String postSlug;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column(columnDefinition = "text", nullable = false)
private String body;
public String getPostSlug() { return postSlug; }
public void setPostSlug(String postSlug) { this.postSlug = postSlug; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
}
@@ -0,0 +1,71 @@
package net.reformedwitness.cog.domain;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
@Entity
@Table(name = "post")
public class Post extends BaseEntity {
@Column(nullable = false, unique = true)
private String slug;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "text")
private String excerpt = "";
private String author = "";
@Column(name = "cover_image")
private String coverImage;
@Column(name = "published_on", nullable = false)
private LocalDate publishedOn;
@Column(columnDefinition = "text")
private String content = "";
@Column(name = "content_html", columnDefinition = "text")
private String contentHtml = "";
private boolean published = true;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "post_tags", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "tag")
private List<String> tags = new ArrayList<>();
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getExcerpt() { return excerpt; }
public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getCoverImage() { return coverImage; }
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
public LocalDate getPublishedOn() { return publishedOn; }
public void setPublishedOn(LocalDate publishedOn) { this.publishedOn = publishedOn; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public boolean isPublished() { return published; }
public void setPublished(boolean published) { this.published = published; }
public List<String> getTags() { return tags; }
public void setTags(List<String> tags) { this.tags = tags; }
}
@@ -0,0 +1,18 @@
package net.reformedwitness.cog.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
@Entity
@Table(name = "subscription")
public class Subscription extends BaseEntity {
@Column(nullable = false, unique = true)
private String email;
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
@@ -0,0 +1,15 @@
package net.reformedwitness.cog.repo;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import net.reformedwitness.cog.domain.Author;
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByName(String name);
List<Author> findAllByOrderByNameAsc();
}
@@ -0,0 +1,14 @@
package net.reformedwitness.cog.repo;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import net.reformedwitness.cog.domain.Comment;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostSlugOrderByCreatedAtAsc(String postSlug);
long countByPostSlug(String postSlug);
}
@@ -0,0 +1,29 @@
package net.reformedwitness.cog.repo;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import net.reformedwitness.cog.domain.Post;
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByPublishedTrueOrderByPublishedOnDescIdDesc();
List<Post> findByPublishedTrueAndAuthorOrderByPublishedOnDescIdDesc(String author);
Optional<Post> findBySlug(String slug);
boolean existsBySlug(String slug);
@Query("select distinct p from Post p join p.tags t "
+ "where p.published = true and t = :tag order by p.publishedOn desc, p.id desc")
List<Post> findPublishedByTag(@Param("tag") String tag);
/** [tag, count] for every tag used by a published post. */
@Query("select t, count(p) from Post p join p.tags t where p.published = true group by t order by t")
List<Object[]> tagCounts();
}
@@ -0,0 +1,10 @@
package net.reformedwitness.cog.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import net.reformedwitness.cog.domain.Subscription;
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
boolean existsByEmail(String email);
}

Some files were not shown because too many files have changed in this diff Show More