init with dummy posts

This commit is contained in:
2025-03-18 15:07:45 -05:00
commit 1120a7720a
42 changed files with 9902 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import '@/styles/globals.css';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import Layout from '@/components/Layout';
const About: React.FC = () => {
return (
<Layout title="About | Confessions of Grace">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">About</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
<h2 className="text-2xl font-bold mb-4">Confessions of Grace</h2>
<p className="text-primary-700 mb-6">
Welcome to Confessions of Grace, a blog dedicated to exploring the riches of Reformed theology
and the doctrines of grace. Our aim is to articulate timeless biblical truths in a clear,
accessible manner, helping believers understand the depth and beauty of God's sovereign grace.
</p>
<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">
We affirm the doctrines of grace as articulated in the historic Reformed confessions,
including the Westminster Standards, the Three Forms of Unity, and the 1689 London
Baptist Confession of Faith.
</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 flex items-center justify-center text-primary-500">
[Author Photo]
</div>
</div>
<div className="md:w-2/3">
<p className="text-primary-700 mb-4">
[Insert author bio here. Share your background, education, ministry experience,
and what led you to start this blog. Discuss your passion for Reformed theology
and your goals for this website.]
</p>
<p className="text-primary-700">
Feel free to reach out through the contact form or connect on social media.
</p>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default About;
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import { format } from 'date-fns';
import Layout from '@/components/Layout';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface ArchiveProps {
posts: PostMetadata[];
years: number[];
}
const Archive: React.FC<ArchiveProps> = ({ posts, years }) => {
return (
<Layout title="Archive | Confessions of Grace">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8">Archive</h1>
{years.map(year => (
<div key={year} className="mb-12">
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{year}</h2>
<ul className="space-y-6">
{posts
.filter(post => new Date(post.date).getFullYear() === year)
.map(post => (
<li key={post.id} className="bg-white p-6 rounded-md shadow-sm border border-primary-200">
<div className="md:flex md:justify-between md:items-center">
<div>
<h3 className="text-xl font-bold mb-2">
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
{post.title}
</Link>
</h3>
<div className="flex items-center mb-2 text-sm text-primary-500">
<span>{post.author}</span>
<span className="mx-2"></span>
<time dateTime={post.date}>
{format(new Date(post.date), 'MMMM d, yyyy')}
</time>
</div>
<p className="text-primary-600">{post.excerpt}</p>
</div>
<div className="mt-4 md:mt-0">
<Link href={`/posts/${post.id}`} className="button text-sm">
Read post
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
{post.tags.map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-xs bg-primary-100 text-primary-600 px-2 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = getSortedPostsData();
// Extract unique years from post dates
const years = Array.from(new Set(
posts.map(post => new Date(post.date).getFullYear())
)).sort((a, b) => b - a); // Sort years in descending order
return {
props: {
posts,
years,
},
};
};
export default Archive;
+206
View File
@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import Layout from '@/components/Layout';
const Contact: React.FC = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (!name.trim() || !email.trim() || !subject.trim() || !message.trim()) {
setError('All fields are required');
return;
}
setIsSubmitting(true);
setError(null);
try {
// In a real implementation, you would send this data to your API
// For now, we'll just simulate a successful submission
await new Promise(resolve => setTimeout(resolve, 1000));
// Clear form and show success message
setName('');
setEmail('');
setSubject('');
setMessage('');
setIsSubmitted(true);
setIsSubmitting(false);
} catch (err) {
setError('Something went wrong. Please try again later.');
setIsSubmitting(false);
}
};
return (
<Layout title="Contact | Confessions of Grace">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Contact</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
<p className="text-primary-700 mb-6">
Have questions, comments, or suggestions? We'd love to hear from you!
Use the form below to reach out, and we'll get back to you as soon as possible.
</p>
{isSubmitted ? (
<div className="bg-green-50 border border-green-200 text-green-700 p-6 rounded-md">
<h3 className="text-xl font-bold mb-2">Thank You!</h3>
<p>Your message has been sent successfully. We'll respond to you as soon as possible.</p>
</div>
) : (
<>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-primary-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-primary-700 mb-1">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
</div>
<div>
<label htmlFor="subject" className="block text-primary-700 mb-1">
Subject <span className="text-red-500">*</span>
</label>
<input
type="text"
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="message" className="block text-primary-700 mb-1">
Message <span className="text-red-500">*</span>
</label>
<textarea
id="message"
rows={6}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
></textarea>
</div>
<button
type="submit"
className="button"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
</>
)}
</div>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<h2 className="text-2xl font-bold mb-4">Other Ways to Connect</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-xl font-bold mb-2">Follow</h3>
<p className="text-primary-700 mb-4">
Stay updated with our latest content on social media.
</p>
<div className="flex space-x-4">
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Twitter"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"></path>
</svg>
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Facebook"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd"></path>
</svg>
</a>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z" clipRule="evenodd"></path>
</svg>
</a>
</div>
</div>
<div>
<h3 className="text-xl font-bold mb-2">Subscribe</h3>
<p className="text-primary-700 mb-4">
Get new posts delivered to your inbox.
</p>
<form className="flex">
<input
type="email"
placeholder="Your email"
className="flex-grow px-4 py-2 rounded-l-md border border-primary-300 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button className="bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-r-md">
Subscribe
</button>
</form>
</div>
</div>
</div>
</div>
</Layout>
);
};
export default Contact;
+126
View File
@@ -0,0 +1,126 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import Sidebar from '@/components/Sidebar';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface HomeProps {
posts: PostMetadata[];
recentPosts: Array<{
id: string;
title: string;
date: string;
}>;
tags: Array<{
tag: string;
count: number;
}>;
}
const Home: React.FC<HomeProps> = ({ posts, recentPosts, tags }) => {
return (
<Layout>
<div className="mb-12 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-4">Confessions of Grace</h1>
<p className="text-xl text-primary-600 max-w-2xl mx-auto">
Exploring the doctrines of grace and the richness of Reformed theology.
</p>
</div>
<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-64 h-64">
{posts[0].coverImage ? (
<img
src={posts[0].coverImage}
alt={posts[0].title}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-accent text-white text-6xl font-bold">
CG
</div>
)}
</div>
<div className="p-8">
<div className="uppercase tracking-wide text-sm text-accent font-semibold">Latest Post</div>
<a href={`/posts/${posts[0].id}`} className="block mt-1 text-2xl leading-tight font-bold text-primary-900 hover:text-accent-dark">
{posts[0].title}
</a>
<p className="mt-2 text-primary-600">
{posts[0].excerpt}
</p>
<div className="mt-4">
<a href={`/posts/${posts[0].id}`} className="inline-flex items-center text-accent-dark hover:text-accent">
Read more
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
)}
<div className="mb-8">
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">Recent Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{posts.slice(1).map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
<div className="text-center mt-12">
<a href="/archive" className="button">
View All Posts
</a>
</div>
</main>
<div className="md:w-1/3">
<Sidebar recentPosts={recentPosts} tags={tags} />
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = getSortedPostsData();
// Create tag data
const allTags = posts.flatMap(post => post.tags);
const tagCounts: Record<string, number> = {};
allTags.forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
const tags = Object.entries(tagCounts).map(([tag, count]) => ({
tag,
count
}));
return {
props: {
posts,
recentPosts: posts.slice(0, 5).map(post => ({
id: post.id,
title: post.title,
date: post.date
})),
tags
},
};
};
export default Home;
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { GetStaticProps, GetStaticPaths } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { format } from 'date-fns';
import Layout from '@/components/Layout';
import Meta from '@/components/Meta';
import ShareButtons from '@/components/ShareButtons';
import CommentSection from '@/components/CommentSection';
import { getAllPostIds, getPostData, getSortedPostsData } from '@/lib/markdown';
import { PostData, PostMetadata } from '@/types';
interface PostProps {
post: PostData;
morePosts: PostMetadata[];
}
const Post: React.FC<PostProps> = ({ post, morePosts }) => {
const postUrl = `https://confessionsofgrace.com/posts/${post.id}`;
return (
<Layout
title={`${post.title} | Confessions of Grace`}
description={post.excerpt}
>
<Meta
title={post.title}
description={post.excerpt}
keywords={post.tags.join(', ')}
image={post.coverImage}
url={postUrl}
type="article"
/>
<article className="max-w-3xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold mb-2">{post.title}</h1>
<div className="flex items-center text-primary-500 mb-6">
<span>{post.author}</span>
<span className="mx-2"></span>
<time dateTime={post.date}>
{format(new Date(post.date), 'MMMM d, yyyy')}
</time>
</div>
{post.coverImage && (
<div className="relative h-64 md:h-96 w-full mb-8 rounded-lg overflow-hidden">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
<div className="flex flex-wrap gap-2 mb-6">
{post.tags.map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-sm bg-primary-100 text-primary-600 px-3 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
</header>
<div
className="blog-post prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<div className="mt-8 pt-6 border-t border-primary-200">
<ShareButtons
url={postUrl}
title={post.title}
description={post.excerpt}
/>
</div>
<div className="mt-12 pt-6 border-t border-primary-200">
<h2 className="text-2xl font-bold mb-6">More Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{morePosts.slice(0, 2).map(post => (
<div key={post.id} className="bg-white p-6 rounded-md border border-primary-200 shadow-sm">
<h3 className="font-bold mb-2">
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
{post.title}
</Link>
</h3>
<p className="text-primary-600 text-sm mb-2">
{format(new Date(post.date), 'MMMM d, yyyy')}
</p>
<p className="text-primary-700">{post.excerpt}</p>
</div>
))}
</div>
</div>
<CommentSection postId={post.id} />
<div className="mt-12 pt-6 border-t border-primary-200">
<Link href="/" className="text-accent-dark hover:text-accent inline-flex items-center">
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to all posts
</Link>
</div>
</article>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
const paths = getAllPostIds();
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await getPostData(params?.id as string);
const allPosts = getSortedPostsData();
// Filter out the current post and get a few related posts (by tags)
const otherPosts = allPosts.filter(p => p.id !== post.id);
// Find posts with matching tags
const relatedPosts = otherPosts
.filter(p => p.tags.some(tag => post.tags.includes(tag)))
.slice(0, 2);
// If we don't have enough related posts, add recent posts
const morePosts = relatedPosts.length < 2
? [...relatedPosts, ...otherPosts.filter(p => !relatedPosts.includes(p))].slice(0, 2)
: relatedPosts;
return {
props: {
post,
morePosts,
},
};
};
export default Post;
+128
View File
@@ -0,0 +1,128 @@
import React from 'react';
import Layout from '@/components/Layout';
interface Resource {
title: string;
author: string;
description: string;
link?: string;
category: string;
}
const Resources: React.FC = () => {
const resources: Resource[] = [
{
title: "Institutes of the Christian Religion",
author: "John Calvin",
description: "Calvin's magnum opus on systematic theology, covering the doctrines of God, man, salvation, and the church.",
category: "Books"
},
{
title: "The Bondage of the Will",
author: "Martin Luther",
description: "Luther's defense of the doctrine of total depravity and God's sovereignty in salvation.",
category: "Books"
},
{
title: "Chosen by God",
author: "R.C. Sproul",
description: "A accessible introduction to the doctrine of predestination.",
category: "Books"
},
{
title: "The Westminster Confession of Faith",
author: "Westminster Assembly",
description: "A historic Reformed confession of faith that remains influential today.",
link: "https://www.pcaac.org/bco/westminster-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"
}
];
const categories = Array.from(new Set(resources.map(resource => resource.category)));
return (
<Layout title="Resources | Confessions of Grace">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Reformed Resources</h1>
<p className="text-lg text-primary-700 mb-10">
This is a curated collection of resources related to Reformed theology and the doctrines of grace.
These books, confessions, and websites have been formative in my own theological journey and
are recommended for those seeking to deepen their understanding of Reformed thought.
</p>
{categories.map(category => (
<div key={category} className="mb-12">
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{category}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{resources
.filter(resource => resource.category === category)
.map((resource, index) => (
<div key={index} className="bg-white rounded-lg shadow-sm p-6 border border-primary-200">
<h3 className="text-xl font-bold mb-2">{resource.title}</h3>
{resource.author && (
<p className="text-primary-500 italic mb-3">by {resource.author}</p>
)}
<p className="text-primary-700 mb-4">{resource.description}</p>
{resource.link && (
<a
href={resource.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-accent-dark hover:text-accent"
>
Visit Resource
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 ml-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
))}
</div>
</div>
))}
<div className="bg-primary-100 rounded-lg p-6 border border-primary-200 mt-10">
<h2 className="text-xl font-bold mb-4">Suggest a Resource</h2>
<p className="text-primary-700 mb-4">
Do you have a resource suggestion that would be valuable for readers of this blog?
Please use the contact form to share your recommendations.
</p>
<a href="#" className="button inline-block">
Contact
</a>
</div>
</div>
</Layout>
);
};
export default Resources;
+94
View File
@@ -0,0 +1,94 @@
import React, { useState, useEffect } from 'react';
import { GetStaticProps } from 'next';
import { useRouter } from 'next/router';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import SearchBar from '@/components/SearchBar';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface SearchPageProps {
allPosts: PostMetadata[];
}
const SearchPage: React.FC<SearchPageProps> = ({ allPosts }) => {
const router = useRouter();
const { q: query } = router.query;
const [searchResults, setSearchResults] = useState<PostMetadata[]>([]);
useEffect(() => {
if (typeof query !== 'string' || !query.trim()) {
setSearchResults([]);
return;
}
const searchTerms = query.toLowerCase().trim().split(/\s+/);
const filteredPosts = allPosts.filter(post => {
const titleMatch = searchTerms.some(term =>
post.title.toLowerCase().includes(term)
);
const excerptMatch = searchTerms.some(term =>
post.excerpt.toLowerCase().includes(term)
);
const tagMatch = post.tags.some(tag =>
searchTerms.some(term => tag.toLowerCase().includes(term))
);
return titleMatch || excerptMatch || tagMatch;
});
setSearchResults(filteredPosts);
}, [query, allPosts]);
return (
<Layout title={`Search: ${query || ''} | Confessions of Grace`}>
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-6">Search Results</h1>
<div className="mb-8">
<SearchBar />
</div>
{typeof query === 'string' && query.trim() ? (
<>
<p className="mb-8 text-primary-600">
{searchResults.length} {searchResults.length === 1 ? 'result' : 'results'} for "{query}"
</p>
{searchResults.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{searchResults.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 text-center">
<p className="text-xl text-primary-600 mb-4">No results found for "{query}"</p>
<p className="text-primary-500">Try using different keywords or browse all posts.</p>
</div>
)}
</>
) : (
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<p className="text-xl text-primary-600 mb-4 text-center">Enter a search term to find posts</p>
</div>
)}
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const allPosts = getSortedPostsData();
return {
props: {
allPosts,
},
};
};
export default SearchPage;
+66
View File
@@ -0,0 +1,66 @@
import React from 'react';
import { GetStaticProps, GetStaticPaths } from 'next';
import Layout from '@/components/Layout';
import PostCard from '@/components/PostCard';
import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface TagPageProps {
posts: PostMetadata[];
tag: string;
}
const TagPage: React.FC<TagPageProps> = ({ posts, tag }) => {
return (
<Layout title={`${tag} | Confessions of Grace`}>
<div className="max-w-4xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold mb-4">Tag: {tag}</h1>
<p className="text-lg text-primary-600">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} tagged with "{tag}"
</p>
</div>
{posts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-xl text-primary-500">No posts found with this tag.</p>
</div>
)}
</div>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
const posts = getSortedPostsData();
const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
const paths = tags.map((tag) => ({
params: { tag },
}));
return {
paths,
fallback: false,
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const tag = params?.tag as string;
const posts = getPostsByTag(tag);
return {
props: {
posts,
tag,
},
};
};
export default TagPage;
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { GetStaticProps } from 'next';
import Link from 'next/link';
import Layout from '@/components/Layout';
import { getSortedPostsData } from '@/lib/markdown';
import { PostMetadata } from '@/types';
interface TagsPageProps {
tags: {
name: string;
count: number;
}[];
}
const TagsPage: React.FC<TagsPageProps> = ({ tags }) => {
return (
<Layout title="Tags | Confessions of Grace">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8">Browse by Tag</h1>
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
<div className="flex flex-wrap gap-3">
{tags.map(tag => (
<Link
key={tag.name}
href={`/tags/${tag.name}`}
className="bg-primary-100 hover:bg-primary-200 text-primary-700 px-4 py-2 rounded-md text-lg transition-colors duration-200 flex items-center"
>
{tag.name}
<span className="ml-2 bg-primary-200 rounded-full px-2 py-0.5 text-sm">
{tag.count}
</span>
</Link>
))}
</div>
</div>
<div className="mt-12 text-center">
<Link href="/archive" className="button">
View All Posts
</Link>
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const posts = getSortedPostsData();
// Extract all tags from posts
const allTags = posts.flatMap(post => post.tags);
// Count occurrences of each tag
const tagCounts = allTags.reduce((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Convert to array and sort alphabetically
const tags = Object.entries(tagCounts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
return {
props: {
tags,
},
};
};
export default TagsPage;