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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user