mirror of
https://github.com/harivansh-afk/Austens-Wedding-Guide.git
synced 2026-04-15 17:00:58 +00:00
Imrpoved blogs page
This commit is contained in:
parent
8fd1ca8260
commit
108b4533e9
17 changed files with 689 additions and 112 deletions
29
src/App.tsx
29
src/App.tsx
|
|
@ -2,9 +2,11 @@ import React, { Suspense } from 'react';
|
|||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import MainLayout from './components/layout/MainLayout';
|
||||
import Home from './pages/Home';
|
||||
import Blogs from './pages/Blogs';
|
||||
import BlogPost from './pages/BlogPost/BlogPost';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
// Lazy load other pages
|
||||
const Blogs = React.lazy(() => import('./pages/Blogs'));
|
||||
const Quiz = React.lazy(() => import('./pages/Quiz'));
|
||||
const Advice = React.lazy(() => import('./pages/Advice'));
|
||||
const Vendors = React.lazy(() => import('./pages/Vendors'));
|
||||
|
|
@ -15,17 +17,20 @@ function App() {
|
|||
return (
|
||||
<Router>
|
||||
<MainLayout>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/blogs/*" element={<Blogs />} />
|
||||
<Route path="/quiz" element={<Quiz />} />
|
||||
<Route path="/advice" element={<Advice />} />
|
||||
<Route path="/vendors" element={<Vendors />} />
|
||||
<Route path="/stories" element={<Stories />} />
|
||||
<Route path="/calculator" element={<MarketCalculator />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/blogs" element={<Blogs />} />
|
||||
<Route path="/blogs/:id" element={<BlogPost />} />
|
||||
<Route path="/quiz" element={<Quiz />} />
|
||||
<Route path="/advice" element={<Advice />} />
|
||||
<Route path="/vendors" element={<Vendors />} />
|
||||
<Route path="/stories" element={<Stories />} />
|
||||
<Route path="/calculator" element={<MarketCalculator />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</MainLayout>
|
||||
</Router>
|
||||
);
|
||||
|
|
|
|||
111
src/components/CommentSection.tsx
Normal file
111
src/components/CommentSection.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Comment {
|
||||
id: number;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: number;
|
||||
}
|
||||
|
||||
// Store comments in memory (in a real app, this would be in a database)
|
||||
const commentsStore: Record<number, Comment[]> = {};
|
||||
|
||||
export const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Load comments for this post
|
||||
useEffect(() => {
|
||||
setComments(commentsStore[postId] || []);
|
||||
}, [postId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const comment: Comment = {
|
||||
id: Date.now(),
|
||||
author: 'Anonymous Farmer', // Replace with actual user system
|
||||
content: newComment.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updatedComments = [comment, ...(commentsStore[postId] || [])];
|
||||
commentsStore[postId] = updatedComments;
|
||||
setComments(updatedComments);
|
||||
setNewComment('');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<h3 className="text-2xl font-bold text-gray-800 mb-6">Comments</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
className="w-full p-4 border border-gray-200 rounded-lg focus:ring-2 focus:ring-sage-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newComment.trim()}
|
||||
className={`mt-2 px-6 py-2 rounded-lg text-white transition-colors ${
|
||||
isSubmitting || !newComment.trim()
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-sage-500 hover:bg-sage-600'
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No comments yet. Be the first to share your thoughts!
|
||||
</p>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="bg-white p-6 rounded-lg shadow-sm border border-gray-100"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="font-medium text-gray-800">{comment.author}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDate(comment.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
src/components/ErrorBoundary.tsx
Normal file
43
src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
Oops! Something went wrong
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-sage-500 text-white px-4 py-2 rounded-lg hover:bg-sage-600 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
7
src/components/LoadingSpinner.tsx
Normal file
7
src/components/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const LoadingSpinner: React.FC = () => (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-sage-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
59
src/components/Pagination.tsx
Normal file
59
src/components/Pagination.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center space-x-2 mt-8">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-white text-gray-700 hover:bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
currentPage === page
|
||||
? 'bg-sage-500 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-white text-gray-700 hover:bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/components/ShareButtons.tsx
Normal file
60
src/components/ShareButtons.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { BlogPost } from '../data/blogPosts';
|
||||
|
||||
interface ShareButtonsProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export const ShareButtons: React.FC<ShareButtonsProps> = ({ post }) => {
|
||||
const url = window.location.href;
|
||||
const title = `Check out ${post.character}'s blog post: ${post.title}`;
|
||||
|
||||
const shareLinks = [
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-blue-400 hover:text-blue-600',
|
||||
},
|
||||
{
|
||||
name: 'Facebook',
|
||||
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-blue-600 hover:text-blue-800',
|
||||
},
|
||||
{
|
||||
name: 'Reddit',
|
||||
url: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-orange-500 hover:text-orange-700',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4 mt-6">
|
||||
<span className="text-gray-600 font-medium">Share:</span>
|
||||
{shareLinks.map((link) => (
|
||||
<button
|
||||
key={link.name}
|
||||
onClick={() => window.open(link.url, '_blank')}
|
||||
className={`${link.color} transition-colors`}
|
||||
aria-label={`Share on ${link.name}`}
|
||||
>
|
||||
{link.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
src/data/blogPosts.ts
Normal file
63
src/data/blogPosts.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export interface BlogPost {
|
||||
id: number;
|
||||
title: string;
|
||||
character: string;
|
||||
date: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
tags?: string[];
|
||||
likes?: number;
|
||||
}
|
||||
|
||||
export const blogPosts: BlogPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "The Spirit of Community",
|
||||
character: "Lewis",
|
||||
date: "Spring 1",
|
||||
content: "As mayor of Pelican Town, nothing brings me more joy than seeing our community thrive. Today, our newest farmer arrived, breathing life into old Grandpa's farm. The look of determination in their eyes reminds me of the valley's golden days. Speaking of which, I should remind everyone about the upcoming Egg Festival - Marnie's chickens have been especially productive this year! And please, if anyone sees Pierre, tell him I need to discuss the flower arrangements for the town square...",
|
||||
imageUrl: "/images/blogs/lewis-blog.png",
|
||||
tags: ["community", "events", "announcements"],
|
||||
likes: 156
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Tales from the Sea",
|
||||
character: "Willy",
|
||||
date: "Spring 15",
|
||||
content: "The morning mist rolled in thick today, just the way I like it. Found something peculiar in my crab pots - a message in a bottle! Reminded me of my old sailor days... For those interested in fishing, the legend of the Crimsonfish resurfaces every summer. Some say it's just a tale, but I've seen things in these waters that would make a seasoned sailor question everything. Drop by the shop if you want to hear more, or need some quality bait.",
|
||||
imageUrl: "/images/blogs/willy-blog.png",
|
||||
tags: ["fishing", "legends", "sea stories"],
|
||||
likes: 89
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Behind the Bar",
|
||||
character: "Gus",
|
||||
date: "Summer 1",
|
||||
content: "Every night at the Stardrop Saloon tells a different story. Yesterday, Pam shared tales of her trucking days while Emily's positive energy kept everyone's spirits high. Shane actually joined a conversation about chicken farming with Marnie - a rare sight indeed! My special spaghetti recipe was a hit again, though I'll never reveal the secret ingredient (sorry, Lewis!). Remember, Fridays mean special discounts on pale ale - locally sourced from our very own farmer!",
|
||||
imageUrl: "/images/blogs/gus-blog.png",
|
||||
tags: ["saloon", "recipes", "community"],
|
||||
likes: 124
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Wizard's Musings",
|
||||
character: "Wizard",
|
||||
date: "Fall 13",
|
||||
content: "The veil between realms grows thin as we approach the season of spirits. I've observed unusual activities in the witch's swamp, and the junimos seem more active than ever. For those brave souls interested in the arcane arts, I'm considering hosting a seminar on mushroom identification and their magical properties. Note: Please stop asking about love potions - they're strictly forbidden and highly unstable...",
|
||||
imageUrl: "/images/blogs/wizard-blog.png",
|
||||
tags: ["magic", "junimos", "mystical"],
|
||||
likes: 201
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Adventures in Mining",
|
||||
character: "Clint",
|
||||
date: "Winter 8",
|
||||
content: "Found an extraordinary geode yesterday - the crystalline structure unlike anything I've seen before. If you're planning to explore the mines, remember to bring your sword and plenty of torches. I'm offering a special discount on tool upgrades this week. And no, I haven't worked up the courage to... well, never mind. Just come by the shop if you need anything forged.",
|
||||
imageUrl: "/images/blogs/clint-blog.png",
|
||||
tags: ["mining", "blacksmith", "tools"],
|
||||
likes: 67
|
||||
}
|
||||
];
|
||||
104
src/pages/BlogPost/BlogPost.tsx
Normal file
104
src/pages/BlogPost/BlogPost.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { BlogPost as BlogPostType, blogPosts } from '../../data/blogPosts';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { ShareButtons } from '../../components/ShareButtons';
|
||||
import { CommentSection } from '../../components/CommentSection';
|
||||
|
||||
const BlogPost: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState<BlogPostType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPost = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const foundPost = blogPosts.find(p => p.id === Number(id));
|
||||
if (!foundPost) {
|
||||
navigate('/blogs');
|
||||
return;
|
||||
}
|
||||
setPost(foundPost);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPost();
|
||||
}, [id, navigate]);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!post) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream-50 py-8">
|
||||
<article className="max-w-4xl mx-auto px-4">
|
||||
<Link
|
||||
to="/blogs"
|
||||
className="inline-flex items-center text-sage-600 hover:text-sage-800 mb-8"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Back to Blogs
|
||||
</Link>
|
||||
|
||||
<header className="mb-8">
|
||||
<img
|
||||
src={post.imageUrl}
|
||||
alt={`${post.character}'s blog post`}
|
||||
className="w-full h-[400px] object-cover rounded-xl shadow-lg mb-6"
|
||||
/>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-gray-600">
|
||||
<span className="font-medium">By {post.character}</span>
|
||||
<span>•</span>
|
||||
<span>{post.date}</span>
|
||||
{post.tags && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<div className="flex gap-2">
|
||||
{post.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-sage-100 text-sage-800 px-2 py-1 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg max-w-none mb-8">
|
||||
<p className="whitespace-pre-wrap">{post.content}</p>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-gray-200 pt-8">
|
||||
<ShareButtons post={post} />
|
||||
<CommentSection postId={post.id} />
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPost;
|
||||
|
|
@ -1,120 +1,200 @@
|
|||
import { useState } from 'react';
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import type { BlogPost } from '../types';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { blogPosts, BlogPost } from '../data/blogPosts';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
|
||||
const SAMPLE_POSTS: BlogPost[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'The Art of Practical Partnership',
|
||||
author: 'Charlotte Lucas',
|
||||
content: 'In my experience, happiness in marriage is entirely a matter of chance...',
|
||||
date: '2023-11-25',
|
||||
category: 'charlotte'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Finding True Romance in a Modern World',
|
||||
author: 'Marianne Dashwood',
|
||||
content: 'To love is to burn, to be on fire with passion that consumes the soul...',
|
||||
date: '2023-11-24',
|
||||
category: 'marianne'
|
||||
}
|
||||
];
|
||||
|
||||
const BlogList = () => {
|
||||
const [filter, setFilter] = useState<'all' | 'charlotte' | 'marianne'>('all');
|
||||
|
||||
const filteredPosts = SAMPLE_POSTS.filter(post =>
|
||||
filter === 'all' ? true : post.category === filter
|
||||
);
|
||||
const POSTS_PER_PAGE = 6;
|
||||
|
||||
const BlogCard: React.FC<BlogPost> = ({
|
||||
id,
|
||||
title,
|
||||
character,
|
||||
date,
|
||||
content,
|
||||
imageUrl,
|
||||
tags,
|
||||
likes,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-cormorant text-4xl text-sage-900">Character Blogs</h1>
|
||||
<div className="space-x-4">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded ${
|
||||
filter === 'all' ? 'bg-sage-500 text-white' : 'bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('charlotte')}
|
||||
className={`px-4 py-2 rounded ${
|
||||
filter === 'charlotte' ? 'bg-sage-500 text-white' : 'bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
Charlotte
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('marianne')}
|
||||
className={`px-4 py-2 rounded ${
|
||||
filter === 'marianne' ? 'bg-sage-500 text-white' : 'bg-sage-100'
|
||||
}`}
|
||||
>
|
||||
Marianne
|
||||
</button>
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`${character}'s blog post`}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">{title}</h3>
|
||||
<div className="flex items-center text-sm text-gray-600 mb-4">
|
||||
<span className="font-medium mr-2">By {character}</span>
|
||||
<span>• {date}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{filteredPosts.map(post => (
|
||||
<p className="text-gray-700 line-clamp-3">{content}</p>
|
||||
{tags && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-sage-100 text-sage-800 px-2 py-1 rounded-full text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/blogs/${post.id}`}
|
||||
className="block bg-cream-50 p-6 rounded-lg hover:shadow-lg transition"
|
||||
to={`/blogs/${id}`}
|
||||
className="text-sage-600 hover:text-sage-800 font-medium inline-flex items-center"
|
||||
>
|
||||
<h2 className="font-cormorant text-2xl text-sage-900 mb-2">{post.title}</h2>
|
||||
<p className="text-sage-700 mb-4">{post.content.substring(0, 150)}...</p>
|
||||
<div className="flex justify-between text-sage-500">
|
||||
<span>{post.author}</span>
|
||||
<span>{new Date(post.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
Read more
|
||||
<svg
|
||||
className="w-4 h-4 ml-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
{likes !== undefined && (
|
||||
<div className="flex items-center text-gray-500">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{likes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogPost = () => {
|
||||
const { pathname } = useLocation();
|
||||
const postId = pathname.split('/').pop();
|
||||
const post = SAMPLE_POSTS.find(p => p.id === postId);
|
||||
const Blogs: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCharacter, setSelectedCharacter] = useState('');
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
if (!post) return <div>Post not found</div>;
|
||||
const characters = useMemo(() => {
|
||||
return [...new Set(blogPosts.map(post => post.character))];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto space-y-6">
|
||||
<Link to="/blogs" className="text-sage-500 hover:text-sage-600">
|
||||
← Back to Blogs
|
||||
</Link>
|
||||
const tags = useMemo(() => {
|
||||
const allTags = blogPosts.flatMap(post => post.tags || []);
|
||||
return [...new Set(allTags)];
|
||||
}, []);
|
||||
|
||||
<header className="text-center space-y-4">
|
||||
<h1 className="font-cormorant text-4xl text-sage-900">{post.title}</h1>
|
||||
<div className="text-sage-700">
|
||||
<span>{post.author}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{new Date(post.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</header>
|
||||
const filteredPosts = useMemo(() => {
|
||||
return blogPosts.filter(post => {
|
||||
const matchesSearch = (
|
||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const matchesCharacter = !selectedCharacter || post.character === selectedCharacter;
|
||||
const matchesTag = !selectedTag || post.tags?.includes(selectedTag);
|
||||
return matchesSearch && matchesCharacter && matchesTag;
|
||||
});
|
||||
}, [searchTerm, selectedCharacter, selectedTag]);
|
||||
|
||||
<div className="prose prose-sage max-w-none">
|
||||
<p>{post.content}</p>
|
||||
</div>
|
||||
</article>
|
||||
const totalPages = Math.ceil(filteredPosts.length / POSTS_PER_PAGE);
|
||||
const currentPosts = filteredPosts.slice(
|
||||
(currentPage - 1) * POSTS_PER_PAGE,
|
||||
currentPage * POSTS_PER_PAGE
|
||||
);
|
||||
};
|
||||
|
||||
const Blogs = () => {
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleCharacterFilter = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedCharacter(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleTagFilter = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedTag(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<BlogList />} />
|
||||
<Route path="/:id" element={<BlogPost />} />
|
||||
</Routes>
|
||||
<div className="min-h-screen bg-cream-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold text-center text-gray-900 mb-8">
|
||||
Character Blogs
|
||||
</h1>
|
||||
|
||||
<div className="mb-8 bg-white p-4 rounded-lg shadow-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search blogs..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
className="p-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-sage-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
value={selectedCharacter}
|
||||
onChange={handleCharacterFilter}
|
||||
className="p-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-sage-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Characters</option>
|
||||
{characters.map(char => (
|
||||
<option key={char} value={char}>
|
||||
{char}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={selectedTag}
|
||||
onChange={handleTagFilter}
|
||||
className="p-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-sage-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
{tags.map(tag => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPosts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">
|
||||
No blog posts found matching your criteria.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{currentPosts.map(post => (
|
||||
<BlogCard key={post.id} {...post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue