widget.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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 [data, setData] = 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 dataRef = useRef(data);
  65. const editInputRef = useRef<HTMLInputElement>(null);
  66. const promptInputRef = useRef<HTMLInputElement>(null);
  67. // Keep dataRef in sync
  68. useEffect(() => {
  69. dataRef.current = data;
  70. }, [data]);
  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, { data });
  125. onGenerated?.();
  126. },
  127. [prompt, send, data, onGenerated],
  128. );
  129. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  130. const handleClear = useCallback(() => {
  131. clear();
  132. setPrompt("");
  133. setData({});
  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, { data, previousSpec: existingSpec });
  156. onGenerated?.();
  157. }
  158. }, [editPrompt, send, data, 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 handleDataChange = useCallback((path: string, value: unknown) => {
  184. setData((prev) => {
  185. const next = { ...prev };
  186. // Convert path like "customerForm/name" to nested object
  187. const parts = path.split("/");
  188. let current: Record<string, unknown> = next;
  189. for (let i = 0; i < parts.length - 1; i++) {
  190. const part = parts[i]!;
  191. if (!(part in current) || typeof current[part] !== "object") {
  192. current[part] = {};
  193. }
  194. current = current[part] as Record<string, unknown>;
  195. }
  196. const lastPart = parts[parts.length - 1]!;
  197. current[lastPart] = value;
  198. return next;
  199. });
  200. }, []);
  201. // Use spec from stream, or initial spec for saved widgets
  202. const currentSpec = spec || initialSpec;
  203. // Auto-run initial actions when spec loads (for saved widgets)
  204. useEffect(() => {
  205. if (!currentSpec || isStreaming) return;
  206. // Check for initialActions in spec metadata
  207. const specWithMeta = currentSpec as Spec & {
  208. initialActions?: Array<{
  209. action: string;
  210. params?: Record<string, unknown>;
  211. }>;
  212. };
  213. if (specWithMeta.initialActions) {
  214. specWithMeta.initialActions.forEach(({ action, params }) => {
  215. executeAction(action, params, setData);
  216. });
  217. return;
  218. }
  219. // Auto-detect: find Tables/Charts and run their data-fetching actions
  220. const elements = currentSpec.elements || {};
  221. const dataComponents = ["Table", "BarChart", "LineChart"];
  222. const actionsRun = new Set<string>();
  223. for (const el of Object.values(elements)) {
  224. const element = el as { type: string; props?: Record<string, unknown> };
  225. if (dataComponents.includes(element.type) && element.props?.dataPath) {
  226. const dataPath = element.props.dataPath as string;
  227. // Extract resource name (e.g., "customers" from "customers.data")
  228. const resource = dataPath.split(".")[0];
  229. if (!resource || actionsRun.has(resource)) continue;
  230. // Find a button that loads this data
  231. for (const btnEl of Object.values(elements)) {
  232. const btn = btnEl as {
  233. type: string;
  234. props?: Record<string, unknown>;
  235. };
  236. if (btn.type === "Button" && btn.props?.action) {
  237. const action = btn.props.action as string;
  238. if (
  239. action.toLowerCase().includes(resource) &&
  240. (action.startsWith("view") ||
  241. action.startsWith("refresh") ||
  242. action.startsWith("load"))
  243. ) {
  244. // Run this action with its params
  245. executeAction(
  246. action,
  247. btn.props.actionParams as Record<string, unknown>,
  248. setData,
  249. );
  250. actionsRun.add(resource);
  251. break;
  252. }
  253. }
  254. }
  255. }
  256. }
  257. }, [currentSpec, isStreaming]);
  258. const hasContent =
  259. currentSpec && Object.keys(currentSpec.elements).length > 0;
  260. // Title derived from prompt
  261. const title = prompt.trim() || "New Widget";
  262. const handleCopyCode = async () => {
  263. if (currentSpec) {
  264. await navigator.clipboard.writeText(JSON.stringify(currentSpec, null, 2));
  265. setCopied(true);
  266. setTimeout(() => setCopied(false), 2000);
  267. }
  268. };
  269. return (
  270. <Card className="group relative flex flex-col aspect-[4/3] py-0 gap-0">
  271. {isStreaming && <AnimatedBorder />}
  272. {/* Title bar */}
  273. <div className="px-3 py-2 border-b flex items-center justify-between bg-muted/30">
  274. <div className="flex items-center gap-1 flex-1 min-w-0">
  275. {dragHandleProps && (
  276. <button
  277. {...dragHandleProps}
  278. 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"
  279. title="Drag to reorder"
  280. >
  281. <GripVertical className="h-4 w-4" />
  282. </button>
  283. )}
  284. <span className="text-sm font-medium truncate flex-1" title={title}>
  285. {title}
  286. </span>
  287. </div>
  288. <div className="flex items-center gap-1">
  289. {/* Hover actions */}
  290. {hasContent && (
  291. <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
  292. <Button
  293. onClick={() => setShowCode(!showCode)}
  294. variant="ghost"
  295. size="sm"
  296. className={`h-6 w-6 p-0 text-muted-foreground hover:text-foreground ${showCode ? "bg-muted" : ""}`}
  297. title={showCode ? "Show preview" : "Show code"}
  298. >
  299. <Code className="h-3.5 w-3.5" />
  300. </Button>
  301. <Button
  302. onClick={handleEdit}
  303. variant="ghost"
  304. size="sm"
  305. className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
  306. title="Edit"
  307. >
  308. <Pencil className="h-3.5 w-3.5" />
  309. </Button>
  310. <Button
  311. onClick={() => setShowDeleteConfirm(true)}
  312. variant="ghost"
  313. size="sm"
  314. className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
  315. title="Delete"
  316. >
  317. <Trash2 className="h-3.5 w-3.5" />
  318. </Button>
  319. </div>
  320. )}
  321. </div>
  322. </div>
  323. {/* Content area */}
  324. <CardContent className="relative flex-1 overflow-auto p-4">
  325. {error ? (
  326. <div className="text-destructive text-sm">{error.message}</div>
  327. ) : !hasContent && !isStreaming ? (
  328. <div className="h-full flex flex-col items-center justify-center gap-3">
  329. <p className="text-muted-foreground text-sm">Try one of these:</p>
  330. <div className="flex flex-wrap gap-1.5 justify-center max-w-[300px]">
  331. {suggestions.map((s) => (
  332. <Button
  333. key={s}
  334. onClick={() => handleGenerate(s)}
  335. variant="outline"
  336. size="sm"
  337. className="text-xs h-7"
  338. >
  339. {s}
  340. </Button>
  341. ))}
  342. </div>
  343. </div>
  344. ) : currentSpec ? (
  345. showCode ? (
  346. <div className="relative h-full">
  347. <Button
  348. onClick={handleCopyCode}
  349. variant="ghost"
  350. size="sm"
  351. className="absolute top-0 right-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
  352. title="Copy code"
  353. >
  354. {copied ? (
  355. <Check className="h-3.5 w-3.5 text-green-500" />
  356. ) : (
  357. <Copy className="h-3.5 w-3.5" />
  358. )}
  359. </Button>
  360. <CodeHighlight code={JSON.stringify(currentSpec, null, 2)} />
  361. </div>
  362. ) : (
  363. <DashboardRenderer
  364. spec={currentSpec}
  365. data={data}
  366. setData={setData}
  367. onDataChange={handleDataChange}
  368. loading={isStreaming}
  369. />
  370. )
  371. ) : null}
  372. {/* Edit overlay */}
  373. {isEditing && (
  374. <div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
  375. <div className="w-full max-w-sm px-4">
  376. <div className="flex items-center gap-2">
  377. <Input
  378. ref={editInputRef}
  379. type="text"
  380. value={editPrompt}
  381. onChange={(e) => setEditPrompt(e.target.value)}
  382. onKeyDown={handleEditKeyDown}
  383. placeholder="Update widget prompt..."
  384. className="flex-1 text-sm"
  385. autoFocus
  386. />
  387. <Button onClick={handleEditSubmit} size="sm">
  388. Go
  389. </Button>
  390. <Button
  391. onClick={() => setIsEditing(false)}
  392. variant="ghost"
  393. size="sm"
  394. className="h-8 w-8 p-0"
  395. >
  396. <X className="h-4 w-4" />
  397. </Button>
  398. </div>
  399. </div>
  400. </div>
  401. )}
  402. </CardContent>
  403. {/* Prompt input at bottom - only show for new widgets */}
  404. {!hasContent && (
  405. <div className="p-3 border-t flex gap-2">
  406. <Input
  407. ref={promptInputRef}
  408. type="text"
  409. value={prompt}
  410. onChange={(e) => setPrompt(e.target.value)}
  411. onKeyDown={handleKeyDown}
  412. placeholder="Describe this widget..."
  413. disabled={isStreaming}
  414. className="flex-1 text-sm"
  415. />
  416. <Button
  417. onClick={() => handleGenerate()}
  418. disabled={isStreaming || !prompt.trim()}
  419. size="sm"
  420. >
  421. {isStreaming ? "..." : "Go"}
  422. </Button>
  423. </div>
  424. )}
  425. {/* Delete confirmation dialog */}
  426. <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
  427. <DialogContent>
  428. <DialogHeader>
  429. <DialogTitle>Delete widget</DialogTitle>
  430. <DialogDescription>
  431. Are you sure you want to delete this widget? This action cannot be
  432. undone.
  433. </DialogDescription>
  434. </DialogHeader>
  435. <DialogFooter>
  436. <Button
  437. onClick={() => setShowDeleteConfirm(false)}
  438. variant="outline"
  439. >
  440. Cancel
  441. </Button>
  442. <Button onClick={handleDelete} variant="destructive">
  443. Delete
  444. </Button>
  445. </DialogFooter>
  446. </DialogContent>
  447. </Dialog>
  448. </Card>
  449. );
  450. }