373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
// components/BlogListing.tsx
|
||
'use client';
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import Image from 'next/image';
|
||
import Link from 'next/link';
|
||
import { ChevronRight } from 'lucide-react';
|
||
import { blogService, Blog } from '../../services/blogService'; // Adjust path as needed
|
||
|
||
const BlogListing: React.FC = () => {
|
||
const [blogs, setBlogs] = useState<Blog[]>([]);
|
||
const [filteredBlogs, setFilteredBlogs] = useState<Blog[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [mounted, setMounted] = useState(false);
|
||
const [selectedCategory, setSelectedCategory] = useState('All Categories');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [displayCount, setDisplayCount] = useState(6);
|
||
const [tagCounts, setTagCounts] = useState<{ [key: string]: number }>({});
|
||
|
||
// Get unique categories from blogs with their counts
|
||
const getCategories = () => {
|
||
const allTags = blogs.flatMap(blog => blog.tags);
|
||
const uniqueTags = Array.from(new Set(allTags));
|
||
return ['All Categories', ...uniqueTags];
|
||
};
|
||
|
||
// Filter blogs based on category and search query
|
||
const filterBlogs = useCallback(() => {
|
||
let filtered = blogs;
|
||
|
||
if (selectedCategory !== 'All Categories') {
|
||
filtered = filtered.filter(blog =>
|
||
blog.tags.some(tag =>
|
||
tag.toLowerCase().includes(selectedCategory.toLowerCase())
|
||
)
|
||
);
|
||
}
|
||
|
||
if (searchQuery.trim()) {
|
||
const query = searchQuery.toLowerCase().trim();
|
||
filtered = filtered.filter(blog =>
|
||
blog.title.toLowerCase().includes(query) ||
|
||
blog.excerpt.toLowerCase().includes(query) ||
|
||
blog.tags.some(tag => tag.toLowerCase().includes(query)) ||
|
||
(blog.professors && blog.professors.some(prof =>
|
||
prof.firstName?.toLowerCase().includes(query) ||
|
||
prof.name?.toLowerCase().includes(query)
|
||
))
|
||
);
|
||
}
|
||
|
||
setFilteredBlogs(filtered);
|
||
}, [blogs, selectedCategory, searchQuery]);
|
||
|
||
// Load blogs from API
|
||
useEffect(() => {
|
||
const loadBlogs = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// Load both posted blogs and tag counts
|
||
const [fetchedBlogs, fetchedTagCounts] = await Promise.all([
|
||
blogService.getPostedBlogs(),
|
||
blogService.getTagsWithCount()
|
||
]);
|
||
|
||
setBlogs(fetchedBlogs);
|
||
setFilteredBlogs(fetchedBlogs);
|
||
setTagCounts(fetchedTagCounts);
|
||
} catch (err) {
|
||
setError('Failed to load blogs. Please try again later.');
|
||
console.error('Error loading blogs:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (mounted) {
|
||
loadBlogs();
|
||
}
|
||
}, [mounted]);
|
||
|
||
// Filter blogs when category or search changes
|
||
useEffect(() => {
|
||
if (mounted && blogs.length > 0) {
|
||
filterBlogs();
|
||
}
|
||
}, [mounted, filterBlogs]);
|
||
|
||
|
||
// Handle mount
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
setSelectedCategory(e.target.value);
|
||
setDisplayCount(6);
|
||
};
|
||
|
||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setSearchQuery(e.target.value);
|
||
setDisplayCount(6);
|
||
};
|
||
|
||
const handleLoadMore = () => {
|
||
setDisplayCount(prev => prev + 6);
|
||
};
|
||
|
||
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {
|
||
// Fallback to default image if the uploaded image fails to load
|
||
const target = event.target as HTMLImageElement;
|
||
target.src = '/images/default-blog-image.jpg';
|
||
};
|
||
|
||
if (!mounted) {
|
||
return null;
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mx-auto mb-4"></div>
|
||
<p className="text-gray-600">Loading blogs...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="text-center text-red-600">
|
||
<p className="text-xl mb-4">⚠️ {error}</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="px-4 py-2 bg-blue-900 text-white rounded hover:bg-blue-800"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const categories = getCategories();
|
||
const blogsToShow = filteredBlogs.slice(0, displayCount);
|
||
const hasMoreBlogs = filteredBlogs.length > displayCount;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-white">
|
||
{/* Breadcrumb Section */}
|
||
<section className="py-4" style={{ backgroundColor: '#f4f4f4' }}>
|
||
<div className="max-w-7xl mx-auto px-4">
|
||
<nav className="flex items-center space-x-2 text-sm">
|
||
<Link
|
||
href="/"
|
||
className="hover:opacity-70 transition-opacity duration-200"
|
||
style={{ color: '#012068' }}
|
||
>
|
||
Home
|
||
</Link>
|
||
<ChevronRight className="w-4 h-4" style={{ color: '#012068' }} />
|
||
<span className="font-medium" style={{ color: '#e64838' }}>
|
||
Trauma Care Resources
|
||
</span>
|
||
</nav>
|
||
|
||
{/* Page Header */}
|
||
<div className="mt-6">
|
||
<div className="flex items-center mb-4">
|
||
<h1 className="text-3xl font-bold" style={{ color: '#012068' }}>
|
||
Trauma Care Resources
|
||
</h1>
|
||
</div>
|
||
<p className="text-base max-w-2xl leading-relaxed" style={{ color: '#333' }}>
|
||
Expert insights, healing strategies, and support resources for trauma recovery
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Filters Section */}
|
||
<div className="flex flex-col sm:flex-row sm:justify-end items-start sm:items-center gap-4 max-w-7xl mx-auto px-4 py-8 bg-white">
|
||
{/* Category Select */}
|
||
<div className="relative w-full sm:w-auto">
|
||
<select
|
||
value={selectedCategory}
|
||
onChange={handleCategoryChange}
|
||
className="w-full sm:w-auto appearance-none bg-gray-100 text-sm border border-blue-900 rounded-lg px-4 py-2 pr-8 focus:outline-none focus:border-blue-900"
|
||
style={{ color: '#333' }}
|
||
>
|
||
{categories.map((category, index) => (
|
||
<option key={index} value={category}>
|
||
{category}
|
||
{category !== 'All Categories' && tagCounts[category] ? ` (${tagCounts[category]})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search Input */}
|
||
<div className="relative w-full sm:w-64">
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={handleSearchChange}
|
||
placeholder="Search blogs..."
|
||
className="w-full sm:w-64 border border-blue-900 rounded-lg px-4 py-2 pl-4 pr-10 text-sm focus:outline-none focus:border-blue-900 text-gray-700"
|
||
/>
|
||
<button className="absolute inset-y-0 right-0 flex items-center px-3 bg-blue-900 rounded-r-lg">
|
||
<svg className="w-4 h-4 text-gray-100" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* Results Info */}
|
||
{(selectedCategory !== 'All Categories' || searchQuery.trim()) && (
|
||
<div className="max-w-7xl mx-auto px-4 pb-4">
|
||
<p className="text-sm text-gray-600">
|
||
{filteredBlogs.length === 0
|
||
? 'No blogs found matching your criteria.'
|
||
: `Showing ${blogsToShow.length} of ${filteredBlogs.length} blog${filteredBlogs.length !== 1 ? 's' : ''}`
|
||
}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Blog Grid Section */}
|
||
<section className="py-4" style={{ backgroundColor: '#fff' }}>
|
||
<div className="max-w-7xl mx-auto px-4">
|
||
{blogsToShow.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-gray-500 text-lg mb-4">
|
||
{searchQuery.trim() || selectedCategory !== 'All Categories'
|
||
? 'No blogs match your search criteria.'
|
||
: 'No blogs available at the moment.'
|
||
}
|
||
</p>
|
||
{(searchQuery.trim() || selectedCategory !== 'All Categories') && (
|
||
<button
|
||
onClick={() => {
|
||
setSearchQuery('');
|
||
setSelectedCategory('All Categories');
|
||
}}
|
||
className="px-4 py-2 text-sm border border-blue-900 text-blue-900 rounded hover:bg-blue-50"
|
||
>
|
||
Clear Filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||
{blogsToShow.map((blog) => (
|
||
<div
|
||
key={blog.id}
|
||
className="group relative bg-white border border-gray-300 rounded-lg overflow-hidden hover:shadow-lg transition-all duration-300 flex flex-col"
|
||
>
|
||
{/* All cards redirect to blog detail page with ID */}
|
||
<Link
|
||
href={`/blog-detail/${blog.id}`}
|
||
className="absolute top-0 left-0 h-full w-full z-10"
|
||
aria-label={`Read article: ${blog.title}`}
|
||
/>
|
||
|
||
{/* Blog Image */}
|
||
<div className="relative h-48 w-full overflow-hidden">
|
||
<img
|
||
src={blog.image}
|
||
alt={blog.title}
|
||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||
onError={handleImageError}
|
||
/>
|
||
</div>
|
||
|
||
{/* Blog Content */}
|
||
<div className="p-6 flex flex-col flex-1">
|
||
{/* Tags */}
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{blog.tags.slice(0, 3).map((tag, tagIndex) => (
|
||
<span
|
||
key={tagIndex}
|
||
className="px-2 py-1 text-xs font-medium rounded"
|
||
style={{
|
||
backgroundColor: '#f4f4f4',
|
||
color: '#e64838'
|
||
}}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{blog.tags.length > 3 && (
|
||
<span
|
||
className="px-2 py-1 text-xs font-medium rounded"
|
||
style={{
|
||
backgroundColor: '#f4f4f4',
|
||
color: '#666'
|
||
}}
|
||
>
|
||
+{blog.tags.length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<h3
|
||
className="text-lg font-medium mb-2 line-clamp-2 group-hover:opacity-70 transition-opacity duration-300"
|
||
style={{ color: '#012068' }}
|
||
>
|
||
{blog.title}
|
||
</h3>
|
||
|
||
{/* Excerpt */}
|
||
<p
|
||
className="text-xs leading-relaxed line-clamp-3 mb-4 flex-1"
|
||
style={{ opacity: 0.8, color: "#333" }}
|
||
>
|
||
{blog.excerpt}
|
||
</p>
|
||
|
||
{/* Authors (if available) */}
|
||
{blog.professors && blog.professors.length > 0 && (
|
||
<div className="mb-2">
|
||
<p className="text-xs text-gray-600">
|
||
By: {blog.professors.map(prof => prof.firstName || prof.name).join(', ')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Meta Information */}
|
||
<div className="flex items-center justify-between text-xs mt-auto" style={{ color: '#333' }}>
|
||
<span>{new Date(blog.publishDate).toLocaleDateString()}</span>
|
||
<span>{blog.readTime}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Load More Button */}
|
||
{hasMoreBlogs && (
|
||
<div className="text-center mt-12">
|
||
<button
|
||
onClick={handleLoadMore}
|
||
className="px-6 py-2 text-sm font-medium rounded hover:opacity-90 transition-opacity duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||
style={{
|
||
backgroundColor: '#012068',
|
||
color: '#f4f4f4',
|
||
'--tw-ring-color': '#012068'
|
||
} as React.CSSProperties}
|
||
>
|
||
Load More Articles ({filteredBlogs.length - displayCount} remaining)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BlogListing; |