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
+36 -35
View File
@@ -1,21 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { Search, CheckCircle2 } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { useAssets } from "@/contexts/AdminAssetsContext";
// ─── Empty ────────────────────────────────────────────────────────────────────
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
@@ -26,8 +18,6 @@ function EmptyState({ fileType }) {
);
}
// ─── Asset Card ───────────────────────────────────────────────────────────────
function AssetCard({ asset, selected, onSelect }) {
const thumb = asset.thumbnail_url ?? asset.file_url;
@@ -43,7 +33,6 @@ function AssetCard({ asset, selected, onSelect }) {
: "border-border",
].join(" ")}
>
{/* Thumbnail */}
<div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img
@@ -57,13 +46,9 @@ function AssetCard({ asset, selected, onSelect }) {
</div>
)}
</div>
{/* Name */}
<div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
</div>
{/* Selected checkmark */}
{selected && (
<div className="absolute top-1.5 right-1.5">
<CheckCircle2 className="h-5 w-5 text-primary fill-white" />
@@ -73,33 +58,39 @@ function AssetCard({ asset, selected, onSelect }) {
);
}
// ─── Sheet ────────────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState("");
const [committed, setCommitted] = useState(""); // ← only updates on search trigger
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const LIMIT = 12;
// ── Fetch on open / search / page change ──────────────────────────────────
const triggerSearch = useCallback(() => {
setCommitted(search);
setPage(1);
}, [search]);
// ── Fetch only when committed search, page, or open changes ──────────────
useEffect(() => {
if (!open) return;
fetchAssets({
page,
limit: LIMIT,
filters: [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(search ? [{ id: "display_name", value: [search] }] : []),
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(committed ? [{ id: "display_name", value: [committed] }] : []),
],
});
}, [open, page, search, fileType]);
}, [open, page, committed, fileType]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setCommitted("");
setPage(1);
setSelected(null);
}
@@ -129,14 +120,24 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Search ── */}
<div className="px-6 py-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9"
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
className="pl-9"
/>
</div>
<Button
type="button"
onClick={triggerSearch}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4" /> : "Search"}
</Button>
</div>
</div>
+64 -46
View File
@@ -1,66 +1,84 @@
// components/generic/CMS/BlockList.jsx
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { TextImageBlock } from "./Blocks/TextImageBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { TextVideoBlock } from "./Blocks/TextVideoBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
//
// blockId is forwarded to TextBlock (and any future rich-text blocks) so the
// WYSIWYG editor knows exactly when to seed its innerHTML from saved data.
function BlockContent({ type, content, onUpdate }) {
switch (type) {
case "text": return <TextBlock content={content} onUpdate={onUpdate} />;
case "image": return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image": return <TextImageBlock content={content} onUpdate={onUpdate} />;
case "video": return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video": return <TextVideoBlock content={content} onUpdate={onUpdate} />;
default: return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
function BlockContent({ block, onUpdate }) {
const { id, type, content } = block;
switch (type) {
case "text":
return (
<TextBlock
blockId={id}
content={content}
onUpdate={onUpdate}
/>
);
case "image":
return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image":
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
case "video":
return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
default:
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
}
// ─── Default content per type ─────────────────────────────────────────────────
export const DEFAULT_CONTENT = {
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
if (!blocks.length) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">Use the button below to add your first block.</p>
</div>
);
}
if (!blocks.length) {
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
type={block.type}
content={block.content}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">
Use the button below to add your first block.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
block={block}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
);
}
+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>
);
}
}
@@ -259,6 +259,7 @@ export default function DataTable({
isOpen={activeColumn === header.column.id}
onOpenChange={(isOpen) => setActiveColumn(isOpen ? header.column.id : null)}
onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
showFilter={attr?.filterable}
/>
)}
</TableHead>
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"