feat: team spotlight carousel with drag, auto-rotate, and progress dots
- Spotlight stage effect with center card lit up, side cards dimmed - Continuous drag navigation with pointer events - Auto-rotation every 4.5s, pauses on interaction - Interpolated card positions (size, opacity, brightness, grayscale) - Progress dots navigation - Fix About stats: 13 → 16 trainers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,19 @@
|
|||||||
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Team Info Fade ===== */
|
||||||
|
|
||||||
|
@keyframes team-info-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Section Divider ===== */
|
/* ===== Section Divider ===== */
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
|
|||||||
@@ -1,64 +1,273 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Instagram } from "lucide-react";
|
import { Instagram } from "lucide-react";
|
||||||
import { siteContent } from "@/data/content";
|
import { siteContent } from "@/data/content";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
|
||||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
const AUTO_PLAY_MS = 4500;
|
||||||
import type { TeamMember } from "@/types";
|
const PAUSE_MS = 12000;
|
||||||
|
const CARD_SPACING = 260; // px between card centers
|
||||||
|
|
||||||
|
function wrapIndex(i: number, total: number) {
|
||||||
|
return ((i % total) + total) % total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiff(index: number, active: number, total: number) {
|
||||||
|
let diff = index - active;
|
||||||
|
if (diff > total / 2) diff -= total;
|
||||||
|
if (diff < -total / 2) diff += total;
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolation helpers
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
function clamp(v: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden)
|
||||||
|
const SLOTS = [
|
||||||
|
{ w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true },
|
||||||
|
{ w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false },
|
||||||
|
{ w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false },
|
||||||
|
{ w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false },
|
||||||
|
{ w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false },
|
||||||
|
];
|
||||||
|
|
||||||
export function Team() {
|
export function Team() {
|
||||||
const { team } = siteContent;
|
const { team } = siteContent;
|
||||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
const total = team.members.length;
|
||||||
totalItems: team.members.length,
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
const pausedUntilRef = useRef(0);
|
||||||
|
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
|
||||||
|
|
||||||
|
const member = team.members[activeIndex];
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(i: number) => {
|
||||||
|
setActiveIndex(wrapIndex(i, total));
|
||||||
|
setDragOffset(0);
|
||||||
|
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||||
|
},
|
||||||
|
[total]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-rotate — completely skip while dragging
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (isDraggingRef.current) return;
|
||||||
|
if (Date.now() < pausedUntilRef.current) return;
|
||||||
|
setActiveIndex((i) => (i + 1) % total);
|
||||||
|
}, AUTO_PLAY_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
// Pointer handlers
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
setActiveIndex((cur) => {
|
||||||
|
dragStartRef.current = { x: e.clientX, startIndex: cur };
|
||||||
|
return cur;
|
||||||
});
|
});
|
||||||
|
setDragOffset(0);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (!dragStartRef.current) return;
|
||||||
|
const dx = e.clientX - dragStartRef.current.x;
|
||||||
|
setDragOffset(dx);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
if (!dragStartRef.current) return;
|
||||||
|
const startIdx = dragStartRef.current.startIndex;
|
||||||
|
// Read current dragOffset from state via functional update trick
|
||||||
|
setDragOffset((currentOffset) => {
|
||||||
|
const wasDrag = Math.abs(currentOffset) > 10;
|
||||||
|
const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
|
||||||
|
if (steps !== 0) {
|
||||||
|
const newIndex = wrapIndex(startIdx - steps, total);
|
||||||
|
setActiveIndex(newIndex);
|
||||||
|
}
|
||||||
|
return 0; // reset offset
|
||||||
|
});
|
||||||
|
dragStartRef.current = null;
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
// Compute interpolated style for each card
|
||||||
|
// During drag, base position is startIndex; otherwise activeIndex
|
||||||
|
const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
|
||||||
|
|
||||||
|
function getCardStyle(index: number) {
|
||||||
|
const baseDiff = getDiff(index, baseIndex, total);
|
||||||
|
const fractionalShift = dragOffset / CARD_SPACING;
|
||||||
|
const continuousDiff = baseDiff + fractionalShift;
|
||||||
|
const absDiff = Math.abs(continuousDiff);
|
||||||
|
|
||||||
|
if (absDiff > 4) return null;
|
||||||
|
|
||||||
|
// Interpolate between the two nearest slot positions
|
||||||
|
const lowerSlot = Math.floor(absDiff);
|
||||||
|
const upperSlot = Math.ceil(absDiff);
|
||||||
|
const t = absDiff - lowerSlot;
|
||||||
|
|
||||||
|
const s0 = SLOTS[clamp(lowerSlot, 0, 4)];
|
||||||
|
const s1 = SLOTS[clamp(upperSlot, 0, 4)];
|
||||||
|
|
||||||
|
const sign = continuousDiff >= 0 ? 1 : -1;
|
||||||
|
const x = sign * lerp(s0.x, s1.x, t);
|
||||||
|
const w = lerp(s0.w, s1.w, t);
|
||||||
|
const h = lerp(s0.h, s1.h, t);
|
||||||
|
const opacity = lerp(s0.opacity, s1.opacity, t);
|
||||||
|
const scale = lerp(s0.scale, s1.scale, t);
|
||||||
|
const brightness = lerp(s0.brightness, s1.brightness, t);
|
||||||
|
const grayscale = lerp(s0.grayscale, s1.grayscale, t);
|
||||||
|
const z = Math.round(lerp(s0.z, s1.z, t));
|
||||||
|
const showBorder = absDiff < 0.5;
|
||||||
|
|
||||||
|
if (opacity < 0.02) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
opacity,
|
||||||
|
zIndex: z,
|
||||||
|
transform: `translateX(${x}px) scale(${scale})`,
|
||||||
|
filter: `brightness(${brightness}) grayscale(${grayscale})`,
|
||||||
|
borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent",
|
||||||
|
boxShadow: showBorder
|
||||||
|
? "0 0 60px rgba(201,169,110,0.12)"
|
||||||
|
: "none",
|
||||||
|
transition: isDraggingRef.current
|
||||||
|
? "none"
|
||||||
|
: "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
|
isCenter: absDiff < 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="team" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
<section
|
||||||
|
id="team"
|
||||||
|
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||||||
|
>
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
|
||||||
|
{/* Stage spotlight glow */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 50% 70% at 50% 30%, rgba(201,169,110,0.07) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading centered>{team.title}</SectionHeading>
|
<SectionHeading centered>{team.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-10">
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<ShowcaseLayout<TeamMember>
|
<div className="mt-10">
|
||||||
items={team.members}
|
{/* Stage */}
|
||||||
activeIndex={activeIndex}
|
<div
|
||||||
onSelect={select}
|
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
|
||||||
onHoverChange={setHovering}
|
style={{ height: 440 }}
|
||||||
counter
|
onPointerDown={onPointerDown}
|
||||||
renderDetail={(member) => (
|
onPointerMove={onPointerMove}
|
||||||
<div className="relative aspect-[3/4] max-h-[600px] w-full overflow-hidden rounded-2xl">
|
onPointerUp={onPointerUp}
|
||||||
<Image
|
onPointerCancel={onPointerUp}
|
||||||
src={member.image}
|
onLostPointerCapture={onPointerUp}
|
||||||
alt={member.name}
|
>
|
||||||
fill
|
{/* Spotlight cone */}
|
||||||
className="object-cover photo-filter"
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-0"
|
||||||
|
style={{
|
||||||
|
width: 400,
|
||||||
|
height: 500,
|
||||||
|
background:
|
||||||
|
"conic-gradient(from 180deg at 50% 0%, transparent 30%, rgba(201,169,110,0.06) 45%, rgba(201,169,110,0.1) 50%, rgba(201,169,110,0.06) 55%, transparent 70%)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gradient overlay */}
|
{/* Cards */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
{team.members.map((m, i) => {
|
||||||
|
const style = getCardStyle(i);
|
||||||
|
if (!style) return null;
|
||||||
|
|
||||||
{/* Text over photo */}
|
return (
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6 sm:p-8">
|
<div
|
||||||
<h3 className="text-2xl font-bold text-white sm:text-3xl">
|
key={m.name}
|
||||||
{member.name}
|
className="absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none"
|
||||||
|
style={{
|
||||||
|
width: style.width,
|
||||||
|
height: style.height,
|
||||||
|
opacity: style.opacity,
|
||||||
|
zIndex: style.zIndex,
|
||||||
|
transform: style.transform,
|
||||||
|
filter: style.filter,
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
transition: style.transition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={m.image}
|
||||||
|
alt={m.name}
|
||||||
|
fill
|
||||||
|
sizes="280px"
|
||||||
|
className="object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{style.isCenter && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white sm:text-xl drop-shadow-lg">
|
||||||
|
{m.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm font-medium text-[#d4b87a]">
|
<p className="text-sm font-medium text-[#d4b87a] drop-shadow-lg">
|
||||||
{member.role}
|
{m.role}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member info */}
|
||||||
|
<div
|
||||||
|
key={activeIndex}
|
||||||
|
className="mx-auto mt-8 max-w-lg text-center"
|
||||||
|
style={{
|
||||||
|
animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{member.instagram && (
|
{member.instagram && (
|
||||||
<a
|
<a
|
||||||
href={member.instagram}
|
href={member.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-white/60 transition-colors hover:text-[#d4b87a]"
|
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-[#d4b87a]"
|
||||||
>
|
>
|
||||||
<Instagram size={14} />
|
<Instagram size={14} />
|
||||||
{member.instagram.split("/").filter(Boolean).pop()}
|
{member.instagram.split("/").filter(Boolean).pop()}
|
||||||
@@ -66,44 +275,29 @@ export function Team() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{member.description && (
|
{member.description && (
|
||||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
<p className="mt-3 text-sm leading-relaxed text-white/55">
|
||||||
{member.description}
|
{member.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
{/* Progress dots */}
|
||||||
)}
|
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||||
renderSelectorItem={(member, _i, isActive) => (
|
{team.members.map((_, i) => (
|
||||||
<div className="flex items-center gap-3 p-2.5 lg:p-3">
|
<button
|
||||||
{/* Thumbnail */}
|
key={i}
|
||||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg lg:h-14 lg:w-14">
|
onClick={() => goTo(i)}
|
||||||
<Image
|
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
||||||
src={member.image}
|
i === activeIndex
|
||||||
alt={member.name}
|
? "w-6 bg-[#c9a96e]"
|
||||||
fill
|
: "w-1.5 bg-white/15 hover:bg-white/30"
|
||||||
className="object-cover photo-filter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p
|
|
||||||
className={`text-sm font-semibold truncate transition-colors ${
|
|
||||||
isActive
|
|
||||||
? "text-[#c9a96e]"
|
|
||||||
: "text-neutral-700 dark:text-neutral-300"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
|
||||||
{member.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
|
||||||
{member.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user