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
+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}