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
+250 -4
View File
@@ -1,5 +1,6 @@
import { useRef, useEffect, useState, useMemo } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle } from "lucide-react";
import { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle, Bold, Italic, List, Heading2, Pencil } from "lucide-react";
import { formatMarkup } from "@/lib/markup";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem } from "@/types/content";
@@ -29,7 +30,7 @@ export function InputField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputCls}
@@ -147,7 +148,7 @@ export function TextareaField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
ref={ref}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
@@ -157,6 +158,251 @@ export function TextareaField({
);
}
interface RichTextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function RichTextarea({
label,
value,
onChange,
placeholder,
rows = 4,
}: RichTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const [editing, setEditing] = useState(false);
const hasContent = Boolean(value?.trim());
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const wrapSelection = useCallback((before: string, after: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = value ?? "";
const selected = text.slice(start, end);
// If already wrapped, unwrap
const beforeCheck = text.slice(Math.max(0, start - before.length), start);
const afterCheck = text.slice(end, end + after.length);
if (beforeCheck === before && afterCheck === after) {
const newText = text.slice(0, start - before.length) + selected + text.slice(end + after.length);
onChange(newText);
requestAnimationFrame(() => {
el.selectionStart = start - before.length;
el.selectionEnd = end - before.length;
el.focus();
});
return;
}
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
onChange(newText);
requestAnimationFrame(() => {
if (selected) {
el.selectionStart = start + before.length;
el.selectionEnd = end + before.length;
} else {
el.selectionStart = start + before.length;
el.selectionEnd = start + before.length;
}
el.focus();
});
}, [value, onChange]);
const insertAtCursor = useCallback((text: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const current = value ?? "";
// Add newline before if not at start of line
const lineStart = current.lastIndexOf("\n", start - 1) + 1;
const prefix = start > lineStart ? "\n" : "";
const newText = current.slice(0, start) + prefix + text + current.slice(start);
onChange(newText);
const cursorPos = start + prefix.length + text.length;
requestAnimationFrame(() => {
el.selectionStart = cursorPos;
el.selectionEnd = cursorPos;
el.focus();
});
}, [value, onChange]);
// Track active formatting at cursor position
const [cursorPos, setCursorPos] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
const updateCursorPos = useCallback(() => {
const el = ref.current;
if (!el) return;
setCursorPos({ start: el.selectionStart, end: el.selectionEnd });
}, []);
const isBold = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
// Check if selection is wrapped in **
return text.slice(Math.max(0, start - 2), start) === "**" && text.slice(end, end + 2) === "**";
}
// Check if cursor is inside **...**
const before = text.slice(0, start);
const after = text.slice(start);
const lastOpen = before.lastIndexOf("**");
if (lastOpen === -1) return false;
const betweenOpen = before.slice(lastOpen + 2);
if (betweenOpen.includes("**")) return false;
return after.indexOf("**") !== -1;
}, [value, cursorPos]);
const isItalic = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
const cb = text[start - 1];
const ca = text[end];
return cb === "*" && ca === "*" && text[start - 2] !== "*" && text[end + 1] !== "*";
}
const before = text.slice(0, start);
const after = text.slice(start);
// Find single * (not **) before cursor
const lastStar = before.lastIndexOf("*");
if (lastStar === -1) return false;
if (lastStar > 0 && before[lastStar - 1] === "*") return false;
const nextStar = after.indexOf("*");
if (nextStar === -1) return false;
if (after[nextStar + 1] === "*") return false;
return true;
}, [value, cursorPos]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Use e.code for layout-independent shortcuts (works with Russian layout)
if ((e.ctrlKey || e.metaKey) && e.code === "KeyB") {
e.preventDefault();
wrapSelection("**", "**");
}
if ((e.ctrlKey || e.metaKey) && e.code === "KeyI") {
e.preventDefault();
wrapSelection("*", "*");
}
}, [wrapSelection]);
const toolbarBtn = (active: boolean) =>
`rounded p-1.5 transition-colors ${
active
? "text-gold bg-gold/15"
: "text-neutral-500 hover:text-white hover:bg-white/10"
}`;
// Preview mode: show rendered markup
if (!editing && hasContent) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div
onClick={() => {
setEditing(true);
requestAnimationFrame(() => ref.current?.focus());
}}
className="group rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative"
>
<div className="text-sm leading-relaxed text-neutral-300">
{formatMarkup(value)}
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Pencil size={10} />
редактировать
</span>
</div>
</div>
</div>
);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="rounded-lg border border-white/10 bg-neutral-800 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors">
{/* Toolbar */}
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-white/5">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("**", "**")}
className={toolbarBtn(isBold)}
title="Жирный (Ctrl+B)"
>
<Bold size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("*", "*")}
className={toolbarBtn(isItalic)}
title="Курсив (Ctrl+I)"
>
<Italic size={14} />
</button>
<div className="w-px h-4 bg-white/10 mx-1" />
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertAtCursor("🤍 ")}
className={toolbarBtn(false)}
title="Пункт списка"
>
<List size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("## ", "")}
className={toolbarBtn(false)}
title="Подзаголовок"
>
<Heading2 size={14} />
</button>
</div>
{/* Textarea */}
<textarea
ref={ref}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={updateCursorPos}
onClick={updateCursorPos}
onSelect={updateCursorPos}
onBlur={() => setEditing(false)}
placeholder={placeholder}
rows={rows}
className="w-full bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none resize-none"
/>
</div>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;