| 1 | "use client"; |
| 2 | |
| 3 | import { useCompletion } from "@ai-sdk/react"; |
| 4 | import { Loader2, Send } from "lucide-react"; |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | |
| 7 | import { Button } from "@/components/ui/button"; |
| 8 | import { cn } from "@/lib/utils"; |
| 9 | |
| 10 | interface EditWithAIProps { |
| 11 | currentSyntax: string; |
| 12 | onSyntaxChange: (newSyntax: string) => void; |
| 13 | onClose: () => void; |
| 14 | } |
| 15 | |
| 16 | const QUICK_SUGGESTIONS = [ |
| 17 | { label: "Add more items", icon: "➕" }, |
| 18 | { label: "Change title", icon: "✏️" }, |
| 19 | { label: "Make it simpler", icon: "✨" }, |
| 20 | { label: "Hand-drawn style", icon: "🎨" }, |
| 21 | ]; |
| 22 | |
| 23 | export function EditWithAI({ |
| 24 | currentSyntax, |
| 25 | onSyntaxChange, |
| 26 | onClose, |
| 27 | }: EditWithAIProps) { |
| 28 | const [prompt, setPrompt] = useState(""); |
| 29 | const inputRef = useRef<HTMLTextAreaElement>(null); |
| 30 | |
| 31 | const { completion, complete, isLoading, error, stop } = useCompletion({ |
| 32 | api: "/api/presentation/edit-diagram", |
| 33 | onFinish: (_prompt, finalCompletion) => { |
| 34 | if (finalCompletion.trim()) { |
| 35 | onSyntaxChange(finalCompletion); |
| 36 | } |
| 37 | setPrompt(""); |
| 38 | }, |
| 39 | }); |
| 40 | |
| 41 | useEffect(() => { |
| 42 | if (completion?.trim()) { |
| 43 | onSyntaxChange(completion); |
| 44 | } |
| 45 | }, [completion, onSyntaxChange]); |
| 46 | |
| 47 | useEffect(() => { |
| 48 | inputRef.current?.focus(); |
| 49 | }, []); |
| 50 | |
| 51 | const handleSubmit = useCallback(() => { |
| 52 | if (!prompt.trim() || isLoading) return; |
| 53 | |
| 54 | void complete(prompt, { |
| 55 | body: { |
| 56 | currentSyntax, |
| 57 | prompt: prompt.trim(), |
| 58 | }, |
| 59 | }); |
| 60 | }, [prompt, isLoading, complete, currentSyntax]); |
| 61 | |
| 62 | const handleStop = useCallback(() => { |
| 63 | stop(); |
| 64 | }, [stop]); |
| 65 | |
| 66 | const handleKeyDown = useCallback( |
| 67 | (e: React.KeyboardEvent) => { |
| 68 | if (e.key === "Enter" && !e.shiftKey) { |
| 69 | e.preventDefault(); |
| 70 | handleSubmit(); |
| 71 | } |
| 72 | if (e.key === "Escape") { |
| 73 | if (isLoading) { |
| 74 | handleStop(); |
| 75 | } else { |
| 76 | onClose(); |
| 77 | } |
| 78 | } |
| 79 | }, |
| 80 | [handleStop, handleSubmit, isLoading, onClose], |
| 81 | ); |
| 82 | |
| 83 | return ( |
| 84 | <div className="ignore-click-outside/toolbar w-80 overflow-hidden rounded-2xl border border-border/70 bg-popover shadow-lg"> |
| 85 | {/* Input Area */} |
| 86 | <div className="space-y-3 p-3"> |
| 87 | <div className="group relative"> |
| 88 | {/* focus halo (no gradients) - keep your focus behavior class */} |
| 89 | <div |
| 90 | className={cn( |
| 91 | "absolute -inset-px rounded-xl bg-primary/20 opacity-0 blur-sm transition-opacity duration-300", |
| 92 | "group-focus-within:opacity-100", |
| 93 | )} |
| 94 | /> |
| 95 | |
| 96 | <div className="relative overflow-hidden rounded-xl border border-border/60 bg-card shadow"> |
| 97 | <textarea |
| 98 | ref={inputRef} |
| 99 | value={prompt} |
| 100 | onChange={(e) => setPrompt(e.target.value)} |
| 101 | onKeyDown={handleKeyDown} |
| 102 | placeholder="What would you like to change?" |
| 103 | disabled={isLoading} |
| 104 | className={cn( |
| 105 | "max-h-35 min-h-18 w-full resize-none bg-transparent px-3 py-2.5 text-sm", |
| 106 | "placeholder:text-muted-foreground/60 focus:outline-none", |
| 107 | "disabled:cursor-not-allowed disabled:opacity-50", |
| 108 | )} |
| 109 | rows={3} |
| 110 | /> |
| 111 | |
| 112 | <div className="flex items-center justify-between border-t border-border/40 bg-muted/20 px-3 py-2"> |
| 113 | <div className="flex items-center gap-2"> |
| 114 | <span className="text-[10px] text-muted-foreground/70"> |
| 115 | Press Enter to send |
| 116 | </span> |
| 117 | <span className="text-[10px] text-muted-foreground/50">•</span> |
| 118 | <span className="text-[10px] text-muted-foreground/70"> |
| 119 | Esc {isLoading ? "cancels" : "closes"} |
| 120 | </span> |
| 121 | </div> |
| 122 | |
| 123 | <Button |
| 124 | size="icon" |
| 125 | variant={prompt.trim() && !isLoading ? "default" : "ghost"} |
| 126 | className={cn( |
| 127 | "h-7 w-7 rounded-lg transition-all duration-200", |
| 128 | prompt.trim() && !isLoading |
| 129 | ? "bg-primary text-primary-foreground shadow hover:bg-primary/90" |
| 130 | : "text-muted-foreground hover:text-foreground", |
| 131 | )} |
| 132 | disabled={!prompt.trim() || isLoading} |
| 133 | onClick={handleSubmit} |
| 134 | > |
| 135 | {isLoading ? ( |
| 136 | <Loader2 className="h-3.5 w-3.5 animate-spin" /> |
| 137 | ) : ( |
| 138 | <Send className="h-3.5 w-3.5" /> |
| 139 | )} |
| 140 | </Button> |
| 141 | </div> |
| 142 | </div> |
| 143 | </div> |
| 144 | |
| 145 | {/* Loading State */} |
| 146 | {isLoading && ( |
| 147 | <div className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5"> |
| 148 | <div className="flex items-center gap-2"> |
| 149 | <div className="relative"> |
| 150 | <div className="absolute inset-0 animate-ping rounded-full bg-muted/40" /> |
| 151 | <Loader2 className="relative h-4 w-4 animate-spin text-foreground" /> |
| 152 | </div> |
| 153 | <span className="text-xs font-medium text-foreground"> |
| 154 | Editing diagram... |
| 155 | </span> |
| 156 | </div> |
| 157 | <button |
| 158 | type="button" |
| 159 | onClick={handleStop} |
| 160 | className="text-xs font-medium text-muted-foreground transition-colors hover:text-destructive" |
| 161 | > |
| 162 | Cancel |
| 163 | </button> |
| 164 | </div> |
| 165 | )} |
| 166 | |
| 167 | {/* Error State */} |
| 168 | {error && ( |
| 169 | <div className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2.5 text-destructive"> |
| 170 | <span className="text-xs font-medium"> |
| 171 | Failed to edit. Please try again. |
| 172 | </span> |
| 173 | </div> |
| 174 | )} |
| 175 | |
| 176 | {/* Quick Suggestions */} |
| 177 | {!isLoading && ( |
| 178 | <div className="space-y-2"> |
| 179 | <span className="px-1 text-[10px] font-medium tracking-wider text-muted-foreground/70 uppercase"> |
| 180 | Quick suggestions |
| 181 | </span> |
| 182 | |
| 183 | {/* chips feel better than “mini cards”, but keep your layout */} |
| 184 | <div className="grid grid-cols-2 gap-1.5"> |
| 185 | {QUICK_SUGGESTIONS.map((suggestion) => ( |
| 186 | <button |
| 187 | key={suggestion.label} |
| 188 | type="button" |
| 189 | onClick={() => setPrompt(suggestion.label)} |
| 190 | className={cn( |
| 191 | "flex items-center gap-2 rounded-lg px-3 py-2 text-left transition-all duration-200", |
| 192 | "border border-transparent bg-muted/30 hover:border-border/50 hover:bg-muted", |
| 193 | "text-xs font-medium text-muted-foreground hover:text-foreground", |
| 194 | "hover:shadow", |
| 195 | "active:scale-[0.99]", |
| 196 | )} |
| 197 | > |
| 198 | <span className="opacity-80">{suggestion.icon}</span> |
| 199 | <span className="truncate">{suggestion.label}</span> |
| 200 | </button> |
| 201 | ))} |
| 202 | </div> |
| 203 | </div> |
| 204 | )} |
| 205 | </div> |
| 206 | </div> |
| 207 | ); |
| 208 | } |
| 209 |