"use client"; import { useState, useRef, useEffect, useCallback } from "react"; import { motion, useMotionValue, animate as motionAnimate } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; import ProjectModal from "@/components/ui/ProjectModal"; import { SiGitea } from "react-icons/si"; import { ChevronLeft, ChevronRight, ExternalLink, Download, ShoppingCart, ShieldCheck, Server, Smartphone, BarChart3, Building, } from "lucide-react"; import GlassCard from "@/components/ui/GlassCard"; import { fadeUp, stagger, viewportConfig } from "@/lib/animations"; import type { Dictionary, Locale } from "@/lib/dictionaries"; const projectIcons: Record = { "security-consultancy": ShieldCheck, "ecommerce-platform": ShoppingCart, "infra-dashboard": Server, "mobile-companion": Smartphone, "analytics-service": BarChart3, "business-landing": Building, "etf-oglasi": Smartphone, }; const projectColors: Record = { "security-consultancy": "from-blue-500/20 to-blue-600/5 text-blue-300", "ecommerce-platform": "from-emerald-500/20 to-emerald-600/5 text-emerald-300", "infra-dashboard": "from-purple-500/20 to-purple-600/5 text-purple-300", "mobile-companion": "from-rose-500/20 to-rose-600/5 text-rose-300", "analytics-service": "from-amber-500/20 to-amber-600/5 text-amber-300", "business-landing": "from-slate-400/20 to-slate-500/5 text-slate-300", "etf-oglasi": "from-violet-500/20 to-violet-600/5 text-violet-300", }; function useCardsToShow() { const [count, setCount] = useState(3); useEffect(() => { function update() { if (window.innerWidth < 640) setCount(1); else if (window.innerWidth < 1024) setCount(2); else setCount(3); } update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); return count; } interface ProjectsCarouselProps { locale: Locale; dict: Dictionary; featured?: boolean; } export default function ProjectsCarousel({ locale, dict, featured = false, }: ProjectsCarouselProps) { const projects = dict.projects.items .filter((p) => p.inCarousel) .sort((a, b) => a.carouselOrder - b.carouselOrder); const cardsToShow = useCardsToShow(); const [index, setIndex] = useState(0); const maxIndex = Math.max(0, projects.length - cardsToShow); // Clamp index when viewport resize shrinks maxIndex useEffect(() => { if (index > maxIndex) { setIndex(maxIndex); } }, [index, maxIndex]); // ─── PIXEL-BASED MOTION VALUE ───────────────────────────────── const trackRef = useRef(null); const x = useMotionValue(0); const gapPx = 16; // stride = distance in px between card positions function getStridePx(): number { const container = trackRef.current?.parentElement; if (!container) return 300; return (container.offsetWidth + gapPx) / cardsToShow; } function animateToIndex(idx: number) { motionAnimate(x, -(idx * getStridePx()), { type: "spring", stiffness: 260, damping: 30, mass: 0.8, }); } // Animate whenever index or cardsToShow changes (handles resize too) useEffect(() => { animateToIndex(index); // eslint-disable-next-line react-hooks/exhaustive-deps }, [index, cardsToShow]); // ─── LEAK-SAFE TIMER SYSTEM ─────────────────────────────────── const interacting = useRef(false); const mountedRef = useRef(true); const timerRef = useRef | null>(null); const pendingTimeouts = useRef>>(new Set()); const goNext = useCallback(() => { setIndex((prev) => (prev >= maxIndex ? 0 : prev + 1)); }, [maxIndex]); const goNextRef = useRef(goNext); useEffect(() => { goNextRef.current = goNext; }, [goNext]); function goPrev() { setIndex((prev) => (prev <= 0 ? maxIndex : prev - 1)); } function clearAllTimers() { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } pendingTimeouts.current.forEach((id) => clearTimeout(id)); pendingTimeouts.current.clear(); } function startAutoScroll() { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { if (!interacting.current && mountedRef.current) goNextRef.current(); }, 15000); } function pauseAuto() { interacting.current = true; if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } } function resumeAuto() { if (!mountedRef.current) return; interacting.current = false; startAutoScroll(); } function safeTimeout(fn: () => void, ms: number) { const id = setTimeout(() => { pendingTimeouts.current.delete(id); if (mountedRef.current) fn(); }, ms); pendingTimeouts.current.add(id); } function handlePrev() { pauseAuto(); goPrev(); safeTimeout(resumeAuto, 1200); } function handleNext() { pauseAuto(); goNext(); safeTimeout(resumeAuto, 1200); } function handleDot(i: number) { pauseAuto(); setIndex(i); safeTimeout(resumeAuto, 1200); } useEffect(() => { mountedRef.current = true; startAutoScroll(); return () => { mountedRef.current = false; clearAllTimers(); }; }, []); // ─── MODAL ─────────────────────────────────────────────────── type Project = (typeof projects)[number]; const [selectedProject, setSelectedProject] = useState(null); // ─── DRAG SYSTEM ───────────────────────────────────────────── // Pointer capture is deliberately NOT taken on pointerdown — doing so // retargets the synthesized `click` event to this container, which would // swallow clicks on the cards/buttons inside. We only capture once an // actual drag begins (threshold crossed), so taps/clicks pass through. const DRAG_THRESHOLD = 6; const isPointerDown = useRef(false); const isDragging = useRef(false); const didDrag = useRef(false); // true if this gesture became a drag const pointerStartX = useRef(0); const xOnDragStart = useRef(0); const [grabbing, setGrabbing] = useState(false); function onPointerDown(e: React.PointerEvent) { pointerStartX.current = e.clientX; xOnDragStart.current = x.get(); isPointerDown.current = true; isDragging.current = false; didDrag.current = false; pauseAuto(); } function onPointerMove(e: React.PointerEvent) { if (!isPointerDown.current) return; const delta = e.clientX - pointerStartX.current; if (!isDragging.current && Math.abs(delta) > DRAG_THRESHOLD) { isDragging.current = true; didDrag.current = true; setGrabbing(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } if (isDragging.current) { x.set(xOnDragStart.current + delta); } } function onPointerUp(e: React.PointerEvent) { if (!isPointerDown.current) return; isPointerDown.current = false; const wasDragging = isDragging.current; isDragging.current = false; setGrabbing(false); const el = e.currentTarget as HTMLElement; if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId); // A plain tap/click — let the card/button onClick handlers run normally. if (!wasDragging) { safeTimeout(resumeAuto, 1200); return; } const delta = e.clientX - pointerStartX.current; const stride = getStridePx(); const threshold = stride * 0.15; let newIndex = index; if (delta < -threshold) { newIndex = Math.min(maxIndex, index + Math.max(1, Math.round(-delta / stride))); } else if (delta > threshold) { newIndex = Math.max(0, index - Math.max(1, Math.round(delta / stride))); } if (newIndex !== index) { setIndex(newIndex); // useEffect above handles the spring animation } else { animateToIndex(index); // snap back } safeTimeout(resumeAuto, 1200); } // Suppress the click that fires at the end of a drag-release so it doesn't // open the modal or follow a link. function onClickCapture(e: React.MouseEvent) { if (didDrag.current) { e.preventDefault(); e.stopPropagation(); } } // ─── RENDER ────────────────────────────────────────────────── const cardPercent = 100 / cardsToShow; return (
{/* Header */}
{dict.projects.badge} {dict.projects.title}
{featured && ( {dict.projects.viewAll} )}
{/* Carousel track */}
{ if (!isDragging.current) resumeAuto(); }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onClickCapture={onClickCapture} style={{ cursor: grabbing ? "grabbing" : "grab", touchAction: "pan-y" }} > {projects.map((project) => { const Icon = projectIcons[project.slug] || Building; const colorClass = projectColors[project.slug] || "from-purple-500/20 to-purple-600/5 text-purple-300"; const gradientClasses = colorClass.split(" ").slice(0, 2).join(" "); const iconColor = colorClass.split(" ").slice(2).join(" "); return (
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
setSelectedProject(project)} > {project.screenshots[0] ? (
{project.title}
) : (
)}
{project.type === "client" ? dict.projects.clientWork : dict.projects.personal} {project.live && } {project.inProgress && }

{project.title}

{project.description}

{project.tags.map((tag) => ( {tag} ))}
e.stopPropagation()} className="mt-auto">
); })}
{/* Dots */}
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
setSelectedProject(null)} />
); } // ---- Shared sub-components ---- export function LiveDot() { return ( Live ); } export function InProgressBadge({ label }: { label: string }) { return ( {label} ); } export function ProjectLinks({ project, dict, }: { project: { live: boolean; url?: string; github?: string; download?: string; slug: string }; dict: Dictionary; }) { return (
{project.live && project.url && ( {dict.projects.viewProject} )} {project.github && ( {dict.projects.source} )} {project.download && ( {dict.projects.download} )}
); }