@johndimm/constellations 1.0.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.
Files changed (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,1023 @@
1
+ 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';
5
+ import { DEFAULT_KIOSK_DOMAINS, saveKioskDomains, saveSelectedKioskDomainId } from '../kioskDomains';
6
+ import type { KioskDomain } from '../kioskDomains';
7
+
8
+ interface ControlPanelProps {
9
+ searchMode: 'explore' | 'connect';
10
+ setSearchMode: (mode: 'explore' | 'connect') => void;
11
+ exploreTerm: string;
12
+ setExploreTerm: (term: string) => void;
13
+ pathStart: string;
14
+ setPathStart: (term: string) => void;
15
+ pathEnd: string;
16
+ setPathEnd: (term: string) => void;
17
+
18
+ onSearch: (term: string) => void;
19
+ onPathSearch: (start: string, end: string) => void;
20
+ isAdminMode?: boolean;
21
+ kioskSeedTerms?: string[];
22
+ kioskDomains?: KioskDomain[];
23
+ selectedKioskDomainId?: string;
24
+ onSelectKioskDomain?: (domainId: string) => void;
25
+ onUpdateKioskDomains?: (domains: KioskDomain[]) => void;
26
+ onClear: () => void;
27
+ onClearCache?: () => void;
28
+ onExpandAllLeafNodes?: () => void;
29
+ isProcessing: boolean;
30
+ isCompact: boolean;
31
+ onToggleCompact: () => void;
32
+ isTimelineMode: boolean;
33
+ onToggleTimeline: () => void;
34
+ isTextOnly: boolean;
35
+ 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
+ isCollapsed: boolean;
46
+ onSetCollapsed: (val: boolean) => void;
47
+ onOpenPeopleBrowser?: () => void;
48
+ onToggleHelp: () => void;
49
+ showHelp?: boolean;
50
+ }
51
+
52
+ const ControlPanel: React.FC<ControlPanelProps> = ({
53
+ searchMode,
54
+ setSearchMode,
55
+ exploreTerm,
56
+ setExploreTerm,
57
+ pathStart,
58
+ setPathStart,
59
+ pathEnd,
60
+ setPathEnd,
61
+
62
+ onSearch,
63
+ onPathSearch,
64
+ isAdminMode = false,
65
+ kioskSeedTerms = [],
66
+ kioskDomains = [],
67
+ selectedKioskDomainId,
68
+ onSelectKioskDomain,
69
+ onUpdateKioskDomains,
70
+ onClear,
71
+ onClearCache,
72
+ onExpandAllLeafNodes,
73
+ isProcessing,
74
+ isCompact,
75
+ onToggleCompact,
76
+ isTimelineMode,
77
+ onToggleTimeline,
78
+ isTextOnly,
79
+ onToggleTextOnly,
80
+ onPrune,
81
+ error,
82
+ onSave,
83
+ onLoad,
84
+ onDeleteGraph,
85
+ onImport,
86
+ savedGraphs,
87
+ helpHover,
88
+ onHelpHoverChange,
89
+ isCollapsed,
90
+ onSetCollapsed,
91
+ onOpenPeopleBrowser,
92
+ onToggleHelp,
93
+ showHelp = false
94
+ }) => {
95
+ const [hasStarted, setHasStarted] = useState(false);
96
+ const [isHovered, setIsHovered] = useState(false);
97
+ const [showEditDomains, setShowEditDomains] = useState(false);
98
+ const [editDomainId, setEditDomainId] = useState<string | null>(null);
99
+ const [newDomainLabel, setNewDomainLabel] = useState('');
100
+ const [newTerm, setNewTerm] = useState('');
101
+ const [bulkTerms, setBulkTerms] = useState('');
102
+
103
+ // Collapsible sections state - combined toggle for examples section
104
+ const [showExamples, setShowExamples] = useState(false);
105
+
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
+ const domainsImportRef = React.useRef<HTMLInputElement>(null);
117
+
118
+ const handleSubmit = (e: React.FormEvent) => {
119
+ e.preventDefault();
120
+ if (searchMode === 'explore') {
121
+ if (exploreTerm.trim()) {
122
+ onSearch(exploreTerm.trim());
123
+ setHasStarted(true);
124
+ if (window.innerWidth < 768) onSetCollapsed(true);
125
+ }
126
+ } else {
127
+ if (pathStart.trim() && pathEnd.trim()) {
128
+ onPathSearch(pathStart.trim(), pathEnd.trim());
129
+ setHasStarted(true);
130
+ if (window.innerWidth < 768) onSetCollapsed(true);
131
+ }
132
+ }
133
+ };
134
+
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
+ const EXAMPLES = [
189
+ "The Godfather",
190
+ "Watergate Scandal",
191
+ "Giant Steps (album)",
192
+ "Napoleon Bonaparte"
193
+ ];
194
+
195
+ useEffect(() => {
196
+ if (!editDomainId && kioskDomains.length) {
197
+ setEditDomainId(selectedKioskDomainId || kioskDomains[0].id);
198
+ }
199
+ }, [editDomainId, kioskDomains, selectedKioskDomainId]);
200
+
201
+ // In admin mode, opening the editor initializes state. Persistence is disabled.
202
+ useEffect(() => {
203
+ if (!showEditDomains || !isAdminMode) return;
204
+ try {
205
+ saveKioskDomains(kioskDomains);
206
+ if (selectedKioskDomainId) {
207
+ saveSelectedKioskDomainId(selectedKioskDomainId);
208
+ }
209
+ } catch { }
210
+ }, [showEditDomains, isAdminMode, kioskDomains, selectedKioskDomainId]);
211
+
212
+ const selectedDomainForEdit = kioskDomains.find(d => d.id === editDomainId) || kioskDomains[0];
213
+ const selectedDomain = kioskDomains.find(d => d.id === selectedKioskDomainId) || kioskDomains[0];
214
+
215
+ // Header actions portal removed; all actions live in the control panel for mobile space
216
+ const headerActions = null;
217
+
218
+ return (
219
+ <>
220
+ {headerActions}
221
+ <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' }}
224
+ >
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)]">
226
+ {/* 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]">
228
+ {/* Persistent Toggle Handle */}
229
+ <button
230
+ onClick={() => onSetCollapsed?.(!isCollapsed)}
231
+ className={`absolute top-1/2 -translate-y-1/2 -right-8 w-8 h-24 bg-slate-800 border border-slate-700 border-l-0 rounded-r-xl flex flex-col items-center justify-center text-slate-400 hover:text-white transition-all group shadow-xl pointer-events-auto ${isCollapsed ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
232
+ title={isCollapsed ? "Expand controls" : "Collapse controls"}
233
+ >
234
+ {isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
235
+ <div className="[writing-mode:vertical-lr] text-[9px] uppercase tracking-tighter mt-1 font-bold">Controls</div>
236
+ </button>
237
+
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>
294
+ </div>
295
+
296
+ {/* Group: View */}
297
+ <div>
298
+ <div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
299
+ <Network size={10} /> View
300
+ </div>
301
+ <div className="flex flex-wrap gap-2 text-xs">
302
+ <button
303
+ onClick={onToggleTimeline}
304
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md uppercase tracking-wider transition-all border shrink-0 ${isTimelineMode
305
+ ? 'bg-amber-500 text-slate-900 border-amber-400 shadow-lg shadow-amber-500/20 hover:bg-amber-400'
306
+ : 'bg-slate-800 text-slate-300 border-slate-700 hover:border-amber-400 hover:text-amber-400'
307
+ }`}
308
+ title="Toggle Timeline/Network View"
309
+ >
310
+ {isTimelineMode ? <Network size={14} /> : <Calendar size={14} />}
311
+ {isTimelineMode ? 'NETWORK' : 'TIMELINE'}
312
+ </button>
313
+ <button
314
+ onClick={onToggleCompact}
315
+ className="flex items-center gap-1.5 text-slate-300 hover:text-white px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 transition-colors"
316
+ title="Toggle Compact Mode"
317
+ >
318
+ {isCompact ? <Maximize2 size={14} /> : <Minimize2 size={14} />}
319
+ {isCompact ? 'FULL' : 'COMPACT'}
320
+ </button>
321
+ <button
322
+ onClick={onToggleTextOnly}
323
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 transition-colors ${isTextOnly ? 'text-indigo-400 border-indigo-500/50' : 'text-slate-300 hover:text-white'}`}
324
+ title="Toggle Text-Only Mode"
325
+ >
326
+ <Type size={14} />
327
+ TEXT ONLY
328
+ </button>
329
+ </div>
330
+ </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
+ </div>
421
+
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
+ </div>
549
+
550
+ <div onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className="flex-1 min-h-0 flex flex-col overflow-hidden">
551
+ <div className="flex border-b border-slate-700 mb-4 flex-shrink-0">
552
+ <button onClick={() => setSearchMode('explore')} className={`flex-1 pb-2 text-sm font-medium transition-colors ${searchMode === 'explore' ? 'text-indigo-400 border-b-2 border-indigo-500' : 'text-slate-400 hover:text-slate-200'}`}>
553
+ <Search size={14} className="inline mr-1.5 mb-0.5" /> Explore
554
+ </button>
555
+ <button onClick={() => setSearchMode('connect')} className={`flex-1 pb-2 text-sm font-medium transition-colors ${searchMode === 'connect' ? 'text-indigo-400 border-b-2 border-indigo-500' : 'text-slate-400 hover:text-slate-200'}`}>
556
+ <LinkIcon size={14} className="inline mr-1.5 mb-0.5" /> Connect
557
+ </button>
558
+ </div>
559
+
560
+ <form onSubmit={handleSubmit} className="relative mb-4 space-y-3 flex-shrink-0">
561
+ <div className="space-y-3">
562
+ {/* Search / connect inputs (always available) */}
563
+ {searchMode === 'explore' ? (
564
+ <div className="space-y-2">
565
+ <div className="flex gap-2">
566
+ <div className="relative flex-1">
567
+ <input type="text" value={exploreTerm} onChange={(e) => setExploreTerm(e.target.value)} placeholder="Enter a person or event..." className="w-full bg-slate-800 border border-slate-600 text-white pl-10 pr-8 py-3 rounded-lg focus:ring-2 focus:ring-purple-500 outline-none text-sm" disabled={isProcessing} />
568
+ <Search className="absolute left-3 top-3.5 text-slate-400" size={16} />
569
+ {exploreTerm && (
570
+ <button type="button" onClick={() => setExploreTerm('')} className="absolute right-2 top-3.5 text-slate-400 hover:text-white">
571
+ <X size={14} />
572
+ </button>
573
+ )}
574
+ </div>
575
+ <button type="submit" disabled={isProcessing} className={`px-4 py-2 rounded-lg text-sm font-bold uppercase tracking-wider transition-all shadow-lg ${isProcessing ? 'bg-slate-700 text-slate-400' : 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-indigo-500/20'}`}>
576
+ {isProcessing ? '...' : 'GO'}
577
+ </button>
578
+ </div>
579
+ <div className="flex gap-2">
580
+ {['The Godfather', 'French Revolution', 'Alan Turing'].map(term => (
581
+ <button
582
+ key={term}
583
+ type="button"
584
+ onClick={() => {
585
+ setExploreTerm(term);
586
+ onSearch(term);
587
+ setHasStarted(true);
588
+ if (window.innerWidth < 768) onSetCollapsed(true);
589
+ }}
590
+ disabled={isProcessing}
591
+ className="flex-1 text-[11px] bg-slate-800/60 hover:bg-indigo-500/20 text-slate-400 hover:text-indigo-300 px-2 py-1.5 rounded-lg border border-slate-700 hover:border-indigo-500/40 transition-all truncate"
592
+ >
593
+ {term}
594
+ </button>
595
+ ))}
596
+ </div>
597
+ </div>
598
+ ) : (
599
+ <div className="flex flex-col gap-2">
600
+ <div className="relative">
601
+ <input type="text" value={pathStart} onChange={(e) => setPathStart(e.target.value)} placeholder="Start Person/Event..." className="w-full bg-slate-800 border border-slate-600 text-white px-4 py-2.5 pr-8 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none text-sm" disabled={isProcessing} />
602
+ {pathStart && (
603
+ <button type="button" onClick={() => setPathStart('')} className="absolute right-2 top-2.5 text-slate-400 hover:text-white">
604
+ <X size={14} />
605
+ </button>
606
+ )}
607
+ </div>
608
+ <div className="flex justify-center -my-2"><ArrowRight size={14} className="text-slate-500" /></div>
609
+ <div className="relative">
610
+ <input type="text" value={pathEnd} onChange={(e) => setPathEnd(e.target.value)} placeholder="End Person/Event..." className="w-full bg-slate-800 border border-slate-600 text-white px-4 py-2.5 pr-8 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none text-sm" disabled={isProcessing} />
611
+ {pathEnd && (
612
+ <button type="button" onClick={() => setPathEnd('')} className="absolute right-2 top-2.5 text-slate-400 hover:text-white">
613
+ <X size={14} />
614
+ </button>
615
+ )}
616
+ </div>
617
+ <button type="submit" disabled={isProcessing} className={`w-full mt-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${isProcessing ? 'bg-slate-700 text-slate-400' : 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}>
618
+ {isProcessing ? 'Processing... ' : 'Find Connection'}
619
+ </button>
620
+ </div>
621
+ )}
622
+
623
+ {/* Group: Kiosk Domain Selector */}
624
+ {kioskDomains.length > 0 && onSelectKioskDomain && (
625
+ <div className="space-y-2">
626
+ <div className="flex items-center justify-between">
627
+ <button
628
+ type="button"
629
+ onClick={() => setShowExamples(!showExamples)}
630
+ className="text-[11px] text-slate-300 hover:text-white uppercase tracking-wider flex items-center gap-1.5"
631
+ >
632
+ {showExamples ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
633
+ Examples {!showExamples && `(Domains • Start Here)`}
634
+ </button>
635
+ {isAdminMode && onUpdateKioskDomains && showExamples && (
636
+ <button
637
+ type="button"
638
+ className="text-[11px] text-slate-300 hover:text-white underline"
639
+ onClick={() => setShowEditDomains(true)}
640
+ >
641
+ Edit
642
+ </button>
643
+ )}
644
+ </div>
645
+
646
+ {showExamples && (
647
+ <>
648
+ <div className="flex flex-wrap gap-1.5">
649
+ {kioskDomains.map(d => (
650
+ <button
651
+ key={d.id}
652
+ type="button"
653
+ onClick={() => onSelectKioskDomain?.(d.id)}
654
+ className={`text-[11px] px-3 py-1.5 rounded-full border transition-colors ${(selectedKioskDomainId || kioskDomains[0].id) === d.id
655
+ ? 'bg-amber-500 text-slate-900 border-amber-400'
656
+ : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'
657
+ }`}
658
+ disabled={isProcessing}
659
+ >
660
+ {d.label}
661
+ </button>
662
+ ))}
663
+ </div>
664
+ {selectedDomain?.description && (
665
+ <div className="text-[11px] text-slate-400 leading-snug">
666
+ <div>{selectedDomain.description}</div>
667
+ </div>
668
+ )}
669
+ </>
670
+ )}
671
+ </div>
672
+ )}
673
+ </div>
674
+ </form>
675
+
676
+ {error && <p className="text-red-400 text-xs mb-3">{error}</p>}
677
+
678
+ <div className="space-y-2 flex-1 min-h-0 flex flex-col">
679
+ {showExamples && (
680
+ <div className="flex flex-wrap gap-1.5 overflow-y-auto overflow-x-hidden pr-1 flex-1 min-h-0">
681
+ {(kioskSeedTerms.length ? kioskSeedTerms : EXAMPLES).map(term => (
682
+ <button
683
+ key={term}
684
+ type="button"
685
+ onClick={() => {
686
+ setSearchMode('explore');
687
+ setExploreTerm(term);
688
+ onSearch(term);
689
+ setHasStarted(true);
690
+ if (window.innerWidth < 768) onSetCollapsed(true);
691
+ }}
692
+ className="text-[11px] bg-slate-800 hover:bg-slate-700 text-slate-200 px-3 py-1.5 rounded-full border border-slate-700 transition-colors"
693
+ disabled={isProcessing}
694
+ >
695
+ {term}
696
+ </button>
697
+ ))}
698
+ </div>
699
+ )}
700
+ </div>
701
+ </div>
702
+ </div>
703
+ </div>
704
+
705
+ {/* Edit Domains Modal (admin-only) */}
706
+ {
707
+ showEditDomains && isAdminMode && onUpdateKioskDomains && (
708
+ <div className="fixed inset-0 z-[110] flex items-center justify-center px-4">
709
+ <div
710
+ className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm"
711
+ onClick={() => setShowEditDomains(false)}
712
+ />
713
+ {/* The app root uses overflow-hidden, so the modal must provide its own scrolling. */}
714
+ <div className="relative w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl p-5 flex flex-col max-h-[85vh]">
715
+ <div className="flex items-center justify-between mb-4">
716
+ <h3 className="text-white font-bold">Edit domains</h3>
717
+ <button onClick={() => setShowEditDomains(false)} className="text-slate-400 hover:text-white">
718
+ <X size={16} />
719
+ </button>
720
+ </div>
721
+
722
+ <div className="min-h-0 overflow-y-auto pr-1 overscroll-contain">
723
+ <div className="grid grid-cols-1 sm:grid-cols-[220px_1fr] gap-4">
724
+ <div className="space-y-2">
725
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Domains</div>
726
+ <div className="space-y-1 max-h-64 overflow-y-auto pr-1">
727
+ {kioskDomains.map(d => (
728
+ <button
729
+ key={d.id}
730
+ type="button"
731
+ className={`w-full text-left px-3 py-2 rounded-lg border transition-colors ${d.id === (editDomainId || kioskDomains[0]?.id)
732
+ ? 'bg-slate-800 border-amber-500 text-white'
733
+ : 'bg-slate-900 border-slate-700 text-slate-300 hover:bg-slate-800'
734
+ }`}
735
+ onClick={() => setEditDomainId(d.id)}
736
+ >
737
+ <div className="font-semibold">{d.label}</div>
738
+ <div className="text-[11px] text-slate-400">{d.terms.length} starting points</div>
739
+ </button>
740
+ ))}
741
+ </div>
742
+
743
+ <div className="pt-2 border-t border-slate-700">
744
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider mb-2">Add domain</div>
745
+ <div className="flex gap-2">
746
+ <input
747
+ className="flex-1 bg-slate-800 border border-slate-700 text-white px-3 py-2 rounded-lg text-sm"
748
+ value={newDomainLabel}
749
+ onChange={(e) => setNewDomainLabel(e.target.value)}
750
+ placeholder="Domain name…"
751
+ />
752
+ <button
753
+ type="button"
754
+ className="px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold"
755
+ onClick={() => {
756
+ const label = newDomainLabel.trim();
757
+ if (!label) return;
758
+ const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || `domain-${Date.now()}`;
759
+ const next = [...kioskDomains, { id, label, terms: [] }];
760
+ onUpdateKioskDomains(next);
761
+ setNewDomainLabel('');
762
+ setEditDomainId(id);
763
+ }}
764
+ >
765
+ Add
766
+ </button>
767
+ </div>
768
+ </div>
769
+ </div>
770
+
771
+ <div className="space-y-3">
772
+ <div className="flex items-center justify-between">
773
+ <div>
774
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Selected</div>
775
+ <div className="text-white font-semibold">{selectedDomainForEdit?.label || '—'}</div>
776
+ </div>
777
+ {selectedDomainForEdit && kioskDomains.length > 1 && (
778
+ <button
779
+ type="button"
780
+ className="text-[11px] text-red-300 hover:text-red-200 underline"
781
+ onClick={() => {
782
+ const id = selectedDomainForEdit.id;
783
+ const next = kioskDomains.filter(d => d.id !== id);
784
+ onUpdateKioskDomains(next);
785
+ setEditDomainId(next[0]?.id || null);
786
+ }}
787
+ >
788
+ Delete domain
789
+ </button>
790
+ )}
791
+ </div>
792
+
793
+ {selectedDomainForEdit && (
794
+ <>
795
+ <div className="space-y-2">
796
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Rename</div>
797
+ <input
798
+ className="w-full bg-slate-800 border border-slate-700 text-white px-3 py-2 rounded-lg text-sm"
799
+ value={selectedDomainForEdit.label}
800
+ onChange={(e) => {
801
+ const label = e.target.value;
802
+ const next = kioskDomains.map(d => d.id === selectedDomainForEdit.id ? { ...d, label } : d);
803
+ onUpdateKioskDomains(next);
804
+ }}
805
+ />
806
+ </div>
807
+
808
+ <div className="space-y-2">
809
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Add starting point</div>
810
+ <div className="flex gap-2">
811
+ <input
812
+ className="flex-1 bg-slate-800 border border-slate-700 text-white px-3 py-2 rounded-lg text-sm"
813
+ value={newTerm}
814
+ onChange={(e) => setNewTerm(e.target.value)}
815
+ placeholder="e.g., The Godfather"
816
+ />
817
+ <button
818
+ type="button"
819
+ className="px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold"
820
+ onClick={() => {
821
+ const term = newTerm.trim();
822
+ if (!term) return;
823
+ const next = kioskDomains.map(d => d.id === selectedDomainForEdit.id
824
+ ? { ...d, terms: [...d.terms, term] }
825
+ : d
826
+ );
827
+ onUpdateKioskDomains(next);
828
+ setNewTerm('');
829
+ }}
830
+ >
831
+ Add
832
+ </button>
833
+ </div>
834
+ </div>
835
+
836
+ <div className="space-y-2">
837
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Bulk add (one per line)</div>
838
+ <textarea
839
+ className="w-full bg-slate-800 border border-slate-700 text-white px-3 py-2 rounded-lg text-sm h-24"
840
+ value={bulkTerms}
841
+ onChange={(e) => setBulkTerms(e.target.value)}
842
+ placeholder={"LeBron James\nsore throat\nBeef"}
843
+ />
844
+ <div className="flex justify-end">
845
+ <button
846
+ type="button"
847
+ className="px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold"
848
+ onClick={() => {
849
+ const terms = bulkTerms
850
+ .split('\n')
851
+ .map(s => s.trim())
852
+ .filter(Boolean);
853
+ if (!terms.length) return;
854
+ const next = kioskDomains.map(d => d.id === selectedDomainForEdit.id
855
+ ? { ...d, terms: [...d.terms, ...terms] }
856
+ : d
857
+ );
858
+ onUpdateKioskDomains(next);
859
+ setBulkTerms('');
860
+ }}
861
+ >
862
+ Add lines
863
+ </button>
864
+ </div>
865
+ </div>
866
+
867
+ <div className="space-y-2">
868
+ <div className="text-[11px] text-slate-400 uppercase tracking-wider">Starting points</div>
869
+ <div className="max-h-56 overflow-y-auto pr-1 space-y-1">
870
+ {selectedDomainForEdit.terms.map((t, idx) => (
871
+ <div key={`${t}-${idx}`} className="flex items-center justify-between gap-2 bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2">
872
+ <div className="text-slate-200 text-sm truncate">{t}</div>
873
+ <button
874
+ type="button"
875
+ className="text-slate-400 hover:text-red-300"
876
+ onClick={() => {
877
+ const next = kioskDomains.map(d => d.id === selectedDomainForEdit.id
878
+ ? { ...d, terms: d.terms.filter((_, i) => i !== idx) }
879
+ : d
880
+ );
881
+ onUpdateKioskDomains(next);
882
+ }}
883
+ title="Remove"
884
+ >
885
+ <X size={14} />
886
+ </button>
887
+ </div>
888
+ ))}
889
+ </div>
890
+ </div>
891
+ </>
892
+ )}
893
+ </div>
894
+ </div>
895
+ </div>
896
+
897
+ <div className="mt-4 pt-3 border-t border-slate-700 flex items-center justify-between">
898
+ <div className="flex flex-wrap items-center gap-2">
899
+ <button
900
+ type="button"
901
+ className="text-[11px] px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-white border border-slate-700"
902
+ onClick={() => {
903
+ try {
904
+ // Explicitly persist current admin edits to localStorage.
905
+ saveKioskDomains(kioskDomains);
906
+ if (selectedKioskDomainId) saveSelectedKioskDomainId(selectedKioskDomainId);
907
+ } catch { }
908
+ }}
909
+ title="Save domains to local storage"
910
+ >
911
+ Save
912
+ </button>
913
+
914
+ <button
915
+ type="button"
916
+ className="text-[11px] px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-white border border-slate-700"
917
+ onClick={() => {
918
+ try {
919
+ const json = JSON.stringify(kioskDomains, null, 2);
920
+ const blob = new Blob([json], { type: "application/json" });
921
+ const url = URL.createObjectURL(blob);
922
+ const a = document.createElement("a");
923
+ a.href = url;
924
+ a.download = "constellations-domains.json";
925
+ document.body.appendChild(a);
926
+ a.click();
927
+ a.remove();
928
+ URL.revokeObjectURL(url);
929
+ } catch { }
930
+ }}
931
+ title="Download domains JSON"
932
+ >
933
+ Export
934
+ </button>
935
+
936
+ <button
937
+ type="button"
938
+ className="text-[11px] px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-white border border-slate-700"
939
+ onClick={() => domainsImportRef.current?.click()}
940
+ title="Import domains JSON"
941
+ >
942
+ Import
943
+ </button>
944
+
945
+ <button
946
+ type="button"
947
+ className="text-[11px] px-3 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 text-red-200 border border-red-900/50"
948
+ onClick={() => {
949
+ const ok = window.confirm("Reset domains to the shipped defaults? This overwrites your current session changes.");
950
+ if (!ok) return;
951
+ // Just reset state. Since persistence is disabled, no localStorage work needed.
952
+ onUpdateKioskDomains([...DEFAULT_KIOSK_DOMAINS]);
953
+
954
+ const nextId = DEFAULT_KIOSK_DOMAINS[0]?.id;
955
+ if (nextId) {
956
+ saveSelectedKioskDomainId(nextId);
957
+ onSelectKioskDomain?.(nextId);
958
+ }
959
+ setEditDomainId(DEFAULT_KIOSK_DOMAINS[0]?.id || null);
960
+ }}
961
+ title="Reset local domains to defaults"
962
+ >
963
+ Reset
964
+ </button>
965
+
966
+ <input
967
+ ref={domainsImportRef}
968
+ type="file"
969
+ accept="application/json,.json"
970
+ className="hidden"
971
+ onChange={(e) => {
972
+ const file = e.target.files?.[0];
973
+ if (!file) return;
974
+ const reader = new FileReader();
975
+ reader.onload = (event) => {
976
+ try {
977
+ const parsed = JSON.parse(event.target?.result as string);
978
+ if (!Array.isArray(parsed)) throw new Error("Expected an array");
979
+ const cleaned: KioskDomain[] = parsed
980
+ .filter((d: any) => d && typeof d.id === "string" && typeof d.label === "string" && Array.isArray(d.terms))
981
+ .map((d: any) => ({
982
+ id: String(d.id),
983
+ label: String(d.label),
984
+ description: typeof d.description === "string" ? d.description : undefined,
985
+ terms: d.terms.map((t: any) => String(t)).filter((t: string) => t.trim().length > 0)
986
+ }));
987
+ if (!cleaned.length) throw new Error("No valid domains");
988
+ onUpdateKioskDomains(cleaned);
989
+ setEditDomainId(cleaned[0].id);
990
+ onSelectKioskDomain?.(cleaned[0].id);
991
+ // Persist immediately in admin workflow
992
+ try { saveKioskDomains(cleaned); } catch { }
993
+ try { saveSelectedKioskDomainId(cleaned[0].id); } catch { }
994
+ } catch (err) {
995
+ console.error(err);
996
+ alert("Invalid domains JSON");
997
+ } finally {
998
+ // allow re-import of same file
999
+ e.target.value = "";
1000
+ }
1001
+ };
1002
+ reader.readAsText(file);
1003
+ }}
1004
+ />
1005
+ </div>
1006
+
1007
+ <button
1008
+ type="button"
1009
+ className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-white text-sm font-semibold"
1010
+ onClick={() => setShowEditDomains(false)}
1011
+ >
1012
+ Done
1013
+ </button>
1014
+ </div>
1015
+ </div>
1016
+ </div>
1017
+ )
1018
+ }
1019
+ </>
1020
+ );
1021
+ };
1022
+
1023
+ export default ControlPanel;