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:
@@ -24,7 +24,7 @@ interface ArrayEditorProps<T> {
|
||||
}
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
items,
|
||||
items = [] as unknown as T[],
|
||||
onChange,
|
||||
renderItem,
|
||||
createItem,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { Upload, Loader2, ImageIcon } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
|
||||
interface ImageCropData {
|
||||
image: string;
|
||||
focalX: number;
|
||||
focalY: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
interface ImageCropFieldProps extends ImageCropData {
|
||||
folder: string;
|
||||
onChange: (data: ImageCropData) => void;
|
||||
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
|
||||
aspect?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ImageCropField({
|
||||
image,
|
||||
focalX,
|
||||
focalY,
|
||||
zoom,
|
||||
folder,
|
||||
onChange,
|
||||
aspect = "aspect-[16/9]",
|
||||
label = "Фото",
|
||||
}: ImageCropFieldProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", folder);
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(e: React.PointerEvent) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
setDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY, startFocalX: focalX, startFocalY: focalY };
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent) {
|
||||
if (!dragging) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
|
||||
const dx = ((e.clientX - startX) / rect.width) * 100;
|
||||
const dy = ((e.clientY - startY) / rect.height) * 100;
|
||||
onChange({
|
||||
image,
|
||||
focalX: Math.round(Math.max(0, Math.min(100, startFocalX - dx))),
|
||||
focalY: Math.round(Math.max(0, Math.min(100, startFocalY - dy))),
|
||||
zoom,
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerUp() { setDragging(false); }
|
||||
|
||||
// Attach wheel as non-passive to allow preventDefault (stops page scroll)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return; // Only zoom with Ctrl+scroll
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
onChange({ image, focalX, focalY, zoom: Math.round(Math.max(1, Math.min(3, zoom + delta)) * 10) / 10 });
|
||||
}
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [zoom, focalX, focalY, image, onChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||
</label>
|
||||
{image ? (
|
||||
<div className="max-w-3xl space-y-2">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt="Превью"
|
||||
fill
|
||||
className="object-cover pointer-events-none"
|
||||
style={{
|
||||
objectPosition: `${focalX}% ${focalY}%`,
|
||||
transform: `scale(${zoom})`,
|
||||
}}
|
||||
sizes="384px"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500">−</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={zoom}
|
||||
onChange={(e) => onChange({ image, focalX, focalY, zoom: parseFloat(e.target.value) })}
|
||||
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">+</span>
|
||||
{zoom > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ image, focalX: 50, focalY: 50, zoom: 1 })}
|
||||
className="text-xs text-neutral-500 hover:text-white transition-colors"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
Заменить
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ image: "", focalX: 50, focalY: 50, zoom: 1 })}
|
||||
className="rounded-md px-2.5 py-1 text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/15 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-300 transition-colors">
|
||||
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
|
||||
<span className="text-xs">{uploading ? "Загрузка..." : "Загрузить фото"}</span>
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { adminFetch } from "@/lib/csrf";
|
||||
interface SectionEditorProps<T> {
|
||||
sectionKey: string;
|
||||
title: string;
|
||||
defaultData?: Partial<T>;
|
||||
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ const DEBOUNCE_MS = 800;
|
||||
export function SectionEditor<T>({
|
||||
sectionKey,
|
||||
title,
|
||||
defaultData,
|
||||
children,
|
||||
}: SectionEditorProps<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
@@ -30,7 +32,7 @@ export function SectionEditor<T>({
|
||||
if (!r.ok) throw new Error("Failed to load");
|
||||
return r.json();
|
||||
})
|
||||
.then(setData)
|
||||
.then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded))
|
||||
.catch(() => setError("Не удалось загрузить данные"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sectionKey]);
|
||||
|
||||
Reference in New Issue
Block a user