Initial portfolio commit (Next.js + Docker + Gitea integration)
This commit is contained in:
185
components/gallery-carousel.tsx
Normal file
185
components/gallery-carousel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
images: string[];
|
||||
title?: string;
|
||||
autoPlay?: boolean;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
export default function GalleryCarousel({
|
||||
images,
|
||||
title = "Gallery",
|
||||
autoPlay = true,
|
||||
intervalMs = 4500,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const safeImages = useMemo(() => images.filter(Boolean), [images]);
|
||||
|
||||
function clampIndex(i: number) {
|
||||
const n = safeImages.length;
|
||||
if (n === 0) return 0;
|
||||
return (i + n) % n;
|
||||
}
|
||||
|
||||
function scrollToIndex(i: number) {
|
||||
const idx = clampIndex(i);
|
||||
setActive(idx);
|
||||
|
||||
const track = containerRef.current;
|
||||
const el = itemRefs.current[idx];
|
||||
if (!track || !el) return;
|
||||
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
const target =
|
||||
track.scrollLeft +
|
||||
(elRect.left + elRect.width / 2) -
|
||||
(trackRect.left + trackRect.width / 2);
|
||||
|
||||
track.scrollTo({ left: target, behavior: "smooth" });
|
||||
}
|
||||
|
||||
|
||||
// Auto-advance
|
||||
useEffect(() => {
|
||||
if (!autoPlay || paused || safeImages.length <= 1) return;
|
||||
const t = setInterval(() => scrollToIndex(active + 1), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoPlay, paused, active, intervalMs, safeImages.length]);
|
||||
|
||||
// Keep active in sync when user swipes/scrolls
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || safeImages.length <= 1) return;
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
|
||||
let bestIdx = active;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < safeImages.length; i++) {
|
||||
const item = itemRefs.current[i];
|
||||
if (!item) continue;
|
||||
const r = item.getBoundingClientRect();
|
||||
const itemCenter = r.left + r.width / 2;
|
||||
const d = Math.abs(itemCenter - centerX);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx !== active) setActive(bestIdx);
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active, safeImages.length]);
|
||||
|
||||
if (safeImages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-black/90">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToIndex(active - 1)}
|
||||
className="rounded-full border border-black/10 bg-white/70 px-3 py-2 text-sm text-black/70 hover:text-black hover:border-black/25 transition"
|
||||
aria-label="Previous"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToIndex(active + 1)}
|
||||
className="rounded-full border border-black/10 bg-white/70 px-3 py-2 text-sm text-black/70 hover:text-black hover:border-black/25 transition"
|
||||
aria-label="Next"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className="mt-4 flex w-full max-w-full gap-4 overflow-x-auto overflow-y-hidden overscroll-x-contain pb-3 scrollbar-hide px-2"
|
||||
style={{ scrollSnapType: "x mandatory" }}
|
||||
>
|
||||
{safeImages.map((img, i) => (
|
||||
<button
|
||||
key={`${img}-${i}`}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => scrollToIndex(i)}
|
||||
className={[
|
||||
"relative shrink-0 rounded-3xl glass-strong specular overflow-hidden",
|
||||
"min-w-[320px] md:min-w-[520px] h-[220px] md:h-[320px]",
|
||||
"transition will-change-transform",
|
||||
"focus:outline-none focus:ring-2 focus:ring-black/20",
|
||||
i === active ? "scale-[1.00]" : "scale-[0.97] opacity-90 hover:opacity-100",
|
||||
].join(" ")}
|
||||
style={{ scrollSnapAlign: "center" }}
|
||||
aria-label={`Select image ${i + 1}`}
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt={`Gallery image ${i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority={i === 0}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent pointer-events-none" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
{safeImages.length > 1 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
{safeImages.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => scrollToIndex(i)}
|
||||
className={[
|
||||
"h-2.5 w-2.5 rounded-full transition",
|
||||
i === active ? "bg-black/50" : "bg-black/15 hover:bg-black/30",
|
||||
].join(" ")}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user