feat: upgrade bio panel design, clickable carousel cards, Escape navigation

- Widen layout (max-w-5xl), larger photo column
- Fix place badge: clean pill instead of clipped diamond
- Increase victory card text sizes for readability
- Cards fill available width instead of fixed size
- Move back button above photo
- Add Escape key: closes lightbox or returns to gallery
- Clicking inactive carousel photos scrolls to them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 15:40:06 +03:00
parent 1b391cdde6
commit d4751975d2
2 changed files with 63 additions and 33 deletions

View File

@@ -49,6 +49,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
const total = members.length;
const [dragOffset, setDragOffset] = useState(0);
const isDraggingRef = useRef(false);
const wasDragRef = useRef(false);
const pausedUntilRef = useRef(0);
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
@@ -76,6 +77,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
(e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
isDraggingRef.current = true;
wasDragRef.current = false;
dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
setDragOffset(0);
},
@@ -86,6 +88,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
(e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
if (Math.abs(dx) > 10) wasDragRef.current = true;
setDragOffset(dx);
},
[]
@@ -194,7 +197,13 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
return (
<div
key={m.name}
className={`absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none ${style.isCenter ? "team-card-glitter" : ""}`}
onClick={() => {
if (!style.isCenter && !wasDragRef.current) {
onActiveChange(i);
pausedUntilRef.current = Date.now() + PAUSE_MS;
}
}}
className={`absolute bottom-0 overflow-hidden rounded-2xl border ${style.isCenter ? "team-card-glitter" : "cursor-pointer"} pointer-events-auto`}
style={{
width: style.width,
height: style.height,

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, MapPin, Calendar, Award, Scale } from "lucide-react";
import type { TeamMember, RichListItem, VictoryItem } from "@/types/content";
@@ -10,6 +10,17 @@ interface TeamProfileProps {
export function TeamProfile({ member, onBack }: TeamProfileProps) {
const [lightbox, setLightbox] = useState<string | null>(null);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (lightbox) setLightbox(null);
else onBack();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightbox, onBack]);
const places = member.victories?.filter(v => !v.type || v.type === 'place') ?? [];
const nominations = member.victories?.filter(v => v.type === 'nomination') ?? [];
const judging = member.victories?.filter(v => v.type === 'judge') ?? [];
@@ -29,19 +40,17 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
className="w-full"
style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }}
>
{/* Back button */}
{/* Magazine editorial layout */}
<div className="relative mx-auto max-w-5xl flex flex-col sm:flex-row sm:items-start">
{/* Photo — left column, sticky */}
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
<button
onClick={onBack}
className="mb-6 inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light cursor-pointer"
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer"
>
<ArrowLeft size={16} />
<ArrowLeft size={14} />
Назад
</button>
{/* Magazine editorial layout */}
<div className="relative mx-auto max-w-4xl flex flex-col sm:flex-row sm:items-start">
{/* Photo — left column, sticky */}
<div className="relative shrink-0 w-full sm:w-[340px] lg:w-[380px] sm:sticky sm:top-8">
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
<Image
src={member.image}
@@ -87,7 +96,20 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
{/* Bio panel — overlaps photo edge on desktop */}
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
<div className="rounded-2xl border border-white/[0.08] bg-black/60 backdrop-blur-xl p-5 sm:p-6 shadow-2xl shadow-black/40">
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40">
{/* Ambient photo background */}
<div className="absolute inset-0">
<Image
src={member.image}
alt=""
fill
sizes="600px"
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
/>
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
</div>
<div className="relative p-5 sm:p-6">
{/* Victory tabs */}
{hasVictories && (
<div>
@@ -110,16 +132,21 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
</button>
))}
</div>
<div className="grid mt-5 pt-1" style={{ gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }}>
{victoryTabs.map(tab => (
activeTab === tab.key && (
<div key={tab.key} className="mt-5 pt-1 flex flex-col sm:flex-row sm:flex-wrap gap-4">
<div
key={tab.key}
className={`flex flex-col sm:flex-row sm:flex-wrap gap-4 col-start-1 row-start-1 ${
activeTab === tab.key ? "" : "invisible"
}`}
>
{tab.items.map((item, i) => (
<VictoryCard key={i} victory={item} />
))}
</div>
)
))}
</div>
</div>
)}
{/* Education */}
@@ -174,6 +201,7 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
</div>
</div>
</div>
</div>
{/* Image lightbox */}
{lightbox && (
@@ -206,33 +234,26 @@ function VictoryCard({ victory }: { victory: VictoryItem }) {
const hasLink = !!victory.link;
return (
<div className="group w-full sm:w-44 shrink-0 rounded-xl border border-white/[0.08] overflow-visible bg-white/[0.03] relative">
<div className="group w-full sm:flex-1 sm:min-w-[180px] rounded-xl border border-white/[0.08] overflow-visible bg-white/[0.03] relative">
<div className="absolute top-0 left-0 w-1 h-full bg-gold/40 rounded-l-xl" />
{victory.place && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span
className="inline-block px-4 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-gold whitespace-nowrap"
style={{
background: "linear-gradient(135deg, rgba(201,169,110,0.12), rgba(201,169,110,0.25), rgba(201,169,110,0.12))",
border: "1px solid rgba(201,169,110,0.35)",
clipPath: "polygon(8px 0%, calc(100% - 8px) 0%, 100% 50%, calc(100% - 8px) 100%, 8px 100%, 0% 50%)",
}}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span className="inline-block rounded-full border border-gold/40 bg-gold/20 px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-gold whitespace-nowrap backdrop-blur-sm">
{victory.place}
</span>
</div>
)}
<div className={`pl-3.5 pr-2.5 pb-2 space-y-0.5 ${victory.place ? "pt-6" : "py-2"}`}>
<div className={`pl-4 pr-3 pb-3 space-y-1 ${victory.place ? "pt-6" : "py-3"}`}>
{victory.category && (
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
)}
<p className="text-xs text-white/50">{victory.competition}</p>
<p className="text-sm text-white/50">{victory.competition}</p>
{hasLink && (
<a
href={victory.link}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-gold/70 hover:text-gold transition-colors"
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
>
<ExternalLink size={10} />
Подробнее