update
This commit is contained in:
65
frontEnd/components/pages/blog/BlogInitAnimations.tsx
Normal file
65
frontEnd/components/pages/blog/BlogInitAnimations.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const SmoothScroll = dynamic(() => import("../../shared/layout/animations/SmoothScroll"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ParallaxImage = dynamic(() => import("../../shared/layout/animations/ParallaxImage"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const FadeImageBottom = dynamic(() => import("../../shared/layout/animations/FadeImageBottom"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ButtonHoverAnimation = dynamic(
|
||||
() => import("../../shared/layout/animations/ButtonHoverAnimation"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const VanillaTiltHover = dynamic(
|
||||
() => import("../../shared/layout/animations/VanillaTiltHover"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const SplitTextAnimations = dynamic(
|
||||
() => import("../../shared/layout/animations/SplitTextAnimations"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const ScrollToElement = dynamic(() => import("../../shared/layout/animations/ScrollToElement"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const AppearDown = dynamic(() => import("../../shared/layout/animations/AppearDown"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const FadeAnimations = dynamic(() => import("../../shared/layout/animations/FadeAnimations"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const BlogInitAnimations = () => {
|
||||
return (
|
||||
<>
|
||||
<SmoothScroll />
|
||||
<ParallaxImage />
|
||||
<FadeImageBottom />
|
||||
<ButtonHoverAnimation />
|
||||
<VanillaTiltHover />
|
||||
<SplitTextAnimations />
|
||||
<ScrollToElement />
|
||||
<AppearDown />
|
||||
<FadeAnimations />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogInitAnimations;
|
||||
150
frontEnd/components/pages/blog/BlogItems.tsx
Normal file
150
frontEnd/components/pages/blog/BlogItems.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import PostFilterItems from "./post-filter/PostFilterItems";
|
||||
|
||||
const BlogItems = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const postsPerPage = 6;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
// Scroll to top of posts section
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotalPagesChange = (total: number) => {
|
||||
setTotalPages(total);
|
||||
};
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxPagesToShow = 5;
|
||||
|
||||
if (totalPages <= maxPagesToShow) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate range around current page
|
||||
let startPage = Math.max(2, currentPage - 1);
|
||||
let endPage = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (startPage > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add pages around current page
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show last page
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="fix-top pb-120 blog-m">
|
||||
<div className="container">
|
||||
<div className="row align-items-center vertical-column-gap">
|
||||
<div className="col-12 col-lg-7">
|
||||
<h2 className="mt-8 fw-7 text-secondary title-anim">Latest Company Insights</h2>
|
||||
</div>
|
||||
<div className="col-12 col-lg-5">
|
||||
<form action="#" method="post" autoComplete="off">
|
||||
<div className="search-form">
|
||||
<input
|
||||
type="search"
|
||||
name="search-post"
|
||||
id="searchPost"
|
||||
placeholder="Search"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="search post"
|
||||
title="search post"
|
||||
>
|
||||
<i className="fa-solid fa-magnifying-glass"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<PostFilterItems
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onTotalPagesChange={handleTotalPagesChange}
|
||||
postsPerPage={postsPerPage}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<div className="row mt-60">
|
||||
<div className="col-12">
|
||||
<div className="section__cta">
|
||||
<ul className="pagination">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
style={{ opacity: currentPage === 1 ? 0.5 : 1, cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<i className="fa-solid fa-angle-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<li key={index}>
|
||||
{typeof page === 'number' ? (
|
||||
<button
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={currentPage === page ? 'active' : ''}
|
||||
aria-label={`Go to page ${page}`}
|
||||
aria-current={currentPage === page ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ padding: '0 10px' }}>{page}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Next page"
|
||||
style={{ opacity: currentPage === totalPages ? 0.5 : 1, cursor: currentPage === totalPages ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<i className="fa-solid fa-angle-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogItems;
|
||||
67
frontEnd/components/pages/blog/BlogScrollProgressButton.tsx
Normal file
67
frontEnd/components/pages/blog/BlogScrollProgressButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const BlogScrollProgressButton = () => {
|
||||
useEffect(() => {
|
||||
window.scroll(0, 0);
|
||||
}, []);
|
||||
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const scrollRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleScroll = () => {
|
||||
const totalHeight = document.body.scrollHeight - window.innerHeight;
|
||||
const progress = (window.scrollY / totalHeight) * 100;
|
||||
setScrollProgress(progress);
|
||||
setIsActive(window.scrollY > 50);
|
||||
};
|
||||
|
||||
const handleProgressClick = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={scrollRef}
|
||||
className={`progress-wrap ${isActive ? " active-progress" : " "}`}
|
||||
onClick={handleProgressClick}
|
||||
title="Go To Top"
|
||||
>
|
||||
<span></span>
|
||||
<svg
|
||||
className="progress-circle svg-content"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="-1 -1 102 102"
|
||||
>
|
||||
<path
|
||||
d="M50,1 a49,49 0 0,1 0,98 a49,49 0 0,1 0,-98"
|
||||
stroke="#3887FE"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
style={{
|
||||
strokeDasharray: "308.66px",
|
||||
strokeDashoffset: `${308.66 - scrollProgress * 3.0866}px`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogScrollProgressButton;
|
||||
272
frontEnd/components/pages/blog/BlogSingle.tsx
Normal file
272
frontEnd/components/pages/blog/BlogSingle.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
import { useParams } from "next/navigation";
|
||||
import Image from "next/legacy/image";
|
||||
import Link from "next/link";
|
||||
import { useBlogPost } from "@/lib/hooks/useBlog";
|
||||
import { getValidImageUrl, getValidImageAlt, FALLBACK_IMAGES } from "@/lib/imageUtils";
|
||||
|
||||
const BlogSingle = () => {
|
||||
const params = useParams();
|
||||
const slug = params?.slug as string;
|
||||
const { post, loading, error } = useBlogPost(slug);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="blog-single-section fix-top pb-120">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-12 col-lg-10 col-xl-8">
|
||||
<div className="loading-state text-center py-5">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-tertiary">Loading insight...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<section className="blog-single-section fix-top pb-120">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-12 col-lg-10 col-xl-8">
|
||||
<div className="error-state text-center py-5">
|
||||
<div className="error-icon mb-4">
|
||||
<i className="fa-solid fa-exclamation-circle fa-4x text-tertiary"></i>
|
||||
</div>
|
||||
<h2 className="text-secondary mb-3">Insight Not Found</h2>
|
||||
<p className="text-tertiary mb-4">
|
||||
The insight you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Link href="/insights" className="btn btn-primary">
|
||||
<i className="fa-solid fa-arrow-left me-2"></i>
|
||||
Back to Insights
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="blog-single-section fix-top pb-120">
|
||||
<div className="container">
|
||||
{/* Article Content */}
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<article className="blog-single-article">
|
||||
{/* Article Header */}
|
||||
<header className="article-header">
|
||||
{/* Top Meta Bar */}
|
||||
<div className="article-top-meta d-flex flex-wrap align-items-center justify-content-between mb-4">
|
||||
<div className="left-meta d-flex align-items-center gap-3">
|
||||
{/* Category Badge */}
|
||||
{post.category && (
|
||||
<Link href={`/insights?category=${post.category.slug}`} className="category-badge">
|
||||
<i className="fa-solid fa-folder-open me-2"></i>
|
||||
{post.category.title}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<div className="meta-item d-flex align-items-center">
|
||||
<i className="fa-solid fa-calendar me-2"></i>
|
||||
<span>
|
||||
{new Date(post.published_at || post.created_at).toLocaleDateString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right-meta d-flex align-items-center gap-3">
|
||||
{/* Reading Time */}
|
||||
<div className="meta-item d-flex align-items-center">
|
||||
<i className="fa-solid fa-clock me-2"></i>
|
||||
<span>{post.reading_time} min</span>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<div className="meta-item d-flex align-items-center">
|
||||
<i className="fa-solid fa-eye me-2"></i>
|
||||
<span>{post.views_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="article-title">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{/* Author and Tags Bar */}
|
||||
<div className="article-bottom-meta d-flex flex-wrap align-items-center justify-content-between mt-4 pt-4">
|
||||
{/* Author */}
|
||||
<div className="author-meta d-flex align-items-center">
|
||||
<div className="author-avatar me-3">
|
||||
{post.author?.avatar ? (
|
||||
<Image
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-circle"
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="author-info">
|
||||
<div className="author-label">Written by</div>
|
||||
<div className="author-name">
|
||||
{post.author?.name || post.author_name || 'Admin'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="article-tags d-flex flex-wrap align-items-center gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/insights?tag=${tag.slug}`}
|
||||
className="tag-badge"
|
||||
>
|
||||
#{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Featured Image */}
|
||||
{(post.featured_image || post.thumbnail) && (
|
||||
<div className="article-featured-image">
|
||||
<div className="image-wrapper">
|
||||
<Image
|
||||
src={getValidImageUrl(
|
||||
post.featured_image || post.thumbnail,
|
||||
FALLBACK_IMAGES.BLOG
|
||||
)}
|
||||
alt={getValidImageAlt(post.title)}
|
||||
width={1200}
|
||||
height={600}
|
||||
layout="responsive"
|
||||
className="featured-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Article Body */}
|
||||
<div className="article-body">
|
||||
{/* Excerpt */}
|
||||
{post.excerpt && (
|
||||
<div className="article-excerpt">
|
||||
<p className="lead">{post.excerpt}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{post.content && (
|
||||
<div
|
||||
className="article-content enterprise-content"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<footer className="article-footer">
|
||||
{/* Share Section */}
|
||||
<div className="article-share">
|
||||
<div className="share-container">
|
||||
<h6 className="share-title">
|
||||
Share this insight
|
||||
</h6>
|
||||
<div className="social-share">
|
||||
<Link
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${typeof window !== 'undefined' ? encodeURIComponent(window.location.href) : ''}&title=${encodeURIComponent(post.title)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="share-btn share-linkedin"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<i className="fa-brands fa-linkedin-in"></i>
|
||||
<span>LinkedIn</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined' && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('Link copied to clipboard!');
|
||||
}
|
||||
}}
|
||||
className="share-btn share-copy"
|
||||
aria-label="Copy link"
|
||||
>
|
||||
<i className="fa-solid fa-link"></i>
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Bio */}
|
||||
{post.author && post.author.bio && (
|
||||
<div className="author-bio-section">
|
||||
<div className="author-bio-card">
|
||||
<div className="author-avatar-container">
|
||||
{post.author.avatar ? (
|
||||
<Image
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
width={90}
|
||||
height={90}
|
||||
className="rounded-circle"
|
||||
/>
|
||||
) : (
|
||||
<div className="author-avatar-large">
|
||||
<i className="fa-solid fa-user"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="author-bio-content">
|
||||
<div className="bio-label">About the Author</div>
|
||||
<h6 className="author-name">{post.author.name}</h6>
|
||||
<p className="author-bio-text">{post.author.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="article-navigation">
|
||||
<Link href="/insights" className="btn-back-insights">
|
||||
<i className="fa-solid fa-arrow-left me-2"></i>
|
||||
Back to All Insights
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSingle;
|
||||
117
frontEnd/components/pages/blog/LatestPost.tsx
Normal file
117
frontEnd/components/pages/blog/LatestPost.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
import Image from "next/legacy/image";
|
||||
import Link from "next/link";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Autoplay } from "swiper/modules";
|
||||
import "swiper/swiper-bundle.css";
|
||||
import { useLatestPosts } from "@/lib/hooks/useBlog";
|
||||
import { getValidImageUrl, getValidImageAlt, FALLBACK_IMAGES } from "@/lib/imageUtils";
|
||||
|
||||
const LatestPost = () => {
|
||||
const { posts, loading, error } = useLatestPosts(8);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="tp-latest-post pt-120 pb-120 bg-quinary">
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<p className="text-center">Loading latest posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || posts.length === 0) {
|
||||
return null; // Don't show the section if there's an error or no posts
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="tp-latest-post pt-120 pb-120 bg-quinary">
|
||||
<div className="container">
|
||||
<div className="row vertical-column-gap align-items-center">
|
||||
<div className="col-12 col-lg-7">
|
||||
<div className="tp-lp-title text-center text-lg-start">
|
||||
<h2 className="mt-8 fw-7 text-secondary title-anim">
|
||||
Related Insights
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-5">
|
||||
<div className="tp-lp-cta text-center text-lg-end">
|
||||
<Link href="/insights" className="btn-line text-uppercase">
|
||||
View All Insights
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<div className="tp-lp-slider-wrapper mt-60">
|
||||
<div className="tp-lp-slider-wrapper">
|
||||
<Swiper
|
||||
slidesPerView={"auto"}
|
||||
spaceBetween={24}
|
||||
slidesPerGroup={1}
|
||||
freeMode={true}
|
||||
speed={1200}
|
||||
loop={posts.length > 3}
|
||||
roundLengths={true}
|
||||
modules={[Autoplay]}
|
||||
autoplay={{
|
||||
delay: 5000,
|
||||
disableOnInteraction: false,
|
||||
pauseOnMouseEnter: true,
|
||||
}}
|
||||
className="tp-lp-slider"
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<SwiperSlide key={post.id}>
|
||||
<div className="tp-lp-slider__single topy-tilt">
|
||||
<div className="thumb mb-24">
|
||||
<Link href={`/insights/${post.slug}`} className="w-100 overflow-hidden">
|
||||
<Image
|
||||
src={getValidImageUrl(post.thumbnail, FALLBACK_IMAGES.BLOG)}
|
||||
width={400}
|
||||
height={220}
|
||||
className="w-100 mh-220"
|
||||
alt={getValidImageAlt(post.title)}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="tp-lp-post__meta mb-24 mt-8">
|
||||
<p className="author text-xs text-tertiary">
|
||||
{post.author_name || 'Admin'}
|
||||
</p>
|
||||
<span></span>
|
||||
<p className="date text-xs text-tertiary">
|
||||
{new Date(post.published_at || post.created_at).toLocaleDateString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<h5 className="mt-8 fw-5 text-secondary">
|
||||
<Link href={`/insights/${post.slug}`}>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default LatestPost;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useBlogCategories } from "@/lib/hooks/useBlog";
|
||||
|
||||
const PostFilterButtons = ({ handleClick, active }: any) => {
|
||||
const { categories: apiCategories, loading, error } = useBlogCategories();
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && apiCategories.length > 0) {
|
||||
// Add "All" category at the beginning
|
||||
const allCategory = {
|
||||
id: 0,
|
||||
title: "All",
|
||||
slug: "all",
|
||||
display_order: 0
|
||||
};
|
||||
setCategories([allCategory, ...apiCategories]);
|
||||
}
|
||||
}, [apiCategories, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<div className="post-filter__wrapper mt-80">
|
||||
<p>Loading categories...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Fallback to showing "All" button only
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<div className="post-filter__wrapper mt-80">
|
||||
<button
|
||||
aria-label="Filter Post"
|
||||
className="active"
|
||||
onClick={() => handleClick("all")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<div className="post-filter__wrapper mt-80">
|
||||
{categories.map((item) => {
|
||||
return (
|
||||
<button
|
||||
aria-label="Filter Post"
|
||||
key={item.id}
|
||||
className={active === item.slug ? " active" : ""}
|
||||
onClick={() => handleClick(item.slug)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostFilterButtons;
|
||||
139
frontEnd/components/pages/blog/post-filter/PostFilterItems.tsx
Normal file
139
frontEnd/components/pages/blog/post-filter/PostFilterItems.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
import { useState, SetStateAction, useEffect } from "react";
|
||||
import Image from "next/legacy/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getValidImageUrl, getValidImageAlt, FALLBACK_IMAGES } from "@/lib/imageUtils";
|
||||
import PostFilterButtons from "./PostFilterButtons";
|
||||
import { useBlogPosts } from "@/lib/hooks/useBlog";
|
||||
|
||||
interface PostFilterItemsProps {
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onTotalPagesChange: (totalPages: number) => void;
|
||||
postsPerPage?: number;
|
||||
}
|
||||
|
||||
const PostFilterItems = ({ currentPage, onPageChange, onTotalPagesChange, postsPerPage = 10 }: PostFilterItemsProps) => {
|
||||
const [active, setActive] = useState("all");
|
||||
const { posts: allPosts, loading, error, totalCount } = useBlogPosts({
|
||||
category: active === "all" ? undefined : active,
|
||||
page: currentPage,
|
||||
page_size: postsPerPage
|
||||
});
|
||||
const [displayData, setDisplayData] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && allPosts.length > 0) {
|
||||
setDisplayData(allPosts);
|
||||
}
|
||||
}, [allPosts, loading]);
|
||||
|
||||
// Calculate and update total pages when totalCount changes
|
||||
useEffect(() => {
|
||||
if (totalCount !== undefined && totalCount !== null) {
|
||||
const calculatedTotalPages = Math.ceil(totalCount / postsPerPage);
|
||||
onTotalPagesChange(calculatedTotalPages);
|
||||
}
|
||||
}, [totalCount, postsPerPage, onTotalPagesChange]);
|
||||
|
||||
const handleCategoryClick = (category: SetStateAction<string>) => {
|
||||
if (category === active) return;
|
||||
setActive(category);
|
||||
setDisplayData([]);
|
||||
onPageChange(1); // Reset to page 1 when category changes
|
||||
|
||||
// The API call will be triggered by the change in active state
|
||||
// which will update allPosts and trigger the useEffect above
|
||||
};
|
||||
|
||||
if (loading && displayData.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<PostFilterButtons active={active} handleClick={handleCategoryClick} />
|
||||
<div className="row mt-60">
|
||||
<div className="col-12">
|
||||
<p className="text-center">Loading posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PostFilterButtons active={active} handleClick={handleCategoryClick} />
|
||||
<div className="row mt-60">
|
||||
<div className="col-12">
|
||||
<p className="text-center text-danger">Error loading posts. Please try again later.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostFilterButtons active={active} handleClick={handleCategoryClick} />
|
||||
<motion.div className="row mt-60 masonry-grid" layout>
|
||||
<AnimatePresence>
|
||||
{displayData.map((item) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="col-12 col-lg-6 grid-item-main"
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.6 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="tp-lp-slider__single topy-tilt">
|
||||
<div className="thumb mb-24">
|
||||
<Link
|
||||
href={`/insights/${item.slug}`}
|
||||
className="w-100 overflow-hidden d-block"
|
||||
>
|
||||
<div className="parallax-image-wrap">
|
||||
<div className="parallax-image-inner">
|
||||
<Image
|
||||
src={getValidImageUrl(item.thumbnail, FALLBACK_IMAGES.BLOG)}
|
||||
className="w-100 mh-220 parallax-image"
|
||||
alt={getValidImageAlt(item.title)}
|
||||
width={400}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="tp-lp-post__meta mb-24 mt-8">
|
||||
<p className="author text-xs text-tertiary">
|
||||
{item.author_name || 'Admin'}
|
||||
</p>
|
||||
<span></span>
|
||||
<p className="date text-xs text-tertiary">
|
||||
{new Date(item.published_at || item.created_at).toLocaleDateString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<h5 className="mt-8 fw-5 text-secondary">
|
||||
<Link href={`/insights/${item.slug}`}>{item.title}</Link>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostFilterItems;
|
||||
Reference in New Issue
Block a user