Archived
init with dummy posts
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CommentFormProps {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
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 handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
if (!name.trim() || !email.trim() || !comment.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('');
|
||||
setComment('');
|
||||
setIsSubmitted(true);
|
||||
setIsSubmitting(false);
|
||||
|
||||
// Reset success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitted(false);
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
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 and is awaiting moderation. 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
|
||||
/>
|
||||
</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="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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Sample comments - in a real implementation, these would be fetched from an API */}
|
||||
<div className="mt-12">
|
||||
<h3 className="text-xl font-bold mb-6">Comments (2)</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div 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">John Calvin</h4>
|
||||
<p className="text-sm text-primary-500">March 10, 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-primary-700">
|
||||
A profound meditation on the doctrine of grace. I particularly appreciate your emphasis on how this truth transforms our daily lives, not just our theological understanding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div 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">Martin Luther</h4>
|
||||
<p className="text-sm text-primary-500">March 5, 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-primary-700">
|
||||
This post eloquently articulates what I've been trying to explain to my congregation. The way you connected Scripture with practical application was masterful. I'll be sharing this widely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentSection;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
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,70 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
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="/archive" className="text-primary-300 hover:text-white">
|
||||
Archive
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/resources" className="text-primary-300 hover:text-white">
|
||||
Resources
|
||||
</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>
|
||||
<div className="flex">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="px-4 py-2 w-full rounded-l-md bg-primary-700 border border-primary-600 text-white placeholder-primary-400"
|
||||
/>
|
||||
<button className="bg-accent hover:bg-accent-dark px-4 py-2 rounded-r-md">
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</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,35 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
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">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<Link href="/" className="no-underline">
|
||||
<h1 className="text-3xl font-bold text-primary-900 mb-0">Confessions of Grace</h1>
|
||||
</Link>
|
||||
<p className="text-primary-500 italic text-sm">Exploring Reformed Theology</p>
|
||||
</div>
|
||||
<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="/archive" className="text-primary-700 hover:text-accent-dark">
|
||||
Archive
|
||||
</Link>
|
||||
<Link href="/resources" className="text-primary-700 hover:text-accent-dark">
|
||||
Resources
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
|
||||
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 | Reformed Theology Blog'
|
||||
: `${title} | Confessions of Grace`;
|
||||
|
||||
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} />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<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;
|
||||
@@ -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 React, { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
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,96 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import TagList from './TagList';
|
||||
|
||||
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>
|
||||
<form className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="button w-full"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -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