switched to approuter

This commit is contained in:
2025-08-18 13:10:47 -05:00
parent c2b0d905e1
commit 6cd941295c
82 changed files with 5154 additions and 9772 deletions
+266
View File
@@ -0,0 +1,266 @@
"use client"
import { supabase } from '@/utils/supabase';
import React, { useState, useEffect } from 'react';
interface CommentFormProps {
postId: string;
}
interface Comment {
id: number; // Supabase tables typically have an ID
name: string;
comment: string;
created_at: string; // Use the Supabase column name
}
const CommentSection: React.FC<CommentFormProps> = ({ postId }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [comment, setComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [isLoadingComments, setIsLoadingComments] = useState(true); // Add loading state
// Fetch comments when the component mounts or postId changes
useEffect(() => {
const fetchComments = async () => {
setIsLoadingComments(true); // Set loading to true
const { data, error } = await supabase
.from('comments') // Replace 'comments' with your Supabase table name
.select('id, name, comment, created_at') // Select specific columns
.eq('post_id', postId) // Filter by post_id
.order('created_at', { ascending: false }); // Order by creation date
if (error) {
console.error('Error fetching comments:', error);
setError('Failed to load comments.'); // Display error to user
setComments([]); // Clear comments on error
} else {
setComments(data || []); // Set comments (handle case where data is null)
setError(null); // Clear any previous errors
}
setIsLoadingComments(false); // Set loading to false
};
fetchComments();
// Optional: Set up real-time subscriptions for new comments
// Be mindful of performance and resource usage with real-time subscriptions
// This is a basic example; you might need more sophisticated handling
const subscription = supabase
.channel(`comments:post_id=eq.${postId}`)
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'comments', filter: `post_id=eq.${postId}` }, (payload) => {
// Add the new comment to the beginning of the list
setComments((currentComments) => [payload.new as Comment, ...currentComments]);
})
.subscribe();
// Cleanup the subscription when the component unmounts or postId changes
return () => {
supabase.removeChannel(subscription);
};
}, [postId]); // Dependency array includes postId
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || !comment.trim()) {
setError('All fields are required');
return;
}
setIsSubmitting(true);
setError(null);
try {
const { data, error } = await supabase
.from('comments') // Replace 'comments' with your Supabase table name
.insert([
{
name,
email, // Storing email is dependent on your privacy policy and RLS
comment,
post_id: postId, // Assuming your Supabase column is named 'post_id'
// created_at will likely be automatically set by Supabase with a default value
},
])
.select('id, name, comment, created_at'); // Select the inserted data
if (error) {
console.error('Error inserting comment:', error);
setError(error.message || 'Something went wrong. Please try again later.');
} else {
// Assuming real-time subscription is active,
// the new comment will be added to the comments state automatically.
// If not using real-time, you would manually add the new comment here:
// if (data && data.length > 0) {
// setComments((currentComments) => [data[0], ...currentComments]);
// }
setName('');
setEmail('');
setComment('');
setIsSubmitted(true);
// No need to re-fetch all comments if using real-time subscriptions
setTimeout(() => setIsSubmitted(false), 5000);
}
} catch (err) {
console.error('Unexpected error during submission:', err);
setError('Something went wrong. Please try again later.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-12 pt-6 border-t border-primary-200">
<h3 className="text-2xl font-bold mb-6">Leave a Comment</h3>
{isSubmitted && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md mb-6">
Your comment has been submitted. Thank you!
</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
onFocus={() => {
if (typeof window !== 'undefined') {
const savedName = localStorage.getItem('name');
if (savedName) setName(savedName);
}
}}
/>
</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
onFocus={() => {
if (typeof window !== 'undefined') {
const savedEmail = localStorage.getItem('email');
if (savedEmail) setEmail(savedEmail);
}
}}
/>
</div>
</div>
<div>
<label htmlFor="comment" className="block text-primary-700 mb-1">
Comment <span className="text-red-500">*</span>
</label>
<textarea
id="comment"
rows={6}
value={comment}
onChange={(e) => setComment(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>
{/* The save info checkbox logic can remain as it interacts with localStorage */}
{/* <div className="flex items-center">
<input
type="checkbox"
id="save-info"
className="h-4 w-4 text-accent border-primary-300 rounded focus:ring-accent"
checked={typeof window !== 'undefined' && localStorage.getItem('saveInfo') === 'true'}
onChange={(e) => {
if (typeof window !== 'undefined') {
const saveInfo = e.target.checked;
localStorage.setItem('saveInfo', saveInfo.toString());
if (saveInfo) {
localStorage.setItem('name', name);
localStorage.setItem('email', email);
} else {
localStorage.removeItem('name');
localStorage.removeItem('email');
}
}
}}
/>
<label htmlFor="save-info" className="ml-2 block text-sm text-primary-600">
Save my name and email for the next time I comment
</label>
</div> */}
<button
type="submit"
className="button"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Post Comment'}
</button>
</form>
<div className="mt-12">
<h3 className="text-xl font-bold mb-6">Comments ({comments.length})</h3>
{isLoadingComments ? (
<p>Loading comments...</p>
) : comments.length === 0 ? (
<p>No comments yet. Be the first to leave one!</p>
) : (
<div className="space-y-6">
{comments.map((comment) => (
<div
// Use a more stable key than createdAt if possible, like comment.id from Supabase
key={comment.id || comment.created_at}
className="bg-white p-6 rounded-md shadow-sm border border-primary-200"
>
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold">{comment.name}</h4>
<p className="text-sm text-primary-500">
{new Date(comment.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
<p className="text-primary-700">{comment.comment}</p>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default CommentSection;
+23
View File
@@ -0,0 +1,23 @@
import { format, parseISO } from 'date-fns';
import React from 'react';
interface DateFormatterProps {
dateString: string;
formatString?: string;
className?: string;
}
const DateFormatter: React.FC<DateFormatterProps> = ({
dateString,
formatString = 'MMMM d, yyyy',
className = ''
}) => {
const date = parseISO(dateString);
return (
<time dateTime={dateString} className={className}>
{format(date, formatString)}
</time>
);
};
export default DateFormatter;
+76
View File
@@ -0,0 +1,76 @@
import React from 'react';
import Link from 'next/link';
import SubscribeForm from './SubscribeForm'; // Import the reusable SubscribeForm
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-primary-800 text-white mt-16">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 className="text-xl font-bold mb-4 text-white">Confessions of Grace</h3>
<p className="text-primary-300">
A blog dedicated to exploring the doctrines of grace and Reformed theology.
</p>
</div>
<div>
<h3 className="text-xl font-bold mb-4 text-white">Navigation</h3>
<ul className="space-y-2">
<li>
<Link href="/" className="text-primary-300 hover:text-white">
Home
</Link>
</li>
<li>
<Link href="/about" className="text-primary-300 hover:text-white">
About
</Link>
</li>
<li>
<Link href="/posts" className="text-primary-300 hover:text-white">
Archive
</Link>
</li>
<li>
<Link href="/confession" className="text-primary-300 hover:text-white">
1689 Confession
</Link>
</li>
<li>
<Link href="/resources" className="text-primary-300 hover:text-white">
Resources
</Link>
</li>
<li>
<Link href="https://www.etsy.com/shop/ConfessionsOfGrace" className="text-primary-700 hover:text-accent-dark">
Shop
</Link>
</li>
</ul>
</div>
<div>
<h3 className="text-xl font-bold mb-4 text-white">Subscribe</h3>
<p className="text-primary-300 mb-4">
Stay updated with the latest posts.
</p>
<SubscribeForm
placeholder="Your email"
buttonLabel="Subscribe"
className="flex flex-col space-y-2"
/> {/* Use the common SubscribeForm */}
</div>
</div>
<div className="border-t border-primary-700 mt-8 pt-8 text-center text-primary-400">
<p>&copy; {currentYear} Confessions of Grace. All rights reserved.</p>
<p className="mt-2 text-sm">
"For by grace you have been saved through faith. And this is not your own doing; it is the gift of God." Ephesians 2:8
</p>
</div>
</div>
</footer>
);
};
export default Footer;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import Link from 'next/link';
import Image from "next/image";
const Header: React.FC = () => {
return (
<header className="bg-white shadow-sm border-b border-primary-200">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col md:flex-row justify-between items-center">
<Link href="/" className="no-underline">
<div className="mb-4 md:mb-0 flex items-center space-x-4">
<Image
src="/assets/logo.svg"
alt="Logo"
width={100}
height={100}
className="h-auto w-auto max-h-12"
/>
<div>
<h1 className="text-3xl font-bold text-primary-900 mb-0">Confessions of Grace</h1>
<p className="text-primary-500 italic text-sm">Confessing Christ. Rejoicing in Grace.</p>
</div>
</div>
</Link>
<nav className="flex space-x-6">
<Link href="/" className="text-primary-700 hover:text-accent-dark">
Home
</Link>
<Link href="/about" className="text-primary-700 hover:text-accent-dark">
About
</Link>
<Link href="/posts" className="text-primary-700 hover:text-accent-dark">
Archive
</Link>
{/*<Link href="/confession" className="text-primary-700 hover:text-accent-dark">*/}
{/* 1689 Confession*/}
{/*</Link>*/}
<Link href="/resources" className="text-primary-700 hover:text-accent-dark">
Resources
</Link>
<Link href="https://www.etsy.com/shop/ConfessionsOfGrace" className="text-primary-700 hover:text-accent-dark">
Shop
</Link>
</nav>
</div>
</div>
</header>
);
};
export default Header;
+62
View File
@@ -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);
}
+113
View File
@@ -0,0 +1,113 @@
import React from 'react';
import Link from 'next/link';
interface PaginationProps {
currentPage: number;
totalPages: number;
basePath: string;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
basePath
}) => {
// Generate page numbers to display
const getPageNumbers = () => {
// Always show first and last page
// Show 2 pages before and after current page
const pageNumbers = new Set<number>();
// Add current page
pageNumbers.add(currentPage);
// Add adjacent pages
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) {
pageNumbers.add(i);
}
// Always add first and last page
pageNumbers.add(1);
pageNumbers.add(totalPages);
// Convert to array and sort
return Array.from(pageNumbers).sort((a, b) => a - b);
};
const pageNumbers = getPageNumbers();
// Generate link for a page number
const getPageLink = (pageNum: number) => {
if (pageNum === 1) {
return basePath;
}
return `${basePath}/page/${pageNum}`;
};
return (
<nav className="flex justify-center my-8">
<ul className="flex items-center space-x-1">
{/* Previous page button */}
{currentPage > 1 && (
<li>
<Link
href={getPageLink(currentPage - 1)}
className="flex items-center justify-center w-10 h-10 rounded-md border border-primary-200 bg-white text-primary-700 hover:bg-primary-50"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
</li>
)}
{/* Page numbers */}
{pageNumbers.map((pageNum, index) => {
// Add ellipsis if there's a gap
const showEllipsisBefore = index > 0 && pageNum > pageNumbers[index - 1] + 1;
return (
<React.Fragment key={pageNum}>
{showEllipsisBefore && (
<li className="flex items-center justify-center w-10 h-10 text-primary-500">
...
</li>
)}
<li>
<Link
href={getPageLink(pageNum)}
className={`flex items-center justify-center w-10 h-10 rounded-md border ${
pageNum === currentPage
? 'bg-accent text-white border-accent font-bold'
: 'bg-white text-primary-700 border-primary-200 hover:bg-primary-50'
}`}
aria-current={pageNum === currentPage ? 'page' : undefined}
>
{pageNum}
</Link>
</li>
</React.Fragment>
);
})}
{/* Next page button */}
{currentPage < totalPages && (
<li>
<Link
href={getPageLink(currentPage + 1)}
className="flex items-center justify-center w-10 h-10 rounded-md border border-primary-200 bg-white text-primary-700 hover:bg-primary-50"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</li>
)}
</ul>
</nav>
);
};
export default Pagination;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { format } from 'date-fns';
import { PostMetadata } from '@/types';
interface PostCardProps {
post: PostMetadata;
}
const PostCard: React.FC<PostCardProps> = ({ post }) => {
return (
<article className="card hover:shadow-md transition-shadow duration-200">
{post.coverImage && (
<div className="mb-4 relative h-48 w-full overflow-hidden rounded-md">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
/>
</div>
)}
<h2 className="text-xl font-bold mb-2">
<Link href={`/posts/${post.id}`} className="hover:text-accent-dark">
{post.title}
</Link>
</h2>
<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 mb-4">{post.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map(tag => (
<Link
key={tag}
href={`/tags/${tag}`}
className="text-xs bg-primary-100 text-primary-600 px-2 py-1 rounded-md hover:bg-primary-200"
>
{tag}
</Link>
))}
</div>
<Link href={`/posts/${post.id}`} className="inline-flex items-center text-accent-dark hover:text-accent">
Read more
<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="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</Link>
</article>
);
};
export default PostCard;
+48
View File
@@ -0,0 +1,48 @@
import { useRouter } from 'next/router';
import React, { useState } from 'react';
const SearchBar: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
}
};
return (
<form onSubmit={handleSubmit} className="relative">
<input
type="text"
placeholder="Search posts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 rounded-md border border-primary-200 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button
type="submit"
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-primary-400"
aria-label="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
</form>
);
};
export default SearchBar;
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
interface ShareButtonsProps {
url: string;
title: string;
description?: string;
}
const ShareButtons: React.FC<ShareButtonsProps> = ({ url, title, description = '' }) => {
// Encode parameters for sharing URLs
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
const encodedDescription = encodeURIComponent(description);
// Generate sharing URLs
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
const emailUrl = `mailto:?subject=${encodedTitle}&body=${encodedDescription}%0A%0A${encodedUrl}`;
return (
<div className="flex items-center space-x-3">
<span className="text-primary-600 font-medium">Share:</span>
{/* Facebook */}
<a
href={facebookUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on Facebook"
title="Share on Facebook"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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"
/>
</svg>
</a>
{/* Twitter */}
<a
href={twitterUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on Twitter"
title="Share on Twitter"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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"
/>
</svg>
</a>
{/* LinkedIn */}
<a
href={linkedinUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share on LinkedIn"
title="Share on LinkedIn"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
clipRule="evenodd"
/>
</svg>
</a>
{/* Email */}
<a
href={emailUrl}
className="text-primary-500 hover:text-accent-dark transition-colors duration-200"
aria-label="Share via Email"
title="Share via Email"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</a>
</div>
);
};
export default ShareButtons;
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import Link from 'next/link';
import TagList from './TagList';
import SubscribeForm from "@/components/SubscribeForm";
interface SidebarProps {
recentPosts: Array<{
id: string;
title: string;
date: string;
}>;
tags?: Array<{
tag: string;
count: number;
}>;
}
const Sidebar: React.FC<SidebarProps> = ({ recentPosts, tags = [] }) => {
return (
<aside className="space-y-8">
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">About</h2>
<p className="text-primary-700 mb-4">
Confessions of Grace explores the riches of Reformed theology and the doctrines of grace.
</p>
<Link
href="/about"
className="text-accent-dark hover:text-accent inline-flex items-center"
>
Learn more
<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="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
</div>
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Recent Posts</h2>
<ul className="space-y-3">
{recentPosts.map(post => (
<li key={post.id}>
<Link
href={`/posts/${post.id}`}
className="hover:text-accent-dark block"
>
<h3 className="font-medium">{post.title}</h3>
<p className="text-sm text-primary-500">
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</Link>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Popular Tags</h2>
<TagList limit={15} tags={tags} />
</div>
<div className="bg-white rounded-md shadow-sm p-6 border border-primary-200">
<h2 className="text-xl font-bold mb-4 border-b border-primary-200 pb-2">Subscribe</h2>
<p className="text-primary-700 mb-4">
Stay updated with the latest posts and resources.
</p>
<SubscribeForm /> {/* Use the common SubscribeForm */}
</div>
</aside>
);
};
export default Sidebar;
+77
View File
@@ -0,0 +1,77 @@
"use client"
import React, { useState } from 'react';
interface SubscribeFormProps {
placeholder?: string;
buttonLabel?: string;
className?: string;
}
const SubscribeForm: React.FC<SubscribeFormProps> = ({
placeholder = 'Your email',
buttonLabel = 'Subscribe',
className = '',
}) => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
setMessage('Please enter your email.');
return;
}
setLoading(true);
setMessage('');
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (response.ok) {
setMessage('You have successfully subscribed!');
setEmail('');
} else {
const errorData = await response.json();
setMessage(`Error: ${errorData.message}`);
}
} catch (error) {
setMessage('Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubscribe} className={`space-y-3 ${className}`}>
<div>
<input
type="email"
placeholder={placeholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
/>
</div>
<button
type="submit"
className="button w-full"
disabled={loading}
>
{loading ? 'Subscribing...' : buttonLabel}
</button>
{message && <p className="text-sm text-primary-700">{message}</p>}
</form>
);
};
export default SubscribeForm;
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import Link from 'next/link';
interface TagListProps {
className?: string;
limit?: number;
tags?: Array<{
tag: string;
count: number;
}>;
}
const TagList: React.FC<TagListProps> = ({ className = '', limit, tags = [] }) => {
// Sort tags by count (descending)
const sortedTags = [...tags].sort((a, b) => b.count - a.count);
// Apply limit if provided
const tagsToShow = limit ? sortedTags.slice(0, limit) : sortedTags;
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{tagsToShow.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${tag}`}
className="bg-primary-100 text-primary-700 px-3 py-1 rounded-md hover:bg-primary-200 text-sm transition-colors duration-200 flex items-center"
>
{tag}
<span className="ml-1 text-xs bg-primary-200 rounded-full px-2 py-0.5">
{count}
</span>
</Link>
))}
</div>
);
};
export default TagList;