Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
21
packages/blocks/src/blocks/accordion.tsx
Normal file
21
packages/blocks/src/blocks/accordion.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Collapsible } from "@cloudflare/kumo";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { AccordionBlock, BlockInteraction } from "../types.js";
|
||||
|
||||
export function AccordionBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: AccordionBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(block.default_open ?? false);
|
||||
|
||||
return (
|
||||
<Collapsible label={block.label} open={open} onOpenChange={setOpen}>
|
||||
<BlockRenderer blocks={block.blocks} onAction={onAction} />
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
18
packages/blocks/src/blocks/actions.tsx
Normal file
18
packages/blocks/src/blocks/actions.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { ActionsBlock, BlockInteraction } from "../types.js";
|
||||
|
||||
export function ActionsBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: ActionsBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{block.elements.map((el, i) => (
|
||||
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
packages/blocks/src/blocks/banner.tsx
Normal file
26
packages/blocks/src/blocks/banner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Banner } from "@cloudflare/kumo";
|
||||
import { Info, Warning, WarningCircle } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { BannerBlock } from "../types.js";
|
||||
|
||||
function useVariantIcon(variant: "default" | "alert" | "error") {
|
||||
return useMemo(() => {
|
||||
switch (variant) {
|
||||
case "alert":
|
||||
return <Warning weight="fill" size={20} />;
|
||||
case "error":
|
||||
return <WarningCircle weight="fill" size={20} />;
|
||||
default:
|
||||
return <Info weight="fill" size={20} />;
|
||||
}
|
||||
}, [variant]);
|
||||
}
|
||||
|
||||
export function BannerBlockComponent({ block }: { block: BannerBlock }) {
|
||||
const variant = block.variant ?? "default";
|
||||
const icon = useVariantIcon(variant);
|
||||
return (
|
||||
<Banner variant={variant} icon={icon} title={block.title} description={block.description} />
|
||||
);
|
||||
}
|
||||
159
packages/blocks/src/blocks/chart.tsx
Normal file
159
packages/blocks/src/blocks/chart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Chart, ChartPalette, TimeseriesChart } from "@cloudflare/kumo/components/chart";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||
import {
|
||||
AriaComponent,
|
||||
AxisPointerComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
} from "echarts/components";
|
||||
import * as echarts from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { ChartBlock } from "../types.js";
|
||||
import { useIsDarkMode } from "../utils.js";
|
||||
|
||||
echarts.use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
AriaComponent,
|
||||
AxisPointerComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
// ── Security: HTML-escape untrusted strings before they reach ECharts ────────
|
||||
// ECharts tooltip renders via innerHTML. Plugin-supplied names/labels must be
|
||||
// escaped to prevent stored XSS in the admin dashboard.
|
||||
|
||||
const RE_AMP = /&/g;
|
||||
const RE_LT = /</g;
|
||||
const RE_GT = />/g;
|
||||
const RE_QUOT = /"/g;
|
||||
const RE_APOS = /'/g;
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(RE_AMP, "&")
|
||||
.replace(RE_LT, "<")
|
||||
.replace(RE_GT, ">")
|
||||
.replace(RE_QUOT, """)
|
||||
.replace(RE_APOS, "'");
|
||||
}
|
||||
|
||||
// ── Security: Sanitize custom ECharts options ────────────────────────────────
|
||||
// Plugin-supplied options are passed to chart.setOption(). ECharts accepts
|
||||
// formatter strings rendered via innerHTML, tooltip HTML, and graphic elements
|
||||
// that can execute arbitrary code. We strip dangerous properties and force
|
||||
// richText tooltip mode to eliminate HTML injection vectors.
|
||||
|
||||
/** Keys that accept HTML strings or executable content in ECharts options */
|
||||
const DANGEROUS_KEYS = new Set(["formatter", "rich", "graphic", "axisPointer"]);
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const RE_HTML_TAG = /<[a-z/!]/i;
|
||||
|
||||
function containsHtml(v: unknown): boolean {
|
||||
return typeof v === "string" && RE_HTML_TAG.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone an ECharts options object, stripping properties that could
|
||||
* inject HTML or executable content. Strings containing HTML tags are
|
||||
* replaced with escaped versions.
|
||||
*/
|
||||
function sanitizeOptions(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (DANGEROUS_KEYS.has(key)) continue;
|
||||
if (containsHtml(value)) {
|
||||
result[key] = escapeHtml(value as string);
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value.map((item) =>
|
||||
isRecord(item)
|
||||
? sanitizeOptions(item)
|
||||
: containsHtml(item)
|
||||
? escapeHtml(item as string)
|
||||
: item,
|
||||
);
|
||||
} else if (isRecord(value)) {
|
||||
result[key] = sanitizeOptions(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function TimeseriesChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
|
||||
const config = block.config;
|
||||
if (config.chart_type !== "timeseries") return null;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
config.series.map((s, i) => ({
|
||||
name: escapeHtml(s.name),
|
||||
data: s.data,
|
||||
color: s.color ?? ChartPalette.color(i, isDarkMode),
|
||||
})),
|
||||
[config.series, isDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimeseriesChart
|
||||
echarts={echarts}
|
||||
isDarkMode={isDarkMode}
|
||||
type={config.style}
|
||||
data={data}
|
||||
xAxisName={config.x_axis_name ? escapeHtml(config.x_axis_name) : undefined}
|
||||
yAxisName={config.y_axis_name ? escapeHtml(config.y_axis_name) : undefined}
|
||||
height={config.height}
|
||||
gradient={config.gradient}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
|
||||
const config = block.config;
|
||||
if (config.chart_type !== "custom") return null;
|
||||
|
||||
const safeOptions = useMemo(() => {
|
||||
const sanitized = sanitizeOptions(config.options);
|
||||
// Force richText tooltip mode — renders via canvas, not innerHTML
|
||||
if (isRecord(sanitized.tooltip)) {
|
||||
sanitized.tooltip.renderMode = "richText";
|
||||
} else {
|
||||
sanitized.tooltip = { renderMode: "richText" };
|
||||
}
|
||||
return sanitized;
|
||||
}, [config.options]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
echarts={echarts}
|
||||
isDarkMode={isDarkMode}
|
||||
options={safeOptions as EChartsOption}
|
||||
height={config.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBlockComponent({ block }: { block: ChartBlock }) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-line p-4">
|
||||
{block.config.chart_type === "timeseries" ? (
|
||||
<TimeseriesChartBlock block={block} isDarkMode={isDarkMode} />
|
||||
) : (
|
||||
<CustomChartBlock block={block} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
packages/blocks/src/blocks/code.tsx
Normal file
7
packages/blocks/src/blocks/code.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CodeBlock as KumoCodeBlock } from "@cloudflare/kumo";
|
||||
|
||||
import type { CodeBlock } from "../types.js";
|
||||
|
||||
export function CodeBlockComponent({ block }: { block: CodeBlock }) {
|
||||
return <KumoCodeBlock code={block.code} lang={block.language} />;
|
||||
}
|
||||
23
packages/blocks/src/blocks/columns.tsx
Normal file
23
packages/blocks/src/blocks/columns.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { BlockInteraction, ColumnsBlock } from "../types.js";
|
||||
|
||||
export function ColumnsBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: ColumnsBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const colCount = Math.min(block.columns.length, 3);
|
||||
const gridClass = colCount === 2 ? "grid grid-cols-2 gap-4" : "grid grid-cols-3 gap-4";
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{block.columns.map((col, i) => (
|
||||
<div key={i}>
|
||||
<BlockRenderer blocks={col} onAction={onAction} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/blocks/src/blocks/context.tsx
Normal file
5
packages/blocks/src/blocks/context.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ContextBlock } from "../types.js";
|
||||
|
||||
export function ContextBlockComponent({ block }: { block: ContextBlock }) {
|
||||
return <p className="text-sm text-kumo-subtle">{block.text}</p>;
|
||||
}
|
||||
3
packages/blocks/src/blocks/divider.tsx
Normal file
3
packages/blocks/src/blocks/divider.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function DividerBlockComponent() {
|
||||
return <hr className="my-4 border-kumo-line" />;
|
||||
}
|
||||
33
packages/blocks/src/blocks/empty.tsx
Normal file
33
packages/blocks/src/blocks/empty.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Empty } from "@cloudflare/kumo";
|
||||
import { Package } from "@phosphor-icons/react";
|
||||
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, EmptyBlock } from "../types.js";
|
||||
|
||||
export function EmptyBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: EmptyBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const contents =
|
||||
block.actions && block.actions.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{block.actions.map((el, i) => (
|
||||
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Empty
|
||||
icon={<Package size={48} weight="duotone" />}
|
||||
title={block.title}
|
||||
description={block.description}
|
||||
commandLine={block.command_line}
|
||||
size={block.size}
|
||||
contents={contents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
packages/blocks/src/blocks/fields.tsx
Normal file
16
packages/blocks/src/blocks/fields.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FieldsBlock } from "../types.js";
|
||||
|
||||
export function FieldsBlockComponent({ block }: { block: FieldsBlock }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
{block.fields.map((field, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-sm text-kumo-subtle">{field.label}</div>
|
||||
<div className="text-kumo-default truncate" title={field.value}>
|
||||
{field.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
packages/blocks/src/blocks/form.tsx
Normal file
75
packages/blocks/src/blocks/form.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, FieldCondition, FormBlock, FormField } from "../types.js";
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evaluateCondition(condition: FieldCondition, values: Record<string, unknown>): boolean {
|
||||
const fieldValue = values[condition.field];
|
||||
if ("eq" in condition && condition.eq !== undefined) {
|
||||
return deepEqual(fieldValue, condition.eq);
|
||||
}
|
||||
if ("neq" in condition && condition.neq !== undefined) {
|
||||
return !deepEqual(fieldValue, condition.neq);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getInitialValues(fields: FormField[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if ("initial_value" in field && field.initial_value !== undefined) {
|
||||
values[field.action_id] = field.initial_value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function FormBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: FormBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
getInitialValues(block.fields),
|
||||
);
|
||||
|
||||
const handleChange = useCallback((actionId: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [actionId]: value }));
|
||||
}, []);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onAction({
|
||||
type: "form_submit",
|
||||
action_id: block.submit.action_id,
|
||||
block_id: block.block_id,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{block.fields.map((field) => {
|
||||
if (field.condition && !evaluateCondition(field.condition, values)) {
|
||||
return null;
|
||||
}
|
||||
return <div key={field.action_id}>{renderElement(field, onAction, handleChange)}</div>;
|
||||
})}
|
||||
<div>
|
||||
<Button type="submit">{block.submit.label}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
5
packages/blocks/src/blocks/header.tsx
Normal file
5
packages/blocks/src/blocks/header.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { HeaderBlock } from "../types.js";
|
||||
|
||||
export function HeaderBlockComponent({ block }: { block: HeaderBlock }) {
|
||||
return <h2 className="text-xl font-bold text-kumo-default">{block.text}</h2>;
|
||||
}
|
||||
12
packages/blocks/src/blocks/image.tsx
Normal file
12
packages/blocks/src/blocks/image.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ImageBlock } from "../types.js";
|
||||
|
||||
export function ImageBlockComponent({ block }: { block: ImageBlock }) {
|
||||
return (
|
||||
<figure>
|
||||
<img src={block.url} alt={block.alt} className="max-w-full rounded" />
|
||||
{block.title && (
|
||||
<figcaption className="mt-1 text-sm text-kumo-subtle">{block.title}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
15
packages/blocks/src/blocks/meter.tsx
Normal file
15
packages/blocks/src/blocks/meter.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Meter } from "@cloudflare/kumo";
|
||||
|
||||
import type { MeterBlock } from "../types.js";
|
||||
|
||||
export function MeterBlockComponent({ block }: { block: MeterBlock }) {
|
||||
return (
|
||||
<Meter
|
||||
label={block.label}
|
||||
value={block.value}
|
||||
max={block.max}
|
||||
min={block.min}
|
||||
customValue={block.custom_value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
packages/blocks/src/blocks/section.tsx
Normal file
19
packages/blocks/src/blocks/section.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, SectionBlock } from "../types.js";
|
||||
|
||||
export function SectionBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: SectionBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 text-kumo-default">{block.text}</div>
|
||||
{block.accessory && (
|
||||
<div className="flex-shrink-0">{renderElement(block.accessory, onAction)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
packages/blocks/src/blocks/stats.tsx
Normal file
40
packages/blocks/src/blocks/stats.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ArrowDown, ArrowUp, Minus } from "@phosphor-icons/react";
|
||||
|
||||
import type { StatItem, StatsBlock } from "../types.js";
|
||||
import { cn } from "../utils.js";
|
||||
|
||||
const trendConfig = {
|
||||
up: { icon: ArrowUp, color: "text-green-600" },
|
||||
down: { icon: ArrowDown, color: "text-red-600" },
|
||||
neutral: { icon: Minus, color: "text-kumo-subtle" },
|
||||
} as const;
|
||||
|
||||
function StatCard({ item }: { item: StatItem }) {
|
||||
const trend = item.trend ? trendConfig[item.trend] : null;
|
||||
const TrendIcon = trend?.icon;
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-lg border border-kumo-line p-4">
|
||||
<div className="text-sm text-kumo-subtle">{item.label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-kumo-default">{item.value}</span>
|
||||
{TrendIcon && (
|
||||
<span className={cn("flex items-center", trend.color)}>
|
||||
<TrendIcon size={16} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <div className="mt-1 text-sm text-kumo-subtle">{item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsBlockComponent({ block }: { block: StatsBlock }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{block.items.map((item, i) => (
|
||||
<StatCard key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
packages/blocks/src/blocks/tab.tsx
Normal file
30
packages/blocks/src/blocks/tab.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Tabs } from "@cloudflare/kumo";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { BlockInteraction, TabBlock } from "../types.js";
|
||||
|
||||
export function TabBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: TabBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState(block.default_tab ?? 0);
|
||||
const tabs = block.panels.map((panel, i) => ({ value: String(i), label: panel.label }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
variant="underline"
|
||||
value={String(activeTab)}
|
||||
onValueChange={(value) => setActiveTab(Number(value))}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<BlockRenderer blocks={block.panels[activeTab]?.blocks ?? []} onAction={onAction} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
packages/blocks/src/blocks/table.tsx
Normal file
122
packages/blocks/src/blocks/table.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Badge } from "@cloudflare/kumo";
|
||||
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { BlockInteraction, TableBlock, TableColumn } from "../types.js";
|
||||
import { cn, formatRelativeTime } from "../utils.js";
|
||||
|
||||
function formatCell(value: unknown, format: TableColumn["format"]): React.ReactNode {
|
||||
let str: string;
|
||||
if (value == null) {
|
||||
str = "";
|
||||
} else if (typeof value === "string") {
|
||||
str = value;
|
||||
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||
str = String(value);
|
||||
} else if (typeof value === "object") {
|
||||
str = JSON.stringify(value);
|
||||
} else {
|
||||
str = "";
|
||||
}
|
||||
switch (format) {
|
||||
case "badge":
|
||||
return <Badge>{str}</Badge>;
|
||||
case "relative_time":
|
||||
return str ? formatRelativeTime(str) : "";
|
||||
case "number": {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? str : num.toLocaleString();
|
||||
}
|
||||
case "code":
|
||||
return <code className="rounded bg-kumo-tint px-1.5 py-0.5 font-mono text-sm">{str}</code>;
|
||||
default:
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
export function TableBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: TableBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
||||
|
||||
function handleSort(key: string) {
|
||||
const next =
|
||||
sort?.key === key && sort.dir === "asc"
|
||||
? { key, dir: "desc" as const }
|
||||
: { key, dir: "asc" as const };
|
||||
setSort(next);
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: block.page_action_id,
|
||||
block_id: block.block_id,
|
||||
value: { sort: next },
|
||||
});
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: block.page_action_id,
|
||||
block_id: block.block_id,
|
||||
value: { cursor: block.next_cursor, sort },
|
||||
});
|
||||
}
|
||||
|
||||
if (block.rows.length === 0 && block.empty_text) {
|
||||
return <p className="py-4 text-center text-sm text-kumo-subtle">{block.empty_text}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-start text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-kumo-line">
|
||||
{block.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium text-kumo-subtle",
|
||||
col.sortable && "cursor-pointer select-none",
|
||||
)}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable &&
|
||||
sort?.key === col.key &&
|
||||
(sort.dir === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-kumo-line last:border-0">
|
||||
{block.columns.map((col) => (
|
||||
<td key={col.key} className="px-3 py-2 text-kumo-default">
|
||||
{formatCell(row[col.key], col.format)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{block.next_cursor && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
className="text-sm text-kumo-link hover:underline"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
535
packages/blocks/src/builders.ts
Normal file
535
packages/blocks/src/builders.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import type {
|
||||
AccordionBlock,
|
||||
ActionsBlock,
|
||||
BannerBlock,
|
||||
Block,
|
||||
ButtonElement,
|
||||
CheckboxElement,
|
||||
ChartBlock,
|
||||
ChartSeries,
|
||||
CodeBlock,
|
||||
ComboboxElement,
|
||||
ColumnsBlock,
|
||||
ConfirmDialog,
|
||||
ContextBlock,
|
||||
DateInputElement,
|
||||
RadioElement,
|
||||
RepeaterElement,
|
||||
RepeaterSubField,
|
||||
DividerBlock,
|
||||
Element,
|
||||
EmptyBlock,
|
||||
FieldsBlock,
|
||||
FormBlock,
|
||||
FormField,
|
||||
HeaderBlock,
|
||||
ImageBlock,
|
||||
MediaPickerElement,
|
||||
MeterBlock,
|
||||
NumberInputElement,
|
||||
SecretInputElement,
|
||||
SectionBlock,
|
||||
SelectElement,
|
||||
StatItem,
|
||||
StatsBlock,
|
||||
TableBlock,
|
||||
TableColumn,
|
||||
TextInputElement,
|
||||
ToggleElement,
|
||||
TabBlock,
|
||||
TabPanel,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Block Builders ───────────────────────────────────────────────────────────
|
||||
|
||||
function header(text: string, opts?: { blockId?: string }): HeaderBlock {
|
||||
return {
|
||||
type: "header",
|
||||
text,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function section(text: string, opts?: { accessory?: Element; blockId?: string }): SectionBlock {
|
||||
return {
|
||||
type: "section",
|
||||
text,
|
||||
...(opts?.accessory !== undefined && { accessory: opts.accessory }),
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function divider(opts?: { blockId?: string }): DividerBlock {
|
||||
return {
|
||||
type: "divider",
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function fieldsBlock(
|
||||
fields: Array<{ label: string; value: string }>,
|
||||
opts?: { blockId?: string },
|
||||
): FieldsBlock {
|
||||
return {
|
||||
type: "fields",
|
||||
fields,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function table(opts: {
|
||||
blockId?: string;
|
||||
columns: TableColumn[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
nextCursor?: string;
|
||||
pageActionId: string;
|
||||
emptyText?: string;
|
||||
}): TableBlock {
|
||||
return {
|
||||
type: "table",
|
||||
columns: opts.columns,
|
||||
rows: opts.rows,
|
||||
page_action_id: opts.pageActionId,
|
||||
...(opts.nextCursor !== undefined && { next_cursor: opts.nextCursor }),
|
||||
...(opts.emptyText !== undefined && { empty_text: opts.emptyText }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function actionsBlock(elements: Element[], opts?: { blockId?: string }): ActionsBlock {
|
||||
return {
|
||||
type: "actions",
|
||||
elements,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function stats(items: StatItem[], opts?: { blockId?: string }): StatsBlock {
|
||||
return {
|
||||
type: "stats",
|
||||
items,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function form(opts: {
|
||||
blockId?: string;
|
||||
fields: FormField[];
|
||||
submit: { label: string; actionId: string };
|
||||
}): FormBlock {
|
||||
return {
|
||||
type: "form",
|
||||
fields: opts.fields,
|
||||
submit: { label: opts.submit.label, action_id: opts.submit.actionId },
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function image(opts: { url: string; alt: string; title?: string; blockId?: string }): ImageBlock {
|
||||
return {
|
||||
type: "image",
|
||||
url: opts.url,
|
||||
alt: opts.alt,
|
||||
...(opts.title !== undefined && { title: opts.title }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function context(text: string, opts?: { blockId?: string }): ContextBlock {
|
||||
return {
|
||||
type: "context",
|
||||
text,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function columnsBlock(columns: Block[][], opts?: { blockId?: string }): ColumnsBlock {
|
||||
return {
|
||||
type: "columns",
|
||||
columns,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function bannerBlock(
|
||||
opts: {
|
||||
blockId?: string;
|
||||
variant?: "default" | "alert" | "error";
|
||||
} & ({ title: string; description?: string } | { title?: string; description: string }),
|
||||
): BannerBlock {
|
||||
return {
|
||||
type: "banner",
|
||||
...(opts.title !== undefined && { title: opts.title }),
|
||||
...(opts.description !== undefined && { description: opts.description }),
|
||||
...(opts.variant !== undefined && { variant: opts.variant }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Element Builders ─────────────────────────────────────────────────────────
|
||||
|
||||
function textInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
multiline?: boolean;
|
||||
},
|
||||
): TextInputElement {
|
||||
return {
|
||||
type: "text_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
...(opts?.multiline !== undefined && { multiline: opts.multiline }),
|
||||
};
|
||||
}
|
||||
|
||||
function numberInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { initialValue?: number; min?: number; max?: number },
|
||||
): NumberInputElement {
|
||||
return {
|
||||
type: "number_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
...(opts?.min !== undefined && { min: opts.min }),
|
||||
...(opts?.max !== undefined && { max: opts.max }),
|
||||
};
|
||||
}
|
||||
|
||||
function select(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string },
|
||||
): SelectElement {
|
||||
return {
|
||||
type: "select",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function toggle(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { description?: string; initialValue?: boolean },
|
||||
): ToggleElement {
|
||||
return {
|
||||
type: "toggle",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.description !== undefined && { description: opts.description }),
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function button(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
style?: "primary" | "danger" | "secondary";
|
||||
value?: unknown;
|
||||
confirm?: ConfirmDialog;
|
||||
},
|
||||
): ButtonElement {
|
||||
return {
|
||||
type: "button",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.style !== undefined && { style: opts.style }),
|
||||
...(opts?.value !== undefined && { value: opts.value }),
|
||||
...(opts?.confirm !== undefined && { confirm: opts.confirm }),
|
||||
};
|
||||
}
|
||||
|
||||
function secretInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { placeholder?: string; hasValue?: boolean },
|
||||
): SecretInputElement {
|
||||
return {
|
||||
type: "secret_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
...(opts?.hasValue !== undefined && { has_value: opts.hasValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function checkbox(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string[] },
|
||||
): CheckboxElement {
|
||||
return {
|
||||
type: "checkbox",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function dateInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { initialValue?: string; placeholder?: string },
|
||||
): DateInputElement {
|
||||
return {
|
||||
type: "date_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function combobox(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string; placeholder?: string },
|
||||
): ComboboxElement {
|
||||
return {
|
||||
type: "combobox",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function radio(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string },
|
||||
): RadioElement {
|
||||
return {
|
||||
type: "radio",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function repeater(
|
||||
actionId: string,
|
||||
label: string,
|
||||
fields: RepeaterSubField[],
|
||||
opts?: {
|
||||
itemLabel?: string;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
initialValue?: Array<Record<string, unknown>>;
|
||||
},
|
||||
): RepeaterElement {
|
||||
return {
|
||||
type: "repeater",
|
||||
action_id: actionId,
|
||||
label,
|
||||
fields,
|
||||
...(opts?.itemLabel !== undefined && { item_label: opts.itemLabel }),
|
||||
...(opts?.minItems !== undefined && { min_items: opts.minItems }),
|
||||
...(opts?.maxItems !== undefined && { max_items: opts.maxItems }),
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaPicker(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
mimeTypeFilter?: string;
|
||||
initialValue?: string;
|
||||
placeholder?: string;
|
||||
},
|
||||
): MediaPickerElement {
|
||||
return {
|
||||
type: "media_picker",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.mimeTypeFilter !== undefined && { mime_type_filter: opts.mimeTypeFilter }),
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function timeseriesChart(opts: {
|
||||
blockId?: string;
|
||||
series: ChartSeries[];
|
||||
style?: "line" | "bar";
|
||||
xAxisName?: string;
|
||||
yAxisName?: string;
|
||||
height?: number;
|
||||
gradient?: boolean;
|
||||
}): ChartBlock {
|
||||
return {
|
||||
type: "chart",
|
||||
config: {
|
||||
chart_type: "timeseries",
|
||||
series: opts.series,
|
||||
...(opts.style !== undefined && { style: opts.style }),
|
||||
...(opts.xAxisName !== undefined && { x_axis_name: opts.xAxisName }),
|
||||
...(opts.yAxisName !== undefined && { y_axis_name: opts.yAxisName }),
|
||||
...(opts.height !== undefined && { height: opts.height }),
|
||||
...(opts.gradient !== undefined && { gradient: opts.gradient }),
|
||||
},
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function customChart(opts: {
|
||||
blockId?: string;
|
||||
options: Record<string, unknown>;
|
||||
height?: number;
|
||||
}): ChartBlock {
|
||||
return {
|
||||
type: "chart",
|
||||
config: {
|
||||
chart_type: "custom",
|
||||
options: opts.options,
|
||||
...(opts.height !== undefined && { height: opts.height }),
|
||||
},
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function meter(opts: {
|
||||
blockId?: string;
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
customValue?: string;
|
||||
}): MeterBlock {
|
||||
return {
|
||||
type: "meter",
|
||||
label: opts.label,
|
||||
value: opts.value,
|
||||
...(opts.max !== undefined && { max: opts.max }),
|
||||
...(opts.min !== undefined && { min: opts.min }),
|
||||
...(opts.customValue !== undefined && { custom_value: opts.customValue }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function codeBlock(opts: {
|
||||
blockId?: string;
|
||||
code: string;
|
||||
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
|
||||
}): CodeBlock {
|
||||
return {
|
||||
type: "code",
|
||||
code: opts.code,
|
||||
...(opts.language !== undefined && { language: opts.language }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function tabBlock(
|
||||
panels: TabPanel[],
|
||||
opts?: {
|
||||
defaultTab?: number;
|
||||
blockId?: string;
|
||||
},
|
||||
): TabBlock {
|
||||
return {
|
||||
type: "tab",
|
||||
panels,
|
||||
...(opts?.defaultTab !== undefined && { default_tab: opts.defaultTab }),
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function empty(opts: {
|
||||
blockId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
commandLine?: string;
|
||||
size?: "sm" | "base" | "lg";
|
||||
actions?: Element[];
|
||||
}): EmptyBlock {
|
||||
return {
|
||||
type: "empty",
|
||||
title: opts.title,
|
||||
...(opts.description !== undefined && { description: opts.description }),
|
||||
...(opts.commandLine !== undefined && { command_line: opts.commandLine }),
|
||||
...(opts.size !== undefined && { size: opts.size }),
|
||||
...(opts.actions !== undefined && { actions: opts.actions }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function accordion(opts: {
|
||||
blockId?: string;
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
defaultOpen?: boolean;
|
||||
}): AccordionBlock {
|
||||
return {
|
||||
type: "accordion",
|
||||
label: opts.label,
|
||||
blocks: opts.blocks,
|
||||
...(opts.defaultOpen !== undefined && { default_open: opts.defaultOpen }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const blocks = {
|
||||
header,
|
||||
section,
|
||||
divider,
|
||||
fields: fieldsBlock,
|
||||
table,
|
||||
actions: actionsBlock,
|
||||
stats,
|
||||
form,
|
||||
image,
|
||||
context,
|
||||
columns: columnsBlock,
|
||||
timeseriesChart,
|
||||
customChart,
|
||||
banner: bannerBlock,
|
||||
meter,
|
||||
code: codeBlock,
|
||||
tab: tabBlock,
|
||||
empty,
|
||||
accordion,
|
||||
};
|
||||
|
||||
export const elements = {
|
||||
textInput,
|
||||
numberInput,
|
||||
select,
|
||||
toggle,
|
||||
button,
|
||||
secretInput,
|
||||
checkbox,
|
||||
combobox,
|
||||
dateInput,
|
||||
radio,
|
||||
repeater,
|
||||
mediaPicker,
|
||||
};
|
||||
69
packages/blocks/src/elements/button.tsx
Normal file
69
packages/blocks/src/elements/button.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Button, Dialog, DialogRoot } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ButtonElement } from "../types.js";
|
||||
|
||||
export function ButtonElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
}: {
|
||||
element: ButtonElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const fireAction = useCallback(() => {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: element.value,
|
||||
});
|
||||
}, [onAction, element.action_id, element.value]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (element.confirm) {
|
||||
setConfirmOpen(true);
|
||||
} else {
|
||||
fireAction();
|
||||
}
|
||||
}, [element.confirm, fireAction]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setConfirmOpen(false);
|
||||
fireAction();
|
||||
}, [fireAction]);
|
||||
|
||||
const variant =
|
||||
element.style === "primary"
|
||||
? ("primary" as const)
|
||||
: element.style === "danger"
|
||||
? ("destructive" as const)
|
||||
: ("secondary" as const);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} onClick={handleClick}>
|
||||
{element.label}
|
||||
</Button>
|
||||
{element.confirm && (
|
||||
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<Dialog>
|
||||
<h3 className="text-lg font-semibold text-kumo-default">{element.confirm.title}</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">{element.confirm.text}</p>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={() => setConfirmOpen(false)}>
|
||||
{element.confirm.deny}
|
||||
</Button>
|
||||
<Button
|
||||
variant={element.confirm.style === "danger" ? "destructive" : "primary"}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{element.confirm.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
packages/blocks/src/elements/checkbox.tsx
Normal file
44
packages/blocks/src/elements/checkbox.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Checkbox } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, CheckboxElement } from "../types.js";
|
||||
|
||||
export function CheckboxElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: CheckboxElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<string[]>(element.initial_value ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(element.initial_value ?? []);
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: string[]) => {
|
||||
setValues(newValues);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValues);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: newValues,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Checkbox.Group legend={element.label} value={values} onValueChange={handleChange}>
|
||||
{element.options.map((opt) => (
|
||||
<Checkbox.Item key={opt.value} value={opt.value} label={opt.label} />
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
62
packages/blocks/src/elements/combobox.tsx
Normal file
62
packages/blocks/src/elements/combobox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Combobox } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ComboboxElement } from "../types.js";
|
||||
|
||||
export function ComboboxElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: ComboboxElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const initialOption = useMemo(
|
||||
() => element.options.find((o) => o.value === element.initial_value) ?? null,
|
||||
[element.options, element.initial_value],
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<{ label: string; value: string } | null>(initialOption);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initialOption);
|
||||
}, [initialOption]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: unknown) => {
|
||||
const opt = newValue as { label: string; value: string } | null;
|
||||
setSelected(opt);
|
||||
const val = opt?.value ?? null;
|
||||
if (onChange) {
|
||||
onChange(element.action_id, val);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: val,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={element.label}
|
||||
items={element.options}
|
||||
value={selected}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<Combobox.TriggerInput placeholder={element.placeholder ?? "Search..."} />
|
||||
<Combobox.Content>
|
||||
<Combobox.List>
|
||||
{(item: { label: string; value: string }) => (
|
||||
<Combobox.Item value={item}>{item.label}</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
<Combobox.Empty>No results</Combobox.Empty>
|
||||
</Combobox.Content>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
57
packages/blocks/src/elements/date-input.tsx
Normal file
57
packages/blocks/src/elements/date-input.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, DateInputElement } from "../types.js";
|
||||
|
||||
export function DateInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: DateInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(element.initial_value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(element.initial_value ?? "");
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValue);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!onChange) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: e.target.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-kumo-text">{element.label}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={element.placeholder}
|
||||
className="h-9 rounded-lg border border-kumo-line bg-kumo-bg px-3 text-sm text-kumo-text outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/blocks/src/elements/number-input.tsx
Normal file
50
packages/blocks/src/elements/number-input.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Input } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, NumberInputElement } from "../types.js";
|
||||
|
||||
export function NumberInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: NumberInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value === "" ? undefined : Number(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, val);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!onChange) {
|
||||
const val = e.target.value === "" ? undefined : Number(e.target.value);
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: val,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
type="number"
|
||||
min={element.min}
|
||||
max={element.max}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
packages/blocks/src/elements/radio.tsx
Normal file
44
packages/blocks/src/elements/radio.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Radio } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, RadioElement } from "../types.js";
|
||||
|
||||
export function RadioElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: RadioElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(element.initial_value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(element.initial_value ?? "");
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValue);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Radio.Group legend={element.label} value={value} onValueChange={handleChange}>
|
||||
{element.options.map((opt) => (
|
||||
<Radio.Item key={opt.value} value={opt.value} label={opt.label} />
|
||||
))}
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
70
packages/blocks/src/elements/secret-input.tsx
Normal file
70
packages/blocks/src/elements/secret-input.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { SensitiveInput } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, SecretInputElement } from "../types.js";
|
||||
|
||||
export function SecretInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: SecretInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [editing, setEditing] = useState(!element.has_value);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(v: string) => {
|
||||
setValue(v);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, v);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (!editing) {
|
||||
setEditing(true);
|
||||
setValue("");
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!onChange && value) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (!value && element.has_value) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [onChange, onAction, element.action_id, value, element.has_value]);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<SensitiveInput
|
||||
label={element.label}
|
||||
value={"••••••••"}
|
||||
readOnly
|
||||
onFocus={handleFocus}
|
||||
placeholder={element.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SensitiveInput
|
||||
label={element.label}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={element.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
packages/blocks/src/elements/select.tsx
Normal file
43
packages/blocks/src/elements/select.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Select } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, SelectElement } from "../types.js";
|
||||
|
||||
export function SelectElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: SelectElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleValueChange = useCallback(
|
||||
(value: unknown) => {
|
||||
if (onChange) {
|
||||
onChange(element.action_id, value);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={element.label}
|
||||
defaultValue={element.initial_value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{element.options.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
58
packages/blocks/src/elements/text-input.tsx
Normal file
58
packages/blocks/src/elements/text-input.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Input, InputArea } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, TextInputElement } from "../types.js";
|
||||
|
||||
export function TextInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: TextInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (onChange) {
|
||||
onChange(element.action_id, e.target.value);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!onChange) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: e.target.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
if (element.multiline) {
|
||||
return (
|
||||
<InputArea
|
||||
label={element.label}
|
||||
placeholder={element.placeholder}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
placeholder={element.placeholder}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
packages/blocks/src/elements/toggle.tsx
Normal file
34
packages/blocks/src/elements/toggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Switch } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ToggleElement } from "../types.js";
|
||||
|
||||
export function ToggleElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: ToggleElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState(element.initial_value ?? false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: boolean) => {
|
||||
setChecked(value);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, value);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return <Switch label={element.label} checked={checked} onCheckedChange={handleChange} />;
|
||||
}
|
||||
67
packages/blocks/src/index.ts
Normal file
67
packages/blocks/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export { BlockRenderer } from "./renderer.js";
|
||||
export type { BlockRendererProps } from "./renderer.js";
|
||||
export { renderElement } from "./render-element.js";
|
||||
export { cn, formatRelativeTime } from "./utils.js";
|
||||
|
||||
// Builders and validation
|
||||
export { blocks, elements } from "./builders.js";
|
||||
export { validateBlocks } from "./validation.js";
|
||||
|
||||
// Re-export all types
|
||||
export type {
|
||||
// Composition objects
|
||||
ConfirmDialog,
|
||||
// Elements
|
||||
ButtonElement,
|
||||
TextInputElement,
|
||||
NumberInputElement,
|
||||
SelectElement,
|
||||
ToggleElement,
|
||||
SecretInputElement,
|
||||
CheckboxElement,
|
||||
ComboboxElement,
|
||||
DateInputElement,
|
||||
RadioElement,
|
||||
RepeaterElement,
|
||||
RepeaterSubField,
|
||||
MediaPickerElement,
|
||||
Element,
|
||||
// Form
|
||||
FieldCondition,
|
||||
FormField,
|
||||
// Block sub-types
|
||||
TableColumn,
|
||||
StatItem,
|
||||
ChartSeries,
|
||||
ChartConfig,
|
||||
TimeseriesChartConfig,
|
||||
CustomChartConfig,
|
||||
TabPanel,
|
||||
// Blocks
|
||||
HeaderBlock,
|
||||
SectionBlock,
|
||||
DividerBlock,
|
||||
FieldsBlock,
|
||||
TableBlock,
|
||||
ActionsBlock,
|
||||
StatsBlock,
|
||||
FormBlock,
|
||||
ImageBlock,
|
||||
ContextBlock,
|
||||
ColumnsBlock,
|
||||
ChartBlock,
|
||||
CodeBlock,
|
||||
TabBlock,
|
||||
BannerBlock,
|
||||
MeterBlock,
|
||||
EmptyBlock,
|
||||
AccordionBlock,
|
||||
Block,
|
||||
// Interactions
|
||||
BlockAction,
|
||||
FormSubmit,
|
||||
PageLoad,
|
||||
BlockInteraction,
|
||||
// Response
|
||||
BlockResponse,
|
||||
} from "./types.js";
|
||||
64
packages/blocks/src/render-element.tsx
Normal file
64
packages/blocks/src/render-element.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ButtonElementComponent } from "./elements/button.js";
|
||||
import { CheckboxElementComponent } from "./elements/checkbox.js";
|
||||
import { ComboboxElementComponent } from "./elements/combobox.js";
|
||||
import { DateInputElementComponent } from "./elements/date-input.js";
|
||||
import { NumberInputElementComponent } from "./elements/number-input.js";
|
||||
import { RadioElementComponent } from "./elements/radio.js";
|
||||
import { SecretInputElementComponent } from "./elements/secret-input.js";
|
||||
import { SelectElementComponent } from "./elements/select.js";
|
||||
import { TextInputElementComponent } from "./elements/text-input.js";
|
||||
import { ToggleElementComponent } from "./elements/toggle.js";
|
||||
import type { BlockInteraction, Element } from "./types.js";
|
||||
|
||||
export function renderElement(
|
||||
element: Element,
|
||||
onAction: (interaction: BlockInteraction) => void,
|
||||
onChange?: (actionId: string, value: unknown) => void,
|
||||
): React.ReactNode {
|
||||
switch (element.type) {
|
||||
case "button":
|
||||
return <ButtonElementComponent element={element} onAction={onAction} />;
|
||||
case "text_input":
|
||||
return (
|
||||
<TextInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "number_input":
|
||||
return (
|
||||
<NumberInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "select":
|
||||
return <SelectElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "toggle":
|
||||
return <ToggleElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "secret_input":
|
||||
return (
|
||||
<SecretInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "checkbox":
|
||||
return <CheckboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "radio":
|
||||
return <RadioElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "date_input":
|
||||
return (
|
||||
<DateInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "combobox":
|
||||
return <ComboboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "repeater":
|
||||
// Admin-authoring only. The runtime block renderer never returns a
|
||||
// DOM node for `repeater` — values are persisted on the parent
|
||||
// block and consumed by the plugin's own runtime component.
|
||||
if ((import.meta as { env?: { DEV?: boolean } }).env?.DEV) {
|
||||
console.warn(
|
||||
"[blocks] renderElement: 'repeater' is an admin-authoring element and renders nothing at runtime",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
case "media_picker":
|
||||
return null;
|
||||
default: {
|
||||
const _exhaustive: never = element;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
packages/blocks/src/renderer.tsx
Normal file
82
packages/blocks/src/renderer.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AccordionBlockComponent } from "./blocks/accordion.js";
|
||||
import { ActionsBlockComponent } from "./blocks/actions.js";
|
||||
import { BannerBlockComponent } from "./blocks/banner.js";
|
||||
import { ChartBlockComponent } from "./blocks/chart.js";
|
||||
import { CodeBlockComponent } from "./blocks/code.js";
|
||||
import { ColumnsBlockComponent } from "./blocks/columns.js";
|
||||
import { ContextBlockComponent } from "./blocks/context.js";
|
||||
import { DividerBlockComponent } from "./blocks/divider.js";
|
||||
import { EmptyBlockComponent } from "./blocks/empty.js";
|
||||
import { FieldsBlockComponent } from "./blocks/fields.js";
|
||||
import { FormBlockComponent } from "./blocks/form.js";
|
||||
import { HeaderBlockComponent } from "./blocks/header.js";
|
||||
import { ImageBlockComponent } from "./blocks/image.js";
|
||||
import { MeterBlockComponent } from "./blocks/meter.js";
|
||||
import { SectionBlockComponent } from "./blocks/section.js";
|
||||
import { StatsBlockComponent } from "./blocks/stats.js";
|
||||
import { TabBlockComponent } from "./blocks/tab.js";
|
||||
import { TableBlockComponent } from "./blocks/table.js";
|
||||
import type { Block, BlockInteraction } from "./types.js";
|
||||
|
||||
function renderBlock(
|
||||
block: Block,
|
||||
onAction: (interaction: BlockInteraction) => void,
|
||||
): React.ReactNode {
|
||||
switch (block.type) {
|
||||
case "header":
|
||||
return <HeaderBlockComponent block={block} />;
|
||||
case "section":
|
||||
return <SectionBlockComponent block={block} onAction={onAction} />;
|
||||
case "divider":
|
||||
return <DividerBlockComponent />;
|
||||
case "fields":
|
||||
return <FieldsBlockComponent block={block} />;
|
||||
case "table":
|
||||
return <TableBlockComponent block={block} onAction={onAction} />;
|
||||
case "actions":
|
||||
return <ActionsBlockComponent block={block} onAction={onAction} />;
|
||||
case "stats":
|
||||
return <StatsBlockComponent block={block} />;
|
||||
case "form":
|
||||
return <FormBlockComponent block={block} onAction={onAction} />;
|
||||
case "image":
|
||||
return <ImageBlockComponent block={block} />;
|
||||
case "context":
|
||||
return <ContextBlockComponent block={block} />;
|
||||
case "columns":
|
||||
return <ColumnsBlockComponent block={block} onAction={onAction} />;
|
||||
case "chart":
|
||||
return <ChartBlockComponent block={block} />;
|
||||
case "meter":
|
||||
return <MeterBlockComponent block={block} />;
|
||||
case "banner":
|
||||
return <BannerBlockComponent block={block} />;
|
||||
case "code":
|
||||
return <CodeBlockComponent block={block} />;
|
||||
case "tab":
|
||||
return <TabBlockComponent block={block} onAction={onAction} />;
|
||||
case "empty":
|
||||
return <EmptyBlockComponent block={block} onAction={onAction} />;
|
||||
case "accordion":
|
||||
return <AccordionBlockComponent block={block} onAction={onAction} />;
|
||||
default: {
|
||||
const _exhaustive: never = block;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlockRendererProps {
|
||||
blocks: Block[];
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}
|
||||
|
||||
export function BlockRenderer({ blocks, onAction }: BlockRendererProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{blocks.map((block, i) => (
|
||||
<div key={block.block_id ?? i}>{renderBlock(block, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
packages/blocks/src/server.ts
Normal file
49
packages/blocks/src/server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Server-safe exports for @emdash-cms/blocks.
|
||||
*
|
||||
* Use this entry point in plugin route handlers and other server-side code
|
||||
* that doesn't have React available. Provides builders, validation, and types
|
||||
* without importing any React components.
|
||||
*/
|
||||
|
||||
export { blocks, elements } from "./builders.js";
|
||||
export { validateBlocks } from "./validation.js";
|
||||
|
||||
export type {
|
||||
// Composition objects
|
||||
ConfirmDialog,
|
||||
// Elements
|
||||
ButtonElement,
|
||||
TextInputElement,
|
||||
NumberInputElement,
|
||||
SelectElement,
|
||||
ToggleElement,
|
||||
SecretInputElement,
|
||||
Element,
|
||||
// Form
|
||||
FieldCondition,
|
||||
FormField,
|
||||
// Block sub-types
|
||||
TableColumn,
|
||||
StatItem,
|
||||
// Blocks
|
||||
HeaderBlock,
|
||||
SectionBlock,
|
||||
DividerBlock,
|
||||
FieldsBlock,
|
||||
TableBlock,
|
||||
ActionsBlock,
|
||||
StatsBlock,
|
||||
FormBlock,
|
||||
ImageBlock,
|
||||
ContextBlock,
|
||||
ColumnsBlock,
|
||||
Block,
|
||||
// Interactions
|
||||
BlockAction,
|
||||
FormSubmit,
|
||||
PageLoad,
|
||||
BlockInteraction,
|
||||
// Response
|
||||
BlockResponse,
|
||||
} from "./types.js";
|
||||
415
packages/blocks/src/types.ts
Normal file
415
packages/blocks/src/types.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
// ── Composition Objects ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfirmDialog {
|
||||
title: string;
|
||||
text: string;
|
||||
confirm: string;
|
||||
deny: string;
|
||||
style?: "danger";
|
||||
}
|
||||
|
||||
// ── Elements ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ButtonElement {
|
||||
type: "button";
|
||||
action_id: string;
|
||||
label: string;
|
||||
style?: "primary" | "danger" | "secondary";
|
||||
value?: unknown;
|
||||
confirm?: ConfirmDialog;
|
||||
}
|
||||
|
||||
export interface TextInputElement {
|
||||
type: "text_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
initial_value?: string;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export interface NumberInputElement {
|
||||
type: "number_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
initial_value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface SelectElement {
|
||||
type: "select";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
/** Plugin route that returns `{ items: Array<{ id, name }> }` to populate options dynamically */
|
||||
optionsRoute?: string;
|
||||
}
|
||||
|
||||
export interface ToggleElement {
|
||||
type: "toggle";
|
||||
action_id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
initial_value?: boolean;
|
||||
}
|
||||
|
||||
export interface SecretInputElement {
|
||||
type: "secret_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
has_value?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckboxElement {
|
||||
type: "checkbox";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string[];
|
||||
}
|
||||
|
||||
export interface DateInputElement {
|
||||
type: "date_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ComboboxElement {
|
||||
type: "combobox";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface RadioElement {
|
||||
type: "radio";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-field types allowed inside a RepeaterElement. Limited to the scalar
|
||||
* inputs the admin widget currently renders inline.
|
||||
*/
|
||||
export type RepeaterSubField =
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement;
|
||||
|
||||
/**
|
||||
* Array-of-objects field. Renders as a list of collapsible cards with inline
|
||||
* add/remove and drag-and-drop reordering. Sub-fields are scalar Block Kit
|
||||
* elements keyed by their `action_id`.
|
||||
*
|
||||
* Admin-authoring only: this element is rendered by the admin widget so plugin
|
||||
* blocks can capture repeating data. The runtime block renderer
|
||||
* (`renderElement`) deliberately returns `null` for `repeater` — repeater
|
||||
* values are persisted on the parent block and consumed by the plugin's own
|
||||
* runtime component, not re-rendered as a stand-alone block.
|
||||
*/
|
||||
export interface RepeaterElement {
|
||||
type: "repeater";
|
||||
action_id: string;
|
||||
label: string;
|
||||
/** Singular label used in the UI (e.g. "FAQ" → "Add FAQ"). */
|
||||
item_label?: string;
|
||||
fields: RepeaterSubField[];
|
||||
min_items?: number;
|
||||
max_items?: number;
|
||||
/**
|
||||
* Default rows for the field. Note: the admin widget seeds new rows from
|
||||
* the sub-field types (empty string / `false`), not from `initial_value`;
|
||||
* plugins should populate persisted state via the form `values` payload
|
||||
* instead of relying on `initial_value` for pre-filled rows.
|
||||
*/
|
||||
initial_value?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an item from the media library (or uploads a new one). The stored value
|
||||
* is the selected asset's URL string, so this element is value-compatible with a
|
||||
* plain `text_input` — existing content continues to work after swapping.
|
||||
*/
|
||||
export interface MediaPickerElement {
|
||||
type: "media_picker";
|
||||
action_id: string;
|
||||
label: string;
|
||||
/** Mime-type prefix filter (e.g. "image/"). Defaults to "image/". */
|
||||
mime_type_filter?: string;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export type Element =
|
||||
| ButtonElement
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement
|
||||
| SecretInputElement
|
||||
| CheckboxElement
|
||||
| DateInputElement
|
||||
| ComboboxElement
|
||||
| RadioElement
|
||||
| RepeaterElement
|
||||
| MediaPickerElement;
|
||||
|
||||
// ── Form Fields (elements + optional condition) ──────────────────────────────
|
||||
|
||||
export type FieldCondition =
|
||||
| { field: string; eq?: unknown; neq?: never }
|
||||
| { field: string; neq?: unknown; eq?: never };
|
||||
|
||||
export type FormField = (
|
||||
| ButtonElement
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement
|
||||
| SecretInputElement
|
||||
| CheckboxElement
|
||||
| DateInputElement
|
||||
| ComboboxElement
|
||||
| RadioElement
|
||||
) & {
|
||||
condition?: FieldCondition;
|
||||
};
|
||||
|
||||
// ── Block Sub-types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
format?: "text" | "badge" | "relative_time" | "number" | "code";
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
}
|
||||
|
||||
/** A single data series for a timeseries chart. */
|
||||
export interface ChartSeries {
|
||||
/** Display name shown in tooltips and legends */
|
||||
name: string;
|
||||
/** Array of `[timestamp_ms, value]` tuples ordered by time */
|
||||
data: [number, number][];
|
||||
/**
|
||||
* Hex color for this series. If omitted, an automatic categorical color
|
||||
* from the Kumo palette is assigned based on the series index.
|
||||
*/
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Timeseries-specific chart configuration */
|
||||
export interface TimeseriesChartConfig {
|
||||
chart_type: "timeseries";
|
||||
/** Visual style of each series. Defaults to `"line"`. */
|
||||
style?: "line" | "bar";
|
||||
/** Array of time series to display */
|
||||
series: ChartSeries[];
|
||||
/** Label for the x-axis */
|
||||
x_axis_name?: string;
|
||||
/** Label for the y-axis */
|
||||
y_axis_name?: string;
|
||||
/** Height of the chart in pixels. Defaults to 350. */
|
||||
height?: number;
|
||||
/** Render a gradient fill beneath line series */
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
/** Custom chart configuration using raw ECharts options (pie, etc.) */
|
||||
export interface CustomChartConfig {
|
||||
chart_type: "custom";
|
||||
/** Raw ECharts option object — passed through to `chart.setOption()` */
|
||||
options: Record<string, unknown>;
|
||||
/** Height of the chart in pixels. Defaults to 350. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type ChartConfig = TimeseriesChartConfig | CustomChartConfig;
|
||||
|
||||
// ── Blocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlockBase {
|
||||
block_id?: string;
|
||||
}
|
||||
|
||||
export interface HeaderBlock extends BlockBase {
|
||||
type: "header";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SectionBlock extends BlockBase {
|
||||
type: "section";
|
||||
text: string;
|
||||
accessory?: Element;
|
||||
}
|
||||
|
||||
export interface DividerBlock extends BlockBase {
|
||||
type: "divider";
|
||||
}
|
||||
|
||||
export interface FieldsBlock extends BlockBase {
|
||||
type: "fields";
|
||||
fields: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface TableBlock extends BlockBase {
|
||||
type: "table";
|
||||
columns: TableColumn[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
next_cursor?: string;
|
||||
page_action_id: string;
|
||||
empty_text?: string;
|
||||
}
|
||||
|
||||
export interface ActionsBlock extends BlockBase {
|
||||
type: "actions";
|
||||
elements: Element[];
|
||||
}
|
||||
|
||||
export interface StatsBlock extends BlockBase {
|
||||
type: "stats";
|
||||
items: StatItem[];
|
||||
}
|
||||
|
||||
export interface FormBlock extends BlockBase {
|
||||
type: "form";
|
||||
fields: FormField[];
|
||||
submit: { label: string; action_id: string };
|
||||
}
|
||||
|
||||
export interface ImageBlock extends BlockBase {
|
||||
type: "image";
|
||||
url: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ContextBlock extends BlockBase {
|
||||
type: "context";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ColumnsBlock extends BlockBase {
|
||||
type: "columns";
|
||||
columns: Block[][];
|
||||
}
|
||||
|
||||
export interface ChartBlock extends BlockBase {
|
||||
type: "chart";
|
||||
config: ChartConfig;
|
||||
}
|
||||
|
||||
export interface BannerBlock extends BlockBase {
|
||||
type: "banner";
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: "default" | "alert" | "error";
|
||||
}
|
||||
|
||||
export interface MeterBlock extends BlockBase {
|
||||
type: "meter";
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
custom_value?: string;
|
||||
}
|
||||
|
||||
export interface CodeBlock extends BlockBase {
|
||||
type: "code";
|
||||
code: string;
|
||||
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
|
||||
}
|
||||
|
||||
export interface TabPanel {
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
export interface TabBlock extends BlockBase {
|
||||
type: "tab";
|
||||
panels: TabPanel[];
|
||||
default_tab?: number;
|
||||
}
|
||||
|
||||
export interface EmptyBlock extends BlockBase {
|
||||
type: "empty";
|
||||
title: string;
|
||||
description?: string;
|
||||
command_line?: string;
|
||||
size?: "sm" | "base" | "lg";
|
||||
actions?: Element[];
|
||||
}
|
||||
|
||||
export interface AccordionBlock extends BlockBase {
|
||||
type: "accordion";
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
default_open?: boolean;
|
||||
}
|
||||
|
||||
export type Block =
|
||||
| HeaderBlock
|
||||
| SectionBlock
|
||||
| DividerBlock
|
||||
| FieldsBlock
|
||||
| TableBlock
|
||||
| ActionsBlock
|
||||
| StatsBlock
|
||||
| FormBlock
|
||||
| ImageBlock
|
||||
| ContextBlock
|
||||
| ColumnsBlock
|
||||
| ChartBlock
|
||||
| BannerBlock
|
||||
| MeterBlock
|
||||
| CodeBlock
|
||||
| TabBlock
|
||||
| EmptyBlock
|
||||
| AccordionBlock;
|
||||
|
||||
// ── Interactions ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlockAction {
|
||||
type: "block_action";
|
||||
action_id: string;
|
||||
block_id?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface FormSubmit {
|
||||
type: "form_submit";
|
||||
action_id: string;
|
||||
block_id?: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PageLoad {
|
||||
type: "page_load";
|
||||
page: string;
|
||||
}
|
||||
|
||||
export type BlockInteraction = BlockAction | FormSubmit | PageLoad;
|
||||
|
||||
// ── Response ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlockResponse {
|
||||
blocks: Block[];
|
||||
toast?: { message: string; type: "success" | "error" | "info" };
|
||||
}
|
||||
93
packages/blocks/src/utils.ts
Normal file
93
packages/blocks/src/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: Parameters<typeof clsx>) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects dark mode from `<html data-theme="dark">` or the system
|
||||
* `prefers-color-scheme` media query and stays in sync reactively.
|
||||
*/
|
||||
export function useIsDarkMode(): boolean {
|
||||
const [dark, setDark] = useState(() => {
|
||||
if (typeof document === "undefined") return false;
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark") return true;
|
||||
if (attr === "light") return false;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for data-theme attribute changes on <html>
|
||||
const observer = new MutationObserver(() => {
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark") return setDark(true);
|
||||
if (attr === "light") return setDark(false);
|
||||
setDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
|
||||
// Also watch the system media query
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setDark(e.matches);
|
||||
}
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mq.removeEventListener("change", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diff = Math.floor((now - date.getTime()) / 1000);
|
||||
|
||||
if (diff < 0) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < MINUTE) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < HOUR) {
|
||||
const mins = Math.floor(diff / MINUTE);
|
||||
return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
|
||||
}
|
||||
if (diff < DAY) {
|
||||
const hours = Math.floor(diff / HOUR);
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
||||
}
|
||||
if (diff < WEEK) {
|
||||
const days = Math.floor(diff / DAY);
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
}
|
||||
if (diff < MONTH) {
|
||||
const weeks = Math.floor(diff / WEEK);
|
||||
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
|
||||
}
|
||||
if (diff < YEAR) {
|
||||
const months = Math.floor(diff / MONTH);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
const years = Math.floor(diff / YEAR);
|
||||
return years === 1 ? "1 year ago" : `${years} years ago`;
|
||||
}
|
||||
1219
packages/blocks/src/validation.ts
Normal file
1219
packages/blocks/src/validation.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user