mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user