Archived
switched to approuter
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user