Initial commit
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
"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<string, typeof ShoppingCart> = {
|
||||
"security-consultancy": ShieldCheck,
|
||||
"ecommerce-platform": ShoppingCart,
|
||||
"infra-dashboard": Server,
|
||||
"mobile-companion": Smartphone,
|
||||
"analytics-service": BarChart3,
|
||||
"business-landing": Building,
|
||||
"etf-oglasi": Smartphone,
|
||||
};
|
||||
|
||||
const projectColors: Record<string, string> = {
|
||||
"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<HTMLDivElement>(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<ReturnType<typeof setInterval> | null>(null);
|
||||
const pendingTimeouts = useRef<Set<ReturnType<typeof setTimeout>>>(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<Project | null>(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 (
|
||||
<section id="projects" className="relative w-full py-24 sm:py-32">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mb-12 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="mb-4 inline-flex items-center gap-2 rounded-full border border-purple-400/30 bg-purple-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-purple-300 backdrop-blur-md"
|
||||
>
|
||||
{dict.projects.badge}
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{dict.projects.title}
|
||||
</motion.h2>
|
||||
</div>
|
||||
|
||||
<motion.div variants={fadeUp} className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/60 bg-surface/40 text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-purple-300"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/60 bg-surface/40 text-text-secondary transition-all hover:border-purple-400/40 hover:bg-purple-500/10 hover:text-purple-300"
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
{featured && (
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="group ml-2 inline-flex items-center gap-2 font-mono text-sm text-purple-300 transition-colors hover:text-purple-200"
|
||||
>
|
||||
{dict.projects.viewAll}
|
||||
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
</Link>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Carousel track */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl"
|
||||
onMouseEnter={pauseAuto}
|
||||
onMouseLeave={() => { if (!isDragging.current) resumeAuto(); }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onClickCapture={onClickCapture}
|
||||
style={{ cursor: grabbing ? "grabbing" : "grab", touchAction: "pan-y" }}
|
||||
>
|
||||
<motion.div
|
||||
ref={trackRef}
|
||||
className="flex"
|
||||
style={{ gap: `${gapPx}px`, x }}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={project.slug}
|
||||
className="shrink-0 select-none"
|
||||
style={{
|
||||
width: `calc(${cardPercent}% - ${(gapPx * (cardsToShow - 1)) / cardsToShow}px)`,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="group h-full"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<GlassCard className="flex h-full flex-col overflow-hidden p-0">
|
||||
{project.screenshots[0] ? (
|
||||
<div className="relative h-44 w-full overflow-hidden">
|
||||
<Image
|
||||
src={project.screenshots[0]}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-36 items-center justify-center bg-gradient-to-br ${gradientClasses}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-10 w-10 ${iconColor} transition-transform duration-500 group-hover:scale-110`}
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider ${project.type === "client"
|
||||
? "border border-purple-400/30 bg-purple-500/15 text-purple-300"
|
||||
: "border border-border/50 bg-white/5 text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{project.type === "client"
|
||||
? dict.projects.clientWork
|
||||
: dict.projects.personal}
|
||||
</span>
|
||||
{project.live && <LiveDot />}
|
||||
{project.inProgress && <InProgressBadge label={dict.projects.inProgress} />}
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="mb-4 line-clamp-2 text-sm leading-relaxed text-text-secondary">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md bg-surface/60 px-2 py-0.5 font-mono text-[11px] text-text-secondary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-auto">
|
||||
<ProjectLinks project={project} dict={dict} />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-3 bg-gradient-to-r from-background/40 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-3 bg-gradient-to-l from-background/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDot(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${i === index
|
||||
? "w-6 bg-purple-400"
|
||||
: "w-1.5 bg-border hover:bg-text-secondary"
|
||||
}`}
|
||||
aria-label={`Slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectModal
|
||||
project={selectedProject}
|
||||
dict={dict}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Shared sub-components ----
|
||||
|
||||
export function LiveDot() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-400/30 bg-emerald-500/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-emerald-300">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InProgressBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md border border-amber-400/30 bg-amber-500/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-amber-300">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectLinks({
|
||||
project,
|
||||
dict,
|
||||
}: {
|
||||
project: { live: boolean; url?: string; github?: string; download?: string; slug: string };
|
||||
dict: Dictionary;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{project.live && project.url && (
|
||||
<a
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-300 transition-all hover:bg-emerald-500/20 hover:shadow-[0_0_12px_rgba(52,211,153,0.15)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{dict.projects.viewProject}
|
||||
</a>
|
||||
)}
|
||||
{project.github && (
|
||||
<a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-3 py-1.5 text-xs text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
<SiGitea className="h-3 w-3" />
|
||||
{dict.projects.source}
|
||||
</a>
|
||||
)}
|
||||
{project.download && (
|
||||
<a
|
||||
href={project.download}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/50 bg-white/5 px-3 py-1.5 text-xs text-text-secondary transition-all hover:border-purple-400/30 hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{dict.projects.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user