Imrpoved blogs page

This commit is contained in:
Harivansh Rathi 2024-11-29 11:30:47 -05:00
parent 8fd1ca8260
commit 108b4533e9
17 changed files with 689 additions and 112 deletions

13
package-lock.json generated
View file

@ -38,6 +38,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@ -52,7 +53,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.22.3",
"react-router-dom": "^6.28.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
@ -2653,6 +2654,15 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
}
},
"node_modules/@tailwindcss/line-clamp": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz",
"integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz",
@ -5298,6 +5308,7 @@
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz",
"integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.21.0",
"react-router": "6.28.0"

View file

@ -40,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@ -54,7 +55,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.22.3",
"react-router-dom": "^6.28.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -0,0 +1,33 @@
import https from 'https';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const characters = ['lewis', 'willy', 'gus', 'wizard', 'clint'];
const imagesDir = path.join(__dirname, '../public/images/blogs');
// Create the directory if it doesn't exist
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true });
}
// Download a placeholder image for each character
characters.forEach(character => {
const url = `https://via.placeholder.com/800x600.png?text=${character}`;
const filePath = path.join(imagesDir, `${character}-blog.png`);
https.get(url, (response) => {
const fileStream = fs.createWriteStream(filePath);
response.pipe(fileStream);
fileStream.on('finish', () => {
console.log(`Downloaded ${character}-blog.png`);
fileStream.close();
});
}).on('error', (err) => {
console.error(`Error downloading ${character}-blog.png:`, err.message);
});
});

View file

@ -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,10 +17,12 @@ function App() {
return (
<Router>
<MainLayout>
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/blogs/*" element={<Blogs />} />
<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 />} />
@ -26,6 +30,7 @@ function App() {
<Route path="/calculator" element={<MarketCalculator />} />
</Routes>
</Suspense>
</ErrorBoundary>
</MainLayout>
</Router>
);

View 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>
);
};

View 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;
}
}

View 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>
);

View 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>
);
};

View 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
View 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
}
];

View 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;

View file

@ -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 => (
<Link
key={post.id}
to={`/blogs/${post.id}`}
className="block bg-cream-50 p-6 rounded-lg hover:shadow-lg transition"
<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"
>
<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>
</Link>
{tag}
</span>
))}
</div>
</div>
);
};
const BlogPost = () => {
const { pathname } = useLocation();
const postId = pathname.split('/').pop();
const post = SAMPLE_POSTS.find(p => p.id === postId);
if (!post) return <div>Post not found</div>;
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
)}
<div className="flex items-center justify-between mt-4">
<Link
to={`/blogs/${id}`}
className="text-sage-600 hover:text-sage-800 font-medium inline-flex items-center"
>
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>
<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>
{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>
</header>
<div className="prose prose-sage max-w-none">
<p>{post.content}</p>
</div>
</article>
);
};
const Blogs = () => {
const Blogs: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCharacter, setSelectedCharacter] = useState('');
const [selectedTag, setSelectedTag] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const characters = useMemo(() => {
return [...new Set(blogPosts.map(post => post.character))];
}, []);
const tags = useMemo(() => {
const allTags = blogPosts.flatMap(post => post.tags || []);
return [...new Set(allTags)];
}, []);
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]);
const totalPages = Math.ceil(filteredPosts.length / POSTS_PER_PAGE);
const currentPosts = filteredPosts.slice(
(currentPage - 1) * POSTS_PER_PAGE,
currentPage * POSTS_PER_PAGE
);
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>
);
};