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