返回 presentation-ai
EditWithAI.tsx
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
209 lines Plain Text