Compare commits
2 Commits
4d90785c5b
...
24d48a9409
| Author | SHA1 | Date | |
|---|---|---|---|
| 24d48a9409 | |||
| e4cb38c409 |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, useEffect, useState, useMemo } from "react";
|
import { useRef, useEffect, useState, useMemo } from "react";
|
||||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -391,6 +391,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
placeholder={placeholder || "Добавить..."}
|
placeholder={placeholder || "Добавить..."}
|
||||||
className={dashedInput}
|
className={dashedInput}
|
||||||
/>
|
/>
|
||||||
@@ -414,9 +415,10 @@ interface VictoryListFieldProps {
|
|||||||
onChange: (items: RichListItem[]) => void;
|
onChange: (items: RichListItem[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onLinkValidate?: (key: string, error: string | null) => void;
|
onLinkValidate?: (key: string, error: string | null) => void;
|
||||||
|
onUploadComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
|
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -455,6 +457,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
|
onUploadComplete?.();
|
||||||
}
|
}
|
||||||
} catch { /* upload failed */ } finally {
|
} catch { /* upload failed */ } finally {
|
||||||
setUploadingIndex(null);
|
setUploadingIndex(null);
|
||||||
@@ -466,7 +469,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{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 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -513,6 +516,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
placeholder={placeholder || "Добавить..."}
|
placeholder={placeholder || "Добавить..."}
|
||||||
className={dashedInput}
|
className={dashedInput}
|
||||||
/>
|
/>
|
||||||
@@ -530,151 +534,6 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Date Range Picker ---
|
|
||||||
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
|
|
||||||
function parseDateRange(value: string): { start: string; end: string } {
|
|
||||||
if (!value) return { start: "", end: "" };
|
|
||||||
|
|
||||||
// "22-23.02.2025" → same month range
|
|
||||||
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
|
||||||
if (sameMonth) {
|
|
||||||
const [, d1, d2, m, y] = sameMonth;
|
|
||||||
return {
|
|
||||||
start: `${y}-${m}-${d1.padStart(2, "0")}`,
|
|
||||||
end: `${y}-${m}-${d2.padStart(2, "0")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// "22.02-01.03.2025" → cross-month range
|
|
||||||
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
|
||||||
if (crossMonth) {
|
|
||||||
const [, d1, m1, d2, m2, y] = crossMonth;
|
|
||||||
return {
|
|
||||||
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
|
|
||||||
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// "22.02.2025" → single date
|
|
||||||
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
|
||||||
if (single) {
|
|
||||||
const [, d, m, y] = single;
|
|
||||||
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
|
|
||||||
return { start: iso, end: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start: "", end: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateRange(start: string, end: string): string {
|
|
||||||
if (!start) return "";
|
|
||||||
const [sy, sm, sd] = start.split("-");
|
|
||||||
if (!end) return `${sd}.${sm}.${sy}`;
|
|
||||||
const [ey, em, ed] = end.split("-");
|
|
||||||
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
|
|
||||||
return `${sd}.${sm}-${ed}.${em}.${ey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DateRangeFieldProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
|
|
||||||
const { start, end } = parseDateRange(value);
|
|
||||||
|
|
||||||
function handleChange(s: string, e: string) {
|
|
||||||
onChange(formatDateRange(s, e));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Calendar size={11} className="text-neutral-500 shrink-0" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={start}
|
|
||||||
onChange={(e) => handleChange(e.target.value, end)}
|
|
||||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
|
||||||
/>
|
|
||||||
<span className="text-neutral-500 text-xs">—</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={end}
|
|
||||||
min={start}
|
|
||||||
onChange={(e) => handleChange(start, e.target.value)}
|
|
||||||
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- City Autocomplete Field ---
|
|
||||||
interface CityFieldProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onSearch?: (query: string) => void;
|
|
||||||
suggestions?: string[];
|
|
||||||
onSelectSuggestion?: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!focused) return;
|
|
||||||
function handle(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setFocused(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handle);
|
|
||||||
return () => document.removeEventListener("mousedown", handle);
|
|
||||||
}, [focused]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative flex-1">
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e.target.value);
|
|
||||||
onSearch?.(e.target.value);
|
|
||||||
}}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
placeholder="Город, страна"
|
|
||||||
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
|
|
||||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
|
|
||||||
</div>
|
|
||||||
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
|
||||||
{focused && suggestions && suggestions.length > 0 && (
|
|
||||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
|
||||||
{suggestions.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
onSelectSuggestion?.(s);
|
|
||||||
setFocused(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Link Field with Validation ---
|
// --- Link Field with Validation ---
|
||||||
interface ValidatedLinkFieldProps {
|
interface ValidatedLinkFieldProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -729,110 +588,6 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VictoryItemListFieldProps {
|
|
||||||
label: string;
|
|
||||||
items: VictoryItem[];
|
|
||||||
onChange: (items: VictoryItem[]) => void;
|
|
||||||
cityErrors?: Record<number, string>;
|
|
||||||
citySuggestions?: { index: number; items: string[] } | null;
|
|
||||||
onCitySearch?: (index: number, query: string) => void;
|
|
||||||
onCitySelect?: (index: number, value: string) => void;
|
|
||||||
onLinkValidate?: (key: string, error: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
|
||||||
function add() {
|
|
||||||
onChange([...items, { type: "place", 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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
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-2.5 space-y-1.5">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<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 || ""}
|
|
||||||
onChange={(e) => update(i, "place", e.target.value)}
|
|
||||||
placeholder="1 место, финалист..."
|
|
||||||
className={`w-28 shrink-0 ${smallInput}`}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.category || ""}
|
|
||||||
onChange={(e) => update(i, "category", e.target.value)}
|
|
||||||
placeholder="Категория"
|
|
||||||
className={`flex-1 ${smallInput}`}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={item.competition || ""}
|
|
||||||
onChange={(e) => update(i, "competition", e.target.value)}
|
|
||||||
placeholder="Чемпионат"
|
|
||||||
className={`flex-1 ${smallInput}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<CityField
|
|
||||||
value={item.location || ""}
|
|
||||||
onChange={(v) => update(i, "location", v)}
|
|
||||||
error={cityErrors?.[i]}
|
|
||||||
onSearch={(q) => onCitySearch?.(i, q)}
|
|
||||||
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
|
|
||||||
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
|
|
||||||
/>
|
|
||||||
<DateRangeField
|
|
||||||
value={item.date || ""}
|
|
||||||
onChange={(v) => update(i, "date", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ValidatedLinkField
|
|
||||||
value={item.link || ""}
|
|
||||||
onChange={(v) => update(i, "link", v)}
|
|
||||||
validationKey={`victory-${i}`}
|
|
||||||
onValidate={onLinkValidate}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Autocomplete Multi-Select ---
|
// --- Autocomplete Multi-Select ---
|
||||||
export function AutocompleteMulti({
|
export function AutocompleteMulti({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||||
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField, AutocompleteMulti } from "../../_components/FormField";
|
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
|
||||||
import { useToast } from "../../_components/Toast";
|
import { useToast } from "../../_components/Toast";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
function extractUsername(value: string): string {
|
function extractUsername(value: string): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
@@ -23,8 +23,7 @@ interface MemberForm {
|
|||||||
instagram: string;
|
instagram: string;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
description: string;
|
description: string;
|
||||||
experience: string[];
|
victories: RichListItem[];
|
||||||
victories: VictoryItem[];
|
|
||||||
education: RichListItem[];
|
education: RichListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ function TeamMemberEditor() {
|
|||||||
instagram: "",
|
instagram: "",
|
||||||
shortDescription: "",
|
shortDescription: "",
|
||||||
description: "",
|
description: "",
|
||||||
experience: [],
|
|
||||||
victories: [],
|
victories: [],
|
||||||
education: [],
|
education: [],
|
||||||
});
|
});
|
||||||
@@ -78,43 +76,6 @@ function TeamMemberEditor() {
|
|||||||
// Link validation for bio
|
// Link validation for bio
|
||||||
const [linkErrors, setLinkErrors] = useState<Record<string, string>>({});
|
const [linkErrors, setLinkErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
function validateUrl(url: string): boolean {
|
|
||||||
if (!url) return true;
|
|
||||||
try { new URL(url); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// City validation for victories
|
|
||||||
const [cityErrors, setCityErrors] = useState<Record<number, string>>({});
|
|
||||||
const [citySuggestions, setCitySuggestions] = useState<{ index: number; items: string[] } | null>(null);
|
|
||||||
const cityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const searchCity = useCallback((index: number, query: string) => {
|
|
||||||
if (cityTimerRef.current) clearTimeout(cityTimerRef.current);
|
|
||||||
if (!query || query.length < 2) { setCitySuggestions(null); return; }
|
|
||||||
cityTimerRef.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&accept-language=ru`,
|
|
||||||
{ headers: { "User-Agent": "BlackheartAdmin/1.0" } }
|
|
||||||
);
|
|
||||||
const results = await res.json();
|
|
||||||
const cities = results
|
|
||||||
.map((r: Record<string, unknown>) => {
|
|
||||||
const addr = r.address as Record<string, string> | undefined;
|
|
||||||
const city = addr?.city || addr?.town || addr?.village || addr?.state || (r.name as string);
|
|
||||||
const country = addr?.country || "";
|
|
||||||
return country ? `${city}, ${country}` : city;
|
|
||||||
})
|
|
||||||
.filter((v: string, i: number, a: string[]) => a.indexOf(v) === i)
|
|
||||||
.slice(0, 6);
|
|
||||||
setCitySuggestions(cities.length > 0 ? { index, items: cities } : null);
|
|
||||||
setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; });
|
|
||||||
} catch {
|
|
||||||
setCitySuggestions(null);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch class styles for role autocomplete
|
// Fetch class styles for role autocomplete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch("/api/admin/sections/classes")
|
adminFetch("/api/admin/sections/classes")
|
||||||
@@ -131,50 +92,75 @@ function TeamMemberEditor() {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((member) => {
|
.then((member) => {
|
||||||
const username = extractUsername(member.instagram || "");
|
const username = extractUsername(member.instagram || "");
|
||||||
setData({
|
const loaded = {
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
image: member.image,
|
image: member.image,
|
||||||
instagram: username,
|
instagram: username,
|
||||||
shortDescription: member.shortDescription || "",
|
shortDescription: member.shortDescription || "",
|
||||||
description: member.description || "",
|
description: member.description || "",
|
||||||
experience: member.experience || [],
|
|
||||||
victories: member.victories || [],
|
victories: member.victories || [],
|
||||||
education: member.education || [],
|
education: member.education || [],
|
||||||
});
|
};
|
||||||
|
setData(loaded);
|
||||||
|
lastSavedRef.current = JSON.stringify(loaded);
|
||||||
if (username) setIgStatus("valid"); // existing data is trusted
|
if (username) setIgStatus("valid"); // existing data is trusted
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id, isNew]);
|
}, [id, isNew]);
|
||||||
|
|
||||||
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
|
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0;
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const lastSavedRef = useRef("");
|
||||||
const initialLoadRef = useRef(true);
|
const dataRef = useRef(data);
|
||||||
|
dataRef.current = data;
|
||||||
|
|
||||||
// Auto-save with 800ms debounce (existing members only)
|
// Shared save logic — compares snapshot, skips if unchanged
|
||||||
|
const saveIfDirty = useCallback(async () => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
const d = dataRef.current;
|
||||||
|
const snapshot = JSON.stringify(d);
|
||||||
|
if (snapshot === lastSavedRef.current) return;
|
||||||
|
if (!d.name || !d.role) return;
|
||||||
|
lastSavedRef.current = snapshot;
|
||||||
|
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||||
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) showSuccess("Сохранено");
|
||||||
|
else showError("Ошибка сохранения");
|
||||||
|
}, [isNew, loading, id, showSuccess, showError]);
|
||||||
|
|
||||||
|
// Save when tab loses focus or user navigates away
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew || loading || initialLoadRef.current) {
|
if (isNew || loading) return;
|
||||||
initialLoadRef.current = false;
|
const onVisibilityChange = () => { if (document.hidden) saveIfDirty(); };
|
||||||
return;
|
const onBeforeUnload = () => {
|
||||||
|
const d = dataRef.current;
|
||||||
|
const snapshot = JSON.stringify(d);
|
||||||
|
if (snapshot === lastSavedRef.current || !d.name || !d.role) return;
|
||||||
|
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||||
|
navigator.sendBeacon(`/api/admin/team/${id}`, JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [isNew, loading, id, saveIfDirty]);
|
||||||
|
|
||||||
|
// Save on blur (when user leaves any field)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
function handleBlur() {
|
||||||
|
setTimeout(() => saveIfDirty(), 300);
|
||||||
}
|
}
|
||||||
if (hasErrors || !data.name || !data.role) return;
|
document.addEventListener("focusout", handleBlur);
|
||||||
|
return () => document.removeEventListener("focusout", handleBlur);
|
||||||
|
}, [isNew, loading, saveIfDirty]);
|
||||||
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
saveTimerRef.current = setTimeout(async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" };
|
|
||||||
const res = await adminFetch(`/api/admin/team/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (res.ok) showSuccess("Сохранено");
|
|
||||||
else showError("Ошибка сохранения");
|
|
||||||
setSaving(false);
|
|
||||||
}, 800);
|
|
||||||
|
|
||||||
return () => clearTimeout(saveTimerRef.current);
|
|
||||||
}, [data, isNew, loading, hasErrors, id]);
|
|
||||||
|
|
||||||
// Manual save for new members
|
// Manual save for new members
|
||||||
async function handleSaveNew() {
|
async function handleSaveNew() {
|
||||||
@@ -207,6 +193,7 @@ function TeamMemberEditor() {
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
setData((prev) => ({ ...prev, image: result.path }));
|
setData((prev) => ({ ...prev, image: result.path }));
|
||||||
|
setTimeout(saveIfDirty, 100);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Upload failed silently
|
// Upload failed silently
|
||||||
@@ -365,31 +352,18 @@ function TeamMemberEditor() {
|
|||||||
<div className="border-t border-white/5 pt-4 mt-4">
|
<div className="border-t border-white/5 pt-4 mt-4">
|
||||||
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
|
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ListField
|
<VictoryListField
|
||||||
label="Опыт"
|
|
||||||
items={data.experience}
|
|
||||||
onChange={(items) => setData({ ...data, experience: items })}
|
|
||||||
placeholder="Например: 10 лет в танцах"
|
|
||||||
/>
|
|
||||||
<VictoryItemListField
|
|
||||||
label="Достижения"
|
label="Достижения"
|
||||||
items={data.victories}
|
items={data.victories}
|
||||||
onChange={(items) => setData({ ...data, victories: items })}
|
onChange={(items) => setData({ ...data, victories: items })}
|
||||||
cityErrors={cityErrors}
|
placeholder="Например: 1 место, Чемпионат Беларуси 2024"
|
||||||
citySuggestions={citySuggestions}
|
|
||||||
onCitySearch={searchCity}
|
|
||||||
onCitySelect={(i, v) => {
|
|
||||||
const updated = data.victories.map((item, idx) => idx === i ? { ...item, location: v } : item);
|
|
||||||
setData({ ...data, victories: updated });
|
|
||||||
setCitySuggestions(null);
|
|
||||||
setCityErrors((prev) => { const n = { ...prev }; delete n[i]; return n; });
|
|
||||||
}}
|
|
||||||
onLinkValidate={(key, error) => {
|
onLinkValidate={(key, error) => {
|
||||||
setLinkErrors((prev) => {
|
setLinkErrors((prev) => {
|
||||||
if (error) return { ...prev, [key]: error };
|
if (error) return { ...prev, [key]: error };
|
||||||
const n = { ...prev }; delete n[key]; return n;
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||||
/>
|
/>
|
||||||
<VictoryListField
|
<VictoryListField
|
||||||
label="Образование"
|
label="Образование"
|
||||||
@@ -402,6 +376,7 @@ function TeamMemberEditor() {
|
|||||||
const n = { ...prev }; delete n[key]; return n;
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getTeamMembers, createTeamMember } from "@/lib/db";
|
import { getTeamMembers, createTeamMember } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const members = getTeamMembers();
|
const members = getTeamMembers();
|
||||||
@@ -17,8 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
image: string;
|
image: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
experience?: string[];
|
victories?: RichListItem[];
|
||||||
victories?: VictoryItem[];
|
|
||||||
education?: RichListItem[];
|
education?: RichListItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
|
||||||
interface TeamProfileProps {
|
interface TeamProfileProps {
|
||||||
@@ -24,17 +24,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [lightbox, onBack]);
|
}, [lightbox, onBack]);
|
||||||
const places = member.victories?.filter(v => !v.type || v.type === 'place') ?? [];
|
const hasVictories = member.victories && member.victories.length > 0;
|
||||||
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 hasEducation = member.education && member.education.length > 0;
|
const hasEducation = member.education && member.education.length > 0;
|
||||||
|
|
||||||
// Extract trainer's groups from schedule using groupId
|
// 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 hasGroups = uniqueGroups.length > 0;
|
||||||
|
|
||||||
const hasBio = hasVictories || hasExperience || hasEducation || hasGroups;
|
const hasBio = hasVictories || hasEducation || hasGroups;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -174,53 +164,17 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative p-5 sm:p-6">
|
<div className="relative p-5 sm:p-6 space-y-6">
|
||||||
{/* Victory tabs */}
|
{/* Groups — first, most actionable */}
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Groups */}
|
|
||||||
{hasGroups && (
|
{hasGroups && (
|
||||||
<div className={hasVictories ? "mt-8" : ""}>
|
<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">
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
||||||
<Clock size={14} />
|
<Clock size={12} />
|
||||||
Группы
|
Группы
|
||||||
</span>
|
</h4>
|
||||||
<ScrollRow>
|
<ScrollRow>
|
||||||
{uniqueGroups.map((g, i) => (
|
{uniqueGroups.map((g, i) => (
|
||||||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{g.merged.map((m, mi) => (
|
{g.merged.map((m, mi) => (
|
||||||
@@ -255,43 +209,33 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Education */}
|
{/* Description */}
|
||||||
|
{member.description && (
|
||||||
|
<p className="text-sm leading-relaxed text-white/50">
|
||||||
|
{member.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Education — collapsible */}
|
||||||
{hasEducation && (
|
{hasEducation && (
|
||||||
<div className={hasVictories || hasGroups ? "mt-8" : ""}>
|
<CollapsibleSection icon={GraduationCap} title="Образование" count={member.education!.length}>
|
||||||
<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">
|
|
||||||
<GraduationCap size={14} />
|
|
||||||
Образование
|
|
||||||
</span>
|
|
||||||
<ScrollRow>
|
<ScrollRow>
|
||||||
{member.education!.map((item, i) => (
|
{member.education!.map((item, i) => (
|
||||||
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||||||
))}
|
))}
|
||||||
</ScrollRow>
|
</ScrollRow>
|
||||||
</div>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Experience */}
|
{/* Victories — collapsible */}
|
||||||
{hasExperience && (
|
{hasVictories && (
|
||||||
<div className={hasVictories || hasGroups || hasEducation ? "mt-8" : ""}>
|
<CollapsibleSection icon={Trophy} title="Достижения" count={member.victories!.length}>
|
||||||
<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>
|
<ScrollRow>
|
||||||
{member.experience!.map((item, i) => (
|
{member.victories!.map((item, i) => (
|
||||||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3">
|
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||||||
<p className="text-sm text-white/60">{item}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</ScrollRow>
|
</ScrollRow>
|
||||||
</div>
|
</CollapsibleSection>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{member.description && (
|
|
||||||
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
|
|
||||||
{member.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
@@ -344,10 +288,61 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-2 w-full text-left cursor-pointer group"
|
||||||
|
>
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
||||||
|
<Icon size={12} />
|
||||||
|
{title}
|
||||||
|
<span className="text-gold/40">{count}</span>
|
||||||
|
</h4>
|
||||||
|
<ChevronDown size={14} className={`text-gold/40 transition-transform duration-200 group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScrollRow({ children }: { children: React.ReactNode }) {
|
function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
||||||
const wasDragged = useRef(false);
|
const wasDragged = useRef(false);
|
||||||
|
const [canScroll, setCanScroll] = useState({ left: false, right: false });
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScroll({
|
||||||
|
left: el.scrollLeft > 2,
|
||||||
|
right: el.scrollLeft < el.scrollWidth - el.clientWidth - 2,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(updateScrollState);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [updateScrollState]);
|
||||||
|
|
||||||
|
function scrollBy(dir: number) {
|
||||||
|
scrollRef.current?.scrollBy({ left: dir * 200, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -369,53 +364,37 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-4">
|
<div className="relative mt-3 group/scroll">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex items-stretch gap-3 overflow-x-auto pb-2 pt-4 cursor-grab active:cursor-grabbing select-none"
|
className="flex items-stretch gap-3 overflow-x-auto pb-2 cursor-grab active:cursor-grabbing select-none"
|
||||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
onLostPointerCapture={onPointerUp}
|
onLostPointerCapture={onPointerUp}
|
||||||
|
onScroll={updateScrollState}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Arrow buttons */}
|
||||||
);
|
{canScroll.left && (
|
||||||
}
|
<button
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
function VictoryCard({ victory }: { victory: VictoryItem }) {
|
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
const hasLink = !!victory.link;
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
return (
|
</button>
|
||||||
<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" />
|
{canScroll.right && (
|
||||||
{victory.place && (
|
<button
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
onClick={() => scrollBy(1)}
|
||||||
<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">
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
{victory.place}
|
>
|
||||||
</span>
|
<ChevronRight size={14} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,27 +405,27 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
|||||||
|
|
||||||
if (hasImage) {
|
if (hasImage) {
|
||||||
return (
|
return (
|
||||||
<div className="group w-48 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
<div className="group w-60 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onImageClick(item.image!)}
|
onClick={() => onImageClick(item.image!)}
|
||||||
className="relative w-14 shrink-0 overflow-hidden cursor-pointer"
|
className="relative w-18 shrink-0 overflow-hidden cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={item.image!}
|
src={item.image!}
|
||||||
alt={item.text}
|
alt={item.text}
|
||||||
fill
|
fill
|
||||||
sizes="56px"
|
sizes="72px"
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
className="object-cover transition-transform group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0 p-2.5">
|
<div className="flex-1 min-w-0 p-3">
|
||||||
<p className="text-xs text-white/70">{item.text}</p>
|
<p className="text-sm text-white/70 group-hover:text-white/90 transition-colors">{item.text}</p>
|
||||||
{hasLink && (
|
{hasLink && (
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.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-1.5 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink size={11} />
|
<ExternalLink size={11} />
|
||||||
Подробнее
|
Подробнее
|
||||||
@@ -458,9 +437,9 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group w-48 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
<div className="group w-60 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<p className="text-sm text-white/60">{item.text}</p>
|
<p className="text-sm text-white/60 group-hover:text-white/80 transition-colors">{item.text}</p>
|
||||||
{hasLink && (
|
{hasLink && (
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
href={item.link}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { SiteContent, TeamMember, RichListItem, VictoryItem } from "@/types/content";
|
import type { SiteContent, TeamMember, RichListItem } from "@/types/content";
|
||||||
import { MS_PER_DAY } from "@/lib/constants";
|
import { MS_PER_DAY } from "@/lib/constants";
|
||||||
|
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
@@ -382,16 +382,18 @@ function parseRichList(val: string | null): RichListItem[] | undefined {
|
|||||||
} catch { return undefined; }
|
} catch { return undefined; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVictories(val: string | null): VictoryItem[] | undefined {
|
function parseVictoriesAsRichList(val: string | null): RichListItem[] | undefined {
|
||||||
if (!val) return undefined;
|
if (!val) return undefined;
|
||||||
try {
|
try {
|
||||||
const arr = JSON.parse(val);
|
const arr = JSON.parse(val);
|
||||||
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
if (!Array.isArray(arr) || arr.length === 0) return undefined;
|
||||||
// Handle old string[], old RichListItem[], and new VictoryItem[] formats
|
// Migrate old VictoryItem[] → RichListItem[]
|
||||||
return arr.map((item: string | Record<string, unknown>) => {
|
return arr.map((item: string | Record<string, unknown>) => {
|
||||||
if (typeof item === "string") return { place: "", category: "", competition: item };
|
if (typeof item === "string") return { text: item };
|
||||||
if ("text" in item && !("competition" in item)) return { place: "", category: "", competition: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined };
|
if ("text" in item) return { text: item.text as string, image: item.image as string | undefined, link: item.link as string | undefined };
|
||||||
return item as unknown as VictoryItem;
|
// Old VictoryItem format: combine place + category + competition into text
|
||||||
|
const parts = [item.place, item.category, item.competition].filter(Boolean);
|
||||||
|
return { text: parts.join(" · "), image: item.image as string | undefined, link: item.link as string | undefined };
|
||||||
});
|
});
|
||||||
} catch { return undefined; }
|
} catch { return undefined; }
|
||||||
}
|
}
|
||||||
@@ -409,8 +411,7 @@ export function getTeamMembers(): (TeamMember & { id: number })[] {
|
|||||||
instagram: r.instagram ?? undefined,
|
instagram: r.instagram ?? undefined,
|
||||||
shortDescription: r.short_description ?? undefined,
|
shortDescription: r.short_description ?? undefined,
|
||||||
description: r.description ?? undefined,
|
description: r.description ?? undefined,
|
||||||
experience: parseJsonArray(r.experience),
|
victories: parseVictoriesAsRichList(r.victories),
|
||||||
victories: parseVictories(r.victories),
|
|
||||||
education: parseRichList(r.education),
|
education: parseRichList(r.education),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -431,8 +432,7 @@ export function getTeamMember(
|
|||||||
instagram: r.instagram ?? undefined,
|
instagram: r.instagram ?? undefined,
|
||||||
shortDescription: r.short_description ?? undefined,
|
shortDescription: r.short_description ?? undefined,
|
||||||
description: r.description ?? undefined,
|
description: r.description ?? undefined,
|
||||||
experience: parseJsonArray(r.experience),
|
victories: parseVictoriesAsRichList(r.victories),
|
||||||
victories: parseVictories(r.victories),
|
|
||||||
education: parseRichList(r.education),
|
education: parseRichList(r.education),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -456,7 +456,7 @@ export function createTeamMember(
|
|||||||
data.instagram ?? null,
|
data.instagram ?? null,
|
||||||
data.shortDescription ?? null,
|
data.shortDescription ?? null,
|
||||||
data.description ?? null,
|
data.description ?? null,
|
||||||
data.experience?.length ? JSON.stringify(data.experience) : null,
|
null,
|
||||||
data.victories?.length ? JSON.stringify(data.victories) : null,
|
data.victories?.length ? JSON.stringify(data.victories) : null,
|
||||||
data.education?.length ? JSON.stringify(data.education) : null,
|
data.education?.length ? JSON.stringify(data.education) : null,
|
||||||
maxOrder.max + 1
|
maxOrder.max + 1
|
||||||
@@ -478,7 +478,6 @@ export function updateTeamMember(
|
|||||||
if (data.instagram !== undefined) { fields.push("instagram = ?"); values.push(data.instagram || null); }
|
if (data.instagram !== undefined) { fields.push("instagram = ?"); values.push(data.instagram || null); }
|
||||||
if (data.shortDescription !== undefined) { fields.push("short_description = ?"); values.push(data.shortDescription || null); }
|
if (data.shortDescription !== undefined) { fields.push("short_description = ?"); values.push(data.shortDescription || null); }
|
||||||
if (data.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); }
|
if (data.description !== undefined) { fields.push("description = ?"); values.push(data.description || null); }
|
||||||
if (data.experience !== undefined) { fields.push("experience = ?"); values.push(data.experience?.length ? JSON.stringify(data.experience) : null); }
|
|
||||||
if (data.victories !== undefined) { fields.push("victories = ?"); values.push(data.victories?.length ? JSON.stringify(data.victories) : null); }
|
if (data.victories !== undefined) { fields.push("victories = ?"); values.push(data.victories?.length ? JSON.stringify(data.victories) : null); }
|
||||||
if (data.education !== undefined) { fields.push("education = ?"); values.push(data.education?.length ? JSON.stringify(data.education) : null); }
|
if (data.education !== undefined) { fields.push("education = ?"); values.push(data.education?.length ? JSON.stringify(data.education) : null); }
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,6 @@ export interface RichListItem {
|
|||||||
link?: string;
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VictoryItem {
|
|
||||||
type?: 'place' | 'nomination' | 'judge';
|
|
||||||
place: string;
|
|
||||||
category: string;
|
|
||||||
competition: string;
|
|
||||||
location?: string;
|
|
||||||
date?: string;
|
|
||||||
image?: string;
|
|
||||||
link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -31,8 +20,7 @@ export interface TeamMember {
|
|||||||
instagram?: string;
|
instagram?: string;
|
||||||
shortDescription?: string;
|
shortDescription?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
experience?: string[];
|
victories?: RichListItem[];
|
||||||
victories?: VictoryItem[];
|
|
||||||
education?: RichListItem[];
|
education?: RichListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||