refactor: simplify team bio — replace complex achievements with simple list, remove experience
- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image) - Remove VictoryItemListField, DateRangeField, CityField and related helpers - Remove experience field from admin form and user profile (can be in bio text) - Simplify TeamProfile: remove victory tabs, show achievements as RichCards - Fix auto-save: snapshot comparison prevents false saves on focus/blur - Add save on tab leave (visibilitychange) and page close (sendBeacon) - Add save after image uploads (main photo, achievements, education) - Auto-migrate old VictoryItem data to RichListItem format in DB parser
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin } from "lucide-react";
|
||||
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
|
||||
interface TeamProfileProps {
|
||||
@@ -24,17 +24,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
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') ?? [];
|
||||
const victoryTabs = [
|
||||
...(places.length > 0 ? [{ key: 'place' as const, label: 'Достижения', icon: Trophy, items: places }] : []),
|
||||
...(nominations.length > 0 ? [{ key: 'nomination' as const, label: 'Номинации', icon: Award, items: nominations }] : []),
|
||||
...(judging.length > 0 ? [{ key: 'judge' as const, label: 'Судейство', icon: Scale, items: judging }] : []),
|
||||
];
|
||||
const hasVictories = victoryTabs.length > 0;
|
||||
const [activeTab, setActiveTab] = useState(victoryTabs[0]?.key ?? 'place');
|
||||
const hasExperience = member.experience && member.experience.length > 0;
|
||||
const hasVictories = member.victories && member.victories.length > 0;
|
||||
const hasEducation = member.education && member.education.length > 0;
|
||||
|
||||
// Extract trainer's groups from schedule using groupId
|
||||
@@ -98,7 +88,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
});
|
||||
const hasGroups = uniqueGroups.length > 0;
|
||||
|
||||
const hasBio = hasVictories || hasExperience || hasEducation || hasGroups;
|
||||
const hasBio = hasVictories || hasEducation || hasGroups;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -175,39 +165,18 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||||
</div>
|
||||
<div className="relative p-5 sm:p-6">
|
||||
{/* Victory tabs */}
|
||||
{/* Victories */}
|
||||
{hasVictories && (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{victoryTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||
activeTab === tab.key
|
||||
? "border-gold/30 bg-gold/10 text-gold"
|
||||
: "border-white/[0.08] bg-white/[0.03] text-white/40 hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={14} />
|
||||
{tab.label}
|
||||
<span className={`ml-0.5 text-xs ${activeTab === tab.key ? "text-gold/60" : "text-white/20"}`}>
|
||||
{tab.items.length}
|
||||
</span>
|
||||
</button>
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid mt-4" style={{ gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }}>
|
||||
{victoryTabs.map(tab => (
|
||||
<div key={tab.key} className={`col-start-1 row-start-1 ${activeTab === tab.key ? "" : "invisible"}`}>
|
||||
<ScrollRow>
|
||||
{tab.items.map((item, i) => (
|
||||
<VictoryCard key={i} victory={item} />
|
||||
))}
|
||||
</ScrollRow>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -270,23 +239,6 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience */}
|
||||
{hasExperience && (
|
||||
<div className={hasVictories || hasGroups || hasEducation ? "mt-8" : ""}>
|
||||
<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={15} />
|
||||
Опыт
|
||||
</span>
|
||||
<ScrollRow>
|
||||
{member.experience!.map((item, i) => (
|
||||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3">
|
||||
<p className="text-sm text-white/60">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</ScrollRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{member.description && (
|
||||
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
|
||||
@@ -386,40 +338,6 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VictoryCard({ victory }: { victory: VictoryItem }) {
|
||||
const hasLink = !!victory.link;
|
||||
|
||||
return (
|
||||
<div className="group w-44 shrink-0 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 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-4 pr-3 pb-3 space-y-1 ${victory.place ? "pt-6" : "py-3"}`}>
|
||||
{victory.category && (
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
|
||||
)}
|
||||
<p className="text-sm text-white/50">{victory.competition}</p>
|
||||
{hasLink && (
|
||||
<a
|
||||
href={victory.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
Подробнее
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (src: string) => void }) {
|
||||
const hasImage = !!item.image;
|
||||
const hasLink = !!item.link;
|
||||
|
||||
Reference in New Issue
Block a user