import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react"; import { BlockRenderer, validateBlocks } from "@emdashcms/blocks"; import type { Block, BlockInteraction } from "@emdashcms/blocks"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { blockCatalog } from "./block-defaults"; import { templates } from "./templates"; import { useResizable } from "./useResizable"; // ── Types ──────────────────────────────────────────────────────────────────── interface ActionLogEntry { id: number; timestamp: Date; interaction: BlockInteraction; } // ── Hash sharing ───────────────────────────────────────────────────────────── function encodeToHash(blocks: Block[]): string { try { const json = JSON.stringify(blocks); return btoa(encodeURIComponent(json)); } catch { return ""; } } function decodeFromHash(hash: string): Block[] | null { try { const json = decodeURIComponent(atob(hash)); const parsed: unknown = JSON.parse(json); if (!Array.isArray(parsed)) return null; const result = validateBlocks(parsed); if (!result.valid) return null; return parsed as Block[]; } catch { return null; } } // ── Drag handle ────────────────────────────────────────────────────────────── function DragHandle({ onMouseDown, isDragging, }: { onMouseDown: (e: React.MouseEvent) => void; isDragging: boolean; }) { return (
{/* Visible border line */}
); } // ── Component ──────────────────────────────────────────────────────────────── export function Playground() { const [theme, setTheme] = useState<"light" | "dark">(() => { if ( typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ) { return "dark"; } return "light"; }); // Load initial blocks from hash or first template const [blocks, setBlocks] = useState(() => { if (typeof window !== "undefined" && window.location.hash.length > 1) { const decoded = decodeFromHash(window.location.hash.slice(1)); if (decoded) return decoded; } return templates[0]?.blocks ?? []; }); const [editorText, setEditorText] = useState(() => JSON.stringify(blocks, null, 2)); const [parseError, setParseError] = useState(null); const [validationErrors, setValidationErrors] = useState([]); const [actionLog, setActionLog] = useState([]); const [copied, setCopied] = useState(false); const [templateMenuOpen, setTemplateMenuOpen] = useState(false); const logEndRef = useRef(null); const nextId = useRef(0); const templateMenuRef = useRef(null); // Resizable panels const catalog = useResizable({ initial: 220, min: 160, max: 320 }); const editor = useResizable({ initial: 480, min: 300, max: 800 }); // Apply theme useEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, [theme]); // Close template menu on outside click useEffect(() => { function handleClick(e: MouseEvent) { if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) { setTemplateMenuOpen(false); } } if (templateMenuOpen) { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); } }, [templateMenuOpen]); // Auto-scroll log useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [actionLog]); // Parse editor text into blocks const updateFromText = useCallback((text: string) => { setEditorText(text); try { const parsed: unknown = JSON.parse(text); setParseError(null); if (!Array.isArray(parsed)) { setParseError("Root must be an array of blocks"); return; } const result = validateBlocks(parsed); const validated = parsed as Block[]; if (!result.valid) { setValidationErrors(result.errors.map((e) => `${e.path}: ${e.message}`)); // Still render what we can setBlocks(validated); } else { setValidationErrors([]); setBlocks(validated); } } catch (err) { setParseError(err instanceof Error ? err.message : "Invalid JSON"); } }, []); // Handle block interactions const handleAction = useCallback((interaction: BlockInteraction) => { setActionLog((prev) => [...prev, { id: nextId.current++, timestamp: new Date(), interaction }]); }, []); // Load a template const loadTemplate = useCallback((index: number) => { const template = templates[index]; if (!template) return; const text = JSON.stringify(template.blocks, null, 2); setEditorText(text); setBlocks(template.blocks); setParseError(null); setValidationErrors([]); setTemplateMenuOpen(false); }, []); // Insert a block from the catalog const insertBlock = useCallback( (catalogIndex: number) => { const entry = blockCatalog[catalogIndex]; if (!entry) return; const newBlock = entry.create(); const updated = [...blocks, newBlock]; const text = JSON.stringify(updated, null, 2); setBlocks(updated); setEditorText(text); setParseError(null); setValidationErrors([]); }, [blocks], ); // Share URL const shareUrl = useCallback(async () => { const hash = encodeToHash(blocks); const url = `${window.location.origin}${window.location.pathname}#${hash}`; window.history.replaceState(null, "", `#${hash}`); try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(setCopied, 2000, false); } catch { // Fallback: just update the URL } }, [blocks]); // Error count for status bar const errorCount = useMemo(() => { let count = 0; if (parseError) count++; count += validationErrors.length; return count; }, [parseError, validationErrors]); return (
{/* ── Toolbar ─────────────────────────────────────── */}
Block Kit Playground
{/* Template picker */}
{templateMenuOpen && (
{templates.map((t, i) => ( ))}
)}
{/* Share */} {/* Theme toggle */}
{/* ── Three-column layout ─────────────────────────── */}
{/* ── Left: Block catalog ──────────────────────── */}
Add Block
{blockCatalog.map((entry, i) => ( ))}
{/* ── Center: JSON editor ──────────────────────── */}
JSON Editor {errorCount > 0 && ( {errorCount} {errorCount === 1 ? "error" : "errors"} )}