feat: replace free-text place input with dropdown select in victories
Predefined options: 1-6 место, Финалист, Полуфиналист, Лауреат, Номинант, Участник, Победитель, Гран-при, Best Show, Vice Champion, Champion — with emoji indicators for top places and nominations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -657,6 +657,80 @@ 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[];
|
||||||
@@ -669,8 +743,6 @@ 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) {
|
||||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
function add() {
|
function add() {
|
||||||
onChange([...items, { place: "", category: "", competition: "" }]);
|
onChange([...items, { place: "", category: "", competition: "" }]);
|
||||||
}
|
}
|
||||||
@@ -683,28 +755,6 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
|
|||||||
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<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>
|
||||||
@@ -712,12 +762,9 @@ 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">
|
||||||
<input
|
<PlaceSelect
|
||||||
type="text"
|
|
||||||
value={item.place || ""}
|
value={item.place || ""}
|
||||||
onChange={(e) => update(i, "place", e.target.value)}
|
onChange={(v) => update(i, "place", v)}
|
||||||
placeholder="Место (🥇, 1...)"
|
|
||||||
className="w-24 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"
|
||||||
@@ -755,29 +802,12 @@ export function VictoryItemListField({ label, items, onChange, cityErrors, cityS
|
|||||||
onChange={(v) => update(i, "date", v)}
|
onChange={(v) => update(i, "date", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<ValidatedLinkField
|
||||||
{item.image ? (
|
value={item.link || ""}
|
||||||
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
onChange={(v) => update(i, "link", v)}
|
||||||
<ImageIcon size={10} className="text-gold" />
|
validationKey={`victory-${i}`}
|
||||||
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
onValidate={onLinkValidate}
|
||||||
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
/>
|
||||||
<X size={9} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors">
|
|
||||||
{uploadingIndex === i ? <Loader2 size={10} className="animate-spin" /> : <Upload size={10} />}
|
|
||||||
{uploadingIndex === i ? "..." : "Фото"}
|
|
||||||
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<ValidatedLinkField
|
|
||||||
value={item.link || ""}
|
|
||||||
onChange={(v) => update(i, "link", v)}
|
|
||||||
validationKey={`victory-${i}`}
|
|
||||||
onValidate={onLinkValidate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user