@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- 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'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;
|