@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
package/App.tsx
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
2
|
+
import { buildWikiUrl } from './utils/wikiUtils';
|
|
3
|
+
import { Key, Search, HelpCircle, Minimize2, Maximize2, ExternalLink } from 'lucide-react';
|
|
4
|
+
import Graph from './components/Graph';
|
|
5
|
+
import ControlPanel from './components/ControlPanel';
|
|
6
|
+
import Sidebar from './components/Sidebar';
|
|
7
|
+
import NodeContextMenu from './components/NodeContextMenu';
|
|
8
|
+
import AppHeader from './components/AppHeader';
|
|
9
|
+
import AppNotifications from './components/AppNotifications';
|
|
10
|
+
import AppConfirmDialog from './components/AppConfirmDialog';
|
|
11
|
+
import HelpOverlay from './components/HelpOverlay';
|
|
12
|
+
import { GraphNode, GraphLink } from './types';
|
|
13
|
+
import { getApiKey, getEnvCacheUrl } from './services/aiUtils';
|
|
14
|
+
import { useNodeClickHandler } from './hooks/useNodeClickHandler';
|
|
15
|
+
|
|
16
|
+
import { useGraphState } from './hooks/useGraphState';
|
|
17
|
+
import { useKioskMode } from './hooks/useKioskMode';
|
|
18
|
+
import { useExpansion } from './hooks/useExpansion';
|
|
19
|
+
import { useSearchHandlers } from './hooks/useSearchHandlers';
|
|
20
|
+
import { useGraphActions } from './hooks/useGraphActions';
|
|
21
|
+
|
|
22
|
+
const PeopleBrowserSidebar = lazy(() => import('./components/PeopleBrowserSidebar'));
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
type AppProps = {
|
|
26
|
+
mode?: 'standalone' | 'extension';
|
|
27
|
+
hideHeader?: boolean;
|
|
28
|
+
hideControlPanel?: boolean;
|
|
29
|
+
hideSidebar?: boolean;
|
|
30
|
+
externalSearch?: { term: string; id: number } | null;
|
|
31
|
+
onExternalSearchConsumed?: (id: number) => void;
|
|
32
|
+
onNodeNavigate?: (node: GraphNode) => void;
|
|
33
|
+
renderEvidencePopup?: (selectedLink: GraphLink | null, onClose: () => void) => React.ReactNode;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ExtensionControls: React.FC<{
|
|
37
|
+
isTimelineMode: boolean;
|
|
38
|
+
onToggle: (val: boolean) => void;
|
|
39
|
+
exploreTerm: string;
|
|
40
|
+
setExploreTerm: (val: string) => void;
|
|
41
|
+
onSearch: (val: string) => void;
|
|
42
|
+
isCompact: boolean;
|
|
43
|
+
onToggleCompact: () => void;
|
|
44
|
+
onToggleHelp: () => void;
|
|
45
|
+
}> = ({
|
|
46
|
+
isTimelineMode, onToggle, exploreTerm, setExploreTerm,
|
|
47
|
+
onSearch, isCompact, onToggleCompact, onToggleHelp
|
|
48
|
+
}) => {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className="fixed top-6 left-6 flex items-center gap-3 bg-slate-900/95 p-1.5 rounded-xl border border-slate-700 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-[9999]"
|
|
52
|
+
>
|
|
53
|
+
<div className="flex bg-slate-800/50 rounded-lg p-0.5 border border-slate-700/50">
|
|
54
|
+
<button
|
|
55
|
+
onClick={() => onToggle(false)}
|
|
56
|
+
className={`px-3 py-1.5 rounded-md text-[9px] font-black uppercase tracking-widest transition-all duration-300 ${!isTimelineMode
|
|
57
|
+
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
|
|
58
|
+
: 'text-slate-500 hover:text-slate-300'
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
Net
|
|
62
|
+
</button>
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => onToggle(true)}
|
|
65
|
+
className={`px-3 py-1.5 rounded-md text-[9px] font-black uppercase tracking-widest transition-all duration-300 ${isTimelineMode
|
|
66
|
+
? 'bg-amber-500 text-slate-950 shadow-lg shadow-amber-500/20'
|
|
67
|
+
: 'text-slate-500 hover:text-slate-300'
|
|
68
|
+
}`}
|
|
69
|
+
>
|
|
70
|
+
Time
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="h-6 w-[1px] bg-slate-700/50" />
|
|
75
|
+
|
|
76
|
+
<form
|
|
77
|
+
onSubmit={(e) => { e.preventDefault(); onSearch(exploreTerm); }}
|
|
78
|
+
className="relative group"
|
|
79
|
+
>
|
|
80
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-indigo-400 transition-colors" size={14} />
|
|
81
|
+
<input
|
|
82
|
+
type="text"
|
|
83
|
+
value={exploreTerm}
|
|
84
|
+
onChange={(e) => setExploreTerm(e.target.value)}
|
|
85
|
+
placeholder="Search..."
|
|
86
|
+
className="bg-slate-800/80 border border-slate-700 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20 w-48 sm:w-64 md:w-80 transition-all"
|
|
87
|
+
/>
|
|
88
|
+
</form>
|
|
89
|
+
|
|
90
|
+
<div className="h-6 w-[1px] bg-slate-700/50" />
|
|
91
|
+
|
|
92
|
+
<div className="flex items-center gap-1">
|
|
93
|
+
<button
|
|
94
|
+
onClick={onToggleCompact}
|
|
95
|
+
className={`p-1.5 rounded-lg border transition-all ${isCompact ? 'bg-indigo-500/20 border-indigo-500/40 text-indigo-400' : 'bg-slate-800/80 border-slate-700 text-slate-400 hover:text-white'}`}
|
|
96
|
+
title="Toggle Compact Mode"
|
|
97
|
+
>
|
|
98
|
+
{isCompact ? <Maximize2 size={16} /> : <Minimize2 size={16} />}
|
|
99
|
+
</button>
|
|
100
|
+
<button
|
|
101
|
+
onClick={onToggleHelp}
|
|
102
|
+
className="p-1.5 rounded-lg bg-slate-800/80 border border-slate-700 text-slate-400 hover:text-white transition-all"
|
|
103
|
+
title="Help"
|
|
104
|
+
>
|
|
105
|
+
<HelpCircle size={16} />
|
|
106
|
+
</button>
|
|
107
|
+
<div className="h-6 w-[1px] bg-slate-700/50" />
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => {
|
|
110
|
+
const isExtension = window.location.protocol === 'chrome-extension:';
|
|
111
|
+
const baseOrigin = isExtension ? 'https://constellations-delta.vercel.app' : window.location.origin;
|
|
112
|
+
const url = new URL(baseOrigin);
|
|
113
|
+
if (exploreTerm) url.searchParams.set('q', exploreTerm);
|
|
114
|
+
window.open(url.toString(), '_blank');
|
|
115
|
+
}}
|
|
116
|
+
className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 hover:bg-indigo-500/20 hover:text-indigo-300 transition-all"
|
|
117
|
+
title="Open in Standalone App"
|
|
118
|
+
>
|
|
119
|
+
<ExternalLink size={16} />
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const App: React.FC<AppProps> = ({
|
|
127
|
+
hideHeader = false,
|
|
128
|
+
hideControlPanel = false,
|
|
129
|
+
hideSidebar = false,
|
|
130
|
+
externalSearch = null,
|
|
131
|
+
onExternalSearchConsumed,
|
|
132
|
+
onNodeNavigate,
|
|
133
|
+
renderEvidencePopup
|
|
134
|
+
}) => {
|
|
135
|
+
const ENABLE_WEB_SEARCH = String((import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH || '').trim().toLowerCase() === 'true' || (import.meta as any)?.env?.VITE_ENABLE_WEB_SEARCH === '1';
|
|
136
|
+
const ENABLE_ACADEMIC_CORPORA = (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== 'false' && (import.meta as any)?.env?.VITE_ENABLE_ACADEMIC_CORPORA !== '0';
|
|
137
|
+
|
|
138
|
+
const cacheBaseUrl = getEnvCacheUrl();
|
|
139
|
+
const cacheEnabled = !!cacheBaseUrl;
|
|
140
|
+
|
|
141
|
+
const {
|
|
142
|
+
isAdminMode, kioskDomains, setKioskDomains, selectedKioskDomainId, setSelectedKioskDomainId,
|
|
143
|
+
selectedKioskDomain, kioskSeedTerms
|
|
144
|
+
} = useKioskMode();
|
|
145
|
+
|
|
146
|
+
const state = useGraphState({ cacheEnabled, cacheBaseUrl });
|
|
147
|
+
const {
|
|
148
|
+
graphData, setGraphData, nodes, links, graphDataRef,
|
|
149
|
+
isProcessing, setIsProcessing, selectedNode, setSelectedNode,
|
|
150
|
+
selectedLink, setSelectedLink, isCompact, setIsCompact,
|
|
151
|
+
isTimelineMode, setIsTimelineMode, isTextOnly, setIsTextOnly,
|
|
152
|
+
dimensions, error, setError, isKeyReady, setIsKeyReady,
|
|
153
|
+
nodesRef, graphRef, autoExpandMoreDoneRef, searchId, setSearchId,
|
|
154
|
+
searchIdRef, deletePreview, setDeletePreview, pathNodeIds, setPathNodeIds,
|
|
155
|
+
newlyExpandedNodeIds, setNewlyExpandedNodeIds, expandingNodeId, setExpandingNodeId,
|
|
156
|
+
newChildNodeIds, setNewChildNodeIds, helpHover, setHelpHover,
|
|
157
|
+
notification, setNotification, confirmDialog, setConfirmDialog,
|
|
158
|
+
contextMenu, setContextMenu, panelCollapsed, setPanelCollapsed,
|
|
159
|
+
sidebarCollapsed, setSidebarCollapsed, sidebarToggleSignal, setSidebarToggleSignal,
|
|
160
|
+
peopleBrowserOpen, setPeopleBrowserOpen, savedGraphs, setSavedGraphs,
|
|
161
|
+
searchMode, setSearchMode, loadNodeImage, handleFindBetterImage, saveCacheNodeMeta
|
|
162
|
+
} = state;
|
|
163
|
+
|
|
164
|
+
const { fetchAndExpandNode, saveCacheExpansion } = useExpansion({
|
|
165
|
+
graphDataRef, setGraphData, setIsProcessing, setError, searchIdRef, lockedPairRef: state.lockedPairRef,
|
|
166
|
+
nodesRef, selectedNodeRef: state.selectedNodeRef, autoExpandMoreDoneRef,
|
|
167
|
+
cacheEnabled, cacheBaseUrl, ENABLE_ACADEMIC_CORPORA, ENABLE_WEB_SEARCH,
|
|
168
|
+
loadNodeImage, saveCacheNodeMeta,
|
|
169
|
+
setNewlyExpandedNodeIds, setExpandingNodeId, setNewChildNodeIds,
|
|
170
|
+
setSelectedNode, setSelectedLink, exploreTerm: '', isTextOnly, graphRef,
|
|
171
|
+
setNotification,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
175
|
+
|
|
176
|
+
const {
|
|
177
|
+
exploreTerm, setExploreTerm, pathStart, setPathStart, pathEnd, setPathEnd,
|
|
178
|
+
handleStartSearch, handlePathSearch
|
|
179
|
+
} = useSearchHandlers({
|
|
180
|
+
graphDataRef, setGraphData, setIsProcessing, setError, setSearchId, searchIdRef,
|
|
181
|
+
setLockedPair: state.setLockedPair, dimensions, cacheEnabled, cacheBaseUrl, loadNodeImage, fetchAndExpandNode,
|
|
182
|
+
setNotification, setSelectedNode, setSelectedLink, setPathNodeIds, setPendingAutoExpandId: () => { },
|
|
183
|
+
showControlPanel: !hideControlPanel, selectedKioskDomain, graphRef
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const {
|
|
187
|
+
handleClear, handleClearCache, handlePrune, handleSmartDelete, handleExpandLeaves,
|
|
188
|
+
handleExpandMore, handleExpandAllLeafNodes, handleDeleteGraph,
|
|
189
|
+
handleSaveGraph, handleLoadGraph, handleImport
|
|
190
|
+
} = useGraphActions({
|
|
191
|
+
nodes, links, setGraphData, setSelectedNode, setSelectedLink,
|
|
192
|
+
setContextMenu, setNotification, setConfirmDialog, setDeletePreview,
|
|
193
|
+
setPathNodeIds, fetchAndExpandNode, setIsProcessing, searchIdRef,
|
|
194
|
+
cacheEnabled, cacheBaseUrl, setSavedGraphs, searchMode, exploreTerm,
|
|
195
|
+
pathStart, pathEnd, isCompact, isTimelineMode, isTextOnly,
|
|
196
|
+
setExpandingNodeId, setNewChildNodeIds
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const onNodeClick = useNodeClickHandler({
|
|
200
|
+
selectedNode, setSelectedNode, setContextMenu,
|
|
201
|
+
graphData,
|
|
202
|
+
setExpandingNodeId,
|
|
203
|
+
setNewChildNodeIds,
|
|
204
|
+
/** DevTools: see why a click opened the menu vs expanded (Vite terminal will not show this). */
|
|
205
|
+
onDebug: (message) => console.info("[Constellations]", message),
|
|
206
|
+
onNavigate: onNodeNavigate ? (node) => {
|
|
207
|
+
onNodeNavigate(node);
|
|
208
|
+
} : undefined,
|
|
209
|
+
onExpand: isTimelineMode ? undefined : fetchAndExpandNode,
|
|
210
|
+
onDeselect: () => {
|
|
211
|
+
setPathNodeIds([]);
|
|
212
|
+
setSelectedLink(null);
|
|
213
|
+
setExpandingNodeId(null);
|
|
214
|
+
setNewChildNodeIds(new Set());
|
|
215
|
+
},
|
|
216
|
+
onClearSecondarySelection: () => {
|
|
217
|
+
setSelectedLink(null);
|
|
218
|
+
},
|
|
219
|
+
getMenuPosition: (node, event) => ({ x: event?.clientX ?? 0, y: event?.clientY ?? 0 })
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const checkKey = async () => {
|
|
224
|
+
if (cacheBaseUrl) {
|
|
225
|
+
setIsKeyReady(true);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const envKey = await getApiKey();
|
|
229
|
+
if ((window as any).aistudio) {
|
|
230
|
+
const hasKey = await (window as any).aistudio.hasSelectedApiKey();
|
|
231
|
+
setIsKeyReady(hasKey || !!envKey);
|
|
232
|
+
} else {
|
|
233
|
+
if (envKey) setIsKeyReady(true);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
checkKey();
|
|
237
|
+
}, [setIsKeyReady]);
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!externalSearch?.term) return;
|
|
241
|
+
handleStartSearch(externalSearch.term);
|
|
242
|
+
if (externalSearch?.id !== undefined) onExternalSearchConsumed?.(externalSearch.id);
|
|
243
|
+
}, [externalSearch?.id, handleStartSearch, onExternalSearchConsumed]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const handlePopState = () => {
|
|
247
|
+
const params = new URLSearchParams(window.location.search);
|
|
248
|
+
setPeopleBrowserOpen(params.get('browse') === 'people');
|
|
249
|
+
};
|
|
250
|
+
window.addEventListener('popstate', handlePopState);
|
|
251
|
+
return () => window.removeEventListener('popstate', handlePopState);
|
|
252
|
+
}, [setPeopleBrowserOpen]);
|
|
253
|
+
|
|
254
|
+
// Auto-start search if ?q= parameter is present in URL
|
|
255
|
+
const urlQueryProcessedRef = useRef(false);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (urlQueryProcessedRef.current) return;
|
|
258
|
+
|
|
259
|
+
const params = new URLSearchParams(window.location.search);
|
|
260
|
+
const queryParam = params.get('q');
|
|
261
|
+
const startParam = params.get('start');
|
|
262
|
+
const endParam = params.get('end');
|
|
263
|
+
|
|
264
|
+
if (queryParam && isKeyReady && nodes.length === 0) {
|
|
265
|
+
urlQueryProcessedRef.current = true;
|
|
266
|
+
handleStartSearch(queryParam);
|
|
267
|
+
} else if (startParam && endParam && isKeyReady && nodes.length === 0) {
|
|
268
|
+
urlQueryProcessedRef.current = true;
|
|
269
|
+
setSearchMode('connect');
|
|
270
|
+
setPathStart(startParam);
|
|
271
|
+
setPathEnd(endParam);
|
|
272
|
+
handlePathSearch(startParam, endParam);
|
|
273
|
+
}
|
|
274
|
+
}, [isKeyReady, nodes.length, handleStartSearch, handlePathSearch, setSearchMode, setPathStart, setPathEnd]);
|
|
275
|
+
|
|
276
|
+
const applyGraphData = useCallback((data: any, sourceLabel: string) => {
|
|
277
|
+
try {
|
|
278
|
+
const savedNodes = data.nodes || [];
|
|
279
|
+
const savedLinks = data.links || [];
|
|
280
|
+
if (savedNodes.length === 0) {
|
|
281
|
+
setNotification({ message: `Graph "${sourceLabel}" is empty.`, type: 'error' });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (data.searchMode) setSearchMode(data.searchMode);
|
|
285
|
+
if (data.exploreTerm) setExploreTerm(data.exploreTerm);
|
|
286
|
+
if (data.pathStart) setPathStart(data.pathStart);
|
|
287
|
+
if (data.pathEnd) setPathEnd(data.pathEnd);
|
|
288
|
+
if (data.isCompact !== undefined) setIsCompact(data.isCompact);
|
|
289
|
+
if (data.isTimelineMode !== undefined) setIsTimelineMode(data.isTimelineMode);
|
|
290
|
+
if (data.isTextOnly !== undefined) setIsTextOnly(data.isTextOnly);
|
|
291
|
+
|
|
292
|
+
setGraphData({
|
|
293
|
+
nodes: savedNodes.map((n: any) => ({ ...n, isLoading: false, vx: 0, vy: 0, fx: null, fy: null })),
|
|
294
|
+
links: savedLinks
|
|
295
|
+
});
|
|
296
|
+
setSearchId(prev => prev + 1);
|
|
297
|
+
setError(null);
|
|
298
|
+
setNotification({ message: `Graph "${sourceLabel}" loaded!`, type: 'success' });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
setError("Failed to load graph data.");
|
|
301
|
+
setNotification({ message: "Error loading graph.", type: 'error' });
|
|
302
|
+
}
|
|
303
|
+
}, [setNotification, setSearchMode, setExploreTerm, setPathStart, setPathEnd, setIsCompact, setIsTimelineMode, setIsTextOnly, setGraphData, setSearchId, setError]);
|
|
304
|
+
|
|
305
|
+
const handleOpenPeopleBrowser = useCallback(() => {
|
|
306
|
+
const newParams = new URLSearchParams(window.location.search);
|
|
307
|
+
newParams.set('browse', 'people');
|
|
308
|
+
window.history.pushState({ browse: 'people' }, '', window.location.pathname + '?' + newParams.toString());
|
|
309
|
+
setPeopleBrowserOpen(true);
|
|
310
|
+
}, [setPeopleBrowserOpen]);
|
|
311
|
+
|
|
312
|
+
if (!isKeyReady) {
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex flex-col items-center justify-center w-screen h-screen bg-slate-900 text-white space-y-6">
|
|
315
|
+
<h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-400 via-purple-400 to-cyan-400 bg-clip-text text-transparent">Constellations</h1>
|
|
316
|
+
<button onClick={async () => { if ((window as any).aistudio) { await (window as any).aistudio.openSelectKey(); setIsKeyReady(true); } }} className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-xl font-medium transition-all hover:scale-105">
|
|
317
|
+
<Key size={20} className="inline mr-2" /> Select API Key
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className="w-screen h-screen bg-slate-950 overflow-hidden font-sans text-slate-200 selection:bg-indigo-500/30">
|
|
325
|
+
{hideControlPanel && (
|
|
326
|
+
<ExtensionControls
|
|
327
|
+
isTimelineMode={isTimelineMode}
|
|
328
|
+
onToggle={setIsTimelineMode}
|
|
329
|
+
exploreTerm={exploreTerm}
|
|
330
|
+
setExploreTerm={setExploreTerm}
|
|
331
|
+
onSearch={handleStartSearch}
|
|
332
|
+
isCompact={isCompact}
|
|
333
|
+
onToggleCompact={() => setIsCompact(!isCompact)}
|
|
334
|
+
onToggleHelp={() => setShowHelp(true)}
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
<HelpOverlay
|
|
339
|
+
isOpen={showHelp}
|
|
340
|
+
onClose={() => setShowHelp(false)}
|
|
341
|
+
isExtension={hideControlPanel}
|
|
342
|
+
onOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
343
|
+
/>
|
|
344
|
+
<AppHeader
|
|
345
|
+
showHeader={!hideHeader}
|
|
346
|
+
panelCollapsed={panelCollapsed}
|
|
347
|
+
setPanelCollapsed={setPanelCollapsed}
|
|
348
|
+
showBrowse={peopleBrowserOpen}
|
|
349
|
+
handleOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
350
|
+
selectedNode={selectedNode}
|
|
351
|
+
sidebarCollapsed={sidebarCollapsed}
|
|
352
|
+
setSidebarCollapsed={setSidebarCollapsed}
|
|
353
|
+
setSidebarToggleSignal={setSidebarToggleSignal}
|
|
354
|
+
onReset={handleClear}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
<div className={`relative w-full h-full transition-all duration-500 ease-in-out ${!hideHeader ? 'pt-14' : ''}`}>
|
|
358
|
+
<Graph
|
|
359
|
+
ref={graphRef}
|
|
360
|
+
nodes={nodes}
|
|
361
|
+
links={links}
|
|
362
|
+
onNodeClick={onNodeClick}
|
|
363
|
+
onLinkClick={(link) => { setSelectedLink(link); setSelectedNode(null); setContextMenu(null); }}
|
|
364
|
+
width={dimensions.width}
|
|
365
|
+
height={dimensions.height}
|
|
366
|
+
isCompact={isCompact}
|
|
367
|
+
isTimelineMode={isTimelineMode}
|
|
368
|
+
isTextOnly={isTextOnly}
|
|
369
|
+
searchId={searchId}
|
|
370
|
+
selectedNode={selectedNode}
|
|
371
|
+
highlightKeepIds={deletePreview ? deletePreview.keepIds : pathNodeIds}
|
|
372
|
+
highlightDropIds={deletePreview ? deletePreview.dropIds : []}
|
|
373
|
+
expandingNodeId={expandingNodeId}
|
|
374
|
+
newChildNodeIds={newChildNodeIds}
|
|
375
|
+
/>
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
{!hideControlPanel && (
|
|
379
|
+
<ControlPanel
|
|
380
|
+
searchMode={searchMode}
|
|
381
|
+
setSearchMode={setSearchMode}
|
|
382
|
+
exploreTerm={exploreTerm}
|
|
383
|
+
setExploreTerm={setExploreTerm}
|
|
384
|
+
pathStart={pathStart}
|
|
385
|
+
setPathStart={setPathStart}
|
|
386
|
+
pathEnd={pathEnd}
|
|
387
|
+
setPathEnd={setPathEnd}
|
|
388
|
+
onSearch={handleStartSearch}
|
|
389
|
+
onPathSearch={handlePathSearch}
|
|
390
|
+
isAdminMode={isAdminMode}
|
|
391
|
+
kioskSeedTerms={kioskSeedTerms}
|
|
392
|
+
kioskDomains={kioskDomains}
|
|
393
|
+
selectedKioskDomainId={selectedKioskDomainId}
|
|
394
|
+
onSelectKioskDomain={(id) => { setSelectedKioskDomainId(id); setPathStart(''); setPathEnd(''); }}
|
|
395
|
+
onUpdateKioskDomains={setKioskDomains}
|
|
396
|
+
onClear={handleClear}
|
|
397
|
+
onClearCache={cacheEnabled ? handleClearCache : undefined}
|
|
398
|
+
onToggleHelp={() => setShowHelp(!showHelp)}
|
|
399
|
+
showHelp={showHelp}
|
|
400
|
+
onExpandAllLeafNodes={handleExpandAllLeafNodes}
|
|
401
|
+
isProcessing={isProcessing}
|
|
402
|
+
isCompact={isCompact}
|
|
403
|
+
onToggleCompact={() => setIsCompact(!isCompact)}
|
|
404
|
+
isTimelineMode={isTimelineMode}
|
|
405
|
+
onToggleTimeline={() => setIsTimelineMode(!isTimelineMode)}
|
|
406
|
+
isTextOnly={isTextOnly}
|
|
407
|
+
onToggleTextOnly={() => setIsTextOnly(!isTextOnly)}
|
|
408
|
+
onPrune={handlePrune}
|
|
409
|
+
error={error}
|
|
410
|
+
onSave={handleSaveGraph}
|
|
411
|
+
onLoad={(name) => handleLoadGraph(name, applyGraphData)}
|
|
412
|
+
onDeleteGraph={handleDeleteGraph}
|
|
413
|
+
onImport={(e) => handleImport(e, applyGraphData)}
|
|
414
|
+
savedGraphs={savedGraphs}
|
|
415
|
+
helpHover={helpHover}
|
|
416
|
+
onHelpHoverChange={setHelpHover}
|
|
417
|
+
isCollapsed={panelCollapsed}
|
|
418
|
+
onSetCollapsed={setPanelCollapsed}
|
|
419
|
+
onOpenPeopleBrowser={handleOpenPeopleBrowser}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{!hideSidebar && (
|
|
424
|
+
<Sidebar
|
|
425
|
+
selectedNode={selectedNode}
|
|
426
|
+
selectedLink={selectedLink}
|
|
427
|
+
onClose={() => { setSelectedNode(null); setSelectedLink(null); setContextMenu(null); setPathNodeIds([]); }}
|
|
428
|
+
onCollapseChange={setSidebarCollapsed}
|
|
429
|
+
externalToggleSignal={sidebarToggleSignal}
|
|
430
|
+
isAdminMode={isAdminMode}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
{renderEvidencePopup && renderEvidencePopup(selectedLink, () => setSelectedLink(null))}
|
|
435
|
+
|
|
436
|
+
<Suspense fallback={null}>
|
|
437
|
+
<PeopleBrowserSidebar
|
|
438
|
+
isOpen={peopleBrowserOpen}
|
|
439
|
+
onClose={() => setPeopleBrowserOpen(false)}
|
|
440
|
+
onSelectPerson={(name) => {
|
|
441
|
+
setExploreTerm(name);
|
|
442
|
+
setPeopleBrowserOpen(false);
|
|
443
|
+
const params = new URLSearchParams(window.location.search);
|
|
444
|
+
params.delete('browse');
|
|
445
|
+
params.set('q', name);
|
|
446
|
+
window.history.pushState({}, '', window.location.pathname + '?' + params.toString());
|
|
447
|
+
handleStartSearch(name, 1);
|
|
448
|
+
}}
|
|
449
|
+
/>
|
|
450
|
+
</Suspense>
|
|
451
|
+
|
|
452
|
+
{contextMenu && (
|
|
453
|
+
<NodeContextMenu
|
|
454
|
+
node={contextMenu.node}
|
|
455
|
+
x={contextMenu.x}
|
|
456
|
+
y={contextMenu.y}
|
|
457
|
+
onExpandLeaves={handleExpandLeaves}
|
|
458
|
+
onAddMore={handleExpandMore}
|
|
459
|
+
onFindBetterPhoto={handleFindBetterImage}
|
|
460
|
+
onDelete={handleSmartDelete}
|
|
461
|
+
onClose={() => setContextMenu(null)}
|
|
462
|
+
isProcessing={isProcessing}
|
|
463
|
+
/>
|
|
464
|
+
)}
|
|
465
|
+
|
|
466
|
+
<AppNotifications notification={notification} />
|
|
467
|
+
<AppConfirmDialog
|
|
468
|
+
confirmDialog={confirmDialog}
|
|
469
|
+
onClose={() => {
|
|
470
|
+
setConfirmDialog(null);
|
|
471
|
+
setDeletePreview(null);
|
|
472
|
+
}}
|
|
473
|
+
/>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export default App;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { lazy, Suspense, type ComponentProps, type FC, type ReactNode } from "react";
|
|
3
|
+
import "./index.css";
|
|
4
|
+
|
|
5
|
+
const App = lazy(() => import("./App"));
|
|
6
|
+
|
|
7
|
+
type AppProps = ComponentProps<typeof App>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Embeddable full-page constellations for host apps (Soundings, Trailer Vision, etc.).
|
|
11
|
+
* Always renders in embedded mode (ResizeObserver, handoff window hook) with full chrome.
|
|
12
|
+
*/
|
|
13
|
+
export type FullPageConstellationsProps = Omit<AppProps, "embedded" | "useViewportForPanels"> & {
|
|
14
|
+
/**
|
|
15
|
+
* - `fixed-overlay` — full-viewport layer above the app (e.g. Soundings, `z-[100]`).
|
|
16
|
+
* - `below-app-chrome` — fills route shell below site nav; parent must supply height.
|
|
17
|
+
*/
|
|
18
|
+
layout: "fixed-overlay" | "below-app-chrome";
|
|
19
|
+
/** Wrapped around the constellations root; use for a host nav link row, etc. */
|
|
20
|
+
chromeSlot?: ReactNode;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const defaults = {
|
|
24
|
+
hideControlPanel: false,
|
|
25
|
+
hideSidebar: false,
|
|
26
|
+
showExtensionWhenPanelHidden: true,
|
|
27
|
+
hostNavOffsetPx: 0,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const FullPageConstellations: FC<FullPageConstellationsProps> = ({
|
|
31
|
+
layout,
|
|
32
|
+
chromeSlot,
|
|
33
|
+
hideHeader = false,
|
|
34
|
+
hideControlPanel = defaults.hideControlPanel,
|
|
35
|
+
hideSidebar = defaults.hideSidebar,
|
|
36
|
+
showExtensionWhenPanelHidden = defaults.showExtensionWhenPanelHidden,
|
|
37
|
+
hostNavOffsetPx = defaults.hostNavOffsetPx,
|
|
38
|
+
...rest
|
|
39
|
+
}) => {
|
|
40
|
+
const app = (
|
|
41
|
+
<Suspense fallback={null}>
|
|
42
|
+
<App
|
|
43
|
+
{...rest}
|
|
44
|
+
embedded
|
|
45
|
+
hideHeader={hideHeader}
|
|
46
|
+
useViewportForPanels={layout === "fixed-overlay"}
|
|
47
|
+
hideControlPanel={hideControlPanel}
|
|
48
|
+
hideSidebar={hideSidebar}
|
|
49
|
+
showExtensionWhenPanelHidden={showExtensionWhenPanelHidden}
|
|
50
|
+
hostNavOffsetPx={hostNavOffsetPx}
|
|
51
|
+
/>
|
|
52
|
+
</Suspense>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (layout === "fixed-overlay") {
|
|
56
|
+
return (
|
|
57
|
+
<div className="fixed inset-0 z-[100] min-h-0 flex flex-col">
|
|
58
|
+
{chromeSlot}
|
|
59
|
+
<div className="min-h-0 min-w-0 flex-1">
|
|
60
|
+
<div className="h-full min-h-0 w-full min-w-0">{app}</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
|
|
68
|
+
{chromeSlot}
|
|
69
|
+
<div className="min-h-0 min-w-0 flex-1">
|
|
70
|
+
<div className="h-full min-h-0 w-full min-w-0">{app}</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
const loadingBase =
|
|
6
|
+
"flex items-center justify-center bg-slate-950 text-sm text-slate-200";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One loading shell for all full-page constellations routes (see `useFullPageConstellationsHost`).
|
|
10
|
+
*/
|
|
11
|
+
export function FullPageConstellationsHostLoading({
|
|
12
|
+
surface,
|
|
13
|
+
children = "Loading…",
|
|
14
|
+
}: {
|
|
15
|
+
/** `in-layout` = under a site nav; `overlay` = full-viewport cover (e.g. Soundings) */
|
|
16
|
+
surface: "in-layout" | "overlay";
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
if (surface === "overlay") {
|
|
20
|
+
return (
|
|
21
|
+
<div className={`min-h-screen ${loadingBase}`}>{children}</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<div className={`flex h-full min-h-0 w-full ${loadingBase}`}>{children}</div>
|
|
26
|
+
);
|
|
27
|
+
}
|