templates.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. /**
  2. * Standalone component templates that don't depend on json-render
  3. * These components receive data as props instead of using hooks
  4. */
  5. export const componentTemplates: Record<string, string> = {
  6. Card: `"use client";
  7. import { ReactNode } from "react";
  8. interface CardProps {
  9. title?: string | null;
  10. description?: string | null;
  11. padding?: "sm" | "md" | "lg" | null;
  12. children?: ReactNode;
  13. }
  14. export function Card({ title, description, padding, children }: CardProps) {
  15. const paddings: Record<string, string> = {
  16. sm: "12px",
  17. md: "16px",
  18. lg: "24px",
  19. };
  20. return (
  21. <div
  22. style={{
  23. background: "var(--card)",
  24. border: "1px solid var(--border)",
  25. borderRadius: "var(--radius)",
  26. }}
  27. >
  28. {(title || description) && (
  29. <div
  30. style={{
  31. padding: "16px 20px",
  32. borderBottom: "1px solid var(--border)",
  33. }}
  34. >
  35. {title && (
  36. <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
  37. {title}
  38. </h3>
  39. )}
  40. {description && (
  41. <p style={{ margin: "4px 0 0", fontSize: 14, color: "var(--muted)" }}>
  42. {description}
  43. </p>
  44. )}
  45. </div>
  46. )}
  47. <div style={{ padding: paddings[padding || "md"] || "16px" }}>
  48. {children}
  49. </div>
  50. </div>
  51. );
  52. }
  53. `,
  54. Grid: `"use client";
  55. import { ReactNode } from "react";
  56. interface GridProps {
  57. columns?: number | null;
  58. gap?: "sm" | "md" | "lg" | null;
  59. children?: ReactNode;
  60. }
  61. export function Grid({ columns, gap, children }: GridProps) {
  62. const gaps: Record<string, string> = {
  63. sm: "8px",
  64. md: "16px",
  65. lg: "24px",
  66. };
  67. return (
  68. <div
  69. style={{
  70. display: "grid",
  71. gridTemplateColumns: \`repeat(\${columns || 2}, 1fr)\`,
  72. gap: gaps[gap || "md"],
  73. }}
  74. >
  75. {children}
  76. </div>
  77. );
  78. }
  79. `,
  80. Stack: `"use client";
  81. import { ReactNode } from "react";
  82. interface StackProps {
  83. direction?: "horizontal" | "vertical" | null;
  84. gap?: "sm" | "md" | "lg" | null;
  85. align?: "start" | "center" | "end" | "stretch" | null;
  86. children?: ReactNode;
  87. }
  88. export function Stack({ direction, gap, align, children }: StackProps) {
  89. const gaps: Record<string, string> = {
  90. sm: "8px",
  91. md: "16px",
  92. lg: "24px",
  93. };
  94. const alignments: Record<string, string> = {
  95. start: "flex-start",
  96. center: "center",
  97. end: "flex-end",
  98. stretch: "stretch",
  99. };
  100. return (
  101. <div
  102. style={{
  103. display: "flex",
  104. flexDirection: direction === "horizontal" ? "row" : "column",
  105. gap: gaps[gap || "md"],
  106. alignItems: alignments[align || "stretch"],
  107. }}
  108. >
  109. {children}
  110. </div>
  111. );
  112. }
  113. `,
  114. Metric: `"use client";
  115. interface MetricProps {
  116. label: string;
  117. valuePath: string;
  118. format?: "number" | "currency" | "percent" | null;
  119. trend?: "up" | "down" | "neutral" | null;
  120. trendValue?: string | null;
  121. data?: Record<string, unknown>;
  122. }
  123. function getByPath(obj: unknown, path: string): unknown {
  124. if (!path) return obj;
  125. const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
  126. let current: unknown = obj;
  127. for (const segment of segments) {
  128. if (current === null || current === undefined) return undefined;
  129. if (typeof current === "object") {
  130. current = (current as Record<string, unknown>)[segment];
  131. } else {
  132. return undefined;
  133. }
  134. }
  135. return current;
  136. }
  137. export function Metric({ label, valuePath, format, trend, trendValue, data }: MetricProps) {
  138. const rawValue = data ? getByPath(data, valuePath) : undefined;
  139. let displayValue = String(rawValue ?? "-");
  140. if (format === "currency" && typeof rawValue === "number") {
  141. displayValue = new Intl.NumberFormat("en-US", {
  142. style: "currency",
  143. currency: "USD",
  144. }).format(rawValue);
  145. } else if (format === "percent" && typeof rawValue === "number") {
  146. displayValue = new Intl.NumberFormat("en-US", {
  147. style: "percent",
  148. minimumFractionDigits: 1,
  149. }).format(rawValue);
  150. } else if (format === "number" && typeof rawValue === "number") {
  151. displayValue = new Intl.NumberFormat("en-US").format(rawValue);
  152. }
  153. return (
  154. <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
  155. <span style={{ fontSize: 14, color: "var(--muted)" }}>{label}</span>
  156. <span style={{ fontSize: 32, fontWeight: 600 }}>{displayValue}</span>
  157. {(trend || trendValue) && (
  158. <span
  159. style={{
  160. fontSize: 14,
  161. color:
  162. trend === "up"
  163. ? "#22c55e"
  164. : trend === "down"
  165. ? "#ef4444"
  166. : "var(--muted)",
  167. }}
  168. >
  169. {trend === "up" ? "+" : trend === "down" ? "-" : ""}
  170. {trendValue}
  171. </span>
  172. )}
  173. </div>
  174. );
  175. }
  176. `,
  177. Chart: `"use client";
  178. interface ChartProps {
  179. type?: "bar" | "line" | "pie" | "area";
  180. dataPath: string;
  181. title?: string | null;
  182. height?: number | null;
  183. data?: Record<string, unknown>;
  184. }
  185. function getByPath(obj: unknown, path: string): unknown {
  186. if (!path) return obj;
  187. const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
  188. let current: unknown = obj;
  189. for (const segment of segments) {
  190. if (current === null || current === undefined) return undefined;
  191. if (typeof current === "object") {
  192. current = (current as Record<string, unknown>)[segment];
  193. } else {
  194. return undefined;
  195. }
  196. }
  197. return current;
  198. }
  199. export function Chart({ title, dataPath, height, data }: ChartProps) {
  200. const chartData = data
  201. ? (getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined)
  202. : undefined;
  203. if (!chartData || !Array.isArray(chartData)) {
  204. return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
  205. }
  206. const maxValue = Math.max(...chartData.map((d) => d.value));
  207. return (
  208. <div>
  209. {title && (
  210. <h4 style={{ margin: "0 0 16px", fontSize: 14, fontWeight: 600 }}>
  211. {title}
  212. </h4>
  213. )}
  214. <div
  215. style={{ display: "flex", gap: 8, alignItems: "flex-end", height: height || 120 }}
  216. >
  217. {chartData.map((d, i) => (
  218. <div
  219. key={i}
  220. style={{
  221. flex: 1,
  222. display: "flex",
  223. flexDirection: "column",
  224. alignItems: "center",
  225. gap: 4,
  226. }}
  227. >
  228. <div
  229. style={{
  230. width: "100%",
  231. height: \`\${(d.value / maxValue) * 100}%\`,
  232. background: "var(--foreground)",
  233. borderRadius: "4px 4px 0 0",
  234. minHeight: 4,
  235. }}
  236. />
  237. <span style={{ fontSize: 12, color: "var(--muted)" }}>
  238. {d.label}
  239. </span>
  240. </div>
  241. ))}
  242. </div>
  243. </div>
  244. );
  245. }
  246. `,
  247. Table: `"use client";
  248. interface TableProps {
  249. dataPath: string;
  250. columns: Array<{ key: string; label: string; format?: "text" | "currency" | "date" | "badge" | null }>;
  251. data?: Record<string, unknown>;
  252. }
  253. function getByPath(obj: unknown, path: string): unknown {
  254. if (!path) return obj;
  255. const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
  256. let current: unknown = obj;
  257. for (const segment of segments) {
  258. if (current === null || current === undefined) return undefined;
  259. if (typeof current === "object") {
  260. current = (current as Record<string, unknown>)[segment];
  261. } else {
  262. return undefined;
  263. }
  264. }
  265. return current;
  266. }
  267. export function Table({ dataPath, columns, data }: TableProps) {
  268. const tableData = data
  269. ? (getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined)
  270. : undefined;
  271. if (!tableData || !Array.isArray(tableData)) {
  272. return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
  273. }
  274. const formatCell = (value: unknown, format?: string | null) => {
  275. if (value === null || value === undefined) return "-";
  276. if (format === "currency" && typeof value === "number") {
  277. return new Intl.NumberFormat("en-US", {
  278. style: "currency",
  279. currency: "USD",
  280. }).format(value);
  281. }
  282. if (format === "date" && typeof value === "string") {
  283. return new Date(value).toLocaleDateString();
  284. }
  285. if (format === "badge") {
  286. return (
  287. <span
  288. style={{
  289. padding: "2px 8px",
  290. borderRadius: 12,
  291. fontSize: 12,
  292. fontWeight: 500,
  293. background: "var(--border)",
  294. color: "var(--foreground)",
  295. }}
  296. >
  297. {String(value)}
  298. </span>
  299. );
  300. }
  301. return String(value);
  302. };
  303. return (
  304. <table style={{ width: "100%", borderCollapse: "collapse" }}>
  305. <thead>
  306. <tr>
  307. {columns.map((col) => (
  308. <th
  309. key={col.key}
  310. style={{
  311. textAlign: "left",
  312. padding: "12px 8px",
  313. borderBottom: "1px solid var(--border)",
  314. fontSize: 12,
  315. fontWeight: 500,
  316. color: "var(--muted)",
  317. textTransform: "uppercase",
  318. letterSpacing: "0.05em",
  319. }}
  320. >
  321. {col.label}
  322. </th>
  323. ))}
  324. </tr>
  325. </thead>
  326. <tbody>
  327. {tableData.map((row, i) => (
  328. <tr key={i}>
  329. {columns.map((col) => (
  330. <td
  331. key={col.key}
  332. style={{
  333. padding: "12px 8px",
  334. borderBottom: "1px solid var(--border)",
  335. fontSize: 14,
  336. }}
  337. >
  338. {formatCell(row[col.key], col.format)}
  339. </td>
  340. ))}
  341. </tr>
  342. ))}
  343. </tbody>
  344. </table>
  345. );
  346. }
  347. `,
  348. Button: `"use client";
  349. interface ButtonProps {
  350. label: string;
  351. variant?: "primary" | "secondary" | "danger" | "ghost" | null;
  352. size?: "sm" | "md" | "lg" | null;
  353. action?: string;
  354. disabled?: boolean | null;
  355. onClick?: () => void;
  356. }
  357. export function Button({ label, variant, disabled, onClick }: ButtonProps) {
  358. const variants: Record<string, React.CSSProperties> = {
  359. primary: {
  360. background: "var(--foreground)",
  361. color: "var(--background)",
  362. border: "none",
  363. },
  364. secondary: {
  365. background: "transparent",
  366. color: "var(--foreground)",
  367. border: "1px solid var(--border)",
  368. },
  369. danger: { background: "#dc2626", color: "#fff", border: "none" },
  370. ghost: { background: "transparent", color: "var(--muted)", border: "none" },
  371. };
  372. return (
  373. <button
  374. onClick={onClick}
  375. disabled={!!disabled}
  376. style={{
  377. padding: "8px 16px",
  378. borderRadius: "var(--radius)",
  379. fontSize: 14,
  380. fontWeight: 500,
  381. opacity: disabled ? 0.5 : 1,
  382. ...variants[variant || "primary"],
  383. }}
  384. >
  385. {label}
  386. </button>
  387. );
  388. }
  389. `,
  390. Heading: `"use client";
  391. interface HeadingProps {
  392. text: string;
  393. level?: "h1" | "h2" | "h3" | "h4" | null;
  394. }
  395. export function Heading({ text, level }: HeadingProps) {
  396. const sizes: Record<string, { fontSize: number; fontWeight: number }> = {
  397. h1: { fontSize: 32, fontWeight: 700 },
  398. h2: { fontSize: 24, fontWeight: 600 },
  399. h3: { fontSize: 20, fontWeight: 600 },
  400. h4: { fontSize: 16, fontWeight: 600 },
  401. };
  402. const style = sizes[level || "h2"];
  403. const Tag = (level || "h2") as keyof JSX.IntrinsicElements;
  404. return (
  405. <Tag style={{ margin: 0, ...style }}>
  406. {text}
  407. </Tag>
  408. );
  409. }
  410. `,
  411. Text: `"use client";
  412. interface TextProps {
  413. content: string;
  414. variant?: "body" | "caption" | "label" | null;
  415. color?: "default" | "muted" | "success" | "warning" | "danger" | null;
  416. }
  417. export function Text({ content, variant, color }: TextProps) {
  418. const sizes: Record<string, number> = {
  419. body: 14,
  420. caption: 12,
  421. label: 13,
  422. };
  423. const colors: Record<string, string> = {
  424. default: "var(--foreground)",
  425. muted: "var(--muted)",
  426. success: "#22c55e",
  427. warning: "#eab308",
  428. danger: "#ef4444",
  429. };
  430. return (
  431. <p
  432. style={{
  433. margin: 0,
  434. fontSize: sizes[variant || "body"],
  435. color: colors[color || "default"],
  436. }}
  437. >
  438. {content}
  439. </p>
  440. );
  441. }
  442. `,
  443. Badge: `"use client";
  444. interface BadgeProps {
  445. text: string;
  446. variant?: "default" | "success" | "warning" | "danger" | "info" | null;
  447. }
  448. export function Badge({ text, variant }: BadgeProps) {
  449. const colors: Record<string, { bg: string; fg: string }> = {
  450. default: { bg: "var(--border)", fg: "var(--foreground)" },
  451. success: { bg: "#22c55e20", fg: "#22c55e" },
  452. warning: { bg: "#eab30820", fg: "#eab308" },
  453. danger: { bg: "#ef444420", fg: "#ef4444" },
  454. info: { bg: "#3b82f620", fg: "#3b82f6" },
  455. };
  456. const { bg, fg } = colors[variant || "default"];
  457. return (
  458. <span
  459. style={{
  460. display: "inline-block",
  461. padding: "2px 8px",
  462. borderRadius: 12,
  463. fontSize: 12,
  464. fontWeight: 500,
  465. background: bg,
  466. color: fg,
  467. }}
  468. >
  469. {text}
  470. </span>
  471. );
  472. }
  473. `,
  474. Alert: `"use client";
  475. interface AlertProps {
  476. type: "info" | "success" | "warning" | "error";
  477. title: string;
  478. message?: string | null;
  479. dismissible?: boolean | null;
  480. }
  481. export function Alert({ type, title, message }: AlertProps) {
  482. const colors: Record<string, string> = {
  483. info: "var(--muted)",
  484. success: "#22c55e",
  485. warning: "#eab308",
  486. error: "#ef4444",
  487. };
  488. return (
  489. <div
  490. style={{
  491. padding: "12px 16px",
  492. borderRadius: "var(--radius)",
  493. background: "var(--card)",
  494. border: "1px solid var(--border)",
  495. borderLeftWidth: 4,
  496. borderLeftColor: colors[type],
  497. }}
  498. >
  499. <div style={{ fontWeight: 500, fontSize: 14 }}>{title}</div>
  500. {message && (
  501. <div style={{ fontSize: 13, color: "var(--muted)", marginTop: 4 }}>
  502. {message}
  503. </div>
  504. )}
  505. </div>
  506. );
  507. }
  508. `,
  509. Divider: `"use client";
  510. interface DividerProps {
  511. label?: string | null;
  512. }
  513. export function Divider({ label }: DividerProps) {
  514. if (label) {
  515. return (
  516. <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
  517. <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
  518. <span style={{ fontSize: 12, color: "var(--muted)" }}>{label}</span>
  519. <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
  520. </div>
  521. );
  522. }
  523. return <hr style={{ border: "none", height: 1, background: "var(--border)", margin: "16px 0" }} />;
  524. }
  525. `,
  526. Empty: `"use client";
  527. interface EmptyProps {
  528. title: string;
  529. description?: string | null;
  530. action?: string | null;
  531. actionLabel?: string | null;
  532. }
  533. export function Empty({ title, description }: EmptyProps) {
  534. return (
  535. <div style={{ textAlign: "center", padding: "40px 20px" }}>
  536. <div style={{ fontSize: 16, fontWeight: 500 }}>{title}</div>
  537. {description && (
  538. <div style={{ fontSize: 14, color: "var(--muted)", marginTop: 8 }}>
  539. {description}
  540. </div>
  541. )}
  542. </div>
  543. );
  544. }
  545. `,
  546. Select: `"use client";
  547. interface SelectProps {
  548. label?: string | null;
  549. bindPath: string;
  550. options: Array<{ value: string; label: string }>;
  551. placeholder?: string | null;
  552. value?: string;
  553. onChange?: (value: string) => void;
  554. }
  555. export function Select({ label, options, placeholder, value, onChange }: SelectProps) {
  556. return (
  557. <div>
  558. {label && (
  559. <label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
  560. {label}
  561. </label>
  562. )}
  563. <select
  564. value={value || ""}
  565. onChange={(e) => onChange?.(e.target.value)}
  566. style={{
  567. width: "100%",
  568. padding: "8px 12px",
  569. background: "var(--card)",
  570. border: "1px solid var(--border)",
  571. borderRadius: "var(--radius)",
  572. color: "var(--foreground)",
  573. fontSize: 14,
  574. }}
  575. >
  576. {placeholder && <option value="">{placeholder}</option>}
  577. {options.map((opt) => (
  578. <option key={opt.value} value={opt.value}>
  579. {opt.label}
  580. </option>
  581. ))}
  582. </select>
  583. </div>
  584. );
  585. }
  586. `,
  587. DatePicker: `"use client";
  588. interface DatePickerProps {
  589. label?: string | null;
  590. bindPath: string;
  591. placeholder?: string | null;
  592. value?: string;
  593. onChange?: (value: string) => void;
  594. }
  595. export function DatePicker({ label, placeholder, value, onChange }: DatePickerProps) {
  596. return (
  597. <div>
  598. {label && (
  599. <label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
  600. {label}
  601. </label>
  602. )}
  603. <input
  604. type="date"
  605. value={value || ""}
  606. onChange={(e) => onChange?.(e.target.value)}
  607. placeholder={placeholder || ""}
  608. style={{
  609. width: "100%",
  610. padding: "8px 12px",
  611. background: "var(--card)",
  612. border: "1px solid var(--border)",
  613. borderRadius: "var(--radius)",
  614. color: "var(--foreground)",
  615. fontSize: 14,
  616. }}
  617. />
  618. </div>
  619. );
  620. }
  621. `,
  622. List: `"use client";
  623. import { ReactNode } from "react";
  624. interface ListProps {
  625. dataPath: string;
  626. emptyMessage?: string | null;
  627. data?: Record<string, unknown>;
  628. children?: ReactNode;
  629. }
  630. function getByPath(obj: unknown, path: string): unknown {
  631. if (!path) return obj;
  632. const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
  633. let current: unknown = obj;
  634. for (const segment of segments) {
  635. if (current === null || current === undefined) return undefined;
  636. if (typeof current === "object") {
  637. current = (current as Record<string, unknown>)[segment];
  638. } else {
  639. return undefined;
  640. }
  641. }
  642. return current;
  643. }
  644. export function List({ dataPath, emptyMessage, data, children }: ListProps) {
  645. const listData = data
  646. ? (getByPath(data, dataPath) as unknown[] | undefined)
  647. : undefined;
  648. if (!listData || !Array.isArray(listData) || listData.length === 0) {
  649. return (
  650. <div style={{ padding: 20, color: "var(--muted)", textAlign: "center" }}>
  651. {emptyMessage || "No items"}
  652. </div>
  653. );
  654. }
  655. return (
  656. <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
  657. {children}
  658. </div>
  659. );
  660. }
  661. `,
  662. };