switched to approuter

This commit is contained in:
2025-08-18 13:10:47 -05:00
parent c2b0d905e1
commit 6cd941295c
82 changed files with 5154 additions and 9772 deletions
+11 -23
View File
@@ -1,7 +1,14 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
@@ -21,9 +28,10 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
@@ -31,23 +39,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea/
.vscode/
# logs
logs
*.log
logs/**/*.log
# OS generated files
Thumbs.db
ehthumbs.db
Desktop.ini
# firebase
.firebase/
.firebaserc
firebase.json
firebase-debug.log
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cog.iml" filepath="$PROJECT_DIR$/.idea/cog.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-1
View File
@@ -1 +0,0 @@
20
+17 -94
View File
@@ -1,113 +1,36 @@
# Confessions of Grace
A blog about Reformed theology built with Next.js and TypeScript, featuring Markdown-based content management.
## Features
- Modern static site built with Next.js
- TypeScript for type safety
- Content management using Markdown files
- Responsive design with Tailwind CSS
- Flat design style with Reformed theology aesthetics
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
### Prerequisites
- Node.js (>= 16.x)
- npm or yarn
### Installation
1. Clone the repository
```bash
git clone https://github.com/yourusername/confessions-of-grace.git
cd confessions-of-grace
```
2. Install dependencies
```bash
npm install
# or
yarn install
```
3. Run the development server
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
4. Open [http://localhost:3000](http://localhost:3000) in your browser to see the result.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Project Structure
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```
├── public/ # Static assets
│ └── images/ # Images for blog posts
├── src/ # Source code
│ ├── components/ # React components
│ ├── lib/ # Utility functions
│ ├── pages/ # Next.js pages
│ ├── posts/ # Markdown blog posts
│ ├── styles/ # CSS styles
│ └── types/ # TypeScript type definitions
├── .gitignore
├── next.config.js
├── package.json
├── postcss.config.js
├── README.md
├── tailwind.config.js
└── tsconfig.json
```
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.
## Writing Blog Posts
## Learn More
To add a new blog post, create a new Markdown file in the `src/posts` directory with the following format:
To learn more about Next.js, take a look at the following resources:
```markdown
---
title: "Your Post Title"
date: "YYYY-MM-DD"
author: "Your Name"
excerpt: "A brief excerpt of your post"
tags: ["Tag1", "Tag2"]
coverImage: "/images/your-image.jpg"
---
- [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.
# Your Post Title
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Your content goes here...
```
## Deploy on Vercel
## Deployment
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.
The site can be deployed to various platforms:
### Vercel (Recommended)
```bash
npm install -g vercel
vercel
```
### Standard Build
```bash
npm run build
npm start
```
## Customization
- Modify the design in `tailwind.config.js` and `src/styles/globals.css`
- Update site content in component files
- Add or modify pages in the `src/pages` directory
## License
This project is licensed under the MIT License.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+107
View File
@@ -0,0 +1,107 @@
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>
);
};
+103
View File
@@ -0,0 +1,103 @@
'use client';
import React, { useEffect, useState } from 'react';
import { supabase } from '@/utils/supabase';
import { PostMetadata } from '@/types';
interface AuthorProfile {
name: string;
bio: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
interface AuthorProfileProps {
author: string;
posts: PostMetadata[];
}
export default function AuthorProfile({ author, posts }: AuthorProfileProps) {
const [authorProfile, setAuthorProfile] = useState<AuthorProfile | null>(null);
const fetchAuthorProfile = async () => {
const { data, error } = await supabase
.from('authors')
.select('name, bio, x_link, fb_link, insta_link, pfp_link')
.eq('name', author)
.single();
if (error) {
console.warn('No author profile found for:', author, '→', error.message);
}
if (data) {
setAuthorProfile(data);
}
};
useEffect(() => {
fetchAuthorProfile();
}, [author]);
return (
<div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6">
{/* Profile Picture */}
{authorProfile?.pfp_link ? (
<img
src={authorProfile.pfp_link}
alt={`${authorProfile.name}'s profile picture`}
className="w-24 h-24 rounded-full object-cover shadow-md"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-500">
?
</div>
)}
<div>
{/* Author Name */}
<h1 className="text-3xl md:text-4xl font-bold mb-2">
{authorProfile?.name || author}
</h1>
{/* Author Bio */}
{authorProfile?.bio && (
<p className="text-primary-600 mb-2">{authorProfile.bio}</p>
)}
{/* Social Links */}
<div className="flex gap-4 mt-2">
{authorProfile?.x_link && (
<a
href={authorProfile.x_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/x.svg" alt="X (Twitter)" className="w-5 h-5" />
</a>
)}
{authorProfile?.fb_link && (
<a
href={authorProfile.fb_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/facebook.svg" alt="Facebook" className="w-5 h-5" />
</a>
)}
{authorProfile?.insta_link && (
<a
href={authorProfile.insta_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/instagram.svg" alt="Instagram" className="w-5 h-5" />
</a>
)}
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import { getSortedPostsData, getPostsByAuthor } from '@/lib/markdown';
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() {
try {
const posts = getSortedPostsData();
const authors = Array.from(new Set(posts.flatMap(post => post.author)));
return authors.map((author) => ({
author: author,
}));
} catch (error) {
console.error('Failed to generate author paths during build:', error);
return [];
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { author } = await params;
return createMetadata({
title: `${author} | Author`,
description: `Posts authored by ${author} on Confessions of Grace.`,
url: `https://confessionsofgrace.com/authors/${author}`,
type: 'website'
});
}
async function getPostsByAuthorData(author: string): Promise<PostMetadata[]> {
return getPostsByAuthor(author);
}
export default async function AuthorPage({ params }: PageProps) {
const { author } = await params;
const posts = await getPostsByAuthorData(author);
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<AuthorProfile author={author} posts={posts} />
{/* Posts */}
<p className="text-lg text-primary-600 mb-6">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by "{author}"
</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>
);
}
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { supabase } from '@/utils/supabase';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
interface AuthorProfile {
name: string;
bio: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
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'
});
}
async function getAuthors(): Promise<AuthorProfile[]> {
try {
const { data: authorsData, error } = await supabase
.from('authors')
.select('name, bio, x_link, fb_link, insta_link, pfp_link');
if (error || !authorsData) {
console.error('Error fetching authors:', error);
return [];
}
return authorsData;
} catch (error) {
console.error('Failed to fetch authors during build:', error);
return [];
}
}
export default async function AuthorsPage() {
const authors = await getAuthors();
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/${author.name}`}
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 relative">
<Image
src={author.pfp_link || '/images/authors/default.jpg'}
alt={`${author.name}'s profile`}
fill
className="rounded-full object-cover"
sizes="96px"
/>
</div>
<h2 className="text-lg font-semibold">{author.name}</h2>
{/* You can include bio or social icons here */}
</Link>
))}
</div>
<div className="mt-12 text-center">
<Link href="/posts" className="button">
View All Posts
</Link>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,175 @@
'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
@@ -0,0 +1,48 @@
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.

After

Width:  |  Height:  |  Size: 25 KiB

+82
View File
@@ -0,0 +1,82 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-primary-50: #f8f9fa;
--color-primary-100: #e9ecef;
--color-primary-200: #dee2e6;
--color-primary-300: #ced4da;
--color-primary-400: #adb5bd;
--color-primary-500: #6c757d;
--color-primary-600: #495057;
--color-primary-700: #343a40;
--color-primary-800: #212529;
--color-primary-900: #121212;
--color-accent-light: #e2d8c6;
--color-accent: #9d8c70;
--color-accent-dark: #695c4a;
--font-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;
}
}
@layer base {
body {
@apply bg-primary-50 text-primary-800 font-serif;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-primary-900 mb-4 font-bold;
}
h1 {
@apply text-3xl md:text-4xl;
}
h2 {
@apply text-2xl md:text-3xl;
}
h3 {
@apply text-xl md:text-2xl;
}
a {
@apply text-accent-dark hover:text-accent transition-colors duration-200;
}
blockquote {
@apply border-l-4 border-accent pl-4 italic my-4;
}
}
@layer components {
.blog-post {
@apply prose prose-lg max-w-none;
}
.blog-post h1, .blog-post h2, .blog-post h3 {
@apply border-b border-primary-200 pb-2;
}
.button {
@apply px-4 py-2 rounded-md bg-accent text-white shadow-sm hover:bg-accent-dark 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;
}
}
+40
View File
@@ -0,0 +1,40 @@
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';
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>
</body>
</html>
);
}
+114
View File
@@ -0,0 +1,114 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import Sidebar from '@/components/Sidebar';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
import Image from 'next/image';
// This function runs on the server during build time
async function getHomeData() {
const posts = 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>
);
}
+135
View File
@@ -0,0 +1,135 @@
import ShareButtons from '@/components/ShareButtons';
import { getPostData, getSortedPostsData } from '@/lib/markdown';
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 CommentSection from "@/components/CommentSection";
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 = 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>
<CommentSection postId={post.id} />
<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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { getSortedPostsData } from '@/lib/markdown';
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 = 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
@@ -0,0 +1,114 @@
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
@@ -0,0 +1,77 @@
'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>
)}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { getSortedPostsData } from '@/lib/markdown';
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>
<SearchClient allPosts={allPosts} />
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ tag: string }>;
}
export async function generateStaticParams() {
const posts = getSortedPostsData();
const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
return tags.map((tag) => ({
tag: tag,
}));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { tag } = await params;
return createMetadata({
title: `Posts tagged "${tag}"`,
description: `Browse all posts tagged with "${tag}" on Confessions of Grace.`,
url: `https://confessionsofgrace.com/tags/${tag}`,
type: 'website'
});
}
async function getPostsByTagData(tag: string): Promise<PostMetadata[]> {
return getPostsByTag(tag);
}
export default async function TagPage({ params }: PageProps) {
const { tag } = await params;
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: {tag}</h1>
<p className="text-lg text-primary-600">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} tagged with "{tag}"
</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>
);
}
+148
View File
@@ -0,0 +1,148 @@
import ShareButtons from '@/components/ShareButtons';
import { getPostData, getSortedPostsData, getAllPostIds } from '@/lib/markdown';
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 CommentSection from "@/components/CommentSection";
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateStaticParams() {
try {
const posts = getSortedPostsData();
return posts.map((post) => ({
id: post.id,
}));
} catch (error) {
console.error('Failed to generate post paths during build:', error);
return [];
}
}
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 = 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>
<CommentSection postId={post.id} />
<div className="mt-12 pt-6 border-t border-primary-200">
<Link href="/posts" 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>
);
}
@@ -1,3 +1,5 @@
"use client"
import { supabase } from '@/utils/supabase';
import React, { useState, useEffect } from 'react';
@@ -29,7 +29,7 @@ const Footer: React.FC = () => {
</Link>
</li>
<li>
<Link href="/archive" className="text-primary-300 hover:text-white">
<Link href="/posts" className="text-primary-300 hover:text-white">
Archive
</Link>
</li>
@@ -33,7 +33,7 @@ const Header: React.FC = () => {
<Link href="/about" className="text-primary-700 hover:text-accent-dark">
About
</Link>
<Link href="/archive" className="text-primary-700 hover:text-accent-dark">
<Link href="/posts" className="text-primary-700 hover:text-accent-dark">
Archive
</Link>
{/*<Link href="/confession" className="text-primary-700 hover:text-accent-dark">*/}
+62
View File
@@ -0,0 +1,62 @@
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://www.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);
}
@@ -1,3 +1,5 @@
"use client"
import React, { useState } from 'react';
interface SubscribeFormProps {
+1 -1
View File
@@ -6,7 +6,7 @@ import html from 'remark-html';
import { PostData, PostMetadata } from '@/types';
// Make sure this is only used on the server side
const postsDirectory = path.join(process.cwd(), 'src/posts');
const postsDirectory = path.join(process.cwd(), 'data/posts');
export function getSortedPostsData(): PostMetadata[] {
// Ensure we only run this on the server
-19
View File
@@ -1,19 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
port: '',
pathname: '/images/**',
},
],
},
};
module.exports = nextConfig
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+905 -5298
View File
File diff suppressed because it is too large Load Diff
+19 -35
View File
@@ -1,49 +1,33 @@
{
"name": "confessions-of-grace",
"version": "1.0.0",
"description": "A blog about reformed theology",
"main": "index.js",
"name": "cog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --turbopack",
"build": "next build",
"build:static": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"deploy:cf": "npm run build:static && npx wrangler deploy",
"preview:cf": "npm run build:static && npx wrangler dev"
"lint": "next lint"
},
"keywords": [
"blog",
"reformed",
"theology",
"grace"
],
"author": "",
"license": "MIT",
"dependencies": {
"@supabase/supabase-js": "^2.49.4",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"autoprefixer": "^10.4.17",
"date-fns": "^3.3.1",
"@supabase/supabase-js": "^2.55.0",
"autoprefixer": "^10.4.21",
"date-fns": "^4.1.0",
"gray-matter": "^4.0.3",
"next": "^15.2.4",
"postcss": "^8.4.33",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"next": "15.4.6",
"postcss": "^8.5.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"supabase": "^2.22.12",
"tailwindcss": "^3.4.1"
"supabase": "^2.34.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20.11.7",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"typescript": "^5.3.3"
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.12",
"typescript": "^5"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

-40
View File
@@ -1,40 +0,0 @@
import Head from 'next/head';
import React, { ReactNode } from 'react';
import Footer from './Footer';
import Header from './Header';
import { Analytics } from "@vercel/analytics/react"
import { SpeedInsights } from "@vercel/speed-insights/next"
interface LayoutProps {
children: ReactNode;
title?: string;
description?: string;
}
const Layout: React.FC<LayoutProps> = ({
children,
title = 'Confessions of Grace',
description = 'A blog dedicated to exploring the doctrines of grace and Reformed theology.'
}) => {
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<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/>
</>
);
};
export default Layout;
-54
View File
@@ -1,54 +0,0 @@
import Head from 'next/head';
import React from 'react';
interface MetaProps {
title?: string;
description?: string;
keywords?: string;
image?: string;
url?: string;
type?: string;
}
const Meta: React.FC<MetaProps> = ({
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'
}) => {
const siteTitle = title === 'Confessions of Grace'
? 'Confessions of Grace | Confessing Christ. Rejoicing in Grace.'
: `${title} | Confessions of Grace`;
image = image.startsWith('http') ? image : `https://www.confessionsofgrace.com${image}`;
return (
<Head>
<title>{siteTitle}</title>
<meta name="description" content={description} />
<meta name="keywords" content={keywords} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{/* Open Graph / Facebook */}
<meta property="og:type" content={type} />
<meta property="og:url" content={url} />
<meta property="og:title" content={siteTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:locale" content="en_US" />
<meta property="og:logo" content="/logo.svg" />
{/* Twitter */}
<meta property="twitter:card" content="summary" />
<meta property="twitter:url" content={url} />
<meta property="twitter:title" content={siteTitle} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={image} />
</Head>
);
};
export default Meta;
-7
View File
@@ -1,7 +0,0 @@
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
-98
View File
@@ -1,98 +0,0 @@
import React from 'react';
import Layout from '@/components/Layout';
import Image from 'next/image';
const About: React.FC = () => {
return (
<Layout title="About | Confessions of Grace">
<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>
</Layout>
);
};
export default About;
-87
View File
@@ -1,87 +0,0 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import { format } from 'date-fns';
import Layout from '@/components/Layout';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface ArchiveProps {
posts: PostMetadata[];
years: number[];
}
const Archive: React.FC<ArchiveProps> = ({ posts, years }) => {
return (
<Layout title="Archive | Confessions of Grace">
<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>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = 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 {
props: {
posts,
years,
},
};
};
export default Archive;
-163
View File
@@ -1,163 +0,0 @@
import React, { useEffect, useState } from 'react';
import { GetStaticProps, GetStaticPaths } from 'next';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import { getSortedPostsData, getPostsByAuthor } from '@/lib/markdown';
import { PostMetadata } from '@/types';
import { supabase } from '@/utils/supabase';
interface AuthorProfile {
name: string;
bio: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
interface AuthorPageProps {
posts: PostMetadata[];
author: string;
}
const AuthorPage: React.FC<AuthorPageProps> = ({ posts, author }) => {
const [authorProfile, setAuthorProfile] = useState<AuthorProfile | null>(null);
const fetchAuthorProfile = async () => {
const { data, error } = await supabase
.from('authors')
.select('name, bio, x_link, fb_link, insta_link, pfp_link')
.eq('name', author) // Or use slug if you've switched to that
.single();
if (error) {
console.warn('No author profile found for:', author, '→', error.message);
}
if (data) {
setAuthorProfile(data);
}
};
useEffect(() => {
fetchAuthorProfile();
}, [author]);
return (
<Layout title={`${authorProfile?.name || author} | Confessions of Grace`}>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6">
{/* Profile Picture */}
{authorProfile?.pfp_link ? (
<img
src={authorProfile.pfp_link}
alt={`${authorProfile.name}'s profile picture`}
className="w-24 h-24 rounded-full object-cover shadow-md"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-500">
?
</div>
)}
<div>
{/* Author Name */}
<h1 className="text-3xl md:text-4xl font-bold mb-2">
{authorProfile?.name || author}
</h1>
{/* Author Bio */}
{authorProfile?.bio && (
<p className="text-primary-600 mb-2">{authorProfile.bio}</p>
)}
{/* Social Links */}
<div className="flex gap-4 mt-2">
{authorProfile?.x_link && (
<a
href={authorProfile.x_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/x.svg" alt="X (Twitter)" className="w-5 h-5" />
</a>
)}
{authorProfile?.fb_link && (
<a
href={authorProfile.fb_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/facebook.svg" alt="Facebook" className="w-5 h-5" />
</a>
)}
{authorProfile?.insta_link && (
<a
href={authorProfile.insta_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/instagram.svg" alt="Instagram" className="w-5 h-5" />
</a>
)}
</div>
</div>
</div>
{/* Posts */}
<p className="text-lg text-primary-600 mb-6">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by{' '}
"{authorProfile?.name || author}"
</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>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
try {
const posts = getSortedPostsData();
const authors = Array.from(new Set(posts.flatMap(post => post.author)));
const paths = authors.map((author) => ({
params: { author },
}));
return {
paths,
fallback: 'blocking',
};
} catch (error) {
console.error('Failed to generate author paths during build:', error);
return {
paths: [],
fallback: 'blocking',
};
}
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const author = params?.author as string;
const posts = getPostsByAuthor(author);
return {
props: {
posts,
author,
},
};
};
export default AuthorPage;
-83
View File
@@ -1,83 +0,0 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import Image from 'next/image';
import Layout from '@/components/Layout';
import { supabase } from '@/utils/supabase';
interface AuthorProfile {
name: string;
bio: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
interface AuthorsPageProps {
authors: AuthorProfile[];
}
const AuthorsPage: React.FC<AuthorsPageProps> = ({ authors }) => {
return (
<Layout title="Authors | Confessions of Grace">
<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/${author.name}`}
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 relative">
<Image
src={author.pfp_link || '/images/authors/default.jpg'}
alt={`${author.name}'s profile`}
fill
className="rounded-full object-cover"
sizes="96px"
/>
</div>
<h2 className="text-lg font-semibold">{author.name}</h2>
{/* You can include bio or social icons here */}
</Link>
))}
</div>
<div className="mt-12 text-center">
<Link href="/archive" className="button">
View All Posts
</Link>
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
try {
const { data: authorsData, error } = await supabase
.from('authors')
.select('name, bio, x_link, fb_link, insta_link, pfp_link');
if (error || !authorsData) {
console.error('Error fetching authors:', error);
// Return empty array for static export fallback
return { props: { authors: [] } };
}
return {
props: {
authors: authorsData,
},
};
} catch (error) {
console.error('Failed to fetch authors during build:', error);
// Fallback for static export when Supabase is not available during build
return { props: { authors: [] } };
}
};
export default AuthorsPage;
-36
View File
@@ -1,36 +0,0 @@
import React from 'react';
import Layout from '@/components/Layout';
const ChurchFinder: React.FC = () => {
return (
<Layout title="RB Church Finder | Confessions of Grace">
<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>
</Layout>
);
};
export default ChurchFinder;
-193
View File
@@ -1,193 +0,0 @@
import React, { useState } from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import Layout from '@/components/Layout';
import confessionData from '@/data/1689-confession.json';
import verseReferences from '@/data/1689-verses.json';
interface ConfessionProps {
confession: {
title: string;
chapters: {
[key: string]: {
title: string;
paragraphs: {
[key: string]: string;
};
};
};
};
verses: {
[key: string]: string;
};
}
const Confession: React.FC<ConfessionProps> = ({ confession, verses }) => {
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 (
<Layout title="1689 Baptist Confession of Faith | Confessions of Grace">
<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>
{/* <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>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
// In a real implementation, you would import these from files
// We're using the sample data provided
return {
props: {
confession: confessionData,
verses: verseReferences,
},
};
};
export default Confession;
-206
View File
@@ -1,206 +0,0 @@
import React, { useState } from 'react';
import Layout from '@/components/Layout';
const Contact: React.FC = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (!name.trim() || !email.trim() || !subject.trim() || !message.trim()) {
setError('All fields are required');
return;
}
setIsSubmitting(true);
setError(null);
try {
// In a real implementation, you would send this data to your API
// For now, we'll just simulate a successful submission
await new Promise(resolve => setTimeout(resolve, 1000));
// Clear form and show success message
setName('');
setEmail('');
setSubject('');
setMessage('');
setIsSubmitted(true);
setIsSubmitting(false);
} catch (err) {
setError('Something went wrong. Please try again later.');
setIsSubmitting(false);
}
};
return (
<Layout title="Contact | Confessions of Grace">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Contact</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
<p className="text-primary-700 mb-6">
Have questions, comments, or suggestions? We'd love to hear from you!
Use the form below to reach out, and we'll get back to you as soon as possible.
</p>
{isSubmitted ? (
<div className="bg-green-50 border border-green-200 text-green-700 p-6 rounded-md">
<h3 className="text-xl font-bold mb-2">Thank You!</h3>
<p>Your message has been sent successfully. We'll respond to you as soon as possible.</p>
</div>
) : (
<>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-primary-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-primary-700 mb-1">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
</div>
<div>
<label htmlFor="subject" className="block text-primary-700 mb-1">
Subject <span className="text-red-500">*</span>
</label>
<input
type="text"
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="message" className="block text-primary-700 mb-1">
Message <span className="text-red-500">*</span>
</label>
<textarea
id="message"
rows={6}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
></textarea>
</div>
<button
type="submit"
className="button"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
</>
)}
</div>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<h2 className="text-2xl font-bold mb-4">Other Ways to Connect</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-xl font-bold mb-2">Follow</h3>
<p className="text-primary-700 mb-4">
Stay updated with our latest content on social media.
</p>
<div className="flex space-x-4">
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Twitter"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<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"></path>
</svg>
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Facebook"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<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"></path>
</svg>
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z" clipRule="evenodd"></path>
</svg>
</a>
</div>
</div>
<div>
<h3 className="text-xl font-bold mb-2">Subscribe</h3>
<p className="text-primary-700 mb-4">
Get new posts delivered to your inbox.
</p>
<form className="flex">
<input
type="email"
placeholder="Your email"
className="flex-grow px-4 py-2 rounded-l-md border border-primary-300 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button className="bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-r-md">
Subscribe
</button>
</form>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default Contact;
-130
View File
@@ -1,130 +0,0 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import Sidebar from '@/components/Sidebar';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
import Image from 'next/image'
import Meta from '@/components/Meta';
interface HomeProps {
posts: PostMetadata[];
recentPosts: Array<{
id: string;
title: string;
date: string;
}>;
tags: Array<{
tag: string;
count: number;
}>;
}
const Home: React.FC<HomeProps> = ({ posts, recentPosts, tags }) => {
return (
<Layout>
<Meta
title="Confessions of Grace"
keywords="reformed theology, doctrines of grace, christianity, calvinism, theology"
image="public/assets/logo-horizontal.svg"
url="https://www.confessionsofgrace.com/"
type="website"
/>
<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="/archive" className="button">
View All Posts
</a>
</div>
</main>
<div className="md:w-1/3">
<Sidebar recentPosts={recentPosts} tags={tags} />
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = 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 {
props: {
posts,
recentPosts: posts.slice(0, 5).map(post => ({
id: post.id,
title: post.title,
date: post.date
})),
tags
},
};
};
export default Home;
-149
View File
@@ -1,149 +0,0 @@
import Layout from '@/components/Layout';
import Meta from '@/components/Meta';
import ShareButtons from '@/components/ShareButtons';
import { getAllPostIds, getPostData, getSortedPostsData } from '@/lib/markdown';
import { PostData, PostMetadata } from '@/types';
import { format } from 'date-fns';
import { GetStaticPaths, GetStaticProps } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import React from 'react';
import CommentSection from "@/components/CommentSection";
interface PostProps {
post: PostData;
morePosts: PostMetadata[];
}
const Post: React.FC<PostProps> = ({ post, morePosts }) => {
const postUrl = `https://confessionsofgrace.com/posts/${post.id}`;
return (
<Layout
title={`${post.title} | Confessions of Grace`}
description={post.excerpt}
>
<Meta
title={post.title}
description={post.excerpt}
keywords={post.tags.join(', ')}
image={post.coverImage}
url={postUrl}
type="article"
/>
<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>
<CommentSection postId={post.id} />
<div className="mt-12 pt-6 border-t border-primary-200">
<Link href="/" 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>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
const paths = getAllPostIds();
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await getPostData(params?.id as string);
const allPosts = 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 {
props: {
post,
morePosts,
},
};
};
export default Post;
-135
View File
@@ -1,135 +0,0 @@
import React from 'react';
import Layout from '@/components/Layout';
interface Resource {
title: string;
author: string;
description: string;
link?: string;
category: string;
}
const Resources: React.FC = () => {
const resources: Resource[] = [
// {
// title: "Institutes of the Christian Religion",
// author: "John Calvin",
// description: "Calvin's magnum opus on systematic theology, covering the doctrines of God, man, salvation, and the church.",
// category: "Books"
// },
// {
// title: "The Bondage of the Will",
// author: "Martin Luther",
// description: "Luther's defense of the doctrine of total depravity and God's sovereignty in salvation.",
// category: "Books"
// },
// {
// title: "Chosen by God",
// author: "R.C. Sproul",
// description: "An accessible introduction to the doctrine of predestination.",
// category: "Books"
// },
{
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 (
<Layout title="Resources | Confessions of Grace">
<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 className="bg-primary-100 rounded-lg p-6 border border-primary-200 mt-10">
<h2 className="text-xl font-bold mb-4">Suggest a Resource</h2>
<p className="text-primary-700 mb-4">
Do you have a resource suggestion that would be valuable for readers of this blog?
Please use the contact form to share your recommendations.
</p>
<a href="#" className="button inline-block">
Contact
</a>
</div> */}
</div>
</Layout>
);
};
export default Resources;
-94
View File
@@ -1,94 +0,0 @@
import React, { useState, useEffect } from 'react';
import { GetStaticProps } from 'next';
import { useRouter } from 'next/router';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import SearchBar from '@/components/SearchBar';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface SearchPageProps {
allPosts: PostMetadata[];
}
const SearchPage: React.FC<SearchPageProps> = ({ allPosts }) => {
const router = useRouter();
const { q: query } = router.query;
const [searchResults, setSearchResults] = useState<PostMetadata[]>([]);
useEffect(() => {
if (typeof query !== 'string' || !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 (
<Layout title={`Search: ${query || ''} | Confessions of Grace`}>
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Search Results</h1>
<div className="mb-8">
<SearchBar />
</div>
{typeof query === 'string' && 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>
)}
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const allPosts = getSortedPostsData();
return {
props: {
allPosts,
},
};
};
export default SearchPage;
-66
View File
@@ -1,66 +0,0 @@
import React from 'react';
import { GetStaticProps, GetStaticPaths } from 'next';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface TagPageProps {
posts: PostMetadata[];
tag: string;
}
const TagPage: React.FC<TagPageProps> = ({ posts, tag }) => {
return (
<Layout title={`${tag} | Confessions of Grace`}>
<div className="max-w-4xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold mb-4">Tag: {tag}</h1>
<p className="text-lg text-primary-600">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} tagged with "{tag}"
</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>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
const posts = getSortedPostsData();
const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
const paths = tags.map((tag) => ({
params: { tag },
}));
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const tag = params?.tag as string;
const posts = getPostsByTag(tag);
return {
props: {
posts,
tag,
},
};
};
export default TagPage;
-72
View File
@@ -1,72 +0,0 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import Layout from '@/components/Layout';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface TagsPageProps {
tags: {
name: string;
count: number;
}[];
}
const TagsPage: React.FC<TagsPageProps> = ({ tags }) => {
return (
<Layout title="Tags | Confessions of Grace">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8">Browse by Tag</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<div className="flex flex-wrap gap-3">
{tags.map(tag => (
<Link
key={tag.name}
href={`/tags/${tag.name}`}
className="bg-primary-100 hover:bg-primary-200 text-primary-700 px-4 py-2 rounded-md text-lg transition-colors duration-200 flex items-center"
>
{tag.name}
<span className="ml-2 bg-primary-200 rounded-full px-2 py-0.5 text-sm">
{tag.count}
</span>
</Link>
))}
</div>
</div>
<div className="mt-12 text-center">
<Link href="/archive" className="button">
View All Posts
</Link>
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = getSortedPostsData();
// Extract all tags from posts
const allTags = posts.flatMap(post => post.tags);
// Count occurrences of each tag
const tagCounts = allTags.reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Convert to array and sort alphabetically
const tags = Object.entries(tagCounts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
return {
props: {
tags,
},
};
};
export default TagsPage;
-51
View File
@@ -1,51 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-primary-50 text-primary-800 font-serif;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-primary-900 mb-4 font-bold;
}
h1 {
@apply text-3xl md:text-4xl;
}
h2 {
@apply text-2xl md:text-3xl;
}
h3 {
@apply text-xl md:text-2xl;
}
a {
@apply text-accent-dark hover:text-accent transition-colors duration-200;
}
blockquote {
@apply border-l-4 border-accent pl-4 italic my-4;
}
}
@layer components {
.blog-post {
@apply prose prose-lg max-w-none;
}
.blog-post h1, .blog-post h2, .blog-post h3 {
@apply border-b border-primary-200 pb-2;
}
.button {
@apply px-4 py-2 rounded-md bg-accent text-white shadow-sm hover:bg-accent-dark 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;
}
}
-43
View File
@@ -1,43 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx}',
'./src/components/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {
colors: {
primary: {
50: '#f8f9fa',
100: '#e9ecef',
200: '#dee2e6',
300: '#ced4da',
400: '#adb5bd',
500: '#6c757d',
600: '#495057',
700: '#343a40',
800: '#212529',
900: '#121212',
},
accent: {
light: '#e2d8c6',
DEFAULT: '#9d8c70',
dark: '#695c4a',
},
},
fontFamily: {
serif: ['Baskerville', 'Georgia', 'Times New Roman', 'serif'],
sans: ['Helvetica Neue', 'Arial', 'sans-serif'],
},
typography: {
DEFAULT: {
css: {
'blockquote p:first-of-type::before': false,
'blockquote p:last-of-type::after': false,
},
},
},
},
},
plugins: [require('@tailwindcss/typography')],
}
+10 -7
View File
@@ -1,24 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["src/*"]
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"name": "confessions-of-grace",
"compatibility_date": "2025-08-18",
"assets": {
"directory": "./out"
},
"routes": [
{
"pattern": "confessionsofgrace.com",
"custom_domain": true
}
]
}