feat: rich text editor, image crop component, empty DB resilience
- RichTextarea with toolbar (Bold, Italic, List, Heading) + Ctrl+B/I
hotkeys (layout-independent), active state highlighting, preview mode
- Shared ImageCropField component (replaces duplicate in news/classes)
with drag-to-reposition, Ctrl+scroll zoom, compact layout
- SectionEditor defaultData prop — all admin pages handle empty DB
- Team: section title editable, toast notifications, unsaved data warning
on navigation (back button, sidebar links, browser close)
- Carousel: continuous card wrapping during drag, edge fade for small teams
- Markup renderer: **bold**, *italic*, ## headings, 🤍 bullet points
- Empty DB guards on all public site sections
- Fix: upload error handling, contact phone field, "team" section key
This commit is contained in:
@@ -114,6 +114,61 @@ function TeamMemberEditor() {
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
const emptyForm: MemberForm = { name: "", role: "", image: "/images/team/placeholder.webp", instagram: "", shortDescription: "", description: "", victories: [], education: [] };
|
||||
const isDirty = isNew && JSON.stringify(data) !== JSON.stringify(emptyForm);
|
||||
|
||||
const dirtyRef = useRef(false);
|
||||
dirtyRef.current = isDirty;
|
||||
|
||||
// Warn before leaving with unsaved new member data
|
||||
useEffect(() => {
|
||||
if (!isNew) return;
|
||||
|
||||
// Browser tab close / refresh
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (dirtyRef.current) e.preventDefault();
|
||||
}
|
||||
|
||||
// Intercept all link clicks (sidebar nav, etc.)
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
if (!dirtyRef.current) return;
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (!link || link.target === "_blank") return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Вы уверены? Несохранённые данные будут потеряны.")) {
|
||||
dirtyRef.current = false;
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
// Browser back/forward
|
||||
function onPopState() {
|
||||
if (!dirtyRef.current) return;
|
||||
if (!confirm("Вы уверены? Несохранённые данные будут потеряны.")) {
|
||||
history.pushState(null, "", window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
}, [isNew]);
|
||||
|
||||
function handleBack() {
|
||||
if (isDirty) {
|
||||
if (!confirm("Вы уверены? Несохранённые данные будут потеряны.")) return;
|
||||
}
|
||||
router.push("/admin/team");
|
||||
}
|
||||
|
||||
// Shared save logic — compares snapshot, skips if unchanged
|
||||
const saveIfDirty = useCallback(async () => {
|
||||
if (isNew || loading) return;
|
||||
@@ -223,7 +278,7 @@ function TeamMemberEditor() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/admin/team")}
|
||||
onClick={handleBack}
|
||||
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
|
||||
+59
-23
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Loader2, Plus, Check } from "lucide-react";
|
||||
import { Loader2, Plus, Check, AlertCircle } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { InputField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import type { TeamMember } from "@/types/content";
|
||||
|
||||
@@ -12,28 +13,52 @@ type Member = TeamMember & { id: number };
|
||||
|
||||
export default function TeamEditorPage() {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [sectionTitle, setSectionTitle] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const titleLoadedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/team")
|
||||
.then((r) => r.json())
|
||||
.then(setMembers)
|
||||
.finally(() => setLoading(false));
|
||||
Promise.all([
|
||||
adminFetch("/api/admin/team").then((r) => r.json()),
|
||||
adminFetch("/api/admin/sections/team").then((r) => r.json()),
|
||||
]).then(([membersData, sectionData]) => {
|
||||
setMembers(membersData);
|
||||
setSectionTitle(sectionData.title || "");
|
||||
titleLoadedRef.current = true;
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Auto-save section title with debounce (skip initial load)
|
||||
const titleChangeCount = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!titleLoadedRef.current) return;
|
||||
titleChangeCount.current++;
|
||||
// Skip the first change (initial load setting the value)
|
||||
if (titleChangeCount.current <= 1) return;
|
||||
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
|
||||
titleTimerRef.current = setTimeout(async () => {
|
||||
const res = await adminFetch("/api/admin/sections/team", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: sectionTitle }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
}, 800);
|
||||
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
|
||||
}, [sectionTitle]);
|
||||
|
||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||
setMembers(updated);
|
||||
setSaving(true);
|
||||
await adminFetch("/api/admin/team/reorder", {
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||
});
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
}, []);
|
||||
|
||||
async function deleteMember(id: number) {
|
||||
@@ -52,19 +77,21 @@ export default function TeamEditorPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toast popup */}
|
||||
{saveStatus !== "idle" && (
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
saveStatus === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||
}`}>
|
||||
{saveStatus === "saved" && <><Check size={14} /> Сохранено</>}
|
||||
{saveStatus === "error" && <><AlertCircle size={14} /> Ошибка сохранения</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Команда</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{(saving || saved) && (
|
||||
<span className="text-sm text-neutral-400 flex items-center gap-1">
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={14} className="text-green-400" />
|
||||
)}
|
||||
{saving ? "Сохранение..." : "Сохранено!"}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
href="/admin/team/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
||||
@@ -75,6 +102,15 @@ export default function TeamEditorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<InputField
|
||||
label="Заголовок секции"
|
||||
value={sectionTitle}
|
||||
onChange={setSectionTitle}
|
||||
placeholder="Наша команда"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<ArrayEditor
|
||||
items={members}
|
||||
|
||||
Reference in New Issue
Block a user