widget.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. "use client";
  2. import React, { useState, useCallback, useRef, useEffect } from "react";
  3. import { toast } from "sonner";
  4. import {
  5. Check,
  6. Code,
  7. Copy,
  8. GripVertical,
  9. Pencil,
  10. Trash2,
  11. X,
  12. } from "lucide-react";
  13. import { useUIStream, type Spec } from "@json-render/react";
  14. import { DashboardRenderer } from "@/lib/render/renderer";
  15. import { executeAction } from "@/lib/render/registry";
  16. import { CodeHighlight } from "@/components/code-highlight";
  17. import { Button } from "@/components/ui/button";
  18. import { Input } from "@/components/ui/input";
  19. import { Card, CardContent } from "@/components/ui/card";
  20. import {
  21. Dialog,
  22. DialogContent,
  23. DialogDescription,
  24. DialogFooter,
  25. DialogHeader,
  26. DialogTitle,
  27. } from "@/components/ui/dialog";
  28. import { AnimatedBorder } from "@/components/ui/animated-border";
  29. const suggestions = [
  30. "Customer list with delete",
  31. "Create customer form",
  32. "Invoice summary",
  33. "Revenue metrics",
  34. ];
  35. interface WidgetProps {
  36. id?: string;
  37. initialPrompt?: string;
  38. initialSpec?: Spec;
  39. onGenerated?: () => void;
  40. onCleared?: () => void;
  41. onDeleted?: () => void;
  42. onSaved?: (id: string) => void;
  43. dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
  44. }
  45. export function Widget({
  46. id: initialId,
  47. initialPrompt,
  48. initialSpec,
  49. onGenerated,
  50. onCleared,
  51. onDeleted,
  52. onSaved,
  53. dragHandleProps,
  54. }: WidgetProps): React.ReactElement {
  55. const [prompt, setPrompt] = useState(initialPrompt || "");
  56. const [state, setState] = useState<Record<string, unknown>>({});
  57. const [widgetId, setWidgetId] = useState<string | undefined>(initialId);
  58. const [isEditing, setIsEditing] = useState(false);
  59. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  60. const [showCode, setShowCode] = useState(false);
  61. const [copied, setCopied] = useState(false);
  62. const [editPrompt, setEditPrompt] = useState("");
  63. const promptRef = useRef(prompt);
  64. const stateRef = useRef(state);
  65. const editInputRef = useRef<HTMLInputElement>(null);
  66. const promptInputRef = useRef<HTMLInputElement>(null);
  67. // Keep stateRef in sync
  68. useEffect(() => {
  69. stateRef.current = state;
  70. }, [state]);
  71. // Auto-focus prompt input for new widgets
  72. useEffect(() => {
  73. if (!initialSpec && promptInputRef.current) {
  74. promptInputRef.current.focus();
  75. }
  76. }, [initialSpec]);
  77. const { spec, isStreaming, error, send, clear } = useUIStream({
  78. api: "/api/generate",
  79. onError: (err) => console.error("Widget generation error:", err),
  80. onComplete: async (completedSpec) => {
  81. // Save to database when generation completes
  82. const currentPrompt = promptRef.current;
  83. if (completedSpec && currentPrompt) {
  84. try {
  85. if (widgetId) {
  86. // Update existing widget
  87. await fetch(`/api/v1/widgets/${widgetId}`, {
  88. method: "PATCH",
  89. headers: { "Content-Type": "application/json" },
  90. body: JSON.stringify({
  91. prompt: currentPrompt,
  92. spec: completedSpec,
  93. }),
  94. });
  95. } else {
  96. // Create new widget
  97. const res = await fetch("/api/v1/widgets", {
  98. method: "POST",
  99. headers: { "Content-Type": "application/json" },
  100. body: JSON.stringify({
  101. prompt: currentPrompt,
  102. spec: completedSpec,
  103. }),
  104. });
  105. const saved = await res.json();
  106. setWidgetId(saved.id);
  107. onSaved?.(saved.id);
  108. }
  109. } catch (err) {
  110. console.error("Failed to save widget:", err);
  111. }
  112. }
  113. },
  114. });
  115. // Keep promptRef in sync
  116. useEffect(() => {
  117. promptRef.current = prompt;
  118. }, [prompt]);
  119. const handleGenerate = useCallback(
  120. async (text?: string) => {
  121. const p = text || prompt;
  122. if (!p.trim()) return;
  123. if (text) setPrompt(text);
  124. await send(p, { state });
  125. onGenerated?.();
  126. },
  127. [prompt, send, state, onGenerated],
  128. );
  129. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  130. const handleClear = useCallback(() => {
  131. clear();
  132. setPrompt("");
  133. setState({});
  134. onCleared?.();
  135. }, [clear, onCleared]);
  136. const handleKeyDown = useCallback(
  137. (e: React.KeyboardEvent) => {
  138. if (e.key === "Enter" && !e.shiftKey) {
  139. e.preventDefault();
  140. handleGenerate();
  141. }
  142. },
  143. [handleGenerate],
  144. );
  145. const handleEdit = useCallback(() => {
  146. setEditPrompt("");
  147. setIsEditing(true);
  148. setTimeout(() => editInputRef.current?.focus(), 0);
  149. }, []);
  150. const handleEditSubmit = useCallback(async () => {
  151. if (editPrompt.trim()) {
  152. setIsEditing(false);
  153. // Pass the current spec so AI can modify it instead of replacing
  154. const existingSpec = spec || initialSpec;
  155. await send(editPrompt, { state, previousSpec: existingSpec });
  156. onGenerated?.();
  157. }
  158. }, [editPrompt, send, state, spec, initialSpec, onGenerated]);
  159. const handleEditKeyDown = useCallback(
  160. (e: React.KeyboardEvent) => {
  161. if (e.key === "Enter" && !e.shiftKey) {
  162. e.preventDefault();
  163. handleEditSubmit();
  164. } else if (e.key === "Escape") {
  165. setIsEditing(false);
  166. }
  167. },
  168. [handleEditSubmit],
  169. );
  170. const handleDelete = useCallback(async () => {
  171. if (widgetId) {
  172. try {
  173. await fetch(`/api/v1/widgets/${widgetId}`, { method: "DELETE" });
  174. toast.success("Widget deleted");
  175. } catch (err) {
  176. console.error("Failed to delete widget:", err);
  177. toast.error("Failed to delete widget");
  178. }
  179. }
  180. setShowDeleteConfirm(false);
  181. onDeleted?.();
  182. }, [widgetId, onDeleted]);
  183. const handleStateChange = useCallback(
  184. (changes: Array<{ path: string; value: unknown }>) => {
  185. setState((prev) => {
  186. const next = { ...prev };
  187. for (const { path, value } of changes) {
  188. const parts = path.split("/");
  189. let current: Record<string, unknown> = next;
  190. for (let i = 0; i < parts.length - 1; i++) {
  191. const part = parts[i]!;
  192. if (!(part in current) || typeof current[part] !== "object") {
  193. current[part] = {};
  194. }
  195. current = current[part] as Record<string, unknown>;
  196. }
  197. const lastPart = parts[parts.length - 1]!;
  198. current[lastPart] = value;
  199. }
  200. return next;
  201. });
  202. },
  203. [],
  204. );
  205. // Use spec from stream, or initial spec for saved widgets
  206. const currentSpec = spec || initialSpec;
  207. // Auto-run initial actions when spec loads (for saved widgets)
  208. useEffect(() => {
  209. if (!currentSpec || isStreaming) return;
  210. // Check for initialActions in spec metadata
  211. const specWithMeta = currentSpec as Spec & {
  212. initialActions?: Array<{
  213. action: string;
  214. params?: Record<string, unknown>;
  215. }>;
  216. };
  217. if (specWithMeta.initialActions) {
  218. specWithMeta.initialActions.forEach(({ action, params }) => {
  219. executeAction(action, params, setState);
  220. });
  221. return;
  222. }
  223. // Auto-detect: find Tables/Charts and run their data-fetching actions
  224. const elements = currentSpec.elements || {};
  225. const dataComponents = ["Table", "BarChart", "LineChart"];
  226. const actionsRun = new Set<string>();
  227. for (const el of Object.values(elements)) {
  228. const element = el as { type: string; props?: Record<string, unknown> };
  229. if (dataComponents.includes(element.type) && element.props?.statePath) {
  230. const statePath = element.props.statePath as string;
  231. // Extract resource name (e.g., "customers" from "customers.data")
  232. const resource = statePath.split(".")[0];
  233. if (!resource || actionsRun.has(resource)) continue;
  234. // Find a button that loads this data
  235. for (const btnEl of Object.values(elements)) {
  236. const btn = btnEl as {
  237. type: string;
  238. props?: Record<string, unknown>;
  239. };
  240. if (btn.type === "Button" && btn.props?.action) {
  241. const action = btn.props.action as string;
  242. if (
  243. action.toLowerCase().includes(resource) &&
  244. (action.startsWith("view") ||
  245. action.startsWith("refresh") ||
  246. action.startsWith("load"))
  247. ) {
  248. // Run this action with its params
  249. executeAction(
  250. action,
  251. btn.props.actionParams as Record<string, unknown>,
  252. setState,
  253. );
  254. actionsRun.add(resource);
  255. break;
  256. }
  257. }
  258. }
  259. }
  260. }
  261. }, [currentSpec, isStreaming]);
  262. const hasContent =
  263. currentSpec && Object.keys(currentSpec.elements).length > 0;
  264. // Title derived from prompt
  265. const title = prompt.trim() || "New Widget";
  266. const handleCopyCode = async () => {
  267. if (currentSpec) {
  268. await navigator.clipboard.writeText(JSON.stringify(currentSpec, null, 2));
  269. setCopied(true);
  270. setTimeout(() => setCopied(false), 2000);
  271. }
  272. };
  273. return (
  274. <Card className="group relative flex flex-col aspect-[4/3] py-0 gap-0">
  275. {isStreaming && <AnimatedBorder />}
  276. {/* Title bar */}
  277. <div className="px-3 py-2 border-b flex items-center justify-between bg-muted/30">
  278. <div className="flex items-center gap-1 flex-1 min-w-0">
  279. {dragHandleProps && (
  280. <button
  281. {...dragHandleProps}
  282. className="h-6 w-6 p-0 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
  283. title="Drag to reorder"
  284. >
  285. <GripVertical className="h-4 w-4" />
  286. </button>
  287. )}
  288. <span className="text-sm font-medium truncate flex-1" title={title}>
  289. {title}
  290. </span>
  291. </div>
  292. <div className="flex items-center gap-1">
  293. {/* Hover actions */}
  294. {hasContent && (
  295. <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
  296. <Button
  297. onClick={() => setShowCode(!showCode)}
  298. variant="ghost"
  299. size="sm"
  300. className={`h-6 w-6 p-0 text-muted-foreground hover:text-foreground ${showCode ? "bg-muted" : ""}`}
  301. title={showCode ? "Show preview" : "Show code"}
  302. >
  303. <Code className="h-3.5 w-3.5" />
  304. </Button>
  305. <Button
  306. onClick={handleEdit}
  307. variant="ghost"
  308. size="sm"
  309. className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
  310. title="Edit"
  311. >
  312. <Pencil className="h-3.5 w-3.5" />
  313. </Button>
  314. <Button
  315. onClick={() => setShowDeleteConfirm(true)}
  316. variant="ghost"
  317. size="sm"
  318. className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
  319. title="Delete"
  320. >
  321. <Trash2 className="h-3.5 w-3.5" />
  322. </Button>
  323. </div>
  324. )}
  325. </div>
  326. </div>
  327. {/* Content area */}
  328. <CardContent className="relative flex-1 overflow-auto p-4">
  329. {error ? (
  330. <div className="text-destructive text-sm">{error.message}</div>
  331. ) : !hasContent && !isStreaming ? (
  332. <div className="h-full flex flex-col items-center justify-center gap-3">
  333. <p className="text-muted-foreground text-sm">Try one of these:</p>
  334. <div className="flex flex-wrap gap-1.5 justify-center max-w-[300px]">
  335. {suggestions.map((s) => (
  336. <Button
  337. key={s}
  338. onClick={() => handleGenerate(s)}
  339. variant="outline"
  340. size="sm"
  341. className="text-xs h-7"
  342. >
  343. {s}
  344. </Button>
  345. ))}
  346. </div>
  347. </div>
  348. ) : currentSpec ? (
  349. showCode ? (
  350. <div className="relative h-full">
  351. <Button
  352. onClick={handleCopyCode}
  353. variant="ghost"
  354. size="sm"
  355. className="absolute top-0 right-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
  356. title="Copy code"
  357. >
  358. {copied ? (
  359. <Check className="h-3.5 w-3.5 text-green-500" />
  360. ) : (
  361. <Copy className="h-3.5 w-3.5" />
  362. )}
  363. </Button>
  364. <CodeHighlight code={JSON.stringify(currentSpec, null, 2)} />
  365. </div>
  366. ) : (
  367. <DashboardRenderer
  368. spec={currentSpec}
  369. state={state}
  370. setState={setState}
  371. onStateChange={handleStateChange}
  372. loading={isStreaming}
  373. />
  374. )
  375. ) : null}
  376. {/* Edit overlay */}
  377. {isEditing && (
  378. <div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
  379. <div className="w-full max-w-sm px-4">
  380. <div className="flex items-center gap-2">
  381. <Input
  382. ref={editInputRef}
  383. type="text"
  384. value={editPrompt}
  385. onChange={(e) => setEditPrompt(e.target.value)}
  386. onKeyDown={handleEditKeyDown}
  387. placeholder="Update widget prompt..."
  388. className="flex-1 text-sm"
  389. autoFocus
  390. />
  391. <Button onClick={handleEditSubmit} size="sm">
  392. Go
  393. </Button>
  394. <Button
  395. onClick={() => setIsEditing(false)}
  396. variant="ghost"
  397. size="sm"
  398. className="h-8 w-8 p-0"
  399. >
  400. <X className="h-4 w-4" />
  401. </Button>
  402. </div>
  403. </div>
  404. </div>
  405. )}
  406. </CardContent>
  407. {/* Prompt input at bottom - only show for new widgets */}
  408. {!hasContent && (
  409. <div className="p-3 border-t flex gap-2">
  410. <Input
  411. ref={promptInputRef}
  412. type="text"
  413. value={prompt}
  414. onChange={(e) => setPrompt(e.target.value)}
  415. onKeyDown={handleKeyDown}
  416. placeholder="Describe this widget..."
  417. disabled={isStreaming}
  418. className="flex-1 text-sm"
  419. />
  420. <Button
  421. onClick={() => handleGenerate()}
  422. disabled={isStreaming || !prompt.trim()}
  423. size="sm"
  424. >
  425. {isStreaming ? "..." : "Go"}
  426. </Button>
  427. </div>
  428. )}
  429. {/* Delete confirmation dialog */}
  430. <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
  431. <DialogContent>
  432. <DialogHeader>
  433. <DialogTitle>Delete widget</DialogTitle>
  434. <DialogDescription>
  435. Are you sure you want to delete this widget? This action cannot be
  436. undone.
  437. </DialogDescription>
  438. </DialogHeader>
  439. <DialogFooter>
  440. <Button
  441. onClick={() => setShowDeleteConfirm(false)}
  442. variant="outline"
  443. >
  444. Cancel
  445. </Button>
  446. <Button onClick={handleDelete} variant="destructive">
  447. Delete
  448. </Button>
  449. </DialogFooter>
  450. </DialogContent>
  451. </Dialog>
  452. </Card>
  453. );
  454. }