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:
@@ -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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user