Files
cmc_frontend/src/components/blogs/BlogListing.tsx
2025-10-10 20:42:13 +05:30

373 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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