feat: improve trainer bio UX — reorder sections, collapsible, scroll arrows, card hover
- Reorder: Groups → Description → Education → Achievements - Education and Achievements are collapsible (collapsed by default) - Section headings now gold instead of gray - Scroll arrows (left/right) replace fade indicators, always visible - Bigger cards (w-60), wider image thumbnails - Card hover: gold border glow, brighter text, subtle shadow (user side) - Admin cards: hover highlight + focus-within gold border for active editing - Auto-add draft item on blur to prevent data loss
This commit is contained in:
@@ -391,6 +391,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
placeholder={placeholder || "Добавить..."}
|
placeholder={placeholder || "Добавить..."}
|
||||||
className={dashedInput}
|
className={dashedInput}
|
||||||
/>
|
/>
|
||||||
@@ -468,7 +469,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -515,6 +516,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
placeholder={placeholder || "Добавить..."}
|
placeholder={placeholder || "Добавить..."}
|
||||||
className={dashedInput}
|
className={dashedInput}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin } from "lucide-react";
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
|
||||||
@@ -164,32 +164,17 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative p-5 sm:p-6">
|
<div className="relative p-5 sm:p-6 space-y-6">
|
||||||
{/* Victories */}
|
{/* Groups — first, most actionable */}
|
||||||
{hasVictories && (
|
|
||||||
<div>
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
|
||||||
<Trophy size={14} />
|
|
||||||
Достижения
|
|
||||||
</span>
|
|
||||||
<ScrollRow>
|
|
||||||
{member.victories!.map((item, i) => (
|
|
||||||
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
|
||||||
))}
|
|
||||||
</ScrollRow>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Groups */}
|
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<div className={hasVictories ? "mt-8" : ""}>
|
<div>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
||||||
<Clock size={14} />
|
<Clock size={12} />
|
||||||
Группы
|
Группы
|
||||||
</span>
|
</h4>
|
||||||
<ScrollRow>
|
<ScrollRow>
|
||||||
{uniqueGroups.map((g, i) => (
|
{uniqueGroups.map((g, i) => (
|
||||||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{g.merged.map((m, mi) => (
|
{g.merged.map((m, mi) => (
|
||||||
@@ -224,26 +209,33 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Education */}
|
{/* Description */}
|
||||||
|
{member.description && (
|
||||||
|
<p className="text-sm leading-relaxed text-white/50">
|
||||||
|
{member.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Education — collapsible */}
|
||||||
{hasEducation && (
|
{hasEducation && (
|
||||||
<div className={hasVictories || hasGroups ? "mt-8" : ""}>
|
<CollapsibleSection icon={GraduationCap} title="Образование" count={member.education!.length}>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
|
||||||
<GraduationCap size={14} />
|
|
||||||
Образование
|
|
||||||
</span>
|
|
||||||
<ScrollRow>
|
<ScrollRow>
|
||||||
{member.education!.map((item, i) => (
|
{member.education!.map((item, i) => (
|
||||||
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||||||
))}
|
))}
|
||||||
</ScrollRow>
|
</ScrollRow>
|
||||||
</div>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Victories — collapsible */}
|
||||||
{member.description && (
|
{hasVictories && (
|
||||||
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
|
<CollapsibleSection icon={Trophy} title="Достижения" count={member.victories!.length}>
|
||||||
{member.description}
|
<ScrollRow>
|
||||||
</p>
|
{member.victories!.map((item, i) => (
|
||||||
|
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
@@ -296,10 +288,61 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-2 w-full text-left cursor-pointer group"
|
||||||
|
>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
||||||
|
<Icon size={12} />
|
||||||
|
{title}
|
||||||
|
<span className="text-gold/40">{count}</span>
|
||||||
|
</h4>
|
||||||
|
<ChevronDown size={14} className={`text-gold/40 transition-transform duration-200 group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScrollRow({ children }: { children: React.ReactNode }) {
|
function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
||||||
const wasDragged = useRef(false);
|
const wasDragged = useRef(false);
|
||||||
|
const [canScroll, setCanScroll] = useState({ left: false, right: false });
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScroll({
|
||||||
|
left: el.scrollLeft > 2,
|
||||||
|
right: el.scrollLeft < el.scrollWidth - el.clientWidth - 2,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(updateScrollState);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [updateScrollState]);
|
||||||
|
|
||||||
|
function scrollBy(dir: number) {
|
||||||
|
scrollRef.current?.scrollBy({ left: dir * 200, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -321,19 +364,37 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-4">
|
<div className="relative mt-3 group/scroll">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex items-stretch gap-3 overflow-x-auto pb-2 pt-4 cursor-grab active:cursor-grabbing select-none"
|
className="flex items-stretch gap-3 overflow-x-auto pb-2 cursor-grab active:cursor-grabbing select-none"
|
||||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
onLostPointerCapture={onPointerUp}
|
onLostPointerCapture={onPointerUp}
|
||||||
|
onScroll={updateScrollState}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Arrow buttons */}
|
||||||
|
{canScroll.left && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canScroll.right && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -344,27 +405,27 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
|||||||
|
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
return (
|
return (
|
||||||
<div className="group w-48 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
<div className="group w-60 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onImageClick(item.image!)}
|
onClick={() => onImageClick(item.image!)}
|
||||||
className="relative w-14 shrink-0 overflow-hidden cursor-pointer"
|
className="relative w-18 shrink-0 overflow-hidden cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={item.image!}
|
src={item.image!}
|
||||||
alt={item.text}
|
alt={item.text}
|
||||||
fill
|
fill
|
||||||
sizes="56px"
|
sizes="72px"
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
className="object-cover transition-transform group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0 p-2.5">
|
<div className="flex-1 min-w-0 p-3">
|
||||||
<p className="text-xs text-white/70">{item.text}</p>
|
<p className="text-sm text-white/70 group-hover:text-white/90 transition-colors">{item.text}</p>
|
||||||
{hasLink && (
|
{hasLink && (
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
className="mt-1.5 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink size={11} />
|
<ExternalLink size={11} />
|
||||||
Подробнее
|
Подробнее
|
||||||
@@ -376,9 +437,9 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group w-48 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
<div className="group w-60 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<p className="text-sm text-white/60">{item.text}</p>
|
<p className="text-sm text-white/60 group-hover:text-white/80 transition-colors">{item.text}</p>
|
||||||
{hasLink && (
|
{hasLink && (
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
|
|||||||
Reference in New Issue
Block a user