switched to approuter
@@ -1,7 +1,14 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -21,9 +28,10 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# local env files
|
# env files (can opt-in for committing if needed)
|
||||||
.env*.local
|
.env*
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -31,23 +39,3 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
logs/**/*.log
|
|
||||||
|
|
||||||
# OS generated files
|
|
||||||
Thumbs.db
|
|
||||||
ehthumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
|
|
||||||
# firebase
|
|
||||||
.firebase/
|
|
||||||
.firebaserc
|
|
||||||
firebase.json
|
|
||||||
firebase-debug.log
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/cog.iml" filepath="$PROJECT_DIR$/.idea/cog.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,113 +1,36 @@
|
|||||||
# Confessions of Grace
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
A blog about Reformed theology built with Next.js and TypeScript, featuring Markdown-based content management.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Modern static site built with Next.js
|
|
||||||
- TypeScript for type safety
|
|
||||||
- Content management using Markdown files
|
|
||||||
- Responsive design with Tailwind CSS
|
|
||||||
- Flat design style with Reformed theology aesthetics
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
First, run the development server:
|
||||||
|
|
||||||
- Node.js (>= 16.x)
|
|
||||||
- npm or yarn
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. Clone the repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/confessions-of-grace.git
|
|
||||||
cd confessions-of-grace
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the development server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
# or
|
||||||
yarn dev
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Open [http://localhost:3000](http://localhost:3000) in your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
## Project Structure
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
```
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
├── public/ # Static assets
|
|
||||||
│ └── images/ # Images for blog posts
|
|
||||||
├── src/ # Source code
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ ├── lib/ # Utility functions
|
|
||||||
│ ├── pages/ # Next.js pages
|
|
||||||
│ ├── posts/ # Markdown blog posts
|
|
||||||
│ ├── styles/ # CSS styles
|
|
||||||
│ └── types/ # TypeScript type definitions
|
|
||||||
├── .gitignore
|
|
||||||
├── next.config.js
|
|
||||||
├── package.json
|
|
||||||
├── postcss.config.js
|
|
||||||
├── README.md
|
|
||||||
├── tailwind.config.js
|
|
||||||
└── tsconfig.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Writing Blog Posts
|
## Learn More
|
||||||
|
|
||||||
To add a new blog post, create a new Markdown file in the `src/posts` directory with the following format:
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
```markdown
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
---
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
title: "Your Post Title"
|
|
||||||
date: "YYYY-MM-DD"
|
|
||||||
author: "Your Name"
|
|
||||||
excerpt: "A brief excerpt of your post"
|
|
||||||
tags: ["Tag1", "Tag2"]
|
|
||||||
coverImage: "/images/your-image.jpg"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Your Post Title
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
Your content goes here...
|
## Deploy on Vercel
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
The site can be deployed to various platforms:
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
### Vercel (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g vercel
|
|
||||||
vercel
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
- Modify the design in `tailwind.config.js` and `src/styles/globals.css`
|
|
||||||
- Update site content in component files
|
|
||||||
- Add or modify pages in the `src/pages` directory
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { supabase } from '@/utils/supabase';
|
import { supabase } from '@/utils/supabase';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ const Footer: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/archive" className="text-primary-300 hover:text-white">
|
<Link href="/posts" className="text-primary-300 hover:text-white">
|
||||||
Archive
|
Archive
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -33,7 +33,7 @@ const Header: React.FC = () => {
|
|||||||
<Link href="/about" className="text-primary-700 hover:text-accent-dark">
|
<Link href="/about" className="text-primary-700 hover:text-accent-dark">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/archive" className="text-primary-700 hover:text-accent-dark">
|
<Link href="/posts" className="text-primary-700 hover:text-accent-dark">
|
||||||
Archive
|
Archive
|
||||||
</Link>
|
</Link>
|
||||||
{/*<Link href="/confession" className="text-primary-700 hover:text-accent-dark">*/}
|
{/*<Link href="/confession" className="text-primary-700 hover:text-accent-dark">*/}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {Metadata} from "next";
|
||||||
|
|
||||||
|
interface MetaProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMetadata({
|
||||||
|
title = 'Confessions of Grace',
|
||||||
|
description = 'A blog dedicated to exploring the doctrines of grace and Reformed theology.',
|
||||||
|
keywords = 'reformed theology, doctrines of grace, christianity, calvinism, theology',
|
||||||
|
image = '/images/og-default.jpg',
|
||||||
|
url = 'https://confessionsofgrace.com',
|
||||||
|
type = 'website'
|
||||||
|
}: MetaProps = {}): Metadata {
|
||||||
|
const siteTitle = title === 'Confessions of Grace'
|
||||||
|
? 'Confessions of Grace | Confessing Christ. Rejoicing in Grace.'
|
||||||
|
: `${title} | Confessions of Grace`;
|
||||||
|
|
||||||
|
const fullImageUrl = image.startsWith('http') ? image : `https://www.confessionsofgrace.com${image}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: siteTitle,
|
||||||
|
description,
|
||||||
|
keywords,
|
||||||
|
openGraph: {
|
||||||
|
type: type as any,
|
||||||
|
url,
|
||||||
|
title: siteTitle,
|
||||||
|
description,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: fullImageUrl,
|
||||||
|
alt: title || 'Confessions of Grace'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
locale: 'en_US',
|
||||||
|
siteName: 'Confessions of Grace'
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: siteTitle,
|
||||||
|
description,
|
||||||
|
images: [fullImageUrl]
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default metadata for the site
|
||||||
|
export const defaultMetadata: Metadata = generateMetadata();
|
||||||
|
|
||||||
|
// Helper function for pages that need custom metadata
|
||||||
|
export function createPageMetadata(props: MetaProps): Metadata {
|
||||||
|
return generateMetadata(props);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
interface SubscribeFormProps {
|
interface SubscribeFormProps {
|
||||||
@@ -6,7 +6,7 @@ import html from 'remark-html';
|
|||||||
import { PostData, PostMetadata } from '@/types';
|
import { PostData, PostMetadata } from '@/types';
|
||||||
|
|
||||||
// Make sure this is only used on the server side
|
// Make sure this is only used on the server side
|
||||||
const postsDirectory = path.join(process.cwd(), 'src/posts');
|
const postsDirectory = path.join(process.cwd(), 'data/posts');
|
||||||
|
|
||||||
export function getSortedPostsData(): PostMetadata[] {
|
export function getSortedPostsData(): PostMetadata[] {
|
||||||
// Ensure we only run this on the server
|
// Ensure we only run this on the server
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
output: 'export',
|
|
||||||
trailingSlash: true,
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '**',
|
|
||||||
port: '',
|
|
||||||
pathname: '/images/**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,49 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "confessions-of-grace",
|
"name": "cog",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "A blog about reformed theology",
|
"private": true,
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:static": "next build",
|
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint"
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"deploy:cf": "npm run build:static && npx wrangler deploy",
|
|
||||||
"preview:cf": "npm run build:static && npx wrangler dev"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
|
||||||
"blog",
|
|
||||||
"reformed",
|
|
||||||
"theology",
|
|
||||||
"grace"
|
|
||||||
],
|
|
||||||
"author": "",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.49.4",
|
"@supabase/supabase-js": "^2.55.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"autoprefixer": "^10.4.21",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"date-fns": "^4.1.0",
|
||||||
"autoprefixer": "^10.4.17",
|
|
||||||
"date-fns": "^3.3.1",
|
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "^15.2.4",
|
"next": "15.4.6",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.5.6",
|
||||||
"react": "^18.2.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "19.1.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-html": "^16.0.1",
|
"remark-html": "^16.0.1",
|
||||||
"supabase": "^2.22.12",
|
"supabase": "^2.34.3"
|
||||||
"tailwindcss": "^3.4.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^20.11.7",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^8.56.0",
|
"tailwindcss": "^4.1.12",
|
||||||
"eslint-config-next": "^14.1.0",
|
"typescript": "^5"
|
||||||
"typescript": "^5.3.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -1,40 +0,0 @@
|
|||||||
import Head from 'next/head';
|
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
import Footer from './Footer';
|
|
||||||
import Header from './Header';
|
|
||||||
import { Analytics } from "@vercel/analytics/react"
|
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next"
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: ReactNode;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({
|
|
||||||
children,
|
|
||||||
title = 'Confessions of Grace',
|
|
||||||
description = 'A blog dedicated to exploring the doctrines of grace and Reformed theology.'
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
<div className="flex flex-col min-h-screen">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-grow container mx-auto px-4 py-8">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
<Analytics />
|
|
||||||
<SpeedInsights/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import Head from 'next/head';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface MetaProps {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
keywords?: string;
|
|
||||||
image?: string;
|
|
||||||
url?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Meta: React.FC<MetaProps> = ({
|
|
||||||
title = 'Confessions of Grace',
|
|
||||||
description = 'A blog dedicated to exploring the doctrines of grace and Reformed theology.',
|
|
||||||
keywords = 'reformed theology, doctrines of grace, christianity, calvinism, theology',
|
|
||||||
image = '/images/og-default.jpg',
|
|
||||||
url = 'https://confessionsofgrace.com',
|
|
||||||
type = 'website'
|
|
||||||
}) => {
|
|
||||||
const siteTitle = title === 'Confessions of Grace'
|
|
||||||
? 'Confessions of Grace | Confessing Christ. Rejoicing in Grace.'
|
|
||||||
: `${title} | Confessions of Grace`;
|
|
||||||
|
|
||||||
image = image.startsWith('http') ? image : `https://www.confessionsofgrace.com${image}`;
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
<title>{siteTitle}</title>
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
<meta name="keywords" content={keywords} />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
|
|
||||||
|
|
||||||
{/* Open Graph / Facebook */}
|
|
||||||
<meta property="og:type" content={type} />
|
|
||||||
<meta property="og:url" content={url} />
|
|
||||||
<meta property="og:title" content={siteTitle} />
|
|
||||||
<meta property="og:description" content={description} />
|
|
||||||
<meta property="og:image" content={image} />
|
|
||||||
<meta property="og:locale" content="en_US" />
|
|
||||||
<meta property="og:logo" content="/logo.svg" />
|
|
||||||
|
|
||||||
{/* Twitter */}
|
|
||||||
<meta property="twitter:card" content="summary" />
|
|
||||||
<meta property="twitter:url" content={url} />
|
|
||||||
<meta property="twitter:title" content={siteTitle} />
|
|
||||||
<meta property="twitter:description" content={description} />
|
|
||||||
<meta property="twitter:image" content={image} />
|
|
||||||
</Head>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Meta;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import '@/styles/globals.css';
|
|
||||||
import type { AppProps } from 'next/app';
|
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
|
||||||
return <Component {...pageProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
const About: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Layout title="About | Confessions of Grace">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">About</h1>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Confessions of Grace</h2>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
Welcome to Confessions of Grace, a blog dedicated to exploring the riches of Reformed theology
|
|
||||||
and the doctrines of grace. Our aim is to articulate timeless biblical truths in a clear,
|
|
||||||
accessible manner, helping believers understand the depth and beauty of God's sovereign grace.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-4">What Do You Mean By "Confessions of Grace"?</h2>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
The name "Confessions of Grace" is a play on Augustine's "Confessions" and John Bunyan's "Grace Abounding to the Chief of Sinners."
|
|
||||||
I originally wanted to call the blog "Confessions of the Chief of Sinners," but that felt a bit too long.
|
|
||||||
The idea behind those books is that we are all sinners saved by grace, needing to confess that grace to others.
|
|
||||||
"Confessions of Grace" seems to fit that idea well.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 className="text-xl font-bold mb-3">Our Vision</h3>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
In an age of theological confusion and spiritual relativism, we seek to provide content
|
|
||||||
that is firmly rooted in Scripture, historically informed, and pastorally sensitive.
|
|
||||||
We believe that sound doctrine leads to doxology—that theology, properly understood,
|
|
||||||
results in worship and wonder at the character and works of God.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 className="text-xl font-bold mb-3">What We Believe</h3>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
We stand in the tradition of the Protestant Reformation, affirming the five "solas":
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside mb-6 text-primary-700 space-y-2">
|
|
||||||
<li><span className="font-semibold italic">Sola Scriptura</span> — Scripture Alone</li>
|
|
||||||
<li><span className="font-semibold italic">Sola Fide</span> — Faith Alone</li>
|
|
||||||
<li><span className="font-semibold italic">Sola Gratia</span> — Grace Alone</li>
|
|
||||||
<li><span className="font-semibold italic">Solus Christus</span> — Christ Alone</li>
|
|
||||||
<li><span className="font-semibold italic">Soli Deo Gloria</span> — Glory to God Alone</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
As Reformed Baptists, we affirm the doctrines of grace as articulated in the 1689 London
|
|
||||||
Baptist Confession of Faith. This historic confession provides a robust and faithful summary
|
|
||||||
of biblical doctrine, emphasizing God's sovereignty in salvation and the centrality of Christ
|
|
||||||
in all things.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 className="text-xl font-bold mb-3">Our Associations</h3>
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
We are associated with the <a href="https://reformedwitness.net" className="text-accent hover:text-accent-dark">
|
|
||||||
Reformed Witness Network (RWN)</a>, a group committed to the proclamation of the gospel and the advancement of Christ's kingdom.
|
|
||||||
Through RWN, we aim to foster fellowship among like-minded believers and support the spread of Reformed theology globally.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<blockquote className="border-l-4 border-accent pl-4 italic my-8 text-primary-600">
|
|
||||||
"For from him and through him and to him are all things. To him be glory forever. Amen."
|
|
||||||
<br />— Romans 11:36
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">About the Author</h2>
|
|
||||||
<div className="md:flex items-start gap-6">
|
|
||||||
{/* <div className="md:w-1/3 mb-4 md:mb-0">
|
|
||||||
<div className="bg-primary-200 h-64 w-full rounded-md mb-4 relative h-48 w-full overflow-hidden rounded-md">
|
|
||||||
<Image
|
|
||||||
src="/images/me.jpeg"
|
|
||||||
alt="Me"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
<div className="md:w-2/3">
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
My wife and I are members of Covenant Community Church,
|
|
||||||
where I have been blessed to grow in faith and fellowship. My passion for theology and
|
|
||||||
technology inspired me to start this blog as a way to share the beauty of God's sovereign grace.
|
|
||||||
I hope to one day reach unreached people groups and share the gospel with them.
|
|
||||||
</p>
|
|
||||||
<p className="text-primary-700">
|
|
||||||
Feel free to connect with me on X @auggie2lbcf or email me at contact@confessionsofgrace.com.
|
|
||||||
I would love to hear from you and learn how I can serve you better.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default About;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import { getSortedPostsData } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
|
|
||||||
interface ArchiveProps {
|
|
||||||
posts: PostMetadata[];
|
|
||||||
years: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Archive: React.FC<ArchiveProps> = ({ posts, years }) => {
|
|
||||||
return (
|
|
||||||
<Layout title="Archive | Confessions of Grace">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-8">Archive</h1>
|
|
||||||
|
|
||||||
{years.map(year => (
|
|
||||||
<div key={year} className="mb-12">
|
|
||||||
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{year}</h2>
|
|
||||||
<ul className="space-y-6">
|
|
||||||
{posts
|
|
||||||
.filter(post => new Date(post.date).getFullYear() === year)
|
|
||||||
.map(post => (
|
|
||||||
<li key={post.id} className="bg-white p-6 rounded-md shadow-sm border border-primary-200">
|
|
||||||
<div className="md:flex md:justify-between md:items-center">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-2">
|
|
||||||
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
|
|
||||||
{post.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center mb-2 text-sm text-primary-500">
|
|
||||||
<span>{post.author}</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<time dateTime={post.date}>
|
|
||||||
{format(new Date(post.date), 'MMMM d, yyyy')}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p className="text-primary-600">{post.excerpt}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-4 items-center">
|
|
||||||
{post.tags.map(tag => (
|
|
||||||
<Link
|
|
||||||
key={tag}
|
|
||||||
href={`/tags/${tag}`}
|
|
||||||
className="text-sm bg-primary-100 text-primary-600 px-3 py-1 rounded-md hover:bg-primary-200"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<div className="mt-4 md:mt-0 md:self-end md:ml-auto flex justify-end">
|
|
||||||
<Link href={`/posts/${post.id}`} className="text-sm bg-accent text-white px-3 py-1 rounded-md hover:bg-accent-dark">
|
|
||||||
Read post
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
const posts = getSortedPostsData();
|
|
||||||
|
|
||||||
// Extract unique years from post dates
|
|
||||||
const years = Array.from(new Set(
|
|
||||||
posts.map(post => new Date(post.date).getFullYear())
|
|
||||||
)).sort((a, b) => b - a); // Sort years in descending order
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
posts,
|
|
||||||
years,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Archive;
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { GetStaticProps, GetStaticPaths } from 'next';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import PostCard from '@/components/PostCard';
|
|
||||||
import { getSortedPostsData, getPostsByAuthor } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
import { supabase } from '@/utils/supabase';
|
|
||||||
|
|
||||||
interface AuthorProfile {
|
|
||||||
name: string;
|
|
||||||
bio: string;
|
|
||||||
x_link?: string;
|
|
||||||
fb_link?: string;
|
|
||||||
insta_link?: string;
|
|
||||||
pfp_link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthorPageProps {
|
|
||||||
posts: PostMetadata[];
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthorPage: React.FC<AuthorPageProps> = ({ posts, author }) => {
|
|
||||||
const [authorProfile, setAuthorProfile] = useState<AuthorProfile | null>(null);
|
|
||||||
|
|
||||||
const fetchAuthorProfile = async () => {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('authors')
|
|
||||||
.select('name, bio, x_link, fb_link, insta_link, pfp_link')
|
|
||||||
.eq('name', author) // Or use slug if you've switched to that
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.warn('No author profile found for:', author, '→', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
setAuthorProfile(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAuthorProfile();
|
|
||||||
}, [author]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout title={`${authorProfile?.name || author} | Confessions of Grace`}>
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6">
|
|
||||||
{/* Profile Picture */}
|
|
||||||
{authorProfile?.pfp_link ? (
|
|
||||||
<img
|
|
||||||
src={authorProfile.pfp_link}
|
|
||||||
alt={`${authorProfile.name}'s profile picture`}
|
|
||||||
className="w-24 h-24 rounded-full object-cover shadow-md"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-500">
|
|
||||||
?
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{/* Author Name */}
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">
|
|
||||||
{authorProfile?.name || author}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Author Bio */}
|
|
||||||
{authorProfile?.bio && (
|
|
||||||
<p className="text-primary-600 mb-2">{authorProfile.bio}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Social Links */}
|
|
||||||
<div className="flex gap-4 mt-2">
|
|
||||||
{authorProfile?.x_link && (
|
|
||||||
<a
|
|
||||||
href={authorProfile.x_link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<img src="/icons/x.svg" alt="X (Twitter)" className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{authorProfile?.fb_link && (
|
|
||||||
<a
|
|
||||||
href={authorProfile.fb_link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<img src="/icons/facebook.svg" alt="Facebook" className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{authorProfile?.insta_link && (
|
|
||||||
<a
|
|
||||||
href={authorProfile.insta_link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<img src="/icons/instagram.svg" alt="Instagram" className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Posts */}
|
|
||||||
<p className="text-lg text-primary-600 mb-6">
|
|
||||||
{posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by{' '}
|
|
||||||
"{authorProfile?.name || author}"
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{posts.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<PostCard key={post.id} post={post} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-xl text-primary-500">No posts found for this author.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
|
||||||
try {
|
|
||||||
const posts = getSortedPostsData();
|
|
||||||
const authors = Array.from(new Set(posts.flatMap(post => post.author)));
|
|
||||||
|
|
||||||
const paths = authors.map((author) => ({
|
|
||||||
params: { author },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
fallback: 'blocking',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate author paths during build:', error);
|
|
||||||
return {
|
|
||||||
paths: [],
|
|
||||||
fallback: 'blocking',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
|
||||||
const author = params?.author as string;
|
|
||||||
const posts = getPostsByAuthor(author);
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
posts,
|
|
||||||
author,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthorPage;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import { supabase } from '@/utils/supabase';
|
|
||||||
|
|
||||||
interface AuthorProfile {
|
|
||||||
name: string;
|
|
||||||
bio: string;
|
|
||||||
x_link?: string;
|
|
||||||
fb_link?: string;
|
|
||||||
insta_link?: string;
|
|
||||||
pfp_link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthorsPageProps {
|
|
||||||
authors: AuthorProfile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthorsPage: React.FC<AuthorsPageProps> = ({ authors }) => {
|
|
||||||
return (
|
|
||||||
<Layout title="Authors | Confessions of Grace">
|
|
||||||
<div className="max-w-5xl mx-auto px-4">
|
|
||||||
<h1 className="text-4xl font-bold mb-10 text-center">Meet the Authors</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
|
||||||
{authors.map((author) => (
|
|
||||||
<Link
|
|
||||||
key={author.name}
|
|
||||||
href={`/authors/${author.name}`}
|
|
||||||
className="bg-white rounded-lg shadow-md p-5 flex flex-col items-center hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="w-24 h-24 mb-4 relative">
|
|
||||||
<Image
|
|
||||||
src={author.pfp_link || '/images/authors/default.jpg'}
|
|
||||||
alt={`${author.name}'s profile`}
|
|
||||||
fill
|
|
||||||
className="rounded-full object-cover"
|
|
||||||
sizes="96px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold">{author.name}</h2>
|
|
||||||
{/* You can include bio or social icons here */}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link href="/archive" className="button">
|
|
||||||
View All Posts
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
try {
|
|
||||||
const { data: authorsData, error } = await supabase
|
|
||||||
.from('authors')
|
|
||||||
.select('name, bio, x_link, fb_link, insta_link, pfp_link');
|
|
||||||
|
|
||||||
if (error || !authorsData) {
|
|
||||||
console.error('Error fetching authors:', error);
|
|
||||||
// Return empty array for static export fallback
|
|
||||||
return { props: { authors: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
authors: authorsData,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch authors during build:', error);
|
|
||||||
// Fallback for static export when Supabase is not available during build
|
|
||||||
return { props: { authors: [] } };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthorsPage;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
|
|
||||||
const ChurchFinder: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Layout title="RB Church Finder | Confessions of Grace">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-8">Find a Reformed Church Near You</h1>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6 border border-primary-200 mb-8">
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
Use the map below to search for Reformed Baptist churches in your area. You can zoom in, search by city, or drag the map to explore different regions.
|
|
||||||
</p>
|
|
||||||
<div className="w-full aspect-video rounded-md overflow-hidden border border-primary-200 shadow-sm">
|
|
||||||
<iframe
|
|
||||||
title="Reformed Church Finder"
|
|
||||||
src="https://www.google.com/maps/d/embed?mid=1lxWCjrZza0cQ8NIen0PMof9r1kg6zsI&ehbc=2E312F"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{ border: 0 }}
|
|
||||||
allowFullScreen
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center text-primary-500 text-sm">
|
|
||||||
<p>
|
|
||||||
Want your church listed? <a href="https://reformedbaptistchurches.com/form" className="text-accent-dark hover:text-accent">Contact us</a> with your church's info!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChurchFinder;
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import confessionData from '@/data/1689-confession.json';
|
|
||||||
import verseReferences from '@/data/1689-verses.json';
|
|
||||||
|
|
||||||
interface ConfessionProps {
|
|
||||||
confession: {
|
|
||||||
title: string;
|
|
||||||
chapters: {
|
|
||||||
[key: string]: {
|
|
||||||
title: string;
|
|
||||||
paragraphs: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
verses: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const Confession: React.FC<ConfessionProps> = ({ confession, verses }) => {
|
|
||||||
const [activeChapter, setActiveChapter] = useState<string>("1");
|
|
||||||
const [showScripture, setShowScripture] = useState<boolean>(true);
|
|
||||||
|
|
||||||
// Get sorted chapter numbers
|
|
||||||
const chapterNumbers = Object.keys(confession.chapters).sort((a, b) =>
|
|
||||||
parseInt(a) - parseInt(b)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Function to render Bible references as links with tooltips
|
|
||||||
const renderTextWithReferences = (text: string) => {
|
|
||||||
const regex = /\b(?:\d\s+)?[A-Za-z]+\s+\d+:\d+(?:-\d+)?(?:,\s+\d+(?:-\d+)?)*\b/g;
|
|
||||||
const parts = text.split(regex);
|
|
||||||
const matches = text.match(regex) || [];
|
|
||||||
|
|
||||||
if (matches.length === 0) return text;
|
|
||||||
|
|
||||||
return parts.reduce((result: React.ReactNode[], part, i) => {
|
|
||||||
result.push(part);
|
|
||||||
if (i < matches.length) {
|
|
||||||
const reference = matches[i];
|
|
||||||
const verseText = verses[reference];
|
|
||||||
|
|
||||||
if (verseText && showScripture) {
|
|
||||||
result.push(
|
|
||||||
<span key={i} className="relative group">
|
|
||||||
<span className="text-accent-dark cursor-pointer underline">
|
|
||||||
{reference}
|
|
||||||
</span>
|
|
||||||
<span className="absolute left-0 -bottom-2 transform translate-y-full bg-white border border-primary-200 p-3 rounded-md shadow-lg w-64 hidden group-hover:block z-10 text-sm text-primary-700">
|
|
||||||
<strong>{reference}</strong>: {verseText}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
result.push(
|
|
||||||
<span key={i} className="text-accent-dark">
|
|
||||||
{reference}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout title="1689 Baptist Confession of Faith | Confessions of Grace">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">The Baptist Confession of Faith of 1689</h1>
|
|
||||||
|
|
||||||
{/* <div className="mb-8 bg-white rounded-lg shadow-sm p-6 border border-primary-200">
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
The 1689 Baptist Confession of Faith, also called the Second London Baptist Confession, was written by Particular Baptists in England who were concerned that their Calvinistic theological positions would be misunderstood.
|
|
||||||
</p>
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
This confession follows the Westminster Confession of Faith and the Savoy Declaration in its doctrine, but with Baptist distinctives related to baptism and church polity.
|
|
||||||
</p>
|
|
||||||
<div className="flex mb-2 mt-6">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowScripture(!showScripture)}
|
|
||||||
className="button text-sm"
|
|
||||||
>
|
|
||||||
{showScripture ? 'Hide Scripture Tooltips' : 'Show Scripture Tooltips'}
|
|
||||||
</button>
|
|
||||||
<p className="ml-3 text-sm text-primary-500 self-center">
|
|
||||||
{showScripture ? 'Hover over references to see Scripture text' : 'Scripture tooltips are hidden'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<div className="md:w-1/4">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-primary-200 sticky top-4">
|
|
||||||
<h2 className="text-xl font-bold p-4 border-b border-primary-200">Chapters</h2>
|
|
||||||
<div className="overflow-y-auto max-h-[70vh] p-2">
|
|
||||||
{chapterNumbers.map(chapterNum => (
|
|
||||||
<button
|
|
||||||
key={chapterNum}
|
|
||||||
onClick={() => setActiveChapter(chapterNum)}
|
|
||||||
className={`w-full text-left p-3 rounded-md my-1 transition-colors ${
|
|
||||||
activeChapter === chapterNum
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'text-primary-700 hover:bg-primary-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-bold">Chapter {chapterNum}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-sm">
|
|
||||||
{confession.chapters[chapterNum].title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="md:w-3/4">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6 border border-primary-200">
|
|
||||||
<h2 className="text-2xl font-bold mb-6 pb-2 border-b border-primary-200">
|
|
||||||
Chapter {activeChapter}: {confession.chapters[activeChapter].title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{Object.keys(confession.chapters[activeChapter].paragraphs).map(paraNum => (
|
|
||||||
<div key={paraNum} className="mb-8 last:mb-0" id={`${activeChapter}-${paraNum}`}>
|
|
||||||
<h3 className="font-bold text-lg mb-3 flex items-baseline">
|
|
||||||
<span className="bg-accent text-white w-8 h-8 inline-flex items-center justify-center rounded-full mr-2 text-sm flex-shrink-0">
|
|
||||||
{paraNum}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href={`#${activeChapter}-${paraNum}`}
|
|
||||||
className="hover:text-accent-dark"
|
|
||||||
>
|
|
||||||
Paragraph {paraNum}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="text-primary-700 leading-relaxed">
|
|
||||||
{renderTextWithReferences(confession.chapters[activeChapter].paragraphs[paraNum])}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
|
||||||
{parseInt(activeChapter) > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveChapter((parseInt(activeChapter) - 1).toString())}
|
|
||||||
className="bg-white text-primary-700 border border-primary-200 px-4 py-2 rounded-md hover:bg-primary-50 flex items-center"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Previous Chapter
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{parseInt(activeChapter) < chapterNumbers.length && (
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveChapter((parseInt(activeChapter) + 1).toString())}
|
|
||||||
className="bg-white text-primary-700 border border-primary-200 px-4 py-2 rounded-md hover:bg-primary-50 flex items-center ml-auto"
|
|
||||||
>
|
|
||||||
Next Chapter
|
|
||||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
// In a real implementation, you would import these from files
|
|
||||||
// We're using the sample data provided
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
confession: confessionData,
|
|
||||||
verses: verseReferences,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Confession;
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
|
|
||||||
const Contact: React.FC = () => {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [subject, setSubject] = useState('');
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!name.trim() || !email.trim() || !subject.trim() || !message.trim()) {
|
|
||||||
setError('All fields are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// In a real implementation, you would send this data to your API
|
|
||||||
// For now, we'll just simulate a successful submission
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Clear form and show success message
|
|
||||||
setName('');
|
|
||||||
setEmail('');
|
|
||||||
setSubject('');
|
|
||||||
setMessage('');
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setIsSubmitting(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Something went wrong. Please try again later.');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout title="Contact | Confessions of Grace">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">Contact</h1>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 mb-10">
|
|
||||||
<p className="text-primary-700 mb-6">
|
|
||||||
Have questions, comments, or suggestions? We'd love to hear from you!
|
|
||||||
Use the form below to reach out, and we'll get back to you as soon as possible.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isSubmitted ? (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 p-6 rounded-md">
|
|
||||||
<h3 className="text-xl font-bold mb-2">Thank You!</h3>
|
|
||||||
<p>Your message has been sent successfully. We'll respond to you as soon as possible.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-primary-700 mb-1">
|
|
||||||
Name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-primary-700 mb-1">
|
|
||||||
Email <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="subject" className="block text-primary-700 mb-1">
|
|
||||||
Subject <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="message" className="block text-primary-700 mb-1">
|
|
||||||
Message <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="message"
|
|
||||||
rows={6}
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Other Ways to Connect</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-2">Follow</h3>
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
Stay updated with our latest content on social media.
|
|
||||||
</p>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
|
|
||||||
aria-label="Twitter"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
|
|
||||||
aria-label="Facebook"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fillRule="evenodd" d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold mb-2">Subscribe</h3>
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
Get new posts delivered to your inbox.
|
|
||||||
</p>
|
|
||||||
<form className="flex">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Your email"
|
|
||||||
className="flex-grow px-4 py-2 rounded-l-md border border-primary-300 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<button className="bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-r-md">
|
|
||||||
Subscribe
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Contact;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import PostCard from '@/components/PostCard';
|
|
||||||
import Sidebar from '@/components/Sidebar';
|
|
||||||
import { getSortedPostsData } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
import Image from 'next/image'
|
|
||||||
import Meta from '@/components/Meta';
|
|
||||||
|
|
||||||
interface HomeProps {
|
|
||||||
posts: PostMetadata[];
|
|
||||||
recentPosts: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
}>;
|
|
||||||
tags: Array<{
|
|
||||||
tag: string;
|
|
||||||
count: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Home: React.FC<HomeProps> = ({ posts, recentPosts, tags }) => {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Meta
|
|
||||||
title="Confessions of Grace"
|
|
||||||
keywords="reformed theology, doctrines of grace, christianity, calvinism, theology"
|
|
||||||
image="public/assets/logo-horizontal.svg"
|
|
||||||
url="https://www.confessionsofgrace.com/"
|
|
||||||
type="website"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
<main className="md:w-2/3">
|
|
||||||
{posts.length > 0 && (
|
|
||||||
<div className="mb-12">
|
|
||||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
||||||
<div className="md:flex">
|
|
||||||
<div className="md:flex-shrink-0 bg-accent relative w-full md:w-1/3 h-64 md:h-auto">
|
|
||||||
{posts[0].coverImage ? (
|
|
||||||
<Image
|
|
||||||
src={posts[0].coverImage}
|
|
||||||
alt={posts[0].title}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
width={1000}
|
|
||||||
height={1000}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full flex items-center justify-center bg-accent text-white text-6xl font-bold">
|
|
||||||
CG
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="uppercase tracking-wide text-sm text-accent font-semibold">Latest Post</div>
|
|
||||||
<a href={`/posts/${posts[0].id}`} className="block mt-1 text-2xl leading-tight font-bold text-primary-900 hover:text-accent-dark">
|
|
||||||
{posts[0].title}
|
|
||||||
</a>
|
|
||||||
<p className="mt-2 text-primary-600">
|
|
||||||
{posts[0].excerpt}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<a href={`/posts/${posts[0].id}`} className="inline-flex items-center text-accent-dark hover:text-accent">
|
|
||||||
Read more
|
|
||||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">Recent Posts</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{posts.slice(1).map((post) => (
|
|
||||||
<PostCard key={post.id} post={post} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mt-12">
|
|
||||||
<a href="/archive" className="button">
|
|
||||||
View All Posts
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div className="md:w-1/3">
|
|
||||||
<Sidebar recentPosts={recentPosts} tags={tags} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
const posts = getSortedPostsData();
|
|
||||||
|
|
||||||
// Create tag data
|
|
||||||
const allTags = posts.flatMap(post => post.tags);
|
|
||||||
const tagCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
allTags.forEach(tag => {
|
|
||||||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tags = Object.entries(tagCounts).map(([tag, count]) => ({
|
|
||||||
tag,
|
|
||||||
count
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
posts,
|
|
||||||
recentPosts: posts.slice(0, 5).map(post => ({
|
|
||||||
id: post.id,
|
|
||||||
title: post.title,
|
|
||||||
date: post.date
|
|
||||||
})),
|
|
||||||
tags
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import Layout from '@/components/Layout';
|
|
||||||
import Meta from '@/components/Meta';
|
|
||||||
import ShareButtons from '@/components/ShareButtons';
|
|
||||||
import { getAllPostIds, getPostData, getSortedPostsData } from '@/lib/markdown';
|
|
||||||
import { PostData, PostMetadata } from '@/types';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { GetStaticPaths, GetStaticProps } from 'next';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React from 'react';
|
|
||||||
import CommentSection from "@/components/CommentSection";
|
|
||||||
|
|
||||||
interface PostProps {
|
|
||||||
post: PostData;
|
|
||||||
morePosts: PostMetadata[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Post: React.FC<PostProps> = ({ post, morePosts }) => {
|
|
||||||
const postUrl = `https://confessionsofgrace.com/posts/${post.id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
title={`${post.title} | Confessions of Grace`}
|
|
||||||
description={post.excerpt}
|
|
||||||
>
|
|
||||||
<Meta
|
|
||||||
title={post.title}
|
|
||||||
description={post.excerpt}
|
|
||||||
keywords={post.tags.join(', ')}
|
|
||||||
image={post.coverImage}
|
|
||||||
url={postUrl}
|
|
||||||
type="article"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<article className="max-w-3xl mx-auto">
|
|
||||||
<header className="mb-8">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">{post.title}</h1>
|
|
||||||
<div className="flex items-center text-primary-500 mb-6">
|
|
||||||
<span>{post.author}</span>
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
<time dateTime={post.date}>
|
|
||||||
{format(new Date(post.date), 'MMMM d, yyyy')}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
{post.coverImage && (
|
|
||||||
<div className="relative h-64 md:h-96 w-full mb-8 rounded-lg overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={post.coverImage}
|
|
||||||
alt={post.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{post.tags.map(tag => (
|
|
||||||
<Link
|
|
||||||
key={tag}
|
|
||||||
href={`/tags/${tag}`}
|
|
||||||
className="text-sm bg-primary-100 text-primary-600 px-3 py-1 rounded-md hover:bg-primary-200"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="blog-post prose prose-lg max-w-none"
|
|
||||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-primary-200">
|
|
||||||
<ShareButtons
|
|
||||||
url={postUrl}
|
|
||||||
title={post.title}
|
|
||||||
description={post.excerpt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 pt-6 border-t border-primary-200">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">More Posts</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{morePosts.slice(0, 2).map(post => (
|
|
||||||
<div key={post.id} className="bg-white p-6 rounded-md border border-primary-200 shadow-sm">
|
|
||||||
<h3 className="font-bold mb-2">
|
|
||||||
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
|
|
||||||
{post.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="text-primary-600 text-sm mb-2">
|
|
||||||
{format(new Date(post.date), 'MMMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
<p className="text-primary-700">{post.excerpt}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommentSection postId={post.id} />
|
|
||||||
|
|
||||||
<div className="mt-12 pt-6 border-t border-primary-200">
|
|
||||||
<Link href="/" className="text-accent-dark hover:text-accent inline-flex items-center">
|
|
||||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
||||||
</svg>
|
|
||||||
Back to all posts
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
|
||||||
const paths = getAllPostIds();
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
fallback: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
|
||||||
const post = await getPostData(params?.id as string);
|
|
||||||
const allPosts = getSortedPostsData();
|
|
||||||
|
|
||||||
// Filter out the current post and get a few related posts (by tags)
|
|
||||||
const otherPosts = allPosts.filter(p => p.id !== post.id);
|
|
||||||
|
|
||||||
// Find posts with matching tags
|
|
||||||
const relatedPosts = otherPosts
|
|
||||||
.filter(p => p.tags.some(tag => post.tags.includes(tag)))
|
|
||||||
.slice(0, 2);
|
|
||||||
|
|
||||||
// If we don't have enough related posts, add recent posts
|
|
||||||
const morePosts = relatedPosts.length < 2
|
|
||||||
? [...relatedPosts, ...otherPosts.filter(p => !relatedPosts.includes(p))].slice(0, 2)
|
|
||||||
: relatedPosts;
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
post,
|
|
||||||
morePosts,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Post;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
|
|
||||||
interface Resource {
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
description: string;
|
|
||||||
link?: string;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Resources: React.FC = () => {
|
|
||||||
const resources: Resource[] = [
|
|
||||||
// {
|
|
||||||
// title: "Institutes of the Christian Religion",
|
|
||||||
// author: "John Calvin",
|
|
||||||
// description: "Calvin's magnum opus on systematic theology, covering the doctrines of God, man, salvation, and the church.",
|
|
||||||
// category: "Books"
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "The Bondage of the Will",
|
|
||||||
// author: "Martin Luther",
|
|
||||||
// description: "Luther's defense of the doctrine of total depravity and God's sovereignty in salvation.",
|
|
||||||
// category: "Books"
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Chosen by God",
|
|
||||||
// author: "R.C. Sproul",
|
|
||||||
// description: "An accessible introduction to the doctrine of predestination.",
|
|
||||||
// category: "Books"
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: "The Second London Baptist Confession of Faith (1689)",
|
|
||||||
author: "Particular Baptists",
|
|
||||||
description: "A historic Reformed Baptist confession of faith that aligns closely with the Westminster Confession but reflects Baptist distinctives.",
|
|
||||||
link: "/confession",
|
|
||||||
category: "Confessions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "The Heidelberg Catechism",
|
|
||||||
author: "Zacharias Ursinus & Caspar Olevianus",
|
|
||||||
description: "A warm, pastoral Reformed catechism organized around comfort in Christ.",
|
|
||||||
link: "https://www.ligonier.org/learn/articles/heidelberg-catechism",
|
|
||||||
category: "Confessions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Ligonier Ministries",
|
|
||||||
author: "Founded by R.C. Sproul",
|
|
||||||
description: "A ministry dedicated to helping Christians know what they believe, why they believe it, how to live it, and how to share it.",
|
|
||||||
link: "https://www.ligonier.org/",
|
|
||||||
category: "Websites"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Monergism",
|
|
||||||
author: "",
|
|
||||||
description: "A comprehensive resource for Reformed theology, including articles, books, and audio resources.",
|
|
||||||
link: "https://www.monergism.com/",
|
|
||||||
category: "Websites"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Church Finder",
|
|
||||||
author: "",
|
|
||||||
description: "Find a Reformed Baptist church near you using our interactive map.",
|
|
||||||
link: "/church-finder",
|
|
||||||
category: "Websites"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const categories = Array.from(new Set(resources.map(resource => resource.category)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout title="Resources | Confessions of Grace">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">Reformed Resources</h1>
|
|
||||||
|
|
||||||
<p className="text-lg text-primary-700 mb-10">
|
|
||||||
This is a curated collection of resources related to Reformed theology and the doctrines of grace.
|
|
||||||
These books, confessions, and websites have been formative in my own theological journey and
|
|
||||||
are recommended for those seeking to deepen their understanding of Reformed thought.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{categories.map(category => (
|
|
||||||
<div key={category} className="mb-12">
|
|
||||||
<h2 className="text-2xl font-bold border-b border-primary-200 pb-2 mb-6">{category}</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{resources
|
|
||||||
.filter(resource => resource.category === category)
|
|
||||||
.map((resource, index) => (
|
|
||||||
<div key={index} className="bg-white rounded-lg shadow-sm p-6 border border-primary-200">
|
|
||||||
<h3 className="text-xl font-bold mb-2">{resource.title}</h3>
|
|
||||||
{resource.author && (
|
|
||||||
<p className="text-primary-500 italic mb-3">by {resource.author}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-primary-700 mb-4">{resource.description}</p>
|
|
||||||
{resource.link && (
|
|
||||||
<a
|
|
||||||
href={resource.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center text-accent-dark hover:text-accent"
|
|
||||||
>
|
|
||||||
Visit Resource
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4 ml-1"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* <div className="bg-primary-100 rounded-lg p-6 border border-primary-200 mt-10">
|
|
||||||
<h2 className="text-xl font-bold mb-4">Suggest a Resource</h2>
|
|
||||||
<p className="text-primary-700 mb-4">
|
|
||||||
Do you have a resource suggestion that would be valuable for readers of this blog?
|
|
||||||
Please use the contact form to share your recommendations.
|
|
||||||
</p>
|
|
||||||
<a href="#" className="button inline-block">
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Resources;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import PostCard from '@/components/PostCard';
|
|
||||||
import SearchBar from '@/components/SearchBar';
|
|
||||||
import { getSortedPostsData } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
|
|
||||||
interface SearchPageProps {
|
|
||||||
allPosts: PostMetadata[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchPage: React.FC<SearchPageProps> = ({ allPosts }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { q: query } = router.query;
|
|
||||||
const [searchResults, setSearchResults] = useState<PostMetadata[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof query !== 'string' || !query.trim()) {
|
|
||||||
setSearchResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchTerms = query.toLowerCase().trim().split(/\s+/);
|
|
||||||
|
|
||||||
const filteredPosts = allPosts.filter(post => {
|
|
||||||
const titleMatch = searchTerms.some(term =>
|
|
||||||
post.title.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
|
|
||||||
const excerptMatch = searchTerms.some(term =>
|
|
||||||
post.excerpt.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
|
|
||||||
const tagMatch = post.tags.some(tag =>
|
|
||||||
searchTerms.some(term => tag.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
|
|
||||||
return titleMatch || excerptMatch || tagMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSearchResults(filteredPosts);
|
|
||||||
}, [query, allPosts]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout title={`Search: ${query || ''} | Confessions of Grace`}>
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-6">Search Results</h1>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<SearchBar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{typeof query === 'string' && query.trim() ? (
|
|
||||||
<>
|
|
||||||
<p className="mb-8 text-primary-600">
|
|
||||||
{searchResults.length} {searchResults.length === 1 ? 'result' : 'results'} for "{query}"
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{searchResults.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{searchResults.map(post => (
|
|
||||||
<PostCard key={post.id} post={post} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200 text-center">
|
|
||||||
<p className="text-xl text-primary-600 mb-4">No results found for "{query}"</p>
|
|
||||||
<p className="text-primary-500">Try using different keywords or browse all posts.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
|
|
||||||
<p className="text-xl text-primary-600 mb-4 text-center">Enter a search term to find posts</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
const allPosts = getSortedPostsData();
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
allPosts,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchPage;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { GetStaticProps, GetStaticPaths } from 'next';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import PostCard from '@/components/PostCard';
|
|
||||||
import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
|
|
||||||
interface TagPageProps {
|
|
||||||
posts: PostMetadata[];
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TagPage: React.FC<TagPageProps> = ({ posts, tag }) => {
|
|
||||||
return (
|
|
||||||
<Layout title={`${tag} | Confessions of Grace`}>
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="mb-10">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">Tag: {tag}</h1>
|
|
||||||
<p className="text-lg text-primary-600">
|
|
||||||
{posts.length} {posts.length === 1 ? 'post' : 'posts'} tagged with "{tag}"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{posts.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<PostCard key={post.id} post={post} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-xl text-primary-500">No posts found with this tag.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
|
||||||
const posts = getSortedPostsData();
|
|
||||||
const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
|
|
||||||
|
|
||||||
const paths = tags.map((tag) => ({
|
|
||||||
params: { tag },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths,
|
|
||||||
fallback: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
|
||||||
const tag = params?.tag as string;
|
|
||||||
const posts = getPostsByTag(tag);
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
posts,
|
|
||||||
tag,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TagPage;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { GetStaticProps } from 'next';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import { getSortedPostsData } from '@/lib/markdown';
|
|
||||||
import { PostMetadata } from '@/types';
|
|
||||||
|
|
||||||
interface TagsPageProps {
|
|
||||||
tags: {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TagsPage: React.FC<TagsPageProps> = ({ tags }) => {
|
|
||||||
return (
|
|
||||||
<Layout title="Tags | Confessions of Grace">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-8">Browse by Tag</h1>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-8 border border-primary-200">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{tags.map(tag => (
|
|
||||||
<Link
|
|
||||||
key={tag.name}
|
|
||||||
href={`/tags/${tag.name}`}
|
|
||||||
className="bg-primary-100 hover:bg-primary-200 text-primary-700 px-4 py-2 rounded-md text-lg transition-colors duration-200 flex items-center"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
<span className="ml-2 bg-primary-200 rounded-full px-2 py-0.5 text-sm">
|
|
||||||
{tag.count}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link href="/archive" className="button">
|
|
||||||
View All Posts
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
const posts = getSortedPostsData();
|
|
||||||
|
|
||||||
// Extract all tags from posts
|
|
||||||
const allTags = posts.flatMap(post => post.tags);
|
|
||||||
|
|
||||||
// Count occurrences of each tag
|
|
||||||
const tagCounts = allTags.reduce((acc, tag) => {
|
|
||||||
acc[tag] = (acc[tag] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
// Convert to array and sort alphabetically
|
|
||||||
const tags = Object.entries(tagCounts)
|
|
||||||
.map(([name, count]) => ({ name, count }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TagsPage;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
@apply bg-primary-50 text-primary-800 font-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
@apply font-serif text-primary-900 mb-4 font-bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
@apply text-3xl md:text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
@apply text-2xl md:text-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
@apply text-xl md:text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
@apply text-accent-dark hover:text-accent transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
@apply border-l-4 border-accent pl-4 italic my-4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.blog-post {
|
|
||||||
@apply prose prose-lg max-w-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blog-post h1, .blog-post h2, .blog-post h3 {
|
|
||||||
@apply border-b border-primary-200 pb-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
@apply px-4 py-2 rounded-md bg-accent text-white shadow-sm hover:bg-accent-dark transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply bg-white rounded-md shadow-sm p-6 border border-primary-200 hover:shadow-md transition-shadow duration-200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: [
|
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx}'
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#f8f9fa',
|
|
||||||
100: '#e9ecef',
|
|
||||||
200: '#dee2e6',
|
|
||||||
300: '#ced4da',
|
|
||||||
400: '#adb5bd',
|
|
||||||
500: '#6c757d',
|
|
||||||
600: '#495057',
|
|
||||||
700: '#343a40',
|
|
||||||
800: '#212529',
|
|
||||||
900: '#121212',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
light: '#e2d8c6',
|
|
||||||
DEFAULT: '#9d8c70',
|
|
||||||
dark: '#695c4a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
serif: ['Baskerville', 'Georgia', 'Times New Roman', 'serif'],
|
|
||||||
sans: ['Helvetica Neue', 'Arial', 'sans-serif'],
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
DEFAULT: {
|
|
||||||
css: {
|
|
||||||
'blockquote p:first-of-type::before': false,
|
|
||||||
'blockquote p:last-of-type::after': false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require('@tailwindcss/typography')],
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": ".",
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "confessions-of-grace",
|
|
||||||
"compatibility_date": "2025-08-18",
|
|
||||||
"assets": {
|
|
||||||
"directory": "./out"
|
|
||||||
},
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"pattern": "confessionsofgrace.com",
|
|
||||||
"custom_domain": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||