create-interview-cockpit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/package-lock.json +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +384 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +530 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +22 -8
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +960 -0
- package/template/client/src/store.ts +250 -6
- package/template/client/src/types.ts +36 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +39 -3
- package/template/server/src/index.ts +954 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +22 -3
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import ReactMarkdown from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import {
|
|
5
|
+
Clipboard,
|
|
6
|
+
ClipboardCheck,
|
|
7
|
+
GripVertical,
|
|
8
|
+
Loader2,
|
|
9
|
+
Maximize2,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
Minimize2,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
Plus,
|
|
15
|
+
Save,
|
|
16
|
+
Send,
|
|
17
|
+
Trash2,
|
|
18
|
+
X,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import * as api from "../api";
|
|
21
|
+
|
|
22
|
+
const MIN_W = 560;
|
|
23
|
+
const MIN_H = 520;
|
|
24
|
+
const DEFAULT_W = 860;
|
|
25
|
+
const DEFAULT_H = 660;
|
|
26
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
27
|
+
|
|
28
|
+
// ── Data model ───────────────────────────────────────────────────
|
|
29
|
+
interface Note {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
text: string;
|
|
33
|
+
updatedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function notesKey(questionId?: string | null): string {
|
|
37
|
+
return questionId ? `notes:q:${questionId}` : "notes:global";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadNotes(key: string): Note[] {
|
|
41
|
+
try {
|
|
42
|
+
const raw = localStorage.getItem(key);
|
|
43
|
+
if (!raw) return [];
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function persistNotes(key: string, notes: Note[]): void {
|
|
52
|
+
try {
|
|
53
|
+
if (notes.length === 0) {
|
|
54
|
+
localStorage.removeItem(key);
|
|
55
|
+
} else {
|
|
56
|
+
localStorage.setItem(key, JSON.stringify(notes));
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// storage full — ignore
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function genId(): string {
|
|
64
|
+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Component ────────────────────────────────────────────────────
|
|
68
|
+
interface Props {
|
|
69
|
+
questionId?: string | null;
|
|
70
|
+
onClose: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function NotesModal({ questionId, onClose }: Props) {
|
|
74
|
+
const storageKey = notesKey(questionId);
|
|
75
|
+
|
|
76
|
+
const [notes, setNotes] = useState<Note[]>(() => loadNotes(storageKey));
|
|
77
|
+
const [activeId, setActiveId] = useState<string | null>(
|
|
78
|
+
() => loadNotes(storageKey)[0]?.id ?? null,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Editable draft for the active note
|
|
82
|
+
const [draftName, setDraftName] = useState<string>(
|
|
83
|
+
() => loadNotes(storageKey)[0]?.name ?? "",
|
|
84
|
+
);
|
|
85
|
+
const [draftText, setDraftText] = useState<string>(
|
|
86
|
+
() => loadNotes(storageKey)[0]?.text ?? "",
|
|
87
|
+
);
|
|
88
|
+
const [dirty, setDirty] = useState(false);
|
|
89
|
+
|
|
90
|
+
// Save As dialog
|
|
91
|
+
const [saveAsValue, setSaveAsValue] = useState<string | null>(null); // null = hidden
|
|
92
|
+
|
|
93
|
+
// Preview / edit toggle
|
|
94
|
+
const [preview, setPreview] = useState(false);
|
|
95
|
+
|
|
96
|
+
// Reset preview when switching notes
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setPreview(false);
|
|
99
|
+
}, [activeId]);
|
|
100
|
+
|
|
101
|
+
// Re-load when the question changes
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const loaded = loadNotes(storageKey);
|
|
104
|
+
setNotes(loaded);
|
|
105
|
+
const first = loaded[0] ?? null;
|
|
106
|
+
setActiveId(first?.id ?? null);
|
|
107
|
+
setDraftName(first?.name ?? "");
|
|
108
|
+
setDraftText(first?.text ?? "");
|
|
109
|
+
setDirty(false);
|
|
110
|
+
}, [storageKey]);
|
|
111
|
+
|
|
112
|
+
// Sync draft when activeId changes (but not on every notes mutation)
|
|
113
|
+
const prevActiveId = useRef<string | null>(null);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (activeId === prevActiveId.current) return;
|
|
116
|
+
prevActiveId.current = activeId;
|
|
117
|
+
const note = notes.find((n) => n.id === activeId);
|
|
118
|
+
if (note) {
|
|
119
|
+
setDraftName(note.name);
|
|
120
|
+
setDraftText(note.text);
|
|
121
|
+
setDirty(false);
|
|
122
|
+
}
|
|
123
|
+
}, [activeId, notes]);
|
|
124
|
+
|
|
125
|
+
// ── Actions ──────────────────────────────────────────────────
|
|
126
|
+
const flushDraft = useCallback(
|
|
127
|
+
(prev: Note[]): Note[] => {
|
|
128
|
+
if (!activeId || !dirty) return prev;
|
|
129
|
+
const updated = prev.map((n) =>
|
|
130
|
+
n.id === activeId
|
|
131
|
+
? {
|
|
132
|
+
...n,
|
|
133
|
+
name: draftName.trim() || "Untitled",
|
|
134
|
+
text: draftText,
|
|
135
|
+
updatedAt: Date.now(),
|
|
136
|
+
}
|
|
137
|
+
: n,
|
|
138
|
+
);
|
|
139
|
+
persistNotes(storageKey, updated);
|
|
140
|
+
return updated;
|
|
141
|
+
},
|
|
142
|
+
[activeId, dirty, draftName, draftText, storageKey],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const handleSave = useCallback(() => {
|
|
146
|
+
if (!activeId) return;
|
|
147
|
+
setNotes((prev) => {
|
|
148
|
+
const updated = prev.map((n) =>
|
|
149
|
+
n.id === activeId
|
|
150
|
+
? {
|
|
151
|
+
...n,
|
|
152
|
+
name: draftName.trim() || "Untitled",
|
|
153
|
+
text: draftText,
|
|
154
|
+
updatedAt: Date.now(),
|
|
155
|
+
}
|
|
156
|
+
: n,
|
|
157
|
+
);
|
|
158
|
+
persistNotes(storageKey, updated);
|
|
159
|
+
return updated;
|
|
160
|
+
});
|
|
161
|
+
setDirty(false);
|
|
162
|
+
}, [activeId, draftName, draftText, storageKey]);
|
|
163
|
+
|
|
164
|
+
const handleAddNote = useCallback(() => {
|
|
165
|
+
setNotes((prev) => {
|
|
166
|
+
const flushed = flushDraft(prev);
|
|
167
|
+
const count = flushed.length + 1;
|
|
168
|
+
const newNote: Note = {
|
|
169
|
+
id: genId(),
|
|
170
|
+
name: `Note ${count}`,
|
|
171
|
+
text: "",
|
|
172
|
+
updatedAt: Date.now(),
|
|
173
|
+
};
|
|
174
|
+
const updated = [...flushed, newNote];
|
|
175
|
+
persistNotes(storageKey, updated);
|
|
176
|
+
setActiveId(newNote.id);
|
|
177
|
+
setDraftName(newNote.name);
|
|
178
|
+
setDraftText("");
|
|
179
|
+
setDirty(false);
|
|
180
|
+
return updated;
|
|
181
|
+
});
|
|
182
|
+
}, [flushDraft, storageKey]);
|
|
183
|
+
|
|
184
|
+
const handleSelectNote = useCallback(
|
|
185
|
+
(id: string) => {
|
|
186
|
+
if (id === activeId) return;
|
|
187
|
+
setNotes((prev) => {
|
|
188
|
+
const updated = flushDraft(prev);
|
|
189
|
+
persistNotes(storageKey, updated);
|
|
190
|
+
return updated;
|
|
191
|
+
});
|
|
192
|
+
setDirty(false);
|
|
193
|
+
setActiveId(id);
|
|
194
|
+
},
|
|
195
|
+
[activeId, flushDraft, storageKey],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const handleDeleteNote = useCallback(
|
|
199
|
+
(id: string) => {
|
|
200
|
+
setNotes((prev) => {
|
|
201
|
+
const updated = prev.filter((n) => n.id !== id);
|
|
202
|
+
persistNotes(storageKey, updated);
|
|
203
|
+
if (id === activeId) {
|
|
204
|
+
const next = updated[0] ?? null;
|
|
205
|
+
setActiveId(next?.id ?? null);
|
|
206
|
+
setDraftName(next?.name ?? "");
|
|
207
|
+
setDraftText(next?.text ?? "");
|
|
208
|
+
setDirty(false);
|
|
209
|
+
}
|
|
210
|
+
return updated;
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
[activeId, storageKey],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const handleSaveAs = useCallback(() => {
|
|
217
|
+
setSaveAsValue((draftName.trim() || "Untitled") + " copy");
|
|
218
|
+
}, [draftName]);
|
|
219
|
+
|
|
220
|
+
const confirmSaveAs = useCallback(() => {
|
|
221
|
+
const name = (saveAsValue ?? "").trim() || "Untitled";
|
|
222
|
+
const newNote: Note = {
|
|
223
|
+
id: genId(),
|
|
224
|
+
name,
|
|
225
|
+
text: draftText,
|
|
226
|
+
updatedAt: Date.now(),
|
|
227
|
+
};
|
|
228
|
+
setNotes((prev) => {
|
|
229
|
+
const updated = [...prev, newNote];
|
|
230
|
+
persistNotes(storageKey, updated);
|
|
231
|
+
return updated;
|
|
232
|
+
});
|
|
233
|
+
setActiveId(newNote.id);
|
|
234
|
+
setDraftName(name);
|
|
235
|
+
setDirty(false);
|
|
236
|
+
setSaveAsValue(null);
|
|
237
|
+
}, [saveAsValue, draftText, storageKey]);
|
|
238
|
+
|
|
239
|
+
// ── Chat ─────────────────────────────────────────────────────
|
|
240
|
+
interface ChatMessage {
|
|
241
|
+
id: string;
|
|
242
|
+
role: "user" | "assistant";
|
|
243
|
+
content: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
|
247
|
+
const [chatInput, setChatInput] = useState("");
|
|
248
|
+
const [chatLoading, setChatLoading] = useState(false);
|
|
249
|
+
const [chatCopiedId, setChatCopiedId] = useState<string | null>(null);
|
|
250
|
+
const chatScrollRef = useRef<HTMLDivElement>(null);
|
|
251
|
+
const chatInputRef = useRef<HTMLTextAreaElement>(null);
|
|
252
|
+
const chatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
|
|
253
|
+
|
|
254
|
+
// Reset chat when switching notes
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
setChatMessages([]);
|
|
257
|
+
setChatInput("");
|
|
258
|
+
setChatLoading(false);
|
|
259
|
+
}, [activeId]);
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (chatScrollRef.current) {
|
|
263
|
+
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
|
|
264
|
+
}
|
|
265
|
+
}, [chatMessages, chatLoading]);
|
|
266
|
+
|
|
267
|
+
const handleChatSend = useCallback(async () => {
|
|
268
|
+
const text = chatInput.trim();
|
|
269
|
+
if (!text || chatLoading) return;
|
|
270
|
+
setChatInput("");
|
|
271
|
+
const userMsg: ChatMessage = {
|
|
272
|
+
id: crypto.randomUUID(),
|
|
273
|
+
role: "user",
|
|
274
|
+
content: text,
|
|
275
|
+
};
|
|
276
|
+
setChatMessages((prev) => [...prev, userMsg]);
|
|
277
|
+
setChatLoading(true);
|
|
278
|
+
const abort = { aborted: false };
|
|
279
|
+
chatAbortRef.current = abort;
|
|
280
|
+
const assistantId = crypto.randomUUID();
|
|
281
|
+
setChatMessages((prev) => [
|
|
282
|
+
...prev,
|
|
283
|
+
{ id: assistantId, role: "assistant", content: "" },
|
|
284
|
+
]);
|
|
285
|
+
try {
|
|
286
|
+
const history = [...chatMessages, userMsg].map((m) => ({
|
|
287
|
+
role: m.role,
|
|
288
|
+
content: m.content,
|
|
289
|
+
}));
|
|
290
|
+
await api.streamNotesAsk(
|
|
291
|
+
{
|
|
292
|
+
messages: history,
|
|
293
|
+
noteContent: draftText,
|
|
294
|
+
noteName: draftName || "Untitled",
|
|
295
|
+
},
|
|
296
|
+
(delta) => {
|
|
297
|
+
if (abort.aborted) return;
|
|
298
|
+
setChatMessages((prev) =>
|
|
299
|
+
prev.map((m) =>
|
|
300
|
+
m.id === assistantId ? { ...m, content: m.content + delta } : m,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
} catch (err: unknown) {
|
|
306
|
+
if (!abort.aborted) {
|
|
307
|
+
setChatMessages((prev) =>
|
|
308
|
+
prev.map((m) =>
|
|
309
|
+
m.id === assistantId
|
|
310
|
+
? { ...m, content: (err as Error)?.message ?? "Request failed" }
|
|
311
|
+
: m,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
if (!abort.aborted) setChatLoading(false);
|
|
317
|
+
}
|
|
318
|
+
}, [chatInput, chatLoading, chatMessages, draftText, draftName]);
|
|
319
|
+
|
|
320
|
+
const handleCopyMessage = useCallback((id: string, content: string) => {
|
|
321
|
+
void navigator.clipboard.writeText(content).then(() => {
|
|
322
|
+
setChatCopiedId(id);
|
|
323
|
+
setTimeout(() => setChatCopiedId(null), 1800);
|
|
324
|
+
});
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
// ── Drag / resize ─────────────────────────────────────────────
|
|
328
|
+
const [pos, setPos] = useState(() => ({
|
|
329
|
+
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
330
|
+
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
331
|
+
}));
|
|
332
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
333
|
+
const [maximized, setMaximized] = useState(false);
|
|
334
|
+
|
|
335
|
+
const dragStart = useRef<{
|
|
336
|
+
mx: number;
|
|
337
|
+
my: number;
|
|
338
|
+
ox: number;
|
|
339
|
+
oy: number;
|
|
340
|
+
} | null>(null);
|
|
341
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
342
|
+
const resizeStart = useRef<{
|
|
343
|
+
mx: number;
|
|
344
|
+
my: number;
|
|
345
|
+
ox: number;
|
|
346
|
+
oy: number;
|
|
347
|
+
ow: number;
|
|
348
|
+
oh: number;
|
|
349
|
+
} | null>(null);
|
|
350
|
+
const savedPos = useRef(pos);
|
|
351
|
+
const savedSize = useRef(size);
|
|
352
|
+
|
|
353
|
+
const onTitleMouseDown = useCallback(
|
|
354
|
+
(e: React.MouseEvent) => {
|
|
355
|
+
if (maximized) return;
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
dragStart.current = {
|
|
358
|
+
mx: e.clientX,
|
|
359
|
+
my: e.clientY,
|
|
360
|
+
ox: pos.x,
|
|
361
|
+
oy: pos.y,
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
[maximized, pos],
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const startResize = useCallback(
|
|
368
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
369
|
+
if (maximized) return;
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
e.stopPropagation();
|
|
372
|
+
resizeDir.current = dir;
|
|
373
|
+
resizeStart.current = {
|
|
374
|
+
mx: e.clientX,
|
|
375
|
+
my: e.clientY,
|
|
376
|
+
ox: pos.x,
|
|
377
|
+
oy: pos.y,
|
|
378
|
+
ow: size.w,
|
|
379
|
+
oh: size.h,
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
[maximized, pos, size],
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const toggleMax = useCallback(() => {
|
|
386
|
+
if (!maximized) {
|
|
387
|
+
savedPos.current = pos;
|
|
388
|
+
savedSize.current = size;
|
|
389
|
+
setMaximized(true);
|
|
390
|
+
} else {
|
|
391
|
+
setPos(savedPos.current);
|
|
392
|
+
setSize(savedSize.current);
|
|
393
|
+
setMaximized(false);
|
|
394
|
+
}
|
|
395
|
+
}, [maximized, pos, size]);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const onMove = (e: MouseEvent) => {
|
|
399
|
+
const drag = dragStart.current;
|
|
400
|
+
const resize = resizeStart.current;
|
|
401
|
+
const dir = resizeDir.current;
|
|
402
|
+
if (drag) {
|
|
403
|
+
setPos({
|
|
404
|
+
x: Math.max(0, drag.ox + e.clientX - drag.mx),
|
|
405
|
+
y: Math.max(0, drag.oy + e.clientY - drag.my),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (resize && dir) {
|
|
409
|
+
const dx = e.clientX - resize.mx;
|
|
410
|
+
const dy = e.clientY - resize.my;
|
|
411
|
+
setSize((prev) => {
|
|
412
|
+
let w = prev.w,
|
|
413
|
+
h = prev.h;
|
|
414
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
415
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
416
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
417
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
418
|
+
return { w, h };
|
|
419
|
+
});
|
|
420
|
+
if (dir.includes("w"))
|
|
421
|
+
setPos((p) => ({
|
|
422
|
+
...p,
|
|
423
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
424
|
+
}));
|
|
425
|
+
if (dir.includes("n"))
|
|
426
|
+
setPos((p) => ({
|
|
427
|
+
...p,
|
|
428
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const onUp = () => {
|
|
433
|
+
dragStart.current = null;
|
|
434
|
+
resizeStart.current = null;
|
|
435
|
+
resizeDir.current = null;
|
|
436
|
+
};
|
|
437
|
+
document.addEventListener("mousemove", onMove);
|
|
438
|
+
document.addEventListener("mouseup", onUp);
|
|
439
|
+
return () => {
|
|
440
|
+
document.removeEventListener("mousemove", onMove);
|
|
441
|
+
document.removeEventListener("mouseup", onUp);
|
|
442
|
+
};
|
|
443
|
+
}, []);
|
|
444
|
+
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
const onKey = (e: KeyboardEvent) => {
|
|
447
|
+
if (e.key === "Escape" && saveAsValue === null) onClose();
|
|
448
|
+
};
|
|
449
|
+
document.addEventListener("keydown", onKey);
|
|
450
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
451
|
+
}, [onClose, saveAsValue]);
|
|
452
|
+
|
|
453
|
+
const windowStyle: React.CSSProperties = maximized
|
|
454
|
+
? {
|
|
455
|
+
position: "fixed",
|
|
456
|
+
inset: 0,
|
|
457
|
+
width: "100vw",
|
|
458
|
+
height: "100vh",
|
|
459
|
+
borderRadius: 0,
|
|
460
|
+
}
|
|
461
|
+
: {
|
|
462
|
+
position: "fixed",
|
|
463
|
+
left: pos.x,
|
|
464
|
+
top: pos.y,
|
|
465
|
+
width: size.w,
|
|
466
|
+
height: size.h,
|
|
467
|
+
minWidth: MIN_W,
|
|
468
|
+
minHeight: MIN_H,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// ── Render ───────────────────────────────────────────────────
|
|
472
|
+
return (
|
|
473
|
+
<div
|
|
474
|
+
className="z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
|
|
475
|
+
style={windowStyle}
|
|
476
|
+
>
|
|
477
|
+
{/* Resize handles */}
|
|
478
|
+
{!maximized && (
|
|
479
|
+
<>
|
|
480
|
+
<div
|
|
481
|
+
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
|
|
482
|
+
onMouseDown={startResize("n")}
|
|
483
|
+
/>
|
|
484
|
+
<div
|
|
485
|
+
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
|
|
486
|
+
onMouseDown={startResize("s")}
|
|
487
|
+
/>
|
|
488
|
+
<div
|
|
489
|
+
className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
|
|
490
|
+
onMouseDown={startResize("w")}
|
|
491
|
+
/>
|
|
492
|
+
<div
|
|
493
|
+
className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
|
|
494
|
+
onMouseDown={startResize("e")}
|
|
495
|
+
/>
|
|
496
|
+
<div
|
|
497
|
+
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
|
|
498
|
+
onMouseDown={startResize("nw")}
|
|
499
|
+
/>
|
|
500
|
+
<div
|
|
501
|
+
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
|
|
502
|
+
onMouseDown={startResize("ne")}
|
|
503
|
+
/>
|
|
504
|
+
<div
|
|
505
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
|
|
506
|
+
onMouseDown={startResize("sw")}
|
|
507
|
+
/>
|
|
508
|
+
<div
|
|
509
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
|
|
510
|
+
onMouseDown={startResize("se")}
|
|
511
|
+
/>
|
|
512
|
+
</>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{/* Title bar */}
|
|
516
|
+
<div
|
|
517
|
+
className="flex items-center gap-2 px-3 py-2.5 bg-slate-800 border-b border-slate-700 shrink-0"
|
|
518
|
+
onMouseDown={onTitleMouseDown}
|
|
519
|
+
style={{ cursor: maximized ? "default" : "grab" }}
|
|
520
|
+
>
|
|
521
|
+
<GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
|
522
|
+
<span className="text-sm font-semibold text-slate-100 flex-1">
|
|
523
|
+
Notes
|
|
524
|
+
{questionId && (
|
|
525
|
+
<span className="ml-2 text-xs font-normal text-slate-500">
|
|
526
|
+
— this question
|
|
527
|
+
</span>
|
|
528
|
+
)}
|
|
529
|
+
</span>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
533
|
+
onClick={toggleMax}
|
|
534
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
|
535
|
+
title={maximized ? "Restore" : "Maximise"}
|
|
536
|
+
>
|
|
537
|
+
{maximized ? (
|
|
538
|
+
<Minimize2 className="w-3.5 h-3.5" />
|
|
539
|
+
) : (
|
|
540
|
+
<Maximize2 className="w-3.5 h-3.5" />
|
|
541
|
+
)}
|
|
542
|
+
</button>
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
546
|
+
onClick={onClose}
|
|
547
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
|
|
548
|
+
title="Close (Esc)"
|
|
549
|
+
>
|
|
550
|
+
<X className="w-3.5 h-3.5" />
|
|
551
|
+
</button>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
{/* Body: sidebar + editor + chat */}
|
|
555
|
+
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
556
|
+
{/* Top: sidebar + editor */}
|
|
557
|
+
<div className="flex-1 min-h-0 flex overflow-hidden">
|
|
558
|
+
{/* Notes list sidebar */}
|
|
559
|
+
<div className="w-44 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900">
|
|
560
|
+
<div className="flex items-center justify-between px-2.5 py-2 border-b border-slate-800 shrink-0">
|
|
561
|
+
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
|
562
|
+
All notes
|
|
563
|
+
</span>
|
|
564
|
+
<button
|
|
565
|
+
type="button"
|
|
566
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
567
|
+
onClick={handleAddNote}
|
|
568
|
+
title="New note"
|
|
569
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
|
570
|
+
>
|
|
571
|
+
<Plus className="w-3.5 h-3.5" />
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
574
|
+
<div className="flex-1 overflow-y-auto">
|
|
575
|
+
{notes.length === 0 && (
|
|
576
|
+
<p className="px-3 py-3 text-xs text-slate-600 italic">
|
|
577
|
+
No notes yet
|
|
578
|
+
</p>
|
|
579
|
+
)}
|
|
580
|
+
{notes.map((note) => (
|
|
581
|
+
<div
|
|
582
|
+
key={note.id}
|
|
583
|
+
onClick={() => handleSelectNote(note.id)}
|
|
584
|
+
className={`group flex items-center gap-1 px-2.5 py-2 cursor-pointer text-xs ${
|
|
585
|
+
note.id === activeId
|
|
586
|
+
? "bg-slate-700 text-slate-100"
|
|
587
|
+
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
588
|
+
}`}
|
|
589
|
+
>
|
|
590
|
+
<span className="flex-1 truncate">
|
|
591
|
+
{note.name || "Untitled"}
|
|
592
|
+
</span>
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
596
|
+
onClick={(e) => {
|
|
597
|
+
e.stopPropagation();
|
|
598
|
+
handleDeleteNote(note.id);
|
|
599
|
+
}}
|
|
600
|
+
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-red-400 transition-all shrink-0"
|
|
601
|
+
title="Delete note"
|
|
602
|
+
>
|
|
603
|
+
<Trash2 className="w-3 h-3" />
|
|
604
|
+
</button>
|
|
605
|
+
</div>
|
|
606
|
+
))}
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
{/* Editor pane */}
|
|
611
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
612
|
+
{activeId ? (
|
|
613
|
+
<>
|
|
614
|
+
{/* Note name + preview toggle */}
|
|
615
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-800 shrink-0">
|
|
616
|
+
<input
|
|
617
|
+
value={draftName}
|
|
618
|
+
onChange={(e) => {
|
|
619
|
+
setDraftName(e.target.value);
|
|
620
|
+
setDirty(true);
|
|
621
|
+
}}
|
|
622
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
623
|
+
placeholder="Note name…"
|
|
624
|
+
className="flex-1 bg-transparent text-sm font-semibold text-slate-100 placeholder-slate-600 outline-none"
|
|
625
|
+
/>
|
|
626
|
+
<button
|
|
627
|
+
type="button"
|
|
628
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
629
|
+
onClick={() => setPreview((v) => !v)}
|
|
630
|
+
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
|
|
631
|
+
preview
|
|
632
|
+
? "bg-violet-600/20 text-violet-300 hover:bg-violet-600/30"
|
|
633
|
+
: "text-slate-500 hover:bg-slate-800 hover:text-slate-300"
|
|
634
|
+
}`}
|
|
635
|
+
title={
|
|
636
|
+
preview
|
|
637
|
+
? "Switch to edit mode"
|
|
638
|
+
: "Preview rendered Markdown"
|
|
639
|
+
}
|
|
640
|
+
>
|
|
641
|
+
{preview ? (
|
|
642
|
+
<EyeOff className="w-3 h-3" />
|
|
643
|
+
) : (
|
|
644
|
+
<Eye className="w-3 h-3" />
|
|
645
|
+
)}
|
|
646
|
+
<span>{preview ? "Edit" : "Preview"}</span>
|
|
647
|
+
</button>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
{/* Editor / Preview */}
|
|
651
|
+
{preview ? (
|
|
652
|
+
<div
|
|
653
|
+
className="flex-1 overflow-y-auto px-5 py-4 prose prose-invert prose-sm max-w-none bg-slate-950 text-slate-200"
|
|
654
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
655
|
+
>
|
|
656
|
+
{draftText.trim() ? (
|
|
657
|
+
<ReactMarkdown
|
|
658
|
+
remarkPlugins={[remarkGfm]}
|
|
659
|
+
components={{
|
|
660
|
+
code({ className, children, ...props }) {
|
|
661
|
+
const isBlock = className?.startsWith("language-");
|
|
662
|
+
return isBlock ? (
|
|
663
|
+
<pre className="bg-slate-900 rounded-lg p-3 overflow-x-auto my-2">
|
|
664
|
+
<code
|
|
665
|
+
className={`${className ?? ""} text-xs`}
|
|
666
|
+
{...props}
|
|
667
|
+
>
|
|
668
|
+
{children}
|
|
669
|
+
</code>
|
|
670
|
+
</pre>
|
|
671
|
+
) : (
|
|
672
|
+
<code
|
|
673
|
+
className="bg-slate-900/70 px-1 rounded text-violet-300 text-[0.8em]"
|
|
674
|
+
{...props}
|
|
675
|
+
>
|
|
676
|
+
{children}
|
|
677
|
+
</code>
|
|
678
|
+
);
|
|
679
|
+
},
|
|
680
|
+
table({ children }) {
|
|
681
|
+
return (
|
|
682
|
+
<table className="border-collapse w-full my-2 text-sm">
|
|
683
|
+
{children}
|
|
684
|
+
</table>
|
|
685
|
+
);
|
|
686
|
+
},
|
|
687
|
+
th({ children }) {
|
|
688
|
+
return (
|
|
689
|
+
<th className="border border-slate-700 px-3 py-1.5 text-left text-slate-200 bg-slate-800">
|
|
690
|
+
{children}
|
|
691
|
+
</th>
|
|
692
|
+
);
|
|
693
|
+
},
|
|
694
|
+
td({ children }) {
|
|
695
|
+
return (
|
|
696
|
+
<td className="border border-slate-700 px-3 py-1.5">
|
|
697
|
+
{children}
|
|
698
|
+
</td>
|
|
699
|
+
);
|
|
700
|
+
},
|
|
701
|
+
}}
|
|
702
|
+
>
|
|
703
|
+
{draftText}
|
|
704
|
+
</ReactMarkdown>
|
|
705
|
+
) : (
|
|
706
|
+
<p className="text-slate-600 italic text-sm">
|
|
707
|
+
Nothing to preview yet.
|
|
708
|
+
</p>
|
|
709
|
+
)}
|
|
710
|
+
</div>
|
|
711
|
+
) : (
|
|
712
|
+
<textarea
|
|
713
|
+
value={draftText}
|
|
714
|
+
onChange={(e) => {
|
|
715
|
+
setDraftText(e.target.value);
|
|
716
|
+
setDirty(true);
|
|
717
|
+
}}
|
|
718
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
719
|
+
placeholder="Write your notes here… (Markdown supported)"
|
|
720
|
+
spellCheck
|
|
721
|
+
className="flex-1 bg-slate-950 text-sm text-slate-200 placeholder-slate-700 px-4 py-3 outline-none resize-none leading-relaxed font-mono"
|
|
722
|
+
/>
|
|
723
|
+
)}
|
|
724
|
+
|
|
725
|
+
{/* Footer */}
|
|
726
|
+
<div className="flex items-center gap-2 px-3 py-2 border-t border-slate-800 bg-slate-900 shrink-0">
|
|
727
|
+
<button
|
|
728
|
+
type="button"
|
|
729
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
730
|
+
onClick={handleSave}
|
|
731
|
+
disabled={!dirty}
|
|
732
|
+
className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition-colors ${
|
|
733
|
+
dirty
|
|
734
|
+
? "bg-blue-600 hover:bg-blue-500 text-white"
|
|
735
|
+
: "bg-slate-800 text-slate-600 cursor-default"
|
|
736
|
+
}`}
|
|
737
|
+
title="Save (Ctrl+S)"
|
|
738
|
+
>
|
|
739
|
+
<Save className="w-3 h-3" />
|
|
740
|
+
Save
|
|
741
|
+
</button>
|
|
742
|
+
<button
|
|
743
|
+
type="button"
|
|
744
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
745
|
+
onClick={handleSaveAs}
|
|
746
|
+
className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors"
|
|
747
|
+
title="Save as a new named note"
|
|
748
|
+
>
|
|
749
|
+
Save As
|
|
750
|
+
</button>
|
|
751
|
+
{dirty && (
|
|
752
|
+
<span className="ml-auto text-xs text-amber-500 select-none">
|
|
753
|
+
Unsaved changes
|
|
754
|
+
</span>
|
|
755
|
+
)}
|
|
756
|
+
</div>
|
|
757
|
+
</>
|
|
758
|
+
) : (
|
|
759
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-600">
|
|
760
|
+
<p className="text-sm">No note selected</p>
|
|
761
|
+
<button
|
|
762
|
+
type="button"
|
|
763
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
764
|
+
onClick={handleAddNote}
|
|
765
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs transition-colors"
|
|
766
|
+
>
|
|
767
|
+
<Plus className="w-3.5 h-3.5" />
|
|
768
|
+
New note
|
|
769
|
+
</button>
|
|
770
|
+
</div>
|
|
771
|
+
)}
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
{/* Bottom: Chat panel */}
|
|
776
|
+
<div className="h-56 shrink-0 border-t border-slate-700 flex flex-col bg-slate-950/80">
|
|
777
|
+
{/* Chat header */}
|
|
778
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-slate-800 shrink-0">
|
|
779
|
+
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-400">
|
|
780
|
+
<MessageSquare className="w-3.5 h-3.5" />
|
|
781
|
+
<span>Chat about this note</span>
|
|
782
|
+
</div>
|
|
783
|
+
{chatMessages.length > 0 && (
|
|
784
|
+
<button
|
|
785
|
+
type="button"
|
|
786
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
787
|
+
onClick={() => setChatMessages([])}
|
|
788
|
+
className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
|
|
789
|
+
>
|
|
790
|
+
clear
|
|
791
|
+
</button>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
{/* Messages */}
|
|
796
|
+
<div
|
|
797
|
+
ref={chatScrollRef}
|
|
798
|
+
className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
|
|
799
|
+
>
|
|
800
|
+
{chatMessages.length === 0 && (
|
|
801
|
+
<p className="text-xs text-slate-600 pt-1">
|
|
802
|
+
Ask anything about your note —{" "}
|
|
803
|
+
<span className="text-slate-500">"Explain this concept"</span>{" "}
|
|
804
|
+
or{" "}
|
|
805
|
+
<span className="text-slate-500">"What's missing here?"</span>
|
|
806
|
+
</p>
|
|
807
|
+
)}
|
|
808
|
+
{chatMessages.map((msg) => (
|
|
809
|
+
<div
|
|
810
|
+
key={msg.id}
|
|
811
|
+
className={`flex flex-col gap-0.5 ${
|
|
812
|
+
msg.role === "user" ? "items-end" : "items-start"
|
|
813
|
+
}`}
|
|
814
|
+
>
|
|
815
|
+
<div
|
|
816
|
+
className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
|
|
817
|
+
msg.role === "user"
|
|
818
|
+
? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
|
|
819
|
+
: "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
|
|
820
|
+
}`}
|
|
821
|
+
>
|
|
822
|
+
{msg.role === "user" ? (
|
|
823
|
+
msg.content
|
|
824
|
+
) : msg.content ? (
|
|
825
|
+
<ReactMarkdown
|
|
826
|
+
remarkPlugins={[remarkGfm]}
|
|
827
|
+
components={{
|
|
828
|
+
code({ className, children, ...props }) {
|
|
829
|
+
const isBlock = className?.startsWith("language-");
|
|
830
|
+
return isBlock ? (
|
|
831
|
+
<pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
|
|
832
|
+
<code
|
|
833
|
+
className={`${className ?? ""} text-[11px]`}
|
|
834
|
+
{...props}
|
|
835
|
+
>
|
|
836
|
+
{children}
|
|
837
|
+
</code>
|
|
838
|
+
</pre>
|
|
839
|
+
) : (
|
|
840
|
+
<code
|
|
841
|
+
className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
|
|
842
|
+
{...props}
|
|
843
|
+
>
|
|
844
|
+
{children}
|
|
845
|
+
</code>
|
|
846
|
+
);
|
|
847
|
+
},
|
|
848
|
+
p({ children }) {
|
|
849
|
+
return <p className="mb-1 last:mb-0">{children}</p>;
|
|
850
|
+
},
|
|
851
|
+
ul({ children }) {
|
|
852
|
+
return (
|
|
853
|
+
<ul className="list-disc list-inside mb-1 space-y-0.5">
|
|
854
|
+
{children}
|
|
855
|
+
</ul>
|
|
856
|
+
);
|
|
857
|
+
},
|
|
858
|
+
ol({ children }) {
|
|
859
|
+
return (
|
|
860
|
+
<ol className="list-decimal list-inside mb-1 space-y-0.5">
|
|
861
|
+
{children}
|
|
862
|
+
</ol>
|
|
863
|
+
);
|
|
864
|
+
},
|
|
865
|
+
}}
|
|
866
|
+
>
|
|
867
|
+
{msg.content}
|
|
868
|
+
</ReactMarkdown>
|
|
869
|
+
) : (
|
|
870
|
+
<span className="flex items-center gap-1.5 text-slate-500">
|
|
871
|
+
<Loader2 className="w-3 h-3 animate-spin" /> thinking…
|
|
872
|
+
</span>
|
|
873
|
+
)}
|
|
874
|
+
</div>
|
|
875
|
+
{msg.role === "assistant" && msg.content && (
|
|
876
|
+
<button
|
|
877
|
+
onClick={() => handleCopyMessage(msg.id, msg.content)}
|
|
878
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
879
|
+
className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
|
|
880
|
+
title="Copy response"
|
|
881
|
+
>
|
|
882
|
+
{chatCopiedId === msg.id ? (
|
|
883
|
+
<>
|
|
884
|
+
<ClipboardCheck className="w-3 h-3 text-emerald-400" />
|
|
885
|
+
<span className="text-emerald-400">Copied</span>
|
|
886
|
+
</>
|
|
887
|
+
) : (
|
|
888
|
+
<>
|
|
889
|
+
<Clipboard className="w-3 h-3" />
|
|
890
|
+
<span>Copy</span>
|
|
891
|
+
</>
|
|
892
|
+
)}
|
|
893
|
+
</button>
|
|
894
|
+
)}
|
|
895
|
+
</div>
|
|
896
|
+
))}
|
|
897
|
+
</div>
|
|
898
|
+
|
|
899
|
+
{/* Input */}
|
|
900
|
+
<div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
|
|
901
|
+
<textarea
|
|
902
|
+
ref={chatInputRef}
|
|
903
|
+
rows={1}
|
|
904
|
+
value={chatInput}
|
|
905
|
+
onChange={(e) => setChatInput(e.target.value)}
|
|
906
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
907
|
+
onKeyDown={(e) => {
|
|
908
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
909
|
+
e.preventDefault();
|
|
910
|
+
void handleChatSend();
|
|
911
|
+
}
|
|
912
|
+
}}
|
|
913
|
+
placeholder={
|
|
914
|
+
activeId ? "Ask about your note…" : "Select a note to chat…"
|
|
915
|
+
}
|
|
916
|
+
disabled={chatLoading || !activeId}
|
|
917
|
+
className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-16"
|
|
918
|
+
/>
|
|
919
|
+
<button
|
|
920
|
+
type="button"
|
|
921
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
922
|
+
onClick={() => void handleChatSend()}
|
|
923
|
+
disabled={chatLoading || !chatInput.trim() || !activeId}
|
|
924
|
+
className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
|
|
925
|
+
title="Send (Enter)"
|
|
926
|
+
>
|
|
927
|
+
{chatLoading ? (
|
|
928
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
929
|
+
) : (
|
|
930
|
+
<Send className="w-3.5 h-3.5" />
|
|
931
|
+
)}
|
|
932
|
+
</button>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
|
|
937
|
+
{/* Save As dialog */}
|
|
938
|
+
{saveAsValue !== null && (
|
|
939
|
+
<div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center z-20">
|
|
940
|
+
<div
|
|
941
|
+
className="bg-slate-800 border border-slate-700 rounded-lg p-4 w-72 flex flex-col gap-3"
|
|
942
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
943
|
+
>
|
|
944
|
+
<p className="text-sm font-semibold text-slate-100">Save As</p>
|
|
945
|
+
<input
|
|
946
|
+
autoFocus
|
|
947
|
+
value={saveAsValue}
|
|
948
|
+
onChange={(e) => setSaveAsValue(e.target.value)}
|
|
949
|
+
onKeyDown={(e) => {
|
|
950
|
+
if (e.key === "Enter") confirmSaveAs();
|
|
951
|
+
if (e.key === "Escape") setSaveAsValue(null);
|
|
952
|
+
}}
|
|
953
|
+
placeholder="Note name…"
|
|
954
|
+
className="px-3 py-1.5 rounded bg-slate-950 border border-slate-700 text-sm text-slate-100 outline-none focus:border-blue-500 transition-colors"
|
|
955
|
+
/>
|
|
956
|
+
<div className="flex gap-2 justify-end">
|
|
957
|
+
<button
|
|
958
|
+
type="button"
|
|
959
|
+
onClick={() => setSaveAsValue(null)}
|
|
960
|
+
className="px-3 py-1 rounded text-xs text-slate-400 hover:bg-slate-700 transition-colors"
|
|
961
|
+
>
|
|
962
|
+
Cancel
|
|
963
|
+
</button>
|
|
964
|
+
<button
|
|
965
|
+
type="button"
|
|
966
|
+
onClick={confirmSaveAs}
|
|
967
|
+
className="px-3 py-1 rounded text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
|
|
968
|
+
>
|
|
969
|
+
Save
|
|
970
|
+
</button>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
)}
|
|
975
|
+
</div>
|
|
976
|
+
);
|
|
977
|
+
}
|