176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
|
import { Navigation, A11y } from 'swiper/modules';
|
|
import type { Swiper as SwiperType } from 'swiper';
|
|
import { getImageUrl } from '@/lib/utils';
|
|
import {
|
|
DEFAULT_BLOG_ARTICLES,
|
|
getSectionTitle,
|
|
} from '@/lib/homepageDefaults';
|
|
|
|
import 'swiper/css';
|
|
import 'swiper/css/navigation';
|
|
|
|
interface ArticleItem {
|
|
id: string;
|
|
title: string;
|
|
excerpt: string;
|
|
image: string;
|
|
link: string;
|
|
}
|
|
|
|
interface ArticlesSectionProps {
|
|
config?: any;
|
|
}
|
|
|
|
function mapArticles(raw: any[]): ArticleItem[] {
|
|
return raw
|
|
.filter((a) => a && (a.title || a.image))
|
|
.map((a: any, idx: number) => {
|
|
const fallback = DEFAULT_BLOG_ARTICLES[idx % DEFAULT_BLOG_ARTICLES.length];
|
|
const img = a.image || a.image_url || '';
|
|
return {
|
|
id: String(a.id || `article-${idx + 1}`),
|
|
title: String(a.title || fallback.title),
|
|
excerpt: String(a.excerpt || a.subtitle || a.description || fallback.excerpt),
|
|
image: img ? getImageUrl(img) : fallback.image,
|
|
link: String(a.link || a.url || a.href || `/blog`),
|
|
};
|
|
});
|
|
}
|
|
|
|
export const ArticlesSection: React.FC<ArticlesSectionProps> = ({ config }) => {
|
|
const raw =
|
|
config?.metadata_json?.articles || config?.metadata?.articles || [];
|
|
const articles =
|
|
Array.isArray(raw) && raw.length > 0 ? mapArticles(raw) : DEFAULT_BLOG_ARTICLES;
|
|
|
|
const title = getSectionTitle(config, 'tech_blog');
|
|
const viewAllHref =
|
|
config?.metadata_json?.view_all_url ||
|
|
config?.metadata?.view_all_url ||
|
|
'/blog';
|
|
|
|
const swiperRef = useRef<SwiperType | null>(null);
|
|
const [canPrev, setCanPrev] = useState(false);
|
|
const [canNext, setCanNext] = useState(false);
|
|
const [showNav, setShowNav] = useState(false);
|
|
|
|
const syncNav = (swiper: SwiperType) => {
|
|
const overflow = !swiper.isLocked && swiper.slides.length > 0;
|
|
setShowNav(overflow);
|
|
setCanPrev(overflow && !swiper.isBeginning);
|
|
setCanNext(overflow && !swiper.isEnd);
|
|
};
|
|
|
|
if (articles.length === 0) return null;
|
|
|
|
return (
|
|
<section className="py-10 bg-white font-sans">
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
|
|
{/* Header + full-width rule — TechShop reference */}
|
|
<div className="flex items-center justify-between gap-4 pb-3.5 border-b border-[#e5e5e5] mb-7">
|
|
<h2 className="text-[20px] md:text-[22px] font-bold text-[#222] tracking-tight text-left">
|
|
{title}
|
|
</h2>
|
|
<Link
|
|
href={viewAllHref}
|
|
className="shrink-0 text-[13px] font-semibold text-[#333] hover:text-primary transition-colors inline-flex items-center gap-1.5"
|
|
>
|
|
View All
|
|
<ArrowRight size={14} strokeWidth={2.5} />
|
|
</Link>
|
|
</div>
|
|
|
|
<div className={`relative ${showNav ? 'md:px-10' : ''}`}>
|
|
{showNav ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
aria-label="Previous articles"
|
|
disabled={!canPrev}
|
|
onClick={() => swiperRef.current?.slidePrev()}
|
|
className={`hidden md:flex absolute left-0 top-[28%] -translate-y-1/2 z-20 w-10 h-10 rounded-full items-center justify-center shadow-md transition-colors ${
|
|
canPrev
|
|
? 'bg-primary text-white hover:bg-primary-hover cursor-pointer'
|
|
: 'bg-primary/40 text-white cursor-default'
|
|
}`}
|
|
>
|
|
<ChevronLeft size={20} strokeWidth={2.5} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Next articles"
|
|
disabled={!canNext}
|
|
onClick={() => swiperRef.current?.slideNext()}
|
|
className={`hidden md:flex absolute right-0 top-[28%] -translate-y-1/2 z-20 w-10 h-10 rounded-full items-center justify-center shadow-md transition-colors ${
|
|
canNext
|
|
? 'bg-primary text-white hover:bg-primary-hover cursor-pointer'
|
|
: 'bg-primary/40 text-white cursor-default'
|
|
}`}
|
|
>
|
|
<ChevronRight size={20} strokeWidth={2.5} />
|
|
</button>
|
|
</>
|
|
) : null}
|
|
|
|
<Swiper
|
|
modules={[Navigation, A11y]}
|
|
onSwiper={(swiper) => {
|
|
swiperRef.current = swiper;
|
|
requestAnimationFrame(() => syncNav(swiper));
|
|
}}
|
|
onSlideChange={syncNav}
|
|
onResize={syncNav}
|
|
onBreakpoint={syncNav}
|
|
onLock={() => {
|
|
setShowNav(false);
|
|
setCanPrev(false);
|
|
setCanNext(false);
|
|
}}
|
|
onUnlock={(swiper) => syncNav(swiper)}
|
|
watchOverflow
|
|
spaceBetween={22}
|
|
slidesPerView={1.15}
|
|
breakpoints={{
|
|
640: { slidesPerView: 2, spaceBetween: 20 },
|
|
1024: { slidesPerView: 3, spaceBetween: 22 },
|
|
1280: { slidesPerView: 4, spaceBetween: 24 },
|
|
}}
|
|
className="!overflow-visible"
|
|
>
|
|
{articles.map((article) => (
|
|
<SwiperSlide key={article.id} className="h-auto">
|
|
<article className="text-left group">
|
|
<Link href={article.link} className="block">
|
|
<div className="relative w-full aspect-[16/10] rounded-xl overflow-hidden bg-[#f0f0f0] mb-3.5">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={article.image}
|
|
alt={article.title}
|
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<h3 className="text-[15px] md:text-[16px] font-bold text-[#222] leading-snug mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
|
{article.title}
|
|
</h3>
|
|
</Link>
|
|
{article.excerpt ? (
|
|
<p className="text-[13px] text-[#777] leading-relaxed line-clamp-2">
|
|
{article.excerpt}
|
|
</p>
|
|
) : null}
|
|
</article>
|
|
</SwiperSlide>
|
|
))}
|
|
</Swiper>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|