switched to approuter

This commit is contained in:
2025-08-18 13:10:47 -05:00
parent 525009d881
commit 5ab7c7853c
82 changed files with 5154 additions and 9772 deletions
+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>
);
};
+68
View File
@@ -0,0 +1,68 @@
import { supabase } from '@/utils/supabase';
import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
export const runtime = 'edge';
export default async function POST(req: NextRequest) {
try {
const { name, email, comment, postId } = await req.json();
if (!name || !email || !comment || !postId) {
return NextResponse.json({ message: 'All fields are required' }, { status: 400 });
}
// Insert data into the 'comments' table
const { data, error } = await supabase
.from('comments') // Replace 'comments' with your Supabase table name
.insert([
{
name,
email,
comment,
post_id: postId,
created_at: new Date().toISOString(), // Supabase often uses ISO strings for timestamps
},
]);
if (error) {
console.error('Error inserting comment:', error);
return NextResponse.json({ message: 'Error submitting comment', error }, { status: 500 });
}
return NextResponse.json({ message: 'Comment submitted successfully', data }, { status: 201 });
} catch (error) {
console.error('Internal server error during POST:', error);
// Catching and returning a 500 for unexpected errors
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
try {
// Get query parameters from the URL
const { searchParams } = new URL(req.url);
const postId = searchParams.get('postId');
if (!postId) {
return NextResponse.json({ message: 'Post ID is required' }, { status: 400 });
}
// Fetch comments for a specific postId, ordered by creation date descending
const { data: comments, error } = await supabase
.from('comments') // Replace 'comments' with your Supabase table name
.select('*')
.eq('post_id', postId) // Assuming your Supabase column for post ID is 'post_id'
.order('created_at', { ascending: false }); // Assuming your timestamp column is 'created_at'
if (error) {
console.error('Error fetching comments:', error);
return NextResponse.json({ message: 'Error fetching comments', error }, { status: 500 });
}
return NextResponse.json(comments, { status: 200 });
} catch (error) {
console.error('Internal server error during GET:', error);
// Catching and returning a 500 for unexpected errors
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { supabase } from '@/utils/supabase';
import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
export const runtime = 'edge';
export default async function POST(req: NextRequest) {
// Only allow POST requests - this is handled by exporting the POST function
const { email } = await req.json(); // Get body from Edge request
// Check if email was provided
if (!email) {
return NextResponse.json({ message: 'Email is required.' }, { status: 400 });
}
// Validate email format
const emailRegex = /\S+@\S+\.\S+/;
if (!emailRegex.test(email)) {
return NextResponse.json({ message: 'Invalid email address.' }, { status: 400 });
}
try {
// Check if the email already exists in the 'subscriptions' table
const { data: existingEmails, error: selectError } = await supabase
.from('subscriptions') // Replace 'subscriptions' with your Supabase table name
.select('email')
.eq('email', email);
if (selectError) {
console.error('Error checking existing email:', selectError);
return NextResponse.json({ message: 'An unexpected error occurred while checking email.' }, { status: 500 });
}
if (existingEmails && existingEmails.length > 0) {
return NextResponse.json({ message: 'Email is already subscribed.' }, { status: 400 });
}
// Save email to the 'subscriptions' table
const { data: insertedData, error: insertError } = await supabase
.from('subscriptions') // Replace 'subscriptions' with your Supabase table name
.insert([
{
email,
created_at: new Date().toISOString(),
},
]);
if (insertError) {
console.error('Failed to save subscription:', insertError);
return NextResponse.json({ message: 'An unexpected error occurred while saving subscription.' }, { status: 500 });
}
// Respond with success
return NextResponse.json({ message: 'Successfully subscribed!' }, { status: 200 });
} catch (error) {
// Log unexpected errors
console.error('An unexpected server error occurred:', error);
return NextResponse.json({ message: 'An unexpected error occurred.' }, { status: 500 });
}
}
+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>
);
}