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:
2026-03-30 00:40:08 +03:00
parent e56a6a1608
commit 22bd117dae
25 changed files with 698 additions and 241 deletions
+56 -1
View File
@@ -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
View File
@@ -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}