4eef81fdfc
Build and push images to the private registry, then deploy to the VPS by commit SHA with health check and rollback.
514 lines
19 KiB
TypeScript
514 lines
19 KiB
TypeScript
"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 [rawIndex, setIndex] = useState(0);
|
|
const maxIndex = Math.max(0, projects.length - cardsToShow);
|
|
|
|
// Clamp during render rather than in an effect: a resize that shrinks
|
|
// maxIndex takes effect on the same render instead of a second one.
|
|
const index = Math.min(rawIndex, 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)`,
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
}
|