This commit is contained in:
rgrgogu
2026-05-20 13:25:03 +08:00
parent 2b1955608d
commit 95242709b0
71 changed files with 5535 additions and 1035 deletions
+453 -14
View File
@@ -1,18 +1,457 @@
// components/generic/CMS/blocks/TextBlock.jsx
// components/generic/CMS/Blocks/TextBlock.jsx
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { useEffect, useRef, useState } from "react";
import {
Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
List, ListOrdered, Indent, Outdent,
Link2, FileText, Table,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../AssetPickerSheet";
import { cn } from "@/lib/utils";
export function TextBlock({ content, onUpdate }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={4}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
/>
// ─── Toolbar button ───────────────────────────────────────────────────────────
function ToolbarBtn({ title, onMouseDown, active, children }) {
return (
<button
type="button"
title={title}
onMouseDown={onMouseDown}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-muted-foreground",
"hover:bg-accent hover:text-accent-foreground transition-colors shrink-0",
active && "bg-accent text-accent-foreground"
)}
>
{children}
</button>
);
}
// ─── Toolbar divider ──────────────────────────────────────────────────────────
function Divider() {
return <span className="w-px h-4 bg-border mx-0.5 shrink-0" />;
}
// ─── Format options ───────────────────────────────────────────────────────────
const FORMAT_OPTIONS = [
{ label: "Paragraph", val: "p" },
{ label: "Heading 1", val: "h1" },
{ label: "Heading 2", val: "h2" },
{ label: "Heading 3", val: "h3" },
];
// ─── Shared styles ────────────────────────────────────────────────────────────
export const WYSIWYG_STYLES = `
.wysiwyg-editor { line-height: 1.7; }
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.3; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.4; }
.wysiwyg-editor p,
.wysiwyg-preview p {
margin: 0 0 0.85rem 0;
text-align: justify;
line-height: 1.75;
}
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor li, .wysiwyg-preview li {
margin-bottom: 0.4rem;
line-height: 1.75;
text-align: justify;
}
.wysiwyg-editor a, .wysiwyg-preview a { color: hsl(var(--primary)); text-decoration: underline; }
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
font-size: 0.8125rem;
text-decoration: none;
border: 1px solid hsl(var(--border));
}
.wysiwyg-editor a.doc-link::before,
.wysiwyg-preview a.doc-link::before {
content: "📄";
font-size: 0.75rem;
}
.wysiwyg-editor table,
.wysiwyg-preview table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 0.75rem 0;
font-size: 0.875rem;
table-layout: fixed;
border: 1.5px solid #cbd5e1;
border-radius: 0.375rem;
overflow: hidden;
}
.wysiwyg-editor th,
.wysiwyg-preview th {
background: #f1f5f9;
color: #1e293b;
font-weight: 600;
text-align: left;
padding: 0.5rem 0.75rem;
word-break: break-word;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-editor td,
.wysiwyg-preview td {
padding: 0.45rem 0.75rem;
vertical-align: top;
word-break: break-word;
min-width: 2rem;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-preview tr:nth-child(even) td {
background: #f8fafc;
}
.wysiwyg-editor td:focus,
.wysiwyg-editor th:focus {
outline: 2px solid hsl(var(--ring));
outline-offset: -2px;
background: hsl(var(--accent) / 0.2);
}
`;
// ─── Table picker popover ─────────────────────────────────────────────────────
// A small grid that lets the user hover to choose rows × columns (up to 8×8),
// then click to confirm. Rendered inline in the toolbar.
const MAX_ROWS = 8;
const MAX_COLS = 8;
function TablePicker({ onInsert, onClose }) {
const [hovered, setHovered] = useState({ r: 0, c: 0 });
return (
// Overlay — clicking outside closes the picker without inserting
<div className="fixed inset-0 z-50" onMouseDown={onClose}>
<div
className="absolute z-50 bg-popover border rounded-lg shadow-lg p-3 flex flex-col gap-2"
style={{ top: "var(--table-picker-y, 40px)", left: "var(--table-picker-x, 0px)" }}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Label */}
<p className="text-xs text-muted-foreground select-none">
{hovered.r > 0 && hovered.c > 0
? `${hovered.r} × ${hovered.c} table`
: "Hover to select size"}
</p>
{/* Grid */}
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX_COLS}, 1.25rem)` }}
>
{Array.from({ length: MAX_ROWS }, (_, r) =>
Array.from({ length: MAX_COLS }, (_, c) => (
<div
key={`${r}-${c}`}
onMouseEnter={() => setHovered({ r: r + 1, c: c + 1 })}
onClick={() => {
onInsert(hovered.r, hovered.c);
onClose();
}}
className={cn(
"h-5 w-5 rounded-sm border cursor-pointer transition-colors",
r < hovered.r && c < hovered.c
? "bg-primary/20 border-primary/50"
: "bg-muted border-border"
)}
/>
))
)}
</div>
);
</div>
</div>
);
}
// ─── RichTextEditor ───────────────────────────────────────────────────────────
export function RichTextEditor({ blockId, value, onChange }) {
const editorRef = useRef(null);
const initializedFor = useRef(null);
const savedRange = useRef(null);
const tableButtonRef = useRef(null);
const [docPickerOpen, setDocPickerOpen] = useState(false);
const [tablePickerOpen, setTablePickerOpen] = useState(false);
// ── Seed innerHTML exactly once per blockId ────────────────────────────────
useEffect(() => {
if (!editorRef.current) return;
if (initializedFor.current === blockId) return;
editorRef.current.innerHTML = value ?? "";
initializedFor.current = blockId;
}, [blockId, value]);
// ── Position the table picker below its toolbar button ────────────────────
useEffect(() => {
if (!tablePickerOpen || !tableButtonRef.current) return;
const rect = tableButtonRef.current.getBoundingClientRect();
document.documentElement.style.setProperty("--table-picker-y", `${rect.bottom + 6}px`);
document.documentElement.style.setProperty("--table-picker-x", `${rect.left}px`);
}, [tablePickerOpen]);
// ── execCommand helpers ────────────────────────────────────────────────────
const exec = (cmd, val = null) => {
editorRef.current?.focus();
document.execCommand(cmd, false, val);
};
const applyFormat = (val) => {
editorRef.current?.focus();
document.execCommand("formatBlock", false, `<${val}>`);
};
const isActive = (cmd) => {
try { return document.queryCommandState(cmd); } catch { return false; }
};
// ── URL link ──────────────────────────────────────────────────────────────
const insertUrlLink = () => {
const url = window.prompt("Enter URL:", "https://");
if (url) exec("createLink", url);
};
// ── Document link ─────────────────────────────────────────────────────────
const openDocPicker = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setDocPickerOpen(true);
};
const handleDocSelect = (asset) => {
setDocPickerOpen(false);
const url = asset.file_url;
const label = asset.display_name ?? "Document";
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
const html = `<a href="${url}" class="doc-link" target="_blank" rel="noopener noreferrer">${label}</a>`;
document.execCommand("insertHTML", false, html);
onChange(editorRef.current.innerHTML);
savedRange.current = null;
};
// ── Table insertion ────────────────────────────────────────────────────────
// Builds a <table> with a header row (th) + (rows-1) data rows.
// Each cell is contenteditable (inherited from the editor).
// A paragraph after the table lets the user continue typing below it.
const insertTable = (rows, cols) => {
if (rows < 1 || cols < 1) return;
// Save cursor before opening picker collapses it
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
// Build header row
const headerCells = Array.from({ length: cols })
.map((_, i) => `<th>Column ${i + 1}</th>`)
.join("");
// Build body rows
const bodyRows = Array.from({ length: rows - 1 })
.map(() =>
`<tr>${Array.from({ length: cols }).map(() => "<td><br></td>").join("")}</tr>`
)
.join("");
const tableHTML = `
<table>
<thead><tr>${headerCells}</tr></thead>
<tbody>${bodyRows}</tbody>
</table>
<p><br></p>
`;
document.execCommand("insertHTML", false, tableHTML);
onChange(editorRef.current.innerHTML);
savedRange.current = null;
};
const openTablePicker = () => {
// Save current cursor so we can restore it after the picker closes
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setTablePickerOpen(true);
};
// ── Toolbar groups ─────────────────────────────────────────────────────────
const GROUPS = [
[
{ cmd: "bold", Icon: Bold, title: "Bold" },
{ cmd: "italic", Icon: Italic, title: "Italic" },
{ cmd: "underline", Icon: Underline, title: "Underline" },
],
[
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
],
[
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
{ cmd: "indent", Icon: Indent, title: "Indent" },
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
],
];
return (
<>
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b bg-muted/40">
{/* Block format select */}
<select
className="h-7 text-xs border rounded px-1 mr-0.5 bg-background cursor-pointer shrink-0"
defaultValue="p"
onChange={(e) => applyFormat(e.target.value)}
>
{FORMAT_OPTIONS.map((o) => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
<Divider />
{/* Formatting groups */}
{GROUPS.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ cmd, Icon, title }) => (
<ToolbarBtn
key={cmd}
title={title}
active={isActive(cmd)}
onMouseDown={(e) => { e.preventDefault(); exec(cmd); }}
>
<Icon className="h-3.5 w-3.5" />
</ToolbarBtn>
))}
<Divider />
</div>
))}
{/* URL link */}
<ToolbarBtn
title="Insert URL link"
onMouseDown={(e) => { e.preventDefault(); insertUrlLink(); }}
>
<Link2 className="h-3.5 w-3.5" />
</ToolbarBtn>
{/* Document link */}
<ToolbarBtn
title="Embed document link"
onMouseDown={(e) => { e.preventDefault(); openDocPicker(); }}
>
<FileText className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
{/* Insert table */}
<ToolbarBtn
title="Insert table"
active={tablePickerOpen}
onMouseDown={(e) => {
e.preventDefault();
tablePickerOpen ? setTablePickerOpen(false) : openTablePicker();
}}
>
<span ref={tableButtonRef} className="flex items-center justify-center">
<Table className="h-3.5 w-3.5" />
</span>
</ToolbarBtn>
</div>
{/* ── Editable area ── */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => onChange(editorRef.current.innerHTML)}
className="wysiwyg-editor min-h-[120px] px-3 m-0 py-0 text-sm focus:outline-none"
/>
<style>{WYSIWYG_STYLES}</style>
</div>
{/* Table size picker */}
{tablePickerOpen && (
<TablePicker
onInsert={insertTable}
onClose={() => setTablePickerOpen(false)}
/>
)}
{/* Document asset picker */}
<AssetPickerSheet
open={docPickerOpen}
onOpenChange={setDocPickerOpen}
fileType="document"
onSelect={handleDocSelect}
/>
</>
);
}
// ─── TextBlock ────────────────────────────────────────────────────────────────
export function TextBlock({ content, onUpdate, blockId }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={blockId}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
);
}
@@ -1,10 +1,9 @@
// components/generic/CMS/blocks/TextImageBlock.jsx
// components/generic/CMS/Blocks/TextImageBlock.jsx
import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -12,15 +11,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextImageBlock({ content, onUpdate }) {
export function TextImageBlock({ content, onUpdate, blockId }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
<div className="space-y-4">
{/* ── Layout ── */}
{/* ── Image position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Image Position</Label>
<Select
@@ -35,19 +35,15 @@ export function TextImageBlock({ content, onUpdate }) {
</Select>
</div>
<div className={[
"grid gap-4",
"grid-cols-1 md:grid-cols-2",
].join(" ")}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side ── */}
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
@@ -108,4 +104,4 @@ export function TextImageBlock({ content, onUpdate }) {
/>
</div>
);
}
}
@@ -1,7 +1,8 @@
// components/generic/CMS/Blocks/TextVideoBlock.jsx
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -9,9 +10,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextVideoBlock({ content, onUpdate }) {
export function TextVideoBlock({ content, onUpdate, blockId }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
@@ -19,7 +21,7 @@ export function TextVideoBlock({ content, onUpdate }) {
return (
<div className="space-y-4">
{/* ── Layout ── */}
{/* ── Video position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Video Position</Label>
<Select
@@ -36,14 +38,13 @@ export function TextVideoBlock({ content, onUpdate }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side ── */}
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
@@ -105,4 +106,4 @@ export function TextVideoBlock({ content, onUpdate }) {
/>
</div>
);
}
}