Files
blackheart-website/src/app/admin/team/[id]/page.tsx
diana.dolgolyova 6cbdba2197 feat: add CSRF protection for admin API routes
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>
2026-03-17 17:53:02 +03:00

359 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}