@johndimm/constellations 1.0.0 → 1.0.2

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.
Files changed (39) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -5
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/embedded.css +38 -0
  15. package/hooks/useExpansion.ts +61 -229
  16. package/hooks/useGraphActions.ts +1 -0
  17. package/hooks/useGraphState.ts +75 -40
  18. package/hooks/useKioskMode.ts +1 -0
  19. package/hooks/useNodeClickHandler.ts +23 -15
  20. package/hooks/useSearchHandlers.ts +57 -19
  21. package/host.ts +1 -1
  22. package/index.css +17 -3
  23. package/package.json +4 -1
  24. package/services/aiService.ts +23 -0
  25. package/services/aiUtils.ts +216 -207
  26. package/services/cacheService.ts +1 -0
  27. package/services/crossrefService.ts +1 -0
  28. package/services/deepseekService.ts +467 -0
  29. package/services/geminiService.ts +532 -733
  30. package/services/graphUtils.ts +128 -18
  31. package/services/imageService.ts +18 -0
  32. package/services/openAlexService.ts +1 -0
  33. package/services/resolveImageForTitle.ts +458 -0
  34. package/services/wikipediaImage.ts +1 -0
  35. package/services/wikipediaService.ts +56 -46
  36. package/types.ts +3 -0
  37. package/utils/evidenceUtils.ts +1 -0
  38. package/utils/graphLogicUtils.ts +1 -0
  39. package/utils/wikiUtils.ts +14 -2
@@ -1,70 +1,110 @@
1
+ "use client";
1
2
  import React from 'react';
2
- import { ChevronRight, ChevronLeft, Key } from 'lucide-react';
3
+ import { ChevronRight, ChevronLeft, X } from 'lucide-react';
3
4
  import { GraphNode } from '../types';
4
5
 
5
6
  interface AppHeaderProps {
6
7
  showHeader: boolean;
7
8
  panelCollapsed: boolean;
8
9
  setPanelCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
9
- showBrowse: boolean;
10
- handleOpenPeopleBrowser: () => void;
11
10
  selectedNode: GraphNode | null;
12
11
  sidebarCollapsed: boolean;
13
- setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
14
12
  setSidebarToggleSignal: React.Dispatch<React.SetStateAction<number>>;
15
- onReset: () => void;
13
+ /** When set, shows a top-right control that leaves full-screen (e.g. back to player). */
14
+ onClose?: () => void;
15
+ /**
16
+ * When set (e.g. `/` or `/player`), the close control is a real `href` link so navigation works
17
+ * even if pointer-event layering blocked the old button. `onClick` can still run for cleanup.
18
+ */
19
+ closeHref?: string;
20
+ /**
21
+ * When the host app shows its own top bar (e.g. Trailer Vision nav, ~44px), set so this header
22
+ * does not sit at viewport top:0 and steal clicks from the host nav. Use `top-11` for 2.75rem.
23
+ */
24
+ offsetTopClass?: string;
16
25
  }
17
26
 
18
27
  const AppHeader: React.FC<AppHeaderProps> = ({
19
28
  showHeader,
20
29
  panelCollapsed,
21
30
  setPanelCollapsed,
22
- showBrowse,
23
- handleOpenPeopleBrowser,
24
31
  selectedNode,
25
32
  sidebarCollapsed,
26
- setSidebarCollapsed,
27
33
  setSidebarToggleSignal,
28
- onReset
34
+ onClose,
35
+ closeHref,
36
+ offsetTopClass = "top-0",
29
37
  }) => {
30
38
  if (!showHeader) return null;
31
39
 
40
+ const showLeave = closeHref || onClose;
41
+
32
42
  return (
33
- <header className="fixed top-0 left-0 right-0 z-50 min-h-14 bg-slate-900/95 backdrop-blur border-b border-slate-800 flex items-center justify-between px-2 sm:px-3 py-2 gap-2 overflow-x-hidden max-w-full">
34
- <div className="flex items-center gap-1.5 sm:gap-2 min-w-0">
43
+ <header
44
+ className={`absolute left-0 right-0 z-[200] h-14 max-h-14 min-h-14 shrink-0 pointer-events-auto bg-slate-900/95 backdrop-blur flex items-center justify-between px-2 sm:px-3 py-2 gap-2 overflow-x-hidden max-w-full ${offsetTopClass}`}
45
+ >
46
+ <div className="pointer-events-auto flex min-w-0 items-center gap-1.5 sm:gap-2">
35
47
  <button
48
+ type="button"
36
49
  onClick={() => setPanelCollapsed(c => !c)}
37
50
  className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white transition flex-shrink-0"
38
- title={panelCollapsed ? "Show controls" : "Hide controls"}
51
+ title={
52
+ panelCollapsed
53
+ ? "Show left panel — search, save/load, graph options"
54
+ : "Hide left panel"
55
+ }
56
+ aria-label={panelCollapsed ? "Show control panel" : "Hide control panel"}
39
57
  >
40
58
  {panelCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
41
59
  </button>
42
- <button
43
- onClick={(e) => {
44
- e.preventDefault();
45
- window.location.href = window.location.origin + window.location.pathname;
46
- }}
47
- className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap hover:text-red-400 transition-colors"
48
- >
60
+ <span className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap">
49
61
  Constellations
50
- </button>
62
+ </span>
51
63
  </div>
52
- <div className="flex items-center gap-3 sm:gap-4 flex-shrink-0 mr-2">
53
- <button
54
- onClick={handleOpenPeopleBrowser}
55
- className={`text-sm font-bold uppercase tracking-widest transition-colors ${showBrowse ? 'text-red-500' : 'text-slate-400 hover:text-white'}`}
56
- >
57
- People
58
- </button>
64
+ <div className="flex items-center gap-2 sm:gap-3 flex-shrink-0 mr-1">
59
65
  {selectedNode && (
60
66
  <button
61
- onClick={() => { setSidebarCollapsed(c => !c); setSidebarToggleSignal(s => s + 1); }}
67
+ type="button"
68
+ onClick={() => {
69
+ setSidebarToggleSignal((s) => s + 1);
70
+ }}
62
71
  className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white transition flex-shrink-0"
63
- title="Toggle details"
72
+ title={
73
+ sidebarCollapsed
74
+ ? "Show right details (selected node on graph)"
75
+ : "Hide right details"
76
+ }
77
+ aria-label={sidebarCollapsed ? "Show details panel" : "Hide details panel"}
64
78
  >
65
79
  {sidebarCollapsed ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
66
80
  </button>
67
81
  )}
82
+ {showLeave && closeHref && (
83
+ <a
84
+ href={closeHref}
85
+ onClick={onClose}
86
+ className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white hover:border-slate-600 transition flex-shrink-0"
87
+ title="Return to the main app"
88
+ aria-label="Return to the main app"
89
+ >
90
+ <X size={20} strokeWidth={2} />
91
+ </a>
92
+ )}
93
+ {showLeave && !closeHref && onClose && (
94
+ <button
95
+ type="button"
96
+ onClick={(e) => {
97
+ e.preventDefault();
98
+ e.stopPropagation();
99
+ onClose();
100
+ }}
101
+ className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white hover:border-slate-600 transition flex-shrink-0"
102
+ title="Leave full screen and return to Trailer Vision"
103
+ aria-label="Close constellations"
104
+ >
105
+ <X size={20} strokeWidth={2} />
106
+ </button>
107
+ )}
68
108
  </div>
69
109
  </header>
70
110
  );
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React from 'react';
2
3
 
3
4
  interface AppNotificationsProps {
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import React, { useState, useEffect, useCallback } from 'react';
2
3
  import { Search, ChevronLeft, ChevronRight, X, Filter } from 'lucide-react';
3
4
 
@@ -124,6 +125,8 @@ const BrowsePeople: React.FC<BrowsePeopleProps> = ({ baseUrl = '', onSelect, exp
124
125
  'South African', 'New Zealander', 'Israeli', 'Saudi Arabian', 'Korean', 'Thai', 'Vietnamese', 'Indonesian'
125
126
  ];
126
127
 
128
+ const isPureBrowse = useCallback(() => !searchTerm.trim() && !occupation && !nationality, [searchTerm, occupation, nationality]);
129
+
127
130
  const buildSearchQuery = useCallback(() => {
128
131
  const parts: string[] = [];
129
132
 
@@ -1,7 +1,6 @@
1
+ "use client";
1
2
  import React, { useState, useEffect } from 'react';
2
- import { Search, Github, HelpCircle, Minimize2, Maximize2, Maximize, Plus, AlertCircle, Scissors, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, Trash2, ChevronLeft, ChevronRight, ChevronDown, Download, Upload, Share2, Copy, Users, Cpu } from 'lucide-react';
3
- import type { LlmProviderId } from '../services/aiUtils';
4
- import { getBrowserLlmOverride, setBrowserLlmOverride, getEnvCacheUrl } from '../services/aiUtils';
3
+ import { Search, Minimize2, Maximize2, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, ChevronLeft, ChevronRight, ChevronDown, Settings } from 'lucide-react';
5
4
  import { DEFAULT_KIOSK_DOMAINS, saveKioskDomains, saveSelectedKioskDomainId } from '../kioskDomains';
6
5
  import type { KioskDomain } from '../kioskDomains';
7
6
 
@@ -23,9 +22,6 @@ interface ControlPanelProps {
23
22
  selectedKioskDomainId?: string;
24
23
  onSelectKioskDomain?: (domainId: string) => void;
25
24
  onUpdateKioskDomains?: (domains: KioskDomain[]) => void;
26
- onClear: () => void;
27
- onClearCache?: () => void;
28
- onExpandAllLeafNodes?: () => void;
29
25
  isProcessing: boolean;
30
26
  isCompact: boolean;
31
27
  onToggleCompact: () => void;
@@ -33,20 +29,23 @@ interface ControlPanelProps {
33
29
  onToggleTimeline: () => void;
34
30
  isTextOnly: boolean;
35
31
  onToggleTextOnly: () => void;
36
- onPrune?: () => void;
37
- error?: string | null;
38
- onSave: (name: string) => void;
39
- onLoad: (name: string) => void;
40
- onDeleteGraph: (name: string) => void;
41
- onImport: (data: any) => void; // New prop for importing
42
- savedGraphs: string[];
43
- helpHover: string | null;
44
- onHelpHoverChange: (value: string | null) => void;
45
32
  isCollapsed: boolean;
46
33
  onSetCollapsed: (val: boolean) => void;
47
34
  onOpenPeopleBrowser?: () => void;
48
- onToggleHelp: () => void;
49
- showHelp?: boolean;
35
+ /** When set, shows a link to the graph settings page at the top of the panel. */
36
+ settingsHref?: string;
37
+ /** Fixed top offset (viewport). Default `top-14` = below constellations header. Use `top-[6.25rem]` when a host app nav (~44px) sits above. */
38
+ offsetTopClass?: string;
39
+ /**
40
+ * When true (e.g. embedded with `hideHeader`), the rail is `top-2 bottom-2` and inner heights
41
+ * use the graph column instead of `100vh` / `60vh` so the panel does not “fall” to the viewport.
42
+ */
43
+ constrainToParentHeight?: boolean;
44
+ /**
45
+ * When true, use `position: fixed` and viewport-based width so the rail matches full-screen
46
+ * overlay hosts (same family as a `fixed` details sidebar). Ignored for normal in-layout absolute rails.
47
+ */
48
+ pinToViewport?: boolean;
50
49
  }
51
50
 
52
51
  const ControlPanel: React.FC<ControlPanelProps> = ({
@@ -67,9 +66,6 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
67
66
  selectedKioskDomainId,
68
67
  onSelectKioskDomain,
69
68
  onUpdateKioskDomains,
70
- onClear,
71
- onClearCache,
72
- onExpandAllLeafNodes,
73
69
  isProcessing,
74
70
  isCompact,
75
71
  onToggleCompact,
@@ -77,20 +73,13 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
77
73
  onToggleTimeline,
78
74
  isTextOnly,
79
75
  onToggleTextOnly,
80
- onPrune,
81
- error,
82
- onSave,
83
- onLoad,
84
- onDeleteGraph,
85
- onImport,
86
- savedGraphs,
87
- helpHover,
88
- onHelpHoverChange,
89
76
  isCollapsed,
90
77
  onSetCollapsed,
91
78
  onOpenPeopleBrowser,
92
- onToggleHelp,
93
- showHelp = false
79
+ settingsHref,
80
+ offsetTopClass = "top-14",
81
+ constrainToParentHeight = false,
82
+ pinToViewport = false,
94
83
  }) => {
95
84
  const [hasStarted, setHasStarted] = useState(false);
96
85
  const [isHovered, setIsHovered] = useState(false);
@@ -103,16 +92,6 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
103
92
  // Collapsible sections state - combined toggle for examples section
104
93
  const [showExamples, setShowExamples] = useState(false);
105
94
 
106
- const [llmSelectValue, setLlmSelectValue] = useState<"env" | LlmProviderId>(() =>
107
- getBrowserLlmOverride() ?? "env"
108
- );
109
-
110
- // Save/Load/Share State
111
- const [showSave, setShowSave] = useState(false);
112
- const [showLoad, setShowLoad] = useState(false);
113
- const [showShare, setShowShare] = useState(false);
114
- const [saveName, setSaveName] = useState('');
115
- const fileInputRef = React.useRef<HTMLInputElement>(null);
116
95
  const domainsImportRef = React.useRef<HTMLInputElement>(null);
117
96
 
118
97
  const handleSubmit = (e: React.FormEvent) => {
@@ -132,59 +111,6 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
132
111
  }
133
112
  };
134
113
 
135
- const handleSaveSubmit = (e: React.FormEvent) => {
136
- e.preventDefault();
137
- if (saveName.trim()) {
138
- onSave(saveName.trim());
139
- setSaveName('');
140
- setShowSave(false);
141
- }
142
- };
143
-
144
- const handleExport = () => {
145
- // We need the current graph data. Ideally passed down, but we can grab from what we know or ask parent.
146
- // Actually, onSave usually saves *current* state.
147
- // To export, we probably need access to the current `nodes` and `links` or a way to get them.
148
- // BUT we don't have them in props here.
149
- // Solution: Let the PARENT handle the export triggered by a callback, OR pass the data down.
150
- // Adding `onExport` prop is safer.
151
- // Wait, the prompt says "Export as JSON and send it".
152
- // I can modify `onSave` to optionally accept an "export" flag? Or just add `onExport` prop.
153
- // Let's add `onExportRequest` prop to `ControlPanel` and implement it in `App`.
154
-
155
- // Changing approach slightly: I will add `onExport` to props in the NEXT step (App.tsx updates),
156
- // but for now I will structure this file to expect it.
157
- // Actually I can keep local logic if I pass the data down? No, passing all nodes/links to ControlPanel causes rerenders.
158
- // Best: `onExport` callback.
159
- };
160
-
161
- // Re-thinking export: User clicks "Export", App.tsx gathers data and downloads it.
162
- // So I need an `onExport` prop. I will add it to the interface above in a sec (or assume it exists and fix App later).
163
- // Actually, I can fix the interface now.
164
-
165
- const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
166
- const file = e.target.files?.[0];
167
- if (!file) return;
168
-
169
- const reader = new FileReader();
170
- reader.onload = (event) => {
171
- try {
172
- const json = JSON.parse(event.target?.result as string);
173
- // Basic validation
174
- if (json.nodes && json.links) {
175
- onImport(json);
176
- setShowLoad(false);
177
- } else {
178
- alert("Invalid graph JSON");
179
- }
180
- } catch (err) {
181
- console.error(err);
182
- alert("Failed to parse JSON");
183
- }
184
- };
185
- reader.readAsText(file);
186
- };
187
-
188
114
  const EXAMPLES = [
189
115
  "The Godfather",
190
116
  "Watergate Scandal",
@@ -219,12 +145,24 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
219
145
  <>
220
146
  {headerActions}
221
147
  <div
222
- className={`absolute left-0 z-40 flex flex-col gap-2 transition-transform duration-300 ease-in-out pointer-events-none ${isCollapsed ? '-translate-x-[calc(100%-24px)]' : 'translate-x-[12px] sm:translate-x-[16px]'} top-16`}
223
- style={{ width: 'calc(100% - 1.5rem)', maxWidth: '28rem' }}
148
+ className={`${pinToViewport ? "fixed" : "absolute"} left-0 z-50 flex flex-col gap-2 transition-transform duration-300 ease-in-out pointer-events-none ${isCollapsed ? "-translate-x-[calc(100%-24px)]" : "translate-x-[12px] sm:translate-x-[16px]"} ${offsetTopClass}`}
149
+ style={
150
+ pinToViewport
151
+ ? { width: "min(28rem, calc(100vw - 1.5rem))", maxWidth: "28rem" }
152
+ : { width: "calc(100% - 1.5rem)", maxWidth: "28rem" }
153
+ }
224
154
  >
225
- <div className="bg-slate-900/95 backdrop-blur-xl p-4 rounded-xl border border-slate-700 shadow-2xl pointer-events-auto relative overflow-hidden flex flex-col max-h-[calc(100vh-64px)]">
155
+ <div
156
+ className={`bg-slate-900/95 backdrop-blur-xl p-4 rounded-xl border border-slate-700 shadow-2xl pointer-events-auto relative overflow-hidden flex flex-col ${constrainToParentHeight
157
+ ? "h-full min-h-0 max-h-full"
158
+ : "max-h-[calc(100vh-64px)]"}`}
159
+ >
226
160
  {/* Scrollable area for everything above the Start Here list if it gets too tall (e.g. Help open) */}
227
- <div className="overflow-y-auto overflow-x-hidden flex-shrink-0 custom-scrollbar max-h-[60vh]">
161
+ <div
162
+ className={`overflow-y-auto overflow-x-hidden custom-scrollbar ${constrainToParentHeight
163
+ ? "min-h-0 max-h-[min(14rem,45%)] flex-shrink-0"
164
+ : "flex-shrink-0 max-h-[60vh]"}`}
165
+ >
228
166
  {/* Persistent Toggle Handle */}
229
167
  <button
230
168
  onClick={() => onSetCollapsed?.(!isCollapsed)}
@@ -235,64 +173,18 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
235
173
  <div className="[writing-mode:vertical-lr] text-[9px] uppercase tracking-tighter mt-1 font-bold">Controls</div>
236
174
  </button>
237
175
 
238
- {/* Button Groups */}
239
- <div className="space-y-4 mb-4">
240
- {/* Group: File */}
241
- <div>
242
- <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
243
- <Download size={10} /> File
244
- </div>
245
- <div className="flex flex-wrap gap-2 text-xs">
246
- <button
247
- onClick={() => {
248
- let defaultName = "";
249
- if (searchMode === 'explore' && exploreTerm) {
250
- defaultName = exploreTerm;
251
- } else if (searchMode === 'connect' && pathStart && pathEnd) {
252
- defaultName = `${pathStart} to ${pathEnd}`;
253
- } else {
254
- defaultName = `Graph ${new Date().toLocaleTimeString()}`;
255
- }
256
- setSaveName(defaultName);
257
- setShowSave(!showSave);
258
- setShowLoad(false);
259
- setShowShare(false);
260
- if (showHelp) onToggleHelp();
261
- onHelpHoverChange(null);
262
- }}
263
- className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'save' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
264
- title="Save Graph"
265
- >
266
- SAVE
267
- </button>
268
- <button
269
- onClick={() => {
270
- setShowLoad(!showLoad);
271
- setShowSave(false);
272
- setShowShare(false);
273
- if (showHelp) onToggleHelp();
274
- }}
275
- className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'load' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
276
- title="Load Graph"
277
- >
278
- LOAD
279
- </button>
280
- <button
281
- onClick={() => {
282
- setShowShare(!showShare);
283
- setShowSave(false);
284
- setShowLoad(false);
285
- if (showHelp) onToggleHelp();
286
- onHelpHoverChange(null);
287
- }}
288
- className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'share' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
289
- title="Share Graph"
290
- >
291
- SHARE
292
- </button>
293
- </div>
176
+ {/* Settings link */}
177
+ {settingsHref && (
178
+ <div className="mb-3">
179
+ <a href={settingsHref} className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors">
180
+ <Settings size={12} />
181
+ Settings
182
+ </a>
294
183
  </div>
184
+ )}
295
185
 
186
+ {/* Button Groups */}
187
+ <div className="space-y-4 mb-4">
296
188
  {/* Group: View */}
297
189
  <div>
298
190
  <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
@@ -328,223 +220,8 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
328
220
  </button>
329
221
  </div>
330
222
  </div>
331
-
332
- {/* Group: LLM — browser override; with cache proxy, sent as llmProvider on each request */}
333
- <div>
334
- <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
335
- <Cpu size={10} /> LLM
336
- </div>
337
- <select
338
- value={llmSelectValue}
339
- onChange={(e) => {
340
- const v = e.target.value as "env" | LlmProviderId;
341
- if (v === "env") {
342
- setBrowserLlmOverride(null);
343
- setLlmSelectValue("env");
344
- } else {
345
- setBrowserLlmOverride(v);
346
- setLlmSelectValue(v);
347
- }
348
- }}
349
- className="w-full bg-slate-900 border border-slate-600 text-slate-100 text-xs rounded-md px-2 py-2 focus:outline-none focus:ring-1 focus:ring-indigo-500"
350
- title="Model provider for AI calls from this tab"
351
- >
352
- <option value="env">Default (from .env)</option>
353
- <option value="gemini">Google Gemini</option>
354
- <option value="openai">OpenAI</option>
355
- <option value="deepseek">DeepSeek</option>
356
- <option value="anthropic">Anthropic</option>
357
- </select>
358
- <p className="text-[9px] text-slate-500 mt-1 leading-snug">
359
- {getEnvCacheUrl() ? (
360
- <>
361
- Proxied requests include <span className="text-slate-400">llmProvider</span> when you pick a provider
362
- (Default uses the cache server&apos;s <span className="text-slate-400">LLM_PROVIDER</span>). Keys live on
363
- the server (e.g. Render).
364
- </>
365
- ) : (
366
- <>
367
- Overrides <span className="text-slate-400">VITE_LLM_PROVIDER</span> for this tab. Keys:{" "}
368
- <span className="text-slate-400">VITE_GEMINI_API_KEY</span>,{" "}
369
- <span className="text-slate-400">VITE_OPENAI_API_KEY</span>, etc. in{" "}
370
- <span className="text-slate-400">.env.local</span>.
371
- </>
372
- )}
373
- </p>
374
- </div>
375
-
376
- {/* Group: Actions */}
377
- <div>
378
- <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
379
- <Plus size={10} /> Actions
380
- </div>
381
- <div className="flex flex-wrap gap-2 text-xs">
382
- <button
383
- onClick={onClear}
384
- className="text-slate-300 hover:text-red-300 p-1.5 rounded-md border border-slate-700 bg-slate-800/80 transition-colors"
385
- title="Clear graph"
386
- >
387
- <Trash2 size={16} />
388
- </button>
389
- {onClearCache && (
390
- <button
391
- onClick={onClearCache}
392
- className="px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-orange-300 transition-colors text-xs"
393
- title="Clear API cache (forces fresh data from LLM)"
394
- >
395
- CLEAR CACHE
396
- </button>
397
- )}
398
- {onExpandAllLeafNodes && (
399
- <button
400
- onClick={onExpandAllLeafNodes}
401
- disabled={isProcessing}
402
- className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-emerald-300 inline-flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors`}
403
- title="Expand everything reachable from the current graph frontier"
404
- >
405
- <Maximize size={14} className="text-emerald-400" />
406
- EXPAND ALL
407
- </button>
408
- )}
409
- <button
410
- onClick={() => {
411
- onToggleHelp();
412
- }}
413
- className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-white flex items-center gap-1 transition-colors ${helpHover === 'help' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
414
- title="Help & Info"
415
- >
416
- <HelpCircle size={14} /> HELP
417
- </button>
418
- </div>
419
- </div>
420
223
  </div>
421
224
 
422
- {/* Help Dialog moved to shared App-level HelpOverlay */}
423
-
424
- {/* Share Dialog */}
425
- {showShare && (
426
- <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 animate-in fade-in slide-in-from-top-2 duration-200">
427
- <div className="flex justify-between items-center mb-3">
428
- <h3 className="text-sm font-bold text-white flex items-center gap-2">
429
- <Share2 size={14} /> Share Graph
430
- </h3>
431
- <button onClick={() => setShowShare(false)}><X size={14} className="text-slate-400" /></button>
432
- </div>
433
- <div className="grid grid-cols-3 gap-2">
434
- <button
435
- onClick={() => onSave('__COPY_LINK__')}
436
- className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
437
- >
438
- <LinkIcon size={20} className="text-orange-400" />
439
- <span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy Link</span>
440
- </button>
441
- <button
442
- onClick={() => onSave('__COPY__')}
443
- className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
444
- >
445
- <Copy size={20} className="text-purple-400" />
446
- <span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy JSON</span>
447
- </button>
448
- <button
449
- onClick={() => onSave('__EXPORT__')}
450
- className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
451
- >
452
- <Download size={20} className="text-indigo-400" />
453
- <span className="text-[10px] font-bold uppercase tracking-wider text-center">Download File</span>
454
- </button>
455
- </div>
456
- <p className="mt-3 text-[10px] text-slate-400 text-center italic">
457
- Share the JSON data with others to let them view your graph.
458
- </p>
459
- </div>
460
- )}
461
-
462
- {/* Save Dialog */}
463
- {showSave && (
464
- <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600">
465
- <div className="flex justify-between items-center mb-2">
466
- <h3 className="text-sm font-bold text-white">Save Graph</h3>
467
- <button onClick={() => setShowSave(false)}><X size={14} className="text-slate-400" /></button>
468
- </div>
469
- <form onSubmit={handleSaveSubmit} className="flex gap-2">
470
- <input
471
- type="text"
472
- value={saveName}
473
- onChange={(e) => setSaveName(e.target.value)}
474
- placeholder="Graph Name..."
475
- className="flex-1 bg-slate-900 border border-slate-700 text-white px-2 py-1 rounded text-sm focus:outline-none focus:border-indigo-500"
476
- autoFocus
477
- />
478
- <button type="submit" className="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm font-medium">
479
- Save
480
- </button>
481
- {/* Export Button (Downloads current as JSON) */}
482
- <button
483
- type="button"
484
- onClick={() => onSave('__EXPORT__')} // Special signal to export
485
- className="bg-indigo-600 hover:bg-indigo-500 text-white px-2 py-1 rounded text-sm font-medium flex items-center"
486
- title="Export as JSON"
487
- >
488
- <Download size={14} />
489
- </button>
490
- </form>
491
- </div>
492
- )}
493
-
494
- {/* Load Dialog */}
495
- {showLoad && (
496
- <div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 max-h-60 overflow-y-auto">
497
- <div className="flex justify-between items-center mb-2">
498
- <h3 className="text-sm font-bold text-white">Load Graph</h3>
499
- <div className="flex items-center gap-2">
500
- <button
501
- onClick={() => fileInputRef.current?.click()}
502
- className="text-slate-400 hover:text-blue-400 flex items-center gap-1 text-xs"
503
- title="Import JSON"
504
- >
505
- <Upload size={14} /> Import
506
- </button>
507
- <button onClick={() => setShowLoad(false)}><X size={14} className="text-slate-400" /></button>
508
- </div>
509
- </div>
510
- <input
511
- type="file"
512
- ref={fileInputRef}
513
- onChange={handleImportFile}
514
- accept=".json"
515
- className="hidden"
516
- />
517
-
518
- {savedGraphs.length === 0 ? (
519
- <p className="text-slate-400 text-xs italic">No saved graphs.</p>
520
- ) : (
521
- <div className="space-y-1">
522
- {savedGraphs.map(name => (
523
- <div key={name} className="flex justify-between items-center bg-slate-900 p-2 rounded hover:bg-slate-700 group transition-colors">
524
- <button
525
- onClick={() => { onLoad(name); setShowLoad(false); }}
526
- className="text-white text-sm text-left flex-1"
527
- >
528
- {name}
529
- </button>
530
- <button
531
- onClick={(e) => {
532
- e.stopPropagation();
533
- onDeleteGraph(name);
534
- setShowLoad(false);
535
- }}
536
- className="text-slate-400 hover:text-red-400 transition-colors p-1.5 rounded-md hover:bg-slate-800"
537
- title="Delete Graph"
538
- >
539
- <Trash2 size={14} />
540
- </button>
541
- </div>
542
- ))}
543
- </div>
544
- )}
545
- </div>
546
- )}
547
-
548
225
  </div>
549
226
 
550
227
  <div onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className="flex-1 min-h-0 flex flex-col overflow-hidden">
@@ -673,8 +350,6 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
673
350
  </div>
674
351
  </form>
675
352
 
676
- {error && <p className="text-red-400 text-xs mb-3">{error}</p>}
677
-
678
353
  <div className="space-y-2 flex-1 min-h-0 flex flex-col">
679
354
  {showExamples && (
680
355
  <div className="flex flex-wrap gap-1.5 overflow-y-auto overflow-x-hidden pr-1 flex-1 min-h-0">