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
+83
View File
@@ -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}</>;
}