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:
2026-03-25 22:53:30 +03:00
parent 4d90785c5b
commit e4cb38c409
15 changed files with 92 additions and 460 deletions

View File

@@ -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;