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>({
|
export function ArrayEditor<T>({
|
||||||
items,
|
items = [] as unknown as T[],
|
||||||
onChange,
|
onChange,
|
||||||
renderItem,
|
renderItem,
|
||||||
createItem,
|
createItem,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useEffect, useState, useMemo } from "react";
|
import { useRef, useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle } from "lucide-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 { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem } from "@/types/content";
|
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>
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
@@ -147,7 +148,7 @@ export function TextareaField({
|
|||||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
value={value}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
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 {
|
interface SelectFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: 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> {
|
interface SectionEditorProps<T> {
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
defaultData?: Partial<T>;
|
||||||
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const DEBOUNCE_MS = 800;
|
|||||||
export function SectionEditor<T>({
|
export function SectionEditor<T>({
|
||||||
sectionKey,
|
sectionKey,
|
||||||
title,
|
title,
|
||||||
|
defaultData,
|
||||||
children,
|
children,
|
||||||
}: SectionEditorProps<T>) {
|
}: SectionEditorProps<T>) {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
@@ -30,7 +32,7 @@ export function SectionEditor<T>({
|
|||||||
if (!r.ok) throw new Error("Failed to load");
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(setData)
|
.then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded))
|
||||||
.catch(() => setError("Не удалось загрузить данные"))
|
.catch(() => setError("Не удалось загрузить данные"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [sectionKey]);
|
}, [sectionKey]);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface AboutData {
|
|||||||
|
|
||||||
export default function AboutEditorPage() {
|
export default function AboutEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<AboutData> sectionKey="about" title="О студии">
|
<SectionEditor<AboutData> sectionKey="about" title="О студии" defaultData={{ paragraphs: [] }}>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<>
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField, RichTextarea } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { ImageCropField } from "../_components/ImageCropField";
|
||||||
import {
|
import {
|
||||||
icons, type LucideIcon,
|
icons, type LucideIcon,
|
||||||
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
|
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
|
||||||
@@ -195,13 +196,16 @@ interface ClassesData {
|
|||||||
icon: string;
|
icon: string;
|
||||||
detailedDescription?: string;
|
detailedDescription?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
imageFocalX?: number;
|
||||||
|
imageFocalY?: number;
|
||||||
|
imageZoom?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClassesEditorPage() {
|
export default function ClassesEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
|
<SectionEditor<ClassesData> sectionKey="classes" title="Направления" defaultData={{ items: [] }}>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<>
|
||||||
<InputField
|
<InputField
|
||||||
@@ -255,13 +259,21 @@ export default function ClassesEditorPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ImageCropField
|
||||||
|
image={item.images?.[0] || ""}
|
||||||
|
focalX={item.imageFocalX ?? 50}
|
||||||
|
focalY={item.imageFocalY ?? 50}
|
||||||
|
zoom={item.imageZoom ?? 1}
|
||||||
|
folder="classes"
|
||||||
|
onChange={(d) => updateItem({ ...item, images: d.image ? [d.image] : [], imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
|
||||||
|
/>
|
||||||
<TextareaField
|
<TextareaField
|
||||||
label="Краткое описание"
|
label="Краткое описание"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(v) => updateItem({ ...item, description: v })}
|
onChange={(v) => updateItem({ ...item, description: v })}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<TextareaField
|
<RichTextarea
|
||||||
label="Подробное описание"
|
label="Подробное описание"
|
||||||
value={item.detailedDescription || ""}
|
value={item.detailedDescription || ""}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
|
|||||||
onChange(formatted);
|
onChange(formatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
const digits = value.replace(/\D/g, "");
|
const digits = (value ?? "").replace(/\D/g, "");
|
||||||
const isComplete = digits.length === 12;
|
const isComplete = digits.length === 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +163,7 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
|||||||
|
|
||||||
export default function ContactEditorPage() {
|
export default function ContactEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты" defaultData={{ addresses: [] }}>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface FAQData {
|
|||||||
|
|
||||||
export default function FAQEditorPage() {
|
export default function FAQEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
|
<SectionEditor<FAQData> sectionKey="faq" title="FAQ" defaultData={{ items: [] }}>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<>
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
<SectionEditor<MasterClassesData>
|
<SectionEditor<MasterClassesData>
|
||||||
sectionKey="masterClasses"
|
sectionKey="masterClasses"
|
||||||
title="Мастер-классы"
|
title="Мастер-классы"
|
||||||
|
defaultData={{ items: [] }}
|
||||||
>
|
>
|
||||||
{(data, update) => {
|
{(data, update) => {
|
||||||
// Sort: active first, archived at bottom
|
// Sort: active first, archived at bottom
|
||||||
|
|||||||
+8
-173
@@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Upload, Loader2, X, AlertCircle } from "lucide-react";
|
import { ImageCropField } from "../_components/ImageCropField";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { AlertCircle } from "lucide-react";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
interface NewsData {
|
interface NewsData {
|
||||||
@@ -14,173 +12,9 @@ interface NewsData {
|
|||||||
items: NewsItem[];
|
items: NewsItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function CropPreview({
|
|
||||||
image,
|
|
||||||
focalX,
|
|
||||||
focalY,
|
|
||||||
zoom,
|
|
||||||
onImageChange,
|
|
||||||
onFocalChange,
|
|
||||||
onZoomChange,
|
|
||||||
}: {
|
|
||||||
image: string;
|
|
||||||
focalX: number;
|
|
||||||
focalY: number;
|
|
||||||
zoom: number;
|
|
||||||
onImageChange: (path: string) => void;
|
|
||||||
onFocalChange: (x: number, y: number) => void;
|
|
||||||
onZoomChange: (z: number) => void;
|
|
||||||
}) {
|
|
||||||
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", "news");
|
|
||||||
try {
|
|
||||||
const res = await adminFetch("/api/admin/upload", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
if (result.path) onImageChange(result.path);
|
|
||||||
} 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;
|
|
||||||
// Invert: dragging right moves focal left (image slides right)
|
|
||||||
const dx = ((e.clientX - startX) / rect.width) * 100;
|
|
||||||
const dy = ((e.clientY - startY) / rect.height) * 100;
|
|
||||||
const newX = Math.max(0, Math.min(100, startFocalX - dx));
|
|
||||||
const newY = Math.max(0, Math.min(100, startFocalY - dy));
|
|
||||||
onFocalChange(Math.round(newX), Math.round(newY));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp() {
|
|
||||||
setDragging(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWheel(e: React.WheelEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|
||||||
const newZoom = Math.max(1, Math.min(3, zoom + delta));
|
|
||||||
onZoomChange(Math.round(newZoom * 10) / 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
|
||||||
Изображение <span className="text-neutral-600">(перетащите фото · колёсико для масштаба)</span>
|
|
||||||
</label>
|
|
||||||
{image ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Crop area — drag image to reposition */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt="Превью"
|
|
||||||
fill
|
|
||||||
className="object-cover pointer-events-none"
|
|
||||||
style={{
|
|
||||||
objectPosition: `${focalX}% ${focalY}%`,
|
|
||||||
transform: `scale(${zoom})`,
|
|
||||||
}}
|
|
||||||
sizes="(max-width: 768px) 100vw, 600px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Zoom slider + actions */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<span className="text-[10px] text-neutral-500">−</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="3"
|
|
||||||
step="0.1"
|
|
||||||
value={zoom}
|
|
||||||
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
|
|
||||||
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-neutral-500">+</span>
|
|
||||||
{zoom > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onZoomChange(1); onFocalChange(50, 50); }}
|
|
||||||
className="text-[10px] text-neutral-500 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 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={() => onImageChange("")}
|
|
||||||
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
|
||||||
{uploading ? (
|
|
||||||
<Loader2 size={20} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload size={20} />
|
|
||||||
<span>Загрузить изображение</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsEditorPage() {
|
export default function NewsEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<NewsData> sectionKey="news" title="Новости">
|
<SectionEditor<NewsData> sectionKey="news" title="Новости" defaultData={{ items: [] }}>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<>
|
||||||
<InputField
|
<InputField
|
||||||
@@ -243,14 +77,15 @@ export default function NewsEditorPage() {
|
|||||||
value={item.text}
|
value={item.text}
|
||||||
onChange={(v) => updateItem({ ...item, text: v })}
|
onChange={(v) => updateItem({ ...item, text: v })}
|
||||||
/>
|
/>
|
||||||
<CropPreview
|
<ImageCropField
|
||||||
image={item.image || ""}
|
image={item.image || ""}
|
||||||
focalX={item.imageFocalX ?? 50}
|
focalX={item.imageFocalX ?? 50}
|
||||||
focalY={item.imageFocalY ?? 50}
|
focalY={item.imageFocalY ?? 50}
|
||||||
zoom={item.imageZoom ?? 1}
|
zoom={item.imageZoom ?? 1}
|
||||||
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
folder="news"
|
||||||
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
aspect="aspect-[21/9]"
|
||||||
onZoomChange={(z) => updateItem({ ...item, imageZoom: z })}
|
label="Изображение"
|
||||||
|
onChange={(d) => updateItem({ ...item, image: d.image || undefined, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Ссылка (необязательно)"
|
label="Ссылка (необязательно)"
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ function PricingContent({ data, update }: { data: PricingData; update: (d: Prici
|
|||||||
|
|
||||||
export default function PricingEditorPage() {
|
export default function PricingEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
<SectionEditor<PricingData> sectionKey="pricing" title="Цены" defaultData={{ items: [], rentalItems: [], rules: [] }}>
|
||||||
{(data, update) => <PricingContent data={data} update={update} />}
|
{(data, update) => <PricingContent data={data} update={update} />}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1285,7 +1285,7 @@ export default function ScheduleEditorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание" defaultData={{ locations: [] }}>
|
||||||
{(data, update) => {
|
{(data, update) => {
|
||||||
const location = data.locations[activeLocation];
|
const location = data.locations[activeLocation];
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,61 @@ function TeamMemberEditor() {
|
|||||||
const dataRef = useRef(data);
|
const dataRef = useRef(data);
|
||||||
dataRef.current = 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
|
// Shared save logic — compares snapshot, skips if unchanged
|
||||||
const saveIfDirty = useCallback(async () => {
|
const saveIfDirty = useCallback(async () => {
|
||||||
if (isNew || loading) return;
|
if (isNew || loading) return;
|
||||||
@@ -223,7 +278,7 @@ function TeamMemberEditor() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/admin/team")}
|
onClick={handleBack}
|
||||||
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} />
|
||||||
|
|||||||
+59
-23
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { adminFetch } from "@/lib/csrf";
|
||||||
|
import { InputField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import type { TeamMember } from "@/types/content";
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
@@ -12,28 +13,52 @@ type Member = TeamMember & { id: number };
|
|||||||
|
|
||||||
export default function TeamEditorPage() {
|
export default function TeamEditorPage() {
|
||||||
const [members, setMembers] = useState<Member[]>([]);
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [sectionTitle, setSectionTitle] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
||||||
const [saved, setSaved] = useState(false);
|
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const titleLoadedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch("/api/admin/team")
|
Promise.all([
|
||||||
.then((r) => r.json())
|
adminFetch("/api/admin/team").then((r) => r.json()),
|
||||||
.then(setMembers)
|
adminFetch("/api/admin/sections/team").then((r) => r.json()),
|
||||||
.finally(() => setLoading(false));
|
]).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[]) => {
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
setMembers(updated);
|
setMembers(updated);
|
||||||
setSaving(true);
|
const res = await adminFetch("/api/admin/team/reorder", {
|
||||||
await adminFetch("/api/admin/team/reorder", {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
});
|
});
|
||||||
setSaving(false);
|
setSaveStatus(res.ok ? "saved" : "error");
|
||||||
setSaved(true);
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function deleteMember(id: number) {
|
async function deleteMember(id: number) {
|
||||||
@@ -52,19 +77,21 @@ export default function TeamEditorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold">Команда</h1>
|
<h1 className="text-2xl font-bold">Команда</h1>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<Link
|
||||||
href="/admin/team/new"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={sectionTitle}
|
||||||
|
onChange={setSectionTitle}
|
||||||
|
placeholder="Наша команда"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<ArrayEditor
|
<ArrayEditor
|
||||||
items={members}
|
items={members}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface AboutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function About({ data: about, stats }: AboutProps) {
|
export function About({ data: about, stats }: AboutProps) {
|
||||||
|
if (!about?.paragraphs) return null;
|
||||||
const statItems = [
|
const statItems = [
|
||||||
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров", ariaLabel: `${stats.trainers} тренеров` },
|
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров", ariaLabel: `${stats.trainers} тренеров` },
|
||||||
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений", ariaLabel: `${stats.classes} направлений` },
|
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений", ariaLabel: `${stats.classes} направлений` },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
|||||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||||
import type { ClassItem, SiteContent } from "@/types";
|
import type { ClassItem, SiteContent } from "@/types";
|
||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
import { formatMarkup } from "@/lib/markup";
|
||||||
|
|
||||||
// kebab "heart-pulse" → PascalCase "HeartPulse"
|
// kebab "heart-pulse" → PascalCase "HeartPulse"
|
||||||
function toPascal(kebab: string) {
|
function toPascal(kebab: string) {
|
||||||
@@ -24,6 +25,7 @@ interface ClassesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Classes({ data: classes }: ClassesProps) {
|
export function Classes({ data: classes }: ClassesProps) {
|
||||||
|
if (!classes?.items?.length) return null;
|
||||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||||
totalItems: classes.items.length,
|
totalItems: classes.items.length,
|
||||||
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||||
@@ -57,6 +59,10 @@ export function Classes({ data: classes }: ClassesProps) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(min-width: 1024px) 60vw, 100vw"
|
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||||
|
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Gradient overlay */}
|
{/* Gradient overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
@@ -75,8 +81,8 @@ export function Classes({ data: classes }: ClassesProps) {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{item.detailedDescription && (
|
{item.detailedDescription && (
|
||||||
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
|
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||||
{item.detailedDescription}
|
{formatMarkup(item.detailedDescription)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface FAQProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FAQ({ data: faq }: FAQProps) {
|
export function FAQ({ data: faq }: FAQProps) {
|
||||||
|
if (!faq?.items?.length) return null;
|
||||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ function MasterClassCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesProps) {
|
export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesProps) {
|
||||||
|
if (!data?.items?.length) return null;
|
||||||
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
const upcoming = useMemo(() => {
|
const upcoming = useMemo(() => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface PricingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Pricing({ data: pricing }: PricingProps) {
|
export function Pricing({ data: pricing }: PricingProps) {
|
||||||
|
if (!pricing?.items) return null;
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ interface ScheduleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
|
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
|
||||||
|
if (!schedule?.locations?.length) return null;
|
||||||
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||||
const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
|
const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
|
||||||
|
|
||||||
|
|||||||
@@ -113,37 +113,50 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
if (!dragStartRef.current) return;
|
if (!dragStartRef.current) return;
|
||||||
const dx = e.clientX - dragStartRef.current.x;
|
const dx = e.clientX - dragStartRef.current.x;
|
||||||
if (Math.abs(dx) > 10) wasDragRef.current = true;
|
if (Math.abs(dx) > 10) wasDragRef.current = true;
|
||||||
setDragOffset(dx);
|
|
||||||
|
// Continuously snap the base index as user drags past card boundaries
|
||||||
|
// This keeps cards wrapping around smoothly during drag
|
||||||
|
const steps = Math.round(dx / CARD_SPACING);
|
||||||
|
if (steps !== 0) {
|
||||||
|
const newBase = wrapIndex(dragStartRef.current.startIndex - steps, total);
|
||||||
|
dragStartRef.current = {
|
||||||
|
x: dragStartRef.current.x + steps * CARD_SPACING,
|
||||||
|
startIndex: newBase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragOffset(e.clientX - dragStartRef.current.x);
|
||||||
},
|
},
|
||||||
[]
|
[total]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerUp = useCallback(() => {
|
const onPointerUp = useCallback(() => {
|
||||||
if (!dragStartRef.current) return;
|
if (!dragStartRef.current) return;
|
||||||
const startIdx = dragStartRef.current.startIndex;
|
|
||||||
const currentOffset = dragOffset;
|
const currentOffset = dragOffset;
|
||||||
const wasDrag = Math.abs(currentOffset) > 10;
|
const wasDrag = Math.abs(currentOffset) > 10;
|
||||||
const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
|
const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
|
||||||
|
const finalIndex = wrapIndex(dragStartRef.current.startIndex - steps, total);
|
||||||
dragStartRef.current = null;
|
dragStartRef.current = null;
|
||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||||
if (steps !== 0) {
|
onActiveChange(finalIndex);
|
||||||
// Update index and reset offset in the same batch so the old card
|
|
||||||
// never becomes center for a frame (prevents label flash)
|
|
||||||
onActiveChange(wrapIndex(startIdx - steps, total));
|
|
||||||
}
|
|
||||||
setDragOffset(0);
|
setDragOffset(0);
|
||||||
}, [total, dragOffset, onActiveChange]);
|
}, [total, dragOffset, onActiveChange]);
|
||||||
|
|
||||||
// Compute interpolated style for each card
|
// Compute interpolated style for each card
|
||||||
const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
|
const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
|
||||||
|
|
||||||
|
// Max distance a card can be from center without duplicating
|
||||||
|
const maxVisible = Math.floor(total / 2);
|
||||||
|
|
||||||
function getCardStyle(index: number) {
|
function getCardStyle(index: number) {
|
||||||
const baseDiff = getDiff(index, baseIndex, total);
|
const baseDiff = getDiff(index, baseIndex, total);
|
||||||
const fractionalShift = dragOffset / CARD_SPACING;
|
const fractionalShift = dragOffset / CARD_SPACING;
|
||||||
const continuousDiff = baseDiff + fractionalShift;
|
const continuousDiff = baseDiff + fractionalShift;
|
||||||
const absDiff = Math.abs(continuousDiff);
|
const absDiff = Math.abs(continuousDiff);
|
||||||
|
|
||||||
|
// Hide cards beyond halfway (prevents same card appearing on both sides)
|
||||||
|
if (absDiff > maxVisible) return null;
|
||||||
if (absDiff > 4) return null;
|
if (absDiff > 4) return null;
|
||||||
|
|
||||||
const lowerSlot = Math.floor(absDiff);
|
const lowerSlot = Math.floor(absDiff);
|
||||||
@@ -157,7 +170,9 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
const x = sign * lerp(s0.x, s1.x, t);
|
const x = sign * lerp(s0.x, s1.x, t);
|
||||||
const w = lerp(s0.w, s1.w, t);
|
const w = lerp(s0.w, s1.w, t);
|
||||||
const h = lerp(s0.h, s1.h, t);
|
const h = lerp(s0.h, s1.h, t);
|
||||||
const opacity = lerp(s0.opacity, s1.opacity, t);
|
// Fade out cards near the edge to avoid abrupt disappearing
|
||||||
|
const edgeFade = maxVisible < 4 ? clamp(1 - (absDiff - (maxVisible - 1)), 0, 1) : 1;
|
||||||
|
const opacity = lerp(s0.opacity, s1.opacity, t) * edgeFade;
|
||||||
const scale = lerp(s0.scale, s1.scale, t);
|
const scale = lerp(s0.scale, s1.scale, t);
|
||||||
const brightness = lerp(s0.brightness, s1.brightness, t);
|
const brightness = lerp(s0.brightness, s1.brightness, t);
|
||||||
const grayscale = lerp(s0.grayscale, s1.grayscale, t);
|
const grayscale = lerp(s0.grayscale, s1.grayscale, t);
|
||||||
|
|||||||
+3
-18
@@ -1,28 +1,13 @@
|
|||||||
import { getSiteContent } from "@/lib/db";
|
import { getSiteContent } from "@/lib/db";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
let cached: { data: SiteContent; expiresAt: number } | null = null;
|
|
||||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
export function getContent(): SiteContent | null {
|
export function getContent(): SiteContent | null {
|
||||||
const now = Date.now();
|
|
||||||
if (cached && now < cached.expiresAt) {
|
|
||||||
return cached.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = getSiteContent();
|
return getSiteContent();
|
||||||
if (content) {
|
|
||||||
cached = { data: content, expiresAt: now + CACHE_TTL };
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invalidate the content cache (call after admin edits). */
|
/** No-op — kept for API compatibility. */
|
||||||
export function invalidateContentCache() {
|
export function invalidateContentCache() {}
|
||||||
cached = null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ const SECTION_KEYS = [
|
|||||||
"hero",
|
"hero",
|
||||||
"about",
|
"about",
|
||||||
"classes",
|
"classes",
|
||||||
|
"team",
|
||||||
"masterClasses",
|
"masterClasses",
|
||||||
"faq",
|
"faq",
|
||||||
"pricing",
|
"pricing",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse inline markup: **bold** and *italic*.
|
||||||
|
* Returns React elements safe from XSS.
|
||||||
|
*/
|
||||||
|
function parseInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
if (match[2]) {
|
||||||
|
parts.push(<strong key={`${keyPrefix}-b${key++}`} className="font-semibold text-white">{match[2]}</strong>);
|
||||||
|
} else if (match[3]) {
|
||||||
|
parts.push(<em key={`${keyPrefix}-i${key++}`}>{match[3]}</em>);
|
||||||
|
}
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple markup renderer.
|
||||||
|
* Supports: **bold**, *italic*, ## headings, — bullet points, blank lines for paragraphs.
|
||||||
|
*/
|
||||||
|
export function formatMarkup(text: string): React.ReactNode {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
|
||||||
|
// ## Heading
|
||||||
|
if (trimmed.startsWith("## ")) {
|
||||||
|
elements.push(
|
||||||
|
<span key={key++} className="block mt-3 mb-1 text-sm font-semibold text-white">
|
||||||
|
{parseInline(trimmed.slice(3), `h${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — Bullet point
|
||||||
|
if (trimmed.startsWith("— ") || trimmed.startsWith("- ") || trimmed.startsWith("🤍 ")) {
|
||||||
|
const content = trimmed.startsWith("🤍 ") ? trimmed.slice(3) : trimmed.slice(2);
|
||||||
|
elements.push(
|
||||||
|
<span key={key++} className="block pl-6 before:content-['🤍'] before:absolute before:left-0 relative">
|
||||||
|
{parseInline(content, `li${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line = paragraph break
|
||||||
|
if (trimmed === "") {
|
||||||
|
elements.push(<span key={key++} className="block h-2" />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular line with inline markup
|
||||||
|
elements.push(
|
||||||
|
<span key={key++} className="block">
|
||||||
|
{parseInline(line, `p${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{elements}</>;
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ export interface ClassItem {
|
|||||||
icon: string;
|
icon: string;
|
||||||
detailedDescription?: string;
|
detailedDescription?: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
imageFocalX?: number;
|
||||||
|
imageFocalY?: number;
|
||||||
|
imageZoom?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user