feat: admin UX — shared input classes, autocomplete role, auto-save team, video improvements
- Extract base input classes (baseInput, textAreaInput, smallInput, dashedInput) with gold hover - Move AutocompleteMulti to shared FormField, support · separator - Team editor: auto-save with toast, split name into first/last, autocomplete role from class styles - Team photo: click-to-upload overlay, smaller 130px thumbnail - Hero videos: play on hover, file size display, 8MB warning, total size performance table - Remove ctaHref field from Hero admin (unused on frontend) - Move Toast to shared _components for reuse across admin pages
This commit is contained in:
@@ -4,7 +4,8 @@ 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 { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField, AutocompleteMulti } from "../../_components/FormField";
|
||||
import { useToast } from "../../_components/Toast";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||
|
||||
@@ -27,7 +28,13 @@ interface MemberForm {
|
||||
education: RichListItem[];
|
||||
}
|
||||
|
||||
import { ToastProvider } from "../../_components/Toast";
|
||||
|
||||
export default function TeamMemberEditorPage() {
|
||||
return <ToastProvider><TeamMemberEditor /></ToastProvider>;
|
||||
}
|
||||
|
||||
function TeamMemberEditor() {
|
||||
const router = useRouter();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isNew = id === "new";
|
||||
@@ -45,8 +52,9 @@ export default function TeamMemberEditorPage() {
|
||||
});
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [styles, setStyles] = useState<string[]>([]);
|
||||
|
||||
// Instagram validation
|
||||
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||
@@ -107,6 +115,16 @@ export default function TeamMemberEditorPage() {
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Fetch class styles for role autocomplete
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/sections/classes")
|
||||
.then((r) => r.json())
|
||||
.then((data: { items?: { name: string }[] }) => {
|
||||
setStyles(data.items?.map((i) => i.name) ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
adminFetch(`/api/admin/team/${id}`)
|
||||
@@ -130,38 +148,45 @@ export default function TeamMemberEditorPage() {
|
||||
}, [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);
|
||||
|
||||
async function handleSave() {
|
||||
if (hasErrors) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
// Auto-save with 800ms debounce (existing members only)
|
||||
useEffect(() => {
|
||||
if (isNew || loading || initialLoadRef.current) {
|
||||
initialLoadRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasErrors || !data.name || !data.role) return;
|
||||
|
||||
// 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 {
|
||||
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) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if (hasErrors || !data.name || !data.role) return;
|
||||
setSaving(true);
|
||||
const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" };
|
||||
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");
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -192,9 +217,16 @@ export default function TeamMemberEditorPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Загрузка...
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-48 bg-neutral-800 rounded-lg" />
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-[130px] shrink-0 aspect-[3/4] bg-neutral-800 rounded-xl" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,42 +245,41 @@ export default function TeamMemberEditorPage() {
|
||||
{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>
|
||||
{isNew ? (
|
||||
<button
|
||||
onClick={handleSaveNew}
|
||||
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" /> : <Save size={16} />}
|
||||
{saving ? "Сохранение..." : "Создать"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
|
||||
<div className="mt-6 flex gap-6 items-start">
|
||||
{/* Photo */}
|
||||
<div>
|
||||
<div className="shrink-0">
|
||||
<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">
|
||||
<label className="relative block w-[130px] aspect-[3/4] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||
<Image
|
||||
src={data.image}
|
||||
alt={data.name || "Фото"}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="240px"
|
||||
sizes="130px"
|
||||
priority
|
||||
/>
|
||||
</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 ? "Загрузка..." : "Загрузить фото"}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
{uploading ? (
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
) : (
|
||||
<>
|
||||
<Upload size={20} className="text-white" />
|
||||
<span className="text-[11px] text-white/80">Изменить фото</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -260,15 +291,32 @@ export default function TeamMemberEditorPage() {
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<InputField
|
||||
label="Имя"
|
||||
value={data.name}
|
||||
onChange={(v) => setData({ ...data, name: v })}
|
||||
/>
|
||||
<InputField
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InputField
|
||||
label="Имя"
|
||||
value={data.name.split(" ")[0] || ""}
|
||||
onChange={(v) => {
|
||||
const last = data.name.split(" ").slice(1).join(" ");
|
||||
setData({ ...data, name: last ? `${v} ${last}` : v });
|
||||
}}
|
||||
placeholder="Анна"
|
||||
/>
|
||||
<InputField
|
||||
label="Фамилия"
|
||||
value={data.name.split(" ").slice(1).join(" ") || ""}
|
||||
onChange={(v) => {
|
||||
const first = data.name.split(" ")[0] || "";
|
||||
setData({ ...data, name: v ? `${first} ${v}` : first });
|
||||
}}
|
||||
placeholder="Тарыба"
|
||||
/>
|
||||
</div>
|
||||
<AutocompleteMulti
|
||||
label="Роль / Специализация"
|
||||
value={data.role}
|
||||
onChange={(v) => setData({ ...data, role: v })}
|
||||
options={styles}
|
||||
placeholder="Добавить стиль..."
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||
|
||||
Reference in New Issue
Block a user