Archived
switched to approuter
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>© {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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user