feat: structured victories/education with photos, links, and editorial profile layout

- Add VictoryItem type (place, category, competition, location, date, image, link)
- Add RichListItem type for education with image/link support
- Backward-compatible DB parsing for old string[] formats
- Admin forms with structured fields and image upload per item
- Victory/education cards with photo overlay and lightbox
- Remove max-width constraint from trainer profile for full-width layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:34:30 +03:00
parent 921d10800b
commit 4918184852
10 changed files with 627 additions and 106 deletions

View File

@@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { Plus, X } from "lucide-react";
import { Plus, X, Upload, Loader2, Link, ImageIcon } from "lucide-react";
import type { RichListItem, VictoryItem } from "@/types/content";
interface InputFieldProps {
label: string;
@@ -104,12 +105,10 @@ export function SelectField({
const filtered = search
? options.filter((o) => {
const q = search.toLowerCase();
// Match any word that starts with the search query
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
})
: options;
// Close on outside click
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
@@ -188,7 +187,6 @@ interface TimeRangeFieldProps {
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
// Parse "HH:MMHH:MM" into start and end
const parts = value.split("");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
@@ -204,7 +202,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
}
function handleStartChange(newStart: string) {
// Reset end if start >= end
if (newStart && end && newStart >= end) {
update(newStart, "");
} else {
@@ -213,7 +210,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
}
function handleEndChange(newEnd: string) {
// Ignore if end <= start
if (start && newEnd && newEnd <= start) return;
update(start, newEnd);
}
@@ -339,3 +335,265 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
</div>
);
}
interface VictoryListFieldProps {
label: string;
items: RichListItem[];
onChange: (items: RichListItem[]) => void;
placeholder?: string;
}
export function VictoryListField({ label, items, onChange, placeholder }: VictoryListFieldProps) {
const [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, { text: val }]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function updateText(index: number, text: string) {
onChange(items.map((item, i) => (i === index ? { ...item, text } : item)));
}
function updateLink(index: number, link: string) {
onChange(items.map((item, i) => (i === index ? { ...item, link: link || undefined } : item)));
}
function removeImage(index: number) {
onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item)));
}
async function handleUpload(index: number, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadingIndex(index);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
}
} catch { /* upload failed */ } finally {
setUploadingIndex(null);
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-3 space-y-2">
<div className="flex items-center gap-2">
<input
type="text"
value={item.text}
onChange={(e) => updateText(i, e.target.value)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
<div className="flex items-center gap-2 pl-1">
{item.image ? (
<div className="flex items-center gap-1.5 rounded-md bg-neutral-700/50 px-2 py-1 text-xs text-neutral-300">
<ImageIcon size={12} className="text-gold" />
<span className="max-w-[120px] truncate">{item.image.split("/").pop()}</span>
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
<X size={10} />
</button>
</div>
) : (
<label className="flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs text-neutral-500 hover:text-neutral-300 transition-colors">
{uploadingIndex === i ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{uploadingIndex === i ? "Загрузка..." : "Фото"}
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
</label>
)}
<div className="flex items-center gap-1.5">
<Link size={12} className="text-neutral-500" />
<input
type="url"
value={item.link || ""}
onChange={(e) => updateLink(i, e.target.value)}
placeholder="Ссылка..."
className="w-48 rounded-md border border-white/5 bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
/>
</div>
</div>
</div>
))}
<div className="flex items-center gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
placeholder={placeholder || "Добавить..."}
className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
/>
<button
type="button"
onClick={add}
disabled={!draft.trim()}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
>
<Plus size={14} />
</button>
</div>
</div>
</div>
);
}
interface VictoryItemListFieldProps {
label: string;
items: VictoryItem[];
onChange: (items: VictoryItem[]) => void;
}
export function VictoryItemListField({ label, items, onChange }: VictoryItemListFieldProps) {
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
function add() {
onChange([...items, { place: "", category: "", competition: "" }]);
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, field: keyof VictoryItem, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
}
function removeImage(index: number) {
onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item)));
}
async function handleUpload(index: number, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadingIndex(index);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
}
} catch { /* upload failed */ } finally {
setUploadingIndex(null);
}
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={item.place || ""}
onChange={(e) => update(i, "place", e.target.value)}
placeholder="Место (🥇, 1 место...)"
className="w-32 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
placeholder="Категория (Exotic Semi-Pro...)"
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
<input
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
placeholder="Чемпионат (REVOLUTION 2025...)"
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<div className="flex gap-2">
<input
type="text"
value={item.location || ""}
onChange={(e) => update(i, "location", e.target.value)}
placeholder="Город, страна"
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.date || ""}
onChange={(e) => update(i, "date", e.target.value)}
placeholder="Дата (22-23.02.2025)"
className="w-40 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
</div>
<div className="flex items-center gap-2 pl-1">
{item.image ? (
<div className="flex items-center gap-1.5 rounded-md bg-neutral-700/50 px-2 py-1 text-xs text-neutral-300">
<ImageIcon size={12} className="text-gold" />
<span className="max-w-[120px] truncate">{item.image.split("/").pop()}</span>
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
<X size={10} />
</button>
</div>
) : (
<label className="flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-xs text-neutral-500 hover:text-neutral-300 transition-colors">
{uploadingIndex === i ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{uploadingIndex === i ? "Загрузка..." : "Фото"}
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
</label>
)}
<div className="flex items-center gap-1.5">
<Link size={12} className="text-neutral-500" />
<input
type="url"
value={item.link || ""}
onChange={(e) => update(i, "link", e.target.value)}
placeholder="Ссылка..."
className="w-48 rounded-md border border-white/5 bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
/>
</div>
</div>
</div>
))}
<button
type="button"
onClick={add}
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={14} />
Добавить достижение
</button>
</div>
</div>
);
}