Archived
init with dummy posts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import '@/styles/globals.css';
|
||||
import type { AppProps } from 'next/app';
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user