Double-submit cookie pattern: login sets bh-csrf-token cookie, proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*. New adminFetch() helper in src/lib/csrf.ts auto-includes the header. All admin pages migrated from fetch() to adminFetch(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
"use client";
|
||
|
||
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 } from "../../_components/FormField";
|
||
import { adminFetch } from "@/lib/csrf";
|
||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||
|
||
function extractUsername(value: string): string {
|
||
if (!value) return "";
|
||
// Strip full URL → username
|
||
const cleaned = value.replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "").replace(/^@/, "");
|
||
return cleaned;
|
||
}
|
||
|
||
interface MemberForm {
|
||
name: string;
|
||
role: string;
|
||
image: string;
|
||
instagram: string;
|
||
description: string;
|
||
experience: string[];
|
||
victories: VictoryItem[];
|
||
education: RichListItem[];
|
||
}
|
||
|
||
export default function TeamMemberEditorPage() {
|
||
const router = useRouter();
|
||
const { id } = useParams<{ id: string }>();
|
||
const isNew = id === "new";
|
||
|
||
const [data, setData] = useState<MemberForm>({
|
||
name: "",
|
||
role: "",
|
||
image: "/images/team/placeholder.webp",
|
||
instagram: "",
|
||
description: "",
|
||
experience: [],
|
||
victories: [],
|
||
education: [],
|
||
});
|
||
const [loading, setLoading] = useState(!isNew);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
// Instagram validation
|
||
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||
const igTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const validateInstagram = useCallback((username: string) => {
|
||
if (igTimerRef.current) clearTimeout(igTimerRef.current);
|
||
if (!username) { setIgStatus("idle"); return; }
|
||
setIgStatus("checking");
|
||
igTimerRef.current = setTimeout(async () => {
|
||
try {
|
||
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||
const result = await res.json();
|
||
setIgStatus(result.valid ? "valid" : "invalid");
|
||
} catch {
|
||
setIgStatus("idle");
|
||
}
|
||
}, 800);
|
||
}, []);
|
||
|
||
// 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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isNew) return;
|
||
adminFetch(`/api/admin/team/${id}`)
|
||
.then((r) => r.json())
|
||
.then((member) => {
|
||
const username = extractUsername(member.instagram || "");
|
||
setData({
|
||
name: member.name,
|
||
role: member.role,
|
||
image: member.image,
|
||
instagram: username,
|
||
description: member.description || "",
|
||
experience: member.experience || [],
|
||
victories: member.victories || [],
|
||
education: member.education || [],
|
||
});
|
||
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;
|
||
|
||
async function handleSave() {
|
||
if (hasErrors) return;
|
||
setSaving(true);
|
||
setSaved(false);
|
||
|
||
// Build instagram as full URL for storage if username is provided
|
||
const payload = {
|
||
...data,
|
||
instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "",
|
||
};
|
||
|
||
if (isNew) {
|
||
const res = await adminFetch("/api/admin/team", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (res.ok) {
|
||
router.push("/admin/team");
|
||
}
|
||
} else {
|
||
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (res.ok) {
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 2000);
|
||
}
|
||
}
|
||
setSaving(false);
|
||
}
|
||
|
||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
setUploading(true);
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
formData.append("folder", "team");
|
||
|
||
try {
|
||
const res = await adminFetch("/api/admin/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const result = await res.json();
|
||
if (result.path) {
|
||
setData((prev) => ({ ...prev, image: result.path }));
|
||
}
|
||
} catch {
|
||
// Upload failed silently
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center gap-2 text-neutral-400">
|
||
<Loader2 size={18} className="animate-spin" />
|
||
Загрузка...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => router.push("/admin/team")}
|
||
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||
>
|
||
<ArrowLeft size={20} />
|
||
</button>
|
||
<h1 className="text-2xl font-bold">
|
||
{isNew ? "Новый участник" : data.name}
|
||
</h1>
|
||
</div>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
|
||
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : saved ? (
|
||
<Check size={16} />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
|
||
{/* Photo */}
|
||
<div>
|
||
<p className="text-sm text-neutral-400 mb-2">Фото</p>
|
||
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
|
||
<Image
|
||
src={data.image}
|
||
alt={data.name || "Фото"}
|
||
fill
|
||
className="object-cover"
|
||
sizes="240px"
|
||
/>
|
||
</div>
|
||
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||
{uploading ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Upload size={16} />
|
||
)}
|
||
{uploading ? "Загрузка..." : "Загрузить фото"}
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleUpload}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Fields */}
|
||
<div className="space-y-4">
|
||
<InputField
|
||
label="Имя"
|
||
value={data.name}
|
||
onChange={(v) => setData({ ...data, name: v })}
|
||
/>
|
||
<InputField
|
||
label="Роль / Специализация"
|
||
value={data.role}
|
||
onChange={(v) => setData({ ...data, role: v })}
|
||
/>
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||
<div className="relative">
|
||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
|
||
<input
|
||
type="text"
|
||
value={data.instagram}
|
||
onChange={(e) => {
|
||
const username = extractUsername(e.target.value);
|
||
setData({ ...data, instagram: username });
|
||
validateInstagram(username);
|
||
}}
|
||
placeholder="username"
|
||
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||
igStatus === "invalid"
|
||
? "border-red-500 focus:border-red-500"
|
||
: igStatus === "valid"
|
||
? "border-green-500/50 focus:border-green-500"
|
||
: "border-white/10 focus:border-gold"
|
||
}`}
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||
{igStatus === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
|
||
{igStatus === "valid" && <Check size={14} className="text-green-400" />}
|
||
{igStatus === "invalid" && <AlertCircle size={14} className="text-red-400" />}
|
||
</span>
|
||
</div>
|
||
{igStatus === "invalid" && (
|
||
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
|
||
)}
|
||
{data.instagram && igStatus !== "invalid" && (
|
||
<p className="mt-1 text-xs text-neutral-500">instagram.com/{data.instagram}</p>
|
||
)}
|
||
</div>
|
||
<TextareaField
|
||
label="Описание"
|
||
value={data.description}
|
||
onChange={(v) => setData({ ...data, description: v })}
|
||
rows={6}
|
||
/>
|
||
|
||
<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
|
||
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; });
|
||
}}
|
||
onLinkValidate={(key, error) => {
|
||
setLinkErrors((prev) => {
|
||
if (error) return { ...prev, [key]: error };
|
||
const n = { ...prev }; delete n[key]; return n;
|
||
});
|
||
}}
|
||
/>
|
||
<VictoryListField
|
||
label="Образование"
|
||
items={data.education}
|
||
onChange={(items) => setData({ ...data, education: items })}
|
||
placeholder="Например: Сертификат IPSF"
|
||
onLinkValidate={(key, error) => {
|
||
setLinkErrors((prev) => {
|
||
if (error) return { ...prev, [key]: error };
|
||
const n = { ...prev }; delete n[key]; return n;
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|