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
+60 -85
View File
@@ -4,10 +4,10 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
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 { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
import type { RichListItem } from "@/types/content";
function extractUsername(value: string): string {
if (!value) return "";
@@ -23,8 +23,7 @@ interface MemberForm {
instagram: string;
shortDescription: string;
description: string;
experience: string[];
victories: VictoryItem[];
victories: RichListItem[];
education: RichListItem[];
}
@@ -46,7 +45,6 @@ function TeamMemberEditor() {
instagram: "",
shortDescription: "",
description: "",
experience: [],
victories: [],
education: [],
});
@@ -78,43 +76,6 @@ function TeamMemberEditor() {
// Link validation for bio
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
useEffect(() => {
adminFetch("/api/admin/sections/classes")
@@ -131,50 +92,75 @@ function TeamMemberEditor() {
.then((r) => r.json())
.then((member) => {
const username = extractUsername(member.instagram || "");
setData({
const loaded = {
name: member.name,
role: member.role,
image: member.image,
instagram: username,
shortDescription: member.shortDescription || "",
description: member.description || "",
experience: member.experience || [],
victories: member.victories || [],
education: member.education || [],
});
};
setData(loaded);
lastSavedRef.current = JSON.stringify(loaded);
if (username) setIgStatus("valid"); // existing data is trusted
})
.finally(() => setLoading(false));
}, [id, isNew]);
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialLoadRef = useRef(true);
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0;
const lastSavedRef = useRef("");
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(() => {
if (isNew || loading || initialLoadRef.current) {
initialLoadRef.current = false;
return;
if (isNew || loading) return;
const onVisibilityChange = () => { if (document.hidden) saveIfDirty(); };
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
async function handleSaveNew() {
@@ -207,6 +193,7 @@ function TeamMemberEditor() {
const result = await res.json();
if (result.path) {
setData((prev) => ({ ...prev, image: result.path }));
setTimeout(saveIfDirty, 100);
}
} catch {
// Upload failed silently
@@ -365,31 +352,18 @@ function TeamMemberEditor() {
<div className="border-t border-white/5 pt-4 mt-4">
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
<div className="space-y-4">
<ListField
label="Опыт"
items={data.experience}
onChange={(items) => setData({ ...data, experience: items })}
placeholder="Например: 10 лет в танцах"
/>
<VictoryItemListField
<VictoryListField
label="Достижения"
items={data.victories}
onChange={(items) => setData({ ...data, victories: items })}
cityErrors={cityErrors}
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; });
}}
placeholder="Например: 1 место, Чемпионат Беларуси 2024"
onLinkValidate={(key, error) => {
setLinkErrors((prev) => {
if (error) return { ...prev, [key]: error };
const n = { ...prev }; delete n[key]; return n;
});
}}
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
/>
<VictoryListField
label="Образование"
@@ -402,6 +376,7 @@ function TeamMemberEditor() {
const n = { ...prev }; delete n[key]; return n;
});
}}
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
/>
</div>
</div>