From 233c117afa429c84c79b7c140efaaaf47b0ad8e1 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Tue, 10 Mar 2026 18:00:39 +0300 Subject: [PATCH] feat: team spotlight carousel with drag, auto-rotate, and progress dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/styles/animations.css | 13 ++ src/components/sections/Team.tsx | 352 ++++++++++++++++++++++++------- 2 files changed, 286 insertions(+), 79 deletions(-) diff --git a/src/app/styles/animations.css b/src/app/styles/animations.css index 445c44f..7c962a8 100644 --- a/src/app/styles/animations.css +++ b/src/app/styles/animations.css @@ -270,6 +270,19 @@ 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 { diff --git a/src/components/sections/Team.tsx b/src/components/sections/Team.tsx index 0a4a4bf..42ae310 100644 --- a/src/components/sections/Team.tsx +++ b/src/components/sections/Team.tsx @@ -1,108 +1,302 @@ "use client"; +import { useState, useRef, useCallback, useEffect } from "react"; import Image from "next/image"; import { Instagram } from "lucide-react"; import { siteContent } from "@/data/content"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; -import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; -import { useShowcaseRotation } from "@/hooks/useShowcaseRotation"; -import type { TeamMember } from "@/types"; + +const AUTO_PLAY_MS = 4500; +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() { const { team } = siteContent; - const { activeIndex, select, setHovering } = useShowcaseRotation({ - totalItems: team.members.length, - }); + const total = 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 ( -
+
+ {/* Stage spotlight glow */} +
+
{team.title} -
- - - items={team.members} - activeIndex={activeIndex} - onSelect={select} - onHoverChange={setHovering} - counter - renderDetail={(member) => ( -
- {member.name} + +
+ {/* Stage */} +
+ {/* Spotlight cone */} +
- {/* Gradient overlay */} -
+ {/* Cards */} + {team.members.map((m, i) => { + const style = getCardStyle(i); + if (!style) return null; - {/* Text over photo */} -
-

- {member.name} -

-

- {member.role} -

- - {member.instagram && ( - - - {member.instagram.split("/").filter(Boolean).pop()} - - )} - - {member.description && ( -

- {member.description} -

- )} -
-
- )} - renderSelectorItem={(member, _i, isActive) => ( -
- {/* Thumbnail */} -
+ return ( +
{member.name} + + {style.isCenter && ( + <> +
+
+

+ {m.name} +

+

+ {m.role} +

+
+ + )}
-
-

- {member.name} -

-

- {member.role} -

-
-
+ ); + })} + +
+ + {/* Member info */} + + + {member.description && ( +

+ {member.description} +

+ )} + + {/* Progress dots */} +
+ {team.members.map((_, i) => ( +
+
+
+
);