feat: upgrade team admin with click-to-edit, Instagram validation, date picker, city autocomplete

- Team list: click card to open editor (remove pencil button), keep drag-to-reorder
- Instagram field: username-only input with @ prefix, async account validation via HEAD request
- Victory dates: date range picker replacing text input, auto-formats to DD.MM.YYYY / DD-DD.MM.YYYY
- Victory location: city autocomplete via Nominatim API with suggestions dropdown
- Links: real-time URL validation with error indicators on all link fields
- Save button blocked when any validation errors exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:34:55 +03:00
parent 4918184852
commit 627781027b
5 changed files with 561 additions and 223 deletions

View File

@@ -1,12 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload } from "lucide-react";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
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;
@@ -38,34 +45,112 @@ export default function TeamMemberEditorPage() {
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 fetch(`/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
.filter((r: Record<string, unknown>) => {
const type = r.type as string;
const cls = r.class as string;
return cls === "place" || type === "city" || type === "town" || type === "village" || type === "administrative";
})
.map((r: Record<string, unknown>) => {
const addr = r.address as Record<string, string> | undefined;
const city = addr?.city || addr?.town || addr?.village || (r.name as string);
const country = addr?.country || "";
return country ? `${city}, ${country}` : city;
})
.filter((v: string, i: number, a: string[]) => a.indexOf(v) === i);
setCitySuggestions(cities.length > 0 ? { index, items: cities } : null);
if (cities.length === 0 && query.length >= 3) {
setCityErrors((prev) => ({ ...prev, [index]: "Город не найден" }));
} else {
setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; });
}
} catch {
setCitySuggestions(null);
}
}, 500);
}, []);
useEffect(() => {
if (isNew) return;
fetch(`/api/admin/team/${id}`)
.then((r) => r.json())
.then((member) =>
.then((member) => {
const username = extractUsername(member.instagram || "");
setData({
name: member.name,
role: member.role,
image: member.image,
instagram: member.instagram || "",
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 fetch("/api/admin/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
body: JSON.stringify(payload),
});
if (res.ok) {
router.push("/admin/team");
@@ -74,7 +159,7 @@ export default function TeamMemberEditorPage() {
const res = await fetch(`/api/admin/team/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
body: JSON.stringify(payload),
});
if (res.ok) {
setSaved(true);
@@ -134,7 +219,7 @@ export default function TeamMemberEditorPage() {
</div>
<button
onClick={handleSave}
disabled={saving || !data.name || !data.role}
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 ? (
@@ -189,13 +274,40 @@ export default function TeamMemberEditorPage() {
value={data.role}
onChange={(v) => setData({ ...data, role: v })}
/>
<InputField
label="Instagram"
value={data.instagram}
onChange={(v) => setData({ ...data, instagram: v })}
type="url"
placeholder="https://instagram.com/..."
/>
<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}
@@ -216,12 +328,33 @@ export default function TeamMemberEditorPage() {
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>

View File

@@ -9,7 +9,6 @@ import {
Plus,
Trash2,
GripVertical,
Pencil,
Check,
} from "lucide-react";
import type { TeamMember } from "@/types/content";
@@ -80,16 +79,23 @@ export default function TeamEditorPage() {
const x = e.clientX;
const y = e.clientY;
const pendingIndex = index;
let moved = false;
function onMove(ev: MouseEvent) {
const dx = ev.clientX - x;
const dy = ev.clientY - y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
moved = true;
cleanup();
startDrag(ev.clientX, ev.clientY, pendingIndex);
}
}
function onUp() { cleanup(); }
function onUp() {
cleanup();
if (!moved) {
window.location.href = `/admin/team/${members[pendingIndex].id}`;
}
}
function cleanup() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
@@ -97,7 +103,7 @@ export default function TeamEditorPage() {
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[startDrag]
[startDrag, members]
);
useEffect(() => {
@@ -177,7 +183,7 @@ export default function TeamEditorPage() {
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
@@ -192,14 +198,9 @@ export default function TeamEditorPage() {
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<div className="flex items-center gap-1">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
));
}
@@ -237,7 +238,7 @@ export default function TeamEditorPage() {
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
@@ -252,14 +253,9 @@ export default function TeamEditorPage() {
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<div className="flex items-center gap-1">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
);
visualIndex++;