| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- "use client";
- import React, { useState, useCallback, useRef, useEffect } from "react";
- import { toast } from "sonner";
- import {
- Check,
- Code,
- Copy,
- GripVertical,
- Pencil,
- Trash2,
- X,
- } from "lucide-react";
- import { useUIStream, type Spec } from "@json-render/react";
- import { DashboardRenderer } from "@/lib/render/renderer";
- import { executeAction } from "@/lib/render/registry";
- import { CodeHighlight } from "@/components/code-highlight";
- import { Button } from "@/components/ui/button";
- import { Input } from "@/components/ui/input";
- import { Card, CardContent } from "@/components/ui/card";
- import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- } from "@/components/ui/dialog";
- import { AnimatedBorder } from "@/components/ui/animated-border";
- const suggestions = [
- "Customer list with delete",
- "Create customer form",
- "Invoice summary",
- "Revenue metrics",
- ];
- interface WidgetProps {
- id?: string;
- initialPrompt?: string;
- initialSpec?: Spec;
- onGenerated?: () => void;
- onCleared?: () => void;
- onDeleted?: () => void;
- onSaved?: (id: string) => void;
- dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
- }
- export function Widget({
- id: initialId,
- initialPrompt,
- initialSpec,
- onGenerated,
- onCleared,
- onDeleted,
- onSaved,
- dragHandleProps,
- }: WidgetProps): React.ReactElement {
- const [prompt, setPrompt] = useState(initialPrompt || "");
- const [state, setState] = useState<Record<string, unknown>>({});
- const [widgetId, setWidgetId] = useState<string | undefined>(initialId);
- const [isEditing, setIsEditing] = useState(false);
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [showCode, setShowCode] = useState(false);
- const [copied, setCopied] = useState(false);
- const [editPrompt, setEditPrompt] = useState("");
- const promptRef = useRef(prompt);
- const stateRef = useRef(state);
- const editInputRef = useRef<HTMLInputElement>(null);
- const promptInputRef = useRef<HTMLInputElement>(null);
- // Keep stateRef in sync
- useEffect(() => {
- stateRef.current = state;
- }, [state]);
- // Auto-focus prompt input for new widgets
- useEffect(() => {
- if (!initialSpec && promptInputRef.current) {
- promptInputRef.current.focus();
- }
- }, [initialSpec]);
- const { spec, isStreaming, error, send, clear } = useUIStream({
- api: "/api/generate",
- onError: (err) => console.error("Widget generation error:", err),
- onComplete: async (completedSpec) => {
- // Save to database when generation completes
- const currentPrompt = promptRef.current;
- if (completedSpec && currentPrompt) {
- try {
- if (widgetId) {
- // Update existing widget
- await fetch(`/api/v1/widgets/${widgetId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- prompt: currentPrompt,
- spec: completedSpec,
- }),
- });
- } else {
- // Create new widget
- const res = await fetch("/api/v1/widgets", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- prompt: currentPrompt,
- spec: completedSpec,
- }),
- });
- const saved = await res.json();
- setWidgetId(saved.id);
- onSaved?.(saved.id);
- }
- } catch (err) {
- console.error("Failed to save widget:", err);
- }
- }
- },
- });
- // Keep promptRef in sync
- useEffect(() => {
- promptRef.current = prompt;
- }, [prompt]);
- const handleGenerate = useCallback(
- async (text?: string) => {
- const p = text || prompt;
- if (!p.trim()) return;
- if (text) setPrompt(text);
- await send(p, { state });
- onGenerated?.();
- },
- [prompt, send, state, onGenerated],
- );
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const handleClear = useCallback(() => {
- clear();
- setPrompt("");
- setState({});
- onCleared?.();
- }, [clear, onCleared]);
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleGenerate();
- }
- },
- [handleGenerate],
- );
- const handleEdit = useCallback(() => {
- setEditPrompt("");
- setIsEditing(true);
- setTimeout(() => editInputRef.current?.focus(), 0);
- }, []);
- const handleEditSubmit = useCallback(async () => {
- if (editPrompt.trim()) {
- setIsEditing(false);
- // Pass the current spec so AI can modify it instead of replacing
- const existingSpec = spec || initialSpec;
- await send(editPrompt, { state, previousSpec: existingSpec });
- onGenerated?.();
- }
- }, [editPrompt, send, state, spec, initialSpec, onGenerated]);
- const handleEditKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleEditSubmit();
- } else if (e.key === "Escape") {
- setIsEditing(false);
- }
- },
- [handleEditSubmit],
- );
- const handleDelete = useCallback(async () => {
- if (widgetId) {
- try {
- await fetch(`/api/v1/widgets/${widgetId}`, { method: "DELETE" });
- toast.success("Widget deleted");
- } catch (err) {
- console.error("Failed to delete widget:", err);
- toast.error("Failed to delete widget");
- }
- }
- setShowDeleteConfirm(false);
- onDeleted?.();
- }, [widgetId, onDeleted]);
- const handleStateChange = useCallback(
- (changes: Array<{ path: string; value: unknown }>) => {
- setState((prev) => {
- const next = { ...prev };
- for (const { path, value } of changes) {
- const parts = path.split("/");
- let current: Record<string, unknown> = next;
- for (let i = 0; i < parts.length - 1; i++) {
- const part = parts[i]!;
- if (!(part in current) || typeof current[part] !== "object") {
- current[part] = {};
- }
- current = current[part] as Record<string, unknown>;
- }
- const lastPart = parts[parts.length - 1]!;
- current[lastPart] = value;
- }
- return next;
- });
- },
- [],
- );
- // Use spec from stream, or initial spec for saved widgets
- const currentSpec = spec || initialSpec;
- // Auto-run initial actions when spec loads (for saved widgets)
- useEffect(() => {
- if (!currentSpec || isStreaming) return;
- // Check for initialActions in spec metadata
- const specWithMeta = currentSpec as Spec & {
- initialActions?: Array<{
- action: string;
- params?: Record<string, unknown>;
- }>;
- };
- if (specWithMeta.initialActions) {
- specWithMeta.initialActions.forEach(({ action, params }) => {
- executeAction(action, params, setState);
- });
- return;
- }
- // Auto-detect: find Tables/Charts and run their data-fetching actions
- const elements = currentSpec.elements || {};
- const dataComponents = ["Table", "BarChart", "LineChart"];
- const actionsRun = new Set<string>();
- for (const el of Object.values(elements)) {
- const element = el as { type: string; props?: Record<string, unknown> };
- if (dataComponents.includes(element.type) && element.props?.statePath) {
- const statePath = element.props.statePath as string;
- // Extract resource name (e.g., "customers" from "customers.data")
- const resource = statePath.split(".")[0];
- if (!resource || actionsRun.has(resource)) continue;
- // Find a button that loads this data
- for (const btnEl of Object.values(elements)) {
- const btn = btnEl as {
- type: string;
- props?: Record<string, unknown>;
- };
- if (btn.type === "Button" && btn.props?.action) {
- const action = btn.props.action as string;
- if (
- action.toLowerCase().includes(resource) &&
- (action.startsWith("view") ||
- action.startsWith("refresh") ||
- action.startsWith("load"))
- ) {
- // Run this action with its params
- executeAction(
- action,
- btn.props.actionParams as Record<string, unknown>,
- setState,
- );
- actionsRun.add(resource);
- break;
- }
- }
- }
- }
- }
- }, [currentSpec, isStreaming]);
- const hasContent =
- currentSpec && Object.keys(currentSpec.elements).length > 0;
- // Title derived from prompt
- const title = prompt.trim() || "New Widget";
- const handleCopyCode = async () => {
- if (currentSpec) {
- await navigator.clipboard.writeText(JSON.stringify(currentSpec, null, 2));
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- };
- return (
- <Card className="group relative flex flex-col aspect-[4/3] py-0 gap-0">
- {isStreaming && <AnimatedBorder />}
- {/* Title bar */}
- <div className="px-3 py-2 border-b flex items-center justify-between bg-muted/30">
- <div className="flex items-center gap-1 flex-1 min-w-0">
- {dragHandleProps && (
- <button
- {...dragHandleProps}
- 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"
- title="Drag to reorder"
- >
- <GripVertical className="h-4 w-4" />
- </button>
- )}
- <span className="text-sm font-medium truncate flex-1" title={title}>
- {title}
- </span>
- </div>
- <div className="flex items-center gap-1">
- {/* Hover actions */}
- {hasContent && (
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
- <Button
- onClick={() => setShowCode(!showCode)}
- variant="ghost"
- size="sm"
- className={`h-6 w-6 p-0 text-muted-foreground hover:text-foreground ${showCode ? "bg-muted" : ""}`}
- title={showCode ? "Show preview" : "Show code"}
- >
- <Code className="h-3.5 w-3.5" />
- </Button>
- <Button
- onClick={handleEdit}
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
- title="Edit"
- >
- <Pencil className="h-3.5 w-3.5" />
- </Button>
- <Button
- onClick={() => setShowDeleteConfirm(true)}
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
- title="Delete"
- >
- <Trash2 className="h-3.5 w-3.5" />
- </Button>
- </div>
- )}
- </div>
- </div>
- {/* Content area */}
- <CardContent className="relative flex-1 overflow-auto p-4">
- {error ? (
- <div className="text-destructive text-sm">{error.message}</div>
- ) : !hasContent && !isStreaming ? (
- <div className="h-full flex flex-col items-center justify-center gap-3">
- <p className="text-muted-foreground text-sm">Try one of these:</p>
- <div className="flex flex-wrap gap-1.5 justify-center max-w-[300px]">
- {suggestions.map((s) => (
- <Button
- key={s}
- onClick={() => handleGenerate(s)}
- variant="outline"
- size="sm"
- className="text-xs h-7"
- >
- {s}
- </Button>
- ))}
- </div>
- </div>
- ) : currentSpec ? (
- showCode ? (
- <div className="relative h-full">
- <Button
- onClick={handleCopyCode}
- variant="ghost"
- size="sm"
- className="absolute top-0 right-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
- title="Copy code"
- >
- {copied ? (
- <Check className="h-3.5 w-3.5 text-green-500" />
- ) : (
- <Copy className="h-3.5 w-3.5" />
- )}
- </Button>
- <CodeHighlight code={JSON.stringify(currentSpec, null, 2)} />
- </div>
- ) : (
- <DashboardRenderer
- spec={currentSpec}
- state={state}
- setState={setState}
- onStateChange={handleStateChange}
- loading={isStreaming}
- />
- )
- ) : null}
- {/* Edit overlay */}
- {isEditing && (
- <div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
- <div className="w-full max-w-sm px-4">
- <div className="flex items-center gap-2">
- <Input
- ref={editInputRef}
- type="text"
- value={editPrompt}
- onChange={(e) => setEditPrompt(e.target.value)}
- onKeyDown={handleEditKeyDown}
- placeholder="Update widget prompt..."
- className="flex-1 text-sm"
- autoFocus
- />
- <Button onClick={handleEditSubmit} size="sm">
- Go
- </Button>
- <Button
- onClick={() => setIsEditing(false)}
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- )}
- </CardContent>
- {/* Prompt input at bottom - only show for new widgets */}
- {!hasContent && (
- <div className="p-3 border-t flex gap-2">
- <Input
- ref={promptInputRef}
- type="text"
- value={prompt}
- onChange={(e) => setPrompt(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Describe this widget..."
- disabled={isStreaming}
- className="flex-1 text-sm"
- />
- <Button
- onClick={() => handleGenerate()}
- disabled={isStreaming || !prompt.trim()}
- size="sm"
- >
- {isStreaming ? "..." : "Go"}
- </Button>
- </div>
- )}
- {/* Delete confirmation dialog */}
- <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Delete widget</DialogTitle>
- <DialogDescription>
- Are you sure you want to delete this widget? This action cannot be
- undone.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter>
- <Button
- onClick={() => setShowDeleteConfirm(false)}
- variant="outline"
- >
- Cancel
- </Button>
- <Button onClick={handleDelete} variant="destructive">
- Delete
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </Card>
- );
- }
|