feat: split victories into tabbed sections (place/nomination/judge)
Add type field to VictoryItem, tabbed UI in trainer profile, and type dropdown in admin form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -657,80 +657,6 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Place/Nomination Select ---
|
|
||||||
const PLACE_OPTIONS = [
|
|
||||||
{ value: "1 место", label: "1 место", icon: "🥇" },
|
|
||||||
{ value: "2 место", label: "2 место", icon: "🥈" },
|
|
||||||
{ value: "3 место", label: "3 место", icon: "🥉" },
|
|
||||||
{ value: "4 место", label: "4 место" },
|
|
||||||
{ value: "5 место", label: "5 место" },
|
|
||||||
{ value: "6 место", label: "6 место" },
|
|
||||||
{ value: "Финалист", label: "Финалист", icon: "🏅" },
|
|
||||||
{ value: "Полуфиналист", label: "Полуфиналист" },
|
|
||||||
{ value: "Лауреат", label: "Лауреат", icon: "🏆" },
|
|
||||||
{ value: "Номинант", label: "Номинант" },
|
|
||||||
{ value: "Участник", label: "Участник" },
|
|
||||||
{ value: "Победитель", label: "Победитель", icon: "🏆" },
|
|
||||||
{ value: "Гран-при", label: "Гран-при", icon: "👑" },
|
|
||||||
{ value: "Best Show", label: "Best Show", icon: "⭐" },
|
|
||||||
{ value: "Vice Champion", label: "Vice Champion" },
|
|
||||||
{ value: "Champion", label: "Champion", icon: "🏆" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface PlaceSelectProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaceSelect({ value, onChange }: PlaceSelectProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handle(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handle);
|
|
||||||
return () => document.removeEventListener("mousedown", handle);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const selected = PLACE_OPTIONS.find((o) => o.value === value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-28 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className={`w-full rounded-md border bg-neutral-800 px-2 py-1.5 text-left text-sm transition-colors ${
|
|
||||||
open ? "border-gold" : "border-white/10"
|
|
||||||
} ${value ? "text-white" : "text-neutral-500"}`}
|
|
||||||
>
|
|
||||||
{selected ? `${selected.icon ? selected.icon + " " : ""}${selected.label}` : "Место..."}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute z-50 mt-1 w-44 rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
|
||||||
<div className="max-h-52 overflow-y-auto">
|
|
||||||
{PLACE_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
|
||||||
className={`w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-white/5 ${
|
|
||||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.icon && <span className="mr-1.5">{opt.icon}</span>}
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VictoryItemListFieldProps {
|
interface VictoryItemListFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
items: VictoryItem[];
|
items: VictoryItem[];
|
||||||
@@ -744,7 +670,7 @@ interface VictoryItemListFieldProps {
|
|||||||
|
|
||||||
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
||||||
function add() {
|
function add() {
|
||||||
onChange([...items, { place: "", category: "", competition: "" }]);
|
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(index: number) {
|
function remove(index: number) {
|
||||||
@@ -762,9 +688,21 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
|
|||||||
{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">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<PlaceSelect
|
<select
|
||||||
|
value={item.type || "place"}
|
||||||
|
onChange={(e) => update(i, "type", e.target.value)}
|
||||||
|
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
>
|
||||||
|
<option value="place">Место</option>
|
||||||
|
<option value="nomination">Номинация</option>
|
||||||
|
<option value="judge">Судейство</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
value={item.place || ""}
|
value={item.place || ""}
|
||||||
onChange={(v) => update(i, "place", v)}
|
onChange={(e) => update(i, "place", e.target.value)}
|
||||||
|
placeholder="1 место, финалист..."
|
||||||
|
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, MapPin, Calendar } from "lucide-react";
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, MapPin, Calendar, Award, Scale } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
import type { TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
interface TeamProfileProps {
|
interface TeamProfileProps {
|
||||||
@@ -10,7 +10,16 @@ interface TeamProfileProps {
|
|||||||
|
|
||||||
export function TeamProfile({ member, onBack }: TeamProfileProps) {
|
export function TeamProfile({ member, onBack }: TeamProfileProps) {
|
||||||
const [lightbox, setLightbox] = useState<string | null>(null);
|
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||||
const hasVictories = member.victories && member.victories.length > 0;
|
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 hasExperience = member.experience && member.experience.length > 0;
|
||||||
const hasEducation = member.education && member.education.length > 0;
|
const hasEducation = member.education && member.education.length > 0;
|
||||||
const hasBio = hasVictories || hasExperience || hasEducation;
|
const hasBio = hasVictories || hasExperience || hasEducation;
|
||||||
@@ -79,18 +88,37 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
|
|||||||
{/* Bio panel — overlaps photo edge on desktop */}
|
{/* 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="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="rounded-2xl border border-white/[0.08] bg-black/60 backdrop-blur-xl p-5 sm:p-6 shadow-2xl shadow-black/40">
|
||||||
{/* Victories */}
|
{/* Victory tabs */}
|
||||||
{hasVictories && (
|
{hasVictories && (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Trophy size={14} />
|
{victoryTabs.map(tab => (
|
||||||
Достижения
|
<button
|
||||||
</span>
|
key={tab.key}
|
||||||
<div className="mt-4 flex flex-col sm:flex-row sm:flex-wrap gap-2.5">
|
onClick={() => setActiveTab(tab.key)}
|
||||||
{member.victories!.map((item, i) => (
|
className={`inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
<VictoryCard key={i} victory={item} onImageClick={setLightbox} />
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{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">
|
||||||
|
{tab.items.map((item, i) => (
|
||||||
|
<VictoryCard key={i} victory={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -174,72 +202,39 @@ export function TeamProfile({ member, onBack }: TeamProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VictoryCard({ victory, onImageClick }: { victory: VictoryItem; onImageClick: (src: string) => void }) {
|
function VictoryCard({ victory }: { victory: VictoryItem }) {
|
||||||
const hasImage = !!victory.image;
|
|
||||||
const hasLink = !!victory.link;
|
const hasLink = !!victory.link;
|
||||||
|
|
||||||
if (hasImage) {
|
|
||||||
return (
|
|
||||||
<div className="group w-full sm:w-44 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => onImageClick(victory.image!)}
|
|
||||||
className="relative w-full aspect-square overflow-hidden cursor-pointer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={victory.image!}
|
|
||||||
alt={victory.competition}
|
|
||||||
fill
|
|
||||||
sizes="176px"
|
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-black/80 backdrop-blur-sm p-2.5 space-y-0.5">
|
|
||||||
{victory.place && (
|
|
||||||
<p className="text-sm font-bold text-gold">{victory.place}</p>
|
|
||||||
)}
|
|
||||||
{victory.category && (
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-white">{victory.category}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-white/80">{victory.competition}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group w-full sm:w-56 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
<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="p-2.5 space-y-0.5">
|
<div className="absolute top-0 left-0 w-1 h-full bg-gold/40 rounded-l-xl" />
|
||||||
{victory.place && (
|
{victory.place && (
|
||||||
<p className="text-lg font-bold text-gold">{victory.place}</p>
|
<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%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{victory.place}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`pl-3.5 pr-2.5 pb-2 space-y-0.5 ${victory.place ? "pt-6" : "py-2"}`}>
|
||||||
{victory.category && (
|
{victory.category && (
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-white/80">{victory.category}</p>
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
|
||||||
)}
|
|
||||||
<p className="text-sm text-white/50">{victory.competition}</p>
|
|
||||||
{(victory.location || victory.date) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-0.5">
|
|
||||||
{victory.location && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-white/30">
|
|
||||||
<MapPin size={10} />
|
|
||||||
{victory.location}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{victory.date && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-white/30">
|
|
||||||
<Calendar size={10} />
|
|
||||||
{victory.date}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-white/50">{victory.competition}</p>
|
||||||
{hasLink && (
|
{hasLink && (
|
||||||
<a
|
<a
|
||||||
href={victory.link}
|
href={victory.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-0.5 inline-flex items-center gap-1 text-[10px] text-gold/70 hover:text-gold transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink size={11} />
|
<ExternalLink size={10} />
|
||||||
Подробнее
|
Подробнее
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface RichListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VictoryItem {
|
export interface VictoryItem {
|
||||||
|
type?: 'place' | 'nomination' | 'judge';
|
||||||
place: string;
|
place: string;
|
||||||
category: string;
|
category: string;
|
||||||
competition: string;
|
competition: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user