@viji-dev/sdk 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/.gitignore +29 -0
- package/LICENSE +13 -0
- package/README.md +103 -0
- package/bin/viji.js +75 -0
- package/eslint.config.js +37 -0
- package/index.html +20 -0
- package/package.json +82 -0
- package/postcss.config.js +6 -0
- package/public/favicon.png +0 -0
- package/scenes/audio-visualizer/main.js +287 -0
- package/scenes/core-demo/main.js +532 -0
- package/scenes/demo-scene/main.js +619 -0
- package/scenes/global.d.ts +15 -0
- package/scenes/particle-system/main.js +349 -0
- package/scenes/tsconfig.json +12 -0
- package/scenes/video-mirror/main.ts +436 -0
- package/src/App.css +42 -0
- package/src/App.tsx +279 -0
- package/src/cli/commands/build.js +147 -0
- package/src/cli/commands/create.js +71 -0
- package/src/cli/commands/dev.js +108 -0
- package/src/cli/commands/init.js +262 -0
- package/src/cli/utils/cli-utils.js +208 -0
- package/src/cli/utils/scene-compiler.js +432 -0
- package/src/components/SDKPage.tsx +337 -0
- package/src/components/core/CoreContainer.tsx +126 -0
- package/src/components/ui/DeviceSelectionList.tsx +137 -0
- package/src/components/ui/FPSCounter.tsx +78 -0
- package/src/components/ui/FileDropzonePanel.tsx +120 -0
- package/src/components/ui/FileListPanel.tsx +285 -0
- package/src/components/ui/InputExpansionPanel.tsx +31 -0
- package/src/components/ui/MediaPlayerControls.tsx +191 -0
- package/src/components/ui/MenuContainer.tsx +71 -0
- package/src/components/ui/ParametersMenu.tsx +797 -0
- package/src/components/ui/ProjectSwitcherMenu.tsx +192 -0
- package/src/components/ui/QuickInputControls.tsx +542 -0
- package/src/components/ui/SDKMenuSystem.tsx +96 -0
- package/src/components/ui/SettingsMenu.tsx +346 -0
- package/src/components/ui/SimpleInputControls.tsx +137 -0
- package/src/index.css +68 -0
- package/src/main.tsx +10 -0
- package/src/scenes-hmr.ts +158 -0
- package/src/services/project-filesystem.ts +436 -0
- package/src/stores/scene-player/index.ts +3 -0
- package/src/stores/scene-player/input-manager.store.ts +1045 -0
- package/src/stores/scene-player/scene-session.store.ts +659 -0
- package/src/styles/globals.css +111 -0
- package/src/templates/minimal-template.js +11 -0
- package/src/utils/debounce.js +34 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +18 -0
- package/tsconfig.app.json +27 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node.json +27 -0
- package/vite.config.ts +54 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Slider,
|
|
6
|
+
Switch,
|
|
7
|
+
Select,
|
|
8
|
+
SelectItem,
|
|
9
|
+
Card,
|
|
10
|
+
CardBody,
|
|
11
|
+
Modal,
|
|
12
|
+
ModalContent,
|
|
13
|
+
ModalHeader,
|
|
14
|
+
ModalBody,
|
|
15
|
+
ModalFooter,
|
|
16
|
+
useDisclosure,
|
|
17
|
+
Spinner,
|
|
18
|
+
Tooltip,
|
|
19
|
+
} from '@heroui/react';
|
|
20
|
+
import {
|
|
21
|
+
PlusIcon,
|
|
22
|
+
PencilIcon,
|
|
23
|
+
TrashIcon,
|
|
24
|
+
ArrowLeftIcon,
|
|
25
|
+
} from '@heroicons/react/24/outline';
|
|
26
|
+
import { useSceneSessionStore } from '../../stores/scene-player/scene-session.store';
|
|
27
|
+
import { useInputManagerStore, AudioInputType, VideoInputType } from '../../stores/scene-player/input-manager.store';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
interface ParametersMenuProps {
|
|
31
|
+
className?: string;
|
|
32
|
+
hidePresetsControls?: boolean; // when true, hide presets column and save-as-preset UI (used by editor)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Removed debouncing - it was making parameter updates slower
|
|
36
|
+
|
|
37
|
+
// Helper function to convert thumbnail storage keys to URLs
|
|
38
|
+
const getThumbnailUrl = (thumbnail: string): string => {
|
|
39
|
+
// If it's already a full URL, return as-is
|
|
40
|
+
if (thumbnail.startsWith('http://') || thumbnail.startsWith('https://')) {
|
|
41
|
+
return thumbnail;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If it's a storage key (like 'thumbnails/presets/id/image.webp'),
|
|
45
|
+
// we need the backend to provide the CDN base URL
|
|
46
|
+
// For now, return the original value and let the backend handle URL construction
|
|
47
|
+
return thumbnail;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ParametersMenu: React.FC<ParametersMenuProps> = ({ className = '', hidePresetsControls = false }) => {
|
|
51
|
+
const [isPresetManagementOpen, setIsPresetManagementOpen] = useState(false);
|
|
52
|
+
const [presetToDelete, setPresetToDelete] = useState<string | null>(null);
|
|
53
|
+
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
|
54
|
+
const [editingName, setEditingName] = useState<string>('');
|
|
55
|
+
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
56
|
+
const [orderedIds, setOrderedIds] = useState<string[]>([]);
|
|
57
|
+
|
|
58
|
+
const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, onClose: onDeleteModalClose } = useDisclosure();
|
|
59
|
+
|
|
60
|
+
// Store selectors - using stable selectors to prevent infinite loops
|
|
61
|
+
const parameters = useSceneSessionStore((state) => state.session?.parameters || {});
|
|
62
|
+
const parameterGroups = useSceneSessionStore((state) => state.session?.parameterGroups || []);
|
|
63
|
+
const availablePresets = useSceneSessionStore((state) => state.session?.availablePresets || []);
|
|
64
|
+
const activePresetId = useSceneSessionStore((state) => state.session?.activePresetId);
|
|
65
|
+
const hasUnsavedChanges = useSceneSessionStore((state) => state.session?.hasUnsavedChanges || false);
|
|
66
|
+
const isLoadingPresets = useSceneSessionStore((state) => state.isLoadingPresets);
|
|
67
|
+
const isSavingPreset = useSceneSessionStore((state) => state.isSavingPreset);
|
|
68
|
+
const presetError = useSceneSessionStore((state) => state.presetError);
|
|
69
|
+
const session = useSceneSessionStore((state) => state.session);
|
|
70
|
+
|
|
71
|
+
// Store actions
|
|
72
|
+
const updateParameters = useSceneSessionStore((state) => state.updateParameters);
|
|
73
|
+
const applyPreset = useSceneSessionStore((state) => state.applyPreset);
|
|
74
|
+
const saveCurrentAsPreset = useSceneSessionStore((state) => state.saveCurrentAsPreset);
|
|
75
|
+
const deletePreset = useSceneSessionStore((state) => state.deletePreset);
|
|
76
|
+
const loadPresets = useSceneSessionStore((state) => state.loadPresets);
|
|
77
|
+
const renamePreset = useSceneSessionStore((state) => state.renamePreset);
|
|
78
|
+
// Note: reorderPresets not implemented in SDK store yet
|
|
79
|
+
const reorderPresets = () => console.log('reorderPresets not implemented in SDK');
|
|
80
|
+
|
|
81
|
+
// SDK doesn't have authentication - use mock user
|
|
82
|
+
const currentUser = { id: 'sdk-user', username: 'SDK User' };
|
|
83
|
+
|
|
84
|
+
// Input state selectors
|
|
85
|
+
const inputConfiguration = useInputManagerStore((state) => state.inputConfiguration);
|
|
86
|
+
|
|
87
|
+
// Get capabilities from core and input state
|
|
88
|
+
const getCapabilities = () => {
|
|
89
|
+
// Compute capabilities based on current input configuration (immediate)
|
|
90
|
+
const configBasedAudio = inputConfiguration.audio.enabled && inputConfiguration.audio.type !== AudioInputType.NONE;
|
|
91
|
+
const configBasedVideo = inputConfiguration.video.enabled && inputConfiguration.video.type !== VideoInputType.NONE;
|
|
92
|
+
const configBasedInteraction = inputConfiguration.interactionEnabled;
|
|
93
|
+
|
|
94
|
+
// Use configuration-based capabilities for immediate UI updates
|
|
95
|
+
return {
|
|
96
|
+
hasGeneral: true, // General parameters are always available
|
|
97
|
+
hasAudio: configBasedAudio, // Use config state for immediate response
|
|
98
|
+
hasVideo: configBasedVideo, // Use config state for immediate response
|
|
99
|
+
hasInteraction: configBasedInteraction, // Use config state for immediate response
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Memoize capabilities to prevent infinite loops - include input state dependencies
|
|
104
|
+
const capabilities = useMemo(() => getCapabilities(), [
|
|
105
|
+
session?.coreInstance,
|
|
106
|
+
inputConfiguration.audio.enabled,
|
|
107
|
+
inputConfiguration.audio.type,
|
|
108
|
+
inputConfiguration.video.enabled,
|
|
109
|
+
inputConfiguration.video.type,
|
|
110
|
+
inputConfiguration.interactionEnabled
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
// Removed excessive debug logging that was causing performance issues on every parameter change
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
// Memoize parameter structure only (values will be read on-demand)
|
|
118
|
+
const processedGroups = useMemo(() => parameterGroups.map((group: any) => {
|
|
119
|
+
const processedParameters: any[] = [];
|
|
120
|
+
|
|
121
|
+
// Only handle object format (Record<string, ParameterDefinition>)
|
|
122
|
+
if (group.parameters && typeof group.parameters === 'object' && !Array.isArray(group.parameters)) {
|
|
123
|
+
Object.entries(group.parameters).forEach(([name, param]: [string, any]) => {
|
|
124
|
+
processedParameters.push({
|
|
125
|
+
name,
|
|
126
|
+
definition: param,
|
|
127
|
+
type: param.type || 'text',
|
|
128
|
+
min: param.config?.min,
|
|
129
|
+
max: param.config?.max,
|
|
130
|
+
step: param.config?.step,
|
|
131
|
+
options: param.config?.options || param.options,
|
|
132
|
+
label: param.label || name.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase()),
|
|
133
|
+
// Remove value from processing - read it on-demand in render
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...group,
|
|
140
|
+
processedParameters,
|
|
141
|
+
};
|
|
142
|
+
}), [parameterGroups]); // Removed parameters dependency - huge performance gain!
|
|
143
|
+
|
|
144
|
+
// PURE CORE DEMO APPROACH: Only immediate core updates, no store updates
|
|
145
|
+
const handleParameterChange = useCallback((paramName: string, value: any) => {
|
|
146
|
+
// 🚀 IMMEDIATE: Update core directly (no await, no constraints) - exactly like the demo
|
|
147
|
+
if (session?.coreInstance) {
|
|
148
|
+
session.coreInstance.setParameter(paramName, value);
|
|
149
|
+
}
|
|
150
|
+
// No store updates at all - inputs manage their own state naturally
|
|
151
|
+
}, [session?.coreInstance]);
|
|
152
|
+
|
|
153
|
+
// No cleanup needed for immediate updates
|
|
154
|
+
|
|
155
|
+
const handleApplyPreset = async (presetId: string) => {
|
|
156
|
+
try {
|
|
157
|
+
await applyPreset(presetId);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('Failed to apply preset:', error);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleSaveAsPreset = async () => {
|
|
164
|
+
try {
|
|
165
|
+
// determine default name: Preset N (next number)
|
|
166
|
+
const base = 'Preset ';
|
|
167
|
+
let maxNum = 0;
|
|
168
|
+
availablePresets.forEach((p: any) => {
|
|
169
|
+
const m = /^Preset\s+(\d+)$/.exec(p.name.trim());
|
|
170
|
+
if (m) {
|
|
171
|
+
const n = parseInt(m[1], 10);
|
|
172
|
+
if (!Number.isNaN(n)) maxNum = Math.max(maxNum, n);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
const defaultName = `${base}${maxNum + 1}`;
|
|
176
|
+
|
|
177
|
+
// SDK doesn't support thumbnail capture yet - skip thumbnails
|
|
178
|
+
await saveCurrentAsPreset(defaultName);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error('Failed to save preset:', error);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const handleDeletePreset = async () => {
|
|
185
|
+
if (!presetToDelete) return;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await deletePreset(presetToDelete);
|
|
189
|
+
setPresetToDelete(null);
|
|
190
|
+
onDeleteModalClose();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('Failed to delete preset:', error);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const confirmDeletePreset = (presetId: string) => {
|
|
197
|
+
setPresetToDelete(presetId);
|
|
198
|
+
onDeleteModalOpen();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Render capabilities info (like in the example)
|
|
202
|
+
const renderCapabilitiesInfo = () => (
|
|
203
|
+
<div className="mb-4 p-3 bg-black/30 rounded-lg border border-white/10">
|
|
204
|
+
<div className="text-white text-sm font-medium mb-2">📊 Active Capabilities</div>
|
|
205
|
+
<div className="space-y-1 text-xs">
|
|
206
|
+
<div className={`flex items-center space-x-2 ${capabilities.hasGeneral ? 'text-green-400' : 'text-gray-500'}`}>
|
|
207
|
+
<span>🔧</span>
|
|
208
|
+
<span>General: {capabilities.hasGeneral ? 'Always Active' : 'Inactive'}</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div className={`flex items-center space-x-2 ${capabilities.hasAudio ? 'text-green-400' : 'text-gray-500'}`}>
|
|
211
|
+
<span>🎵</span>
|
|
212
|
+
<span>Audio: {capabilities.hasAudio ? 'Connected' : 'Not Connected'}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div className={`flex items-center space-x-2 ${capabilities.hasVideo ? 'text-green-400' : 'text-gray-500'}`}>
|
|
215
|
+
<span>📹</span>
|
|
216
|
+
<span>Video: {capabilities.hasVideo ? 'Connected' : 'Not Connected'}</span>
|
|
217
|
+
</div>
|
|
218
|
+
<div className={`flex items-center space-x-2 ${capabilities.hasInteraction ? 'text-green-400' : 'text-gray-500'}`}>
|
|
219
|
+
<span>🖱️</span>
|
|
220
|
+
<span>Interaction: {capabilities.hasInteraction ? 'Enabled' : 'Disabled'}</span>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="text-white/60 text-xs mt-2">
|
|
224
|
+
Only parameters for active capabilities are shown below.
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Drag-and-drop ordering (HTML5 DnD)
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!draggingId) {
|
|
232
|
+
setOrderedIds(availablePresets.map((p: any) => p.id));
|
|
233
|
+
}
|
|
234
|
+
}, [availablePresets, draggingId]);
|
|
235
|
+
|
|
236
|
+
const onDragStart = (id: string) => {
|
|
237
|
+
setDraggingId(id);
|
|
238
|
+
setOrderedIds(availablePresets.map((p: any) => p.id));
|
|
239
|
+
};
|
|
240
|
+
const onDragOver = (e: React.DragEvent, overId: string) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
if (!draggingId || draggingId === overId) return;
|
|
243
|
+
setOrderedIds(prev => {
|
|
244
|
+
const next = [...prev];
|
|
245
|
+
const from = next.indexOf(draggingId);
|
|
246
|
+
const to = next.indexOf(overId);
|
|
247
|
+
if (from === -1 || to === -1) return prev;
|
|
248
|
+
next.splice(from, 1);
|
|
249
|
+
next.splice(to, 0, draggingId);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
const onDrop = () => {
|
|
254
|
+
if (orderedIds.length > 0) {
|
|
255
|
+
console.log('Reorder presets - not implemented in SDK yet:', orderedIds);
|
|
256
|
+
// reorderPresets(orderedIds); // Not implemented yet
|
|
257
|
+
}
|
|
258
|
+
setDraggingId(null);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const renderParameterControl = (param: any) => {
|
|
262
|
+
const { name, type, min, max, step, options, label } = param;
|
|
263
|
+
// Read current value from store or default (inputs will handle their own responsiveness)
|
|
264
|
+
const value = parameters[name] !== undefined ? parameters[name] : param.definition?.defaultValue;
|
|
265
|
+
|
|
266
|
+
switch (type) {
|
|
267
|
+
case 'slider':
|
|
268
|
+
return (
|
|
269
|
+
<div key={`${name}-${value ?? ''}`} className="mb-3">
|
|
270
|
+
<div className="flex items-center justify-between mb-2">
|
|
271
|
+
<label className="text-white text-sm">{label || name}</label>
|
|
272
|
+
<span className="text-white/70 text-xs">{value}</span>
|
|
273
|
+
</div>
|
|
274
|
+
<Slider
|
|
275
|
+
defaultValue={value}
|
|
276
|
+
onChange={(val: number | number[]) => {
|
|
277
|
+
const numValue = Array.isArray(val) ? val[0] : val;
|
|
278
|
+
// Immediate core update for smooth scene response
|
|
279
|
+
handleParameterChange(name, numValue);
|
|
280
|
+
}}
|
|
281
|
+
onChangeEnd={(val: number | number[]) => {
|
|
282
|
+
const numValue = Array.isArray(val) ? val[0] : val;
|
|
283
|
+
// Commit to store after interaction ends to avoid UI thrash
|
|
284
|
+
updateParameters({ [name]: numValue }, true);
|
|
285
|
+
}}
|
|
286
|
+
minValue={min ?? 0}
|
|
287
|
+
maxValue={max ?? 100}
|
|
288
|
+
step={step ?? 1}
|
|
289
|
+
className="w-full"
|
|
290
|
+
color="primary"
|
|
291
|
+
size="sm"
|
|
292
|
+
aria-label={label || name}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
case 'toggle':
|
|
298
|
+
return (
|
|
299
|
+
<div key={`${name}-${value ?? ''}`} className="flex items-center justify-between mb-3">
|
|
300
|
+
<span className="text-white text-sm">{label || name}</span>
|
|
301
|
+
<Switch
|
|
302
|
+
defaultSelected={value}
|
|
303
|
+
onValueChange={(val) => {
|
|
304
|
+
handleParameterChange(name, val);
|
|
305
|
+
updateParameters({ [name]: val }, true);
|
|
306
|
+
}}
|
|
307
|
+
color="primary"
|
|
308
|
+
size="sm"
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
case 'color':
|
|
314
|
+
return (
|
|
315
|
+
<div key={`${name}-${value ?? ''}`} className="mb-3">
|
|
316
|
+
<label className="text-white text-sm block mb-2">{label || name}</label>
|
|
317
|
+
<div className="flex items-center space-x-2">
|
|
318
|
+
<Input
|
|
319
|
+
type="color"
|
|
320
|
+
key={`${name}-colorPicker-${value ?? ''}`}
|
|
321
|
+
defaultValue={value}
|
|
322
|
+
onChange={(e) => handleParameterChange(name, e.target.value)}
|
|
323
|
+
onBlur={(e) => updateParameters({ [name]: e.target.value }, true)}
|
|
324
|
+
className="w-16 h-8"
|
|
325
|
+
size="sm"
|
|
326
|
+
/>
|
|
327
|
+
<Input
|
|
328
|
+
key={`${name}-colorText-${value ?? ''}`}
|
|
329
|
+
defaultValue={String(value)}
|
|
330
|
+
onChange={(e) => handleParameterChange(name, e.target.value)}
|
|
331
|
+
onBlur={(e) => updateParameters({ [name]: e.target.value }, true)}
|
|
332
|
+
className="flex-1 text-white"
|
|
333
|
+
size="sm"
|
|
334
|
+
variant="bordered"
|
|
335
|
+
classNames={{
|
|
336
|
+
input: "text-white",
|
|
337
|
+
inputWrapper: "bg-black/20 border-white/20"
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
case 'select':
|
|
345
|
+
// Only set selectedKeys if the value exists in options to prevent HeroUI warnings
|
|
346
|
+
const validOptions = options || [];
|
|
347
|
+
const currentValueStr = String(value);
|
|
348
|
+
const selectedKeys = validOptions.includes(currentValueStr) ? [currentValueStr] : [];
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<div key={name} className="mb-3">
|
|
352
|
+
<Select
|
|
353
|
+
key={`${name}-${validOptions.join('-')}`}
|
|
354
|
+
label={label || name}
|
|
355
|
+
selectedKeys={selectedKeys}
|
|
356
|
+
onSelectionChange={(keys) => {
|
|
357
|
+
const selectedValue = Array.from(keys)[0];
|
|
358
|
+
handleParameterChange(name, selectedValue);
|
|
359
|
+
updateParameters({ [name]: selectedValue }, true);
|
|
360
|
+
}}
|
|
361
|
+
className="w-full visualizer-select [&_[data-slot=value]]:!text-white [&_[data-slot=value]]:!opacity-100"
|
|
362
|
+
size="sm"
|
|
363
|
+
variant="bordered"
|
|
364
|
+
classNames={{
|
|
365
|
+
trigger: "bg-black/20 border-white/20 text-white data-[hover=true]:bg-black/30",
|
|
366
|
+
label: "text-white/80",
|
|
367
|
+
value: "!text-white !opacity-100",
|
|
368
|
+
innerWrapper: "text-white",
|
|
369
|
+
selectorIcon: "text-white/60",
|
|
370
|
+
listbox: "bg-black/90",
|
|
371
|
+
popoverContent: "bg-black/90 border border-white/20"
|
|
372
|
+
}}
|
|
373
|
+
style={{
|
|
374
|
+
// @ts-ignore - Force override HeroUI CSS variables
|
|
375
|
+
"--nextui-content1": "white",
|
|
376
|
+
"--nextui-content1-foreground": "white",
|
|
377
|
+
"--nextui-foreground": "white"
|
|
378
|
+
} as any}
|
|
379
|
+
>
|
|
380
|
+
{validOptions.map((option: string) => (
|
|
381
|
+
<SelectItem
|
|
382
|
+
key={option}
|
|
383
|
+
className="text-white data-[hover=true]:bg-white/10"
|
|
384
|
+
|
|
385
|
+
>
|
|
386
|
+
{option}
|
|
387
|
+
</SelectItem>
|
|
388
|
+
))}
|
|
389
|
+
</Select>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
case 'number':
|
|
394
|
+
return (
|
|
395
|
+
<div key={`${name}-${value ?? ''}`} className="mb-3">
|
|
396
|
+
<Input
|
|
397
|
+
type="number"
|
|
398
|
+
label={label || name}
|
|
399
|
+
defaultValue={String(value)}
|
|
400
|
+
onChange={(e) => handleParameterChange(name, parseFloat(e.target.value) || 0)}
|
|
401
|
+
onBlur={(e) => updateParameters({ [name]: parseFloat(e.target.value) || 0 }, true)}
|
|
402
|
+
min={min}
|
|
403
|
+
max={max}
|
|
404
|
+
step={step}
|
|
405
|
+
className="w-full"
|
|
406
|
+
size="sm"
|
|
407
|
+
variant="bordered"
|
|
408
|
+
classNames={{
|
|
409
|
+
input: "text-white",
|
|
410
|
+
inputWrapper: "bg-black/20 border-white/20",
|
|
411
|
+
label: "text-white/80"
|
|
412
|
+
}}
|
|
413
|
+
/>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
case 'text':
|
|
418
|
+
default:
|
|
419
|
+
return (
|
|
420
|
+
<div key={`${name}-${value ?? ''}`} className="mb-3">
|
|
421
|
+
<Input
|
|
422
|
+
label={label || name}
|
|
423
|
+
defaultValue={String(value)}
|
|
424
|
+
onChange={(e) => handleParameterChange(name, e.target.value)}
|
|
425
|
+
onBlur={(e) => updateParameters({ [name]: e.target.value }, true)}
|
|
426
|
+
className="w-full"
|
|
427
|
+
size="sm"
|
|
428
|
+
variant="bordered"
|
|
429
|
+
classNames={{
|
|
430
|
+
input: "text-white",
|
|
431
|
+
inputWrapper: "bg-black/20 border-white/20",
|
|
432
|
+
label: "text-white/80"
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Render parameter group (like in the example)
|
|
441
|
+
const renderParameterGroup = (group: any) => {
|
|
442
|
+
if (group.processedParameters.length === 0) return null;
|
|
443
|
+
|
|
444
|
+
return (
|
|
445
|
+
<div key={group.groupName} className="mb-4">
|
|
446
|
+
<div className="bg-black/40 border border-white/20 rounded-lg">
|
|
447
|
+
<div className="px-3 py-2 border-b border-white/10 flex items-center justify-between">
|
|
448
|
+
<span className="text-white font-medium text-sm">{group.groupName}</span>
|
|
449
|
+
<span className="text-white/60 text-xs">−</span>
|
|
450
|
+
</div>
|
|
451
|
+
<div className="p-3 space-y-2">
|
|
452
|
+
{group.processedParameters.map((param: any) => renderParameterControl(param))}
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
if (!session) {
|
|
460
|
+
return (
|
|
461
|
+
<div className="p-4 text-center text-white/60">
|
|
462
|
+
<p>No active scene</p>
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (processedGroups.length === 0) {
|
|
468
|
+
return (
|
|
469
|
+
<div className="p-4 text-center text-white/60">
|
|
470
|
+
<p>Loading parameters...</p>
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
<div className={`flex h-full ${className}`}>
|
|
477
|
+
{/* Presets Panel */}
|
|
478
|
+
{!hidePresetsControls && (
|
|
479
|
+
<div className={`${isPresetManagementOpen ? 'w-full' : 'w-24'} bg-black/50 border-r border-white/10 transition-all duration-300`}>
|
|
480
|
+
{isPresetManagementOpen ? (
|
|
481
|
+
/* Full Preset Management View */
|
|
482
|
+
<div className="p-4 h-full flex flex-col">
|
|
483
|
+
{/* Header */}
|
|
484
|
+
<div className="flex items-center justify-between mb-4">
|
|
485
|
+
<Button
|
|
486
|
+
isIconOnly
|
|
487
|
+
variant="flat"
|
|
488
|
+
size="sm"
|
|
489
|
+
className="text-white/60 hover:text-white"
|
|
490
|
+
onPress={() => setIsPresetManagementOpen(false)}
|
|
491
|
+
>
|
|
492
|
+
<ArrowLeftIcon className="w-4 h-4" />
|
|
493
|
+
</Button>
|
|
494
|
+
<h3 className="text-white font-medium">Manage Presets</h3>
|
|
495
|
+
<div className="w-8" /> {/* Spacer */}
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
{/* Preset List */}
|
|
499
|
+
<div className="flex-1 overflow-y-auto space-y-2">
|
|
500
|
+
{isLoadingPresets ? (
|
|
501
|
+
<div className="text-center py-8">
|
|
502
|
+
<Spinner color="primary" size="sm" />
|
|
503
|
+
<p className="text-white/60 text-sm mt-2">Loading presets...</p>
|
|
504
|
+
</div>
|
|
505
|
+
) : presetError ? (
|
|
506
|
+
<div className="text-center py-8 text-red-400">
|
|
507
|
+
<p className="text-sm">Failed to load presets</p>
|
|
508
|
+
<p className="text-xs text-white/60 mt-1">{presetError}</p>
|
|
509
|
+
<Button
|
|
510
|
+
size="sm"
|
|
511
|
+
variant="flat"
|
|
512
|
+
className="mt-3 bg-white/10 text-white"
|
|
513
|
+
onPress={() => session?.sceneId && loadPresets(session.sceneId)}
|
|
514
|
+
isLoading={isLoadingPresets}
|
|
515
|
+
>
|
|
516
|
+
Retry
|
|
517
|
+
</Button>
|
|
518
|
+
</div>
|
|
519
|
+
) : availablePresets.length === 0 ? (
|
|
520
|
+
<div className="text-center py-8 text-white/60">
|
|
521
|
+
<p className="text-sm">No presets available</p>
|
|
522
|
+
<p className="text-xs mt-1">Create your first preset to get started</p>
|
|
523
|
+
</div>
|
|
524
|
+
) : (
|
|
525
|
+
(availablePresets as any[])
|
|
526
|
+
.slice()
|
|
527
|
+
.sort((a: any, b: any) => orderedIds.indexOf(a.id) - orderedIds.indexOf(b.id))
|
|
528
|
+
.map((preset: any) => (
|
|
529
|
+
<Card
|
|
530
|
+
key={preset.id}
|
|
531
|
+
className={`w-full bg-white/5 hover:bg-white/10 transition-colors ${
|
|
532
|
+
activePresetId === preset.id ? 'ring-1 ring-primary-400' : ''
|
|
533
|
+
}`}
|
|
534
|
+
isPressable
|
|
535
|
+
onPress={() => handleApplyPreset(preset.id)}
|
|
536
|
+
draggable
|
|
537
|
+
onDragStart={() => onDragStart(preset.id)}
|
|
538
|
+
onDragOver={(e) => onDragOver(e, preset.id)}
|
|
539
|
+
onDrop={onDrop}
|
|
540
|
+
>
|
|
541
|
+
<CardBody className="p-3">
|
|
542
|
+
<div className="flex items-center justify-between gap-3">
|
|
543
|
+
{/* Thumbnail on the left */}
|
|
544
|
+
<div className="w-14 h-14 rounded border border-white/10 flex-shrink-0 overflow-hidden bg-viji-pattern">
|
|
545
|
+
{preset.thumbnail ? (
|
|
546
|
+
<img
|
|
547
|
+
src={getThumbnailUrl(preset.thumbnail)}
|
|
548
|
+
alt={preset.name}
|
|
549
|
+
className="w-full h-full object-cover"
|
|
550
|
+
onError={(e) => {
|
|
551
|
+
const target = e.currentTarget as HTMLImageElement;
|
|
552
|
+
target.style.display = 'none';
|
|
553
|
+
}}
|
|
554
|
+
/>
|
|
555
|
+
) : null}
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
{/* Title/Name center (click to apply) */}
|
|
559
|
+
<div
|
|
560
|
+
className="flex-1 min-w-0"
|
|
561
|
+
>
|
|
562
|
+
{editingPresetId === preset.id ? (
|
|
563
|
+
<Input
|
|
564
|
+
size="sm"
|
|
565
|
+
variant="bordered"
|
|
566
|
+
classNames={{ input: 'text-white', inputWrapper: 'bg-black/20 border-white/20' }}
|
|
567
|
+
value={editingName}
|
|
568
|
+
autoFocus
|
|
569
|
+
onChange={(e) => setEditingName(e.target.value)}
|
|
570
|
+
onClick={(e) => e.stopPropagation()}
|
|
571
|
+
onBlur={async () => {
|
|
572
|
+
const name = editingName.trim();
|
|
573
|
+
setEditingPresetId(null);
|
|
574
|
+
if (name && name !== preset.name) {
|
|
575
|
+
await renamePreset(preset.id, name);
|
|
576
|
+
}
|
|
577
|
+
}}
|
|
578
|
+
onKeyDown={async (e) => {
|
|
579
|
+
if (e.key === 'Enter') {
|
|
580
|
+
const name = editingName.trim();
|
|
581
|
+
(e.target as HTMLInputElement).blur();
|
|
582
|
+
if (name && name !== preset.name) {
|
|
583
|
+
await renamePreset(preset.id, name);
|
|
584
|
+
}
|
|
585
|
+
} else if (e.key === 'Escape') {
|
|
586
|
+
setEditingPresetId(null);
|
|
587
|
+
}
|
|
588
|
+
}}
|
|
589
|
+
/>
|
|
590
|
+
) : (
|
|
591
|
+
<>
|
|
592
|
+
<h4 className="text-white text-sm font-medium truncate">{preset.name}</h4>
|
|
593
|
+
<p className="text-white/60 text-xs">{new Date(preset.created).toLocaleDateString()}</p>
|
|
594
|
+
</>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
{/* Actions on the right in one row */}
|
|
599
|
+
<div className="flex items-center gap-1 ml-2" onClick={(e) => e.stopPropagation()}>
|
|
600
|
+
<Tooltip content="Edit name">
|
|
601
|
+
<Button
|
|
602
|
+
isIconOnly
|
|
603
|
+
size="sm"
|
|
604
|
+
variant="flat"
|
|
605
|
+
className="text-white/60 hover:text-white min-w-6 h-6"
|
|
606
|
+
onPress={() => { setEditingPresetId(preset.id); setEditingName(preset.name); }}
|
|
607
|
+
>
|
|
608
|
+
<PencilIcon className="w-3 h-3" />
|
|
609
|
+
</Button>
|
|
610
|
+
</Tooltip>
|
|
611
|
+
|
|
612
|
+
<Tooltip content="Delete">
|
|
613
|
+
<Button
|
|
614
|
+
isIconOnly
|
|
615
|
+
size="sm"
|
|
616
|
+
variant="flat"
|
|
617
|
+
className="text-red-400 hover:text-red-300 min-w-6 h-6"
|
|
618
|
+
onPress={() => { confirmDeletePreset(preset.id); }}
|
|
619
|
+
>
|
|
620
|
+
<TrashIcon className="w-3 h-3" />
|
|
621
|
+
</Button>
|
|
622
|
+
</Tooltip>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
</CardBody>
|
|
626
|
+
</Card>
|
|
627
|
+
))
|
|
628
|
+
)}
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
) : (
|
|
632
|
+
/* Narrow Preset Thumbnails View */
|
|
633
|
+
<div className="p-2 h-full flex flex-col">
|
|
634
|
+
{/* Edit Button */}
|
|
635
|
+
<Tooltip content="Manage Presets" placement="right">
|
|
636
|
+
<Button
|
|
637
|
+
isIconOnly
|
|
638
|
+
variant="flat"
|
|
639
|
+
size="sm"
|
|
640
|
+
className="text-white/60 hover:text-white mb-3"
|
|
641
|
+
onPress={() => setIsPresetManagementOpen(true)}
|
|
642
|
+
>
|
|
643
|
+
<PencilIcon className="w-4 h-4" />
|
|
644
|
+
</Button>
|
|
645
|
+
</Tooltip>
|
|
646
|
+
|
|
647
|
+
{/* Preset Thumbnails */}
|
|
648
|
+
<div className="flex-1 overflow-y-auto space-y-2">
|
|
649
|
+
{availablePresets.map((preset: any) => (
|
|
650
|
+
<Tooltip key={preset.id} content={preset.name} placement="right">
|
|
651
|
+
<Card
|
|
652
|
+
className={`bg-white/5 hover:bg-white/10 cursor-pointer transition-colors aspect-square ${
|
|
653
|
+
activePresetId === preset.id ? 'ring-1 ring-primary-400' : ''
|
|
654
|
+
}`}
|
|
655
|
+
isPressable
|
|
656
|
+
onPress={() => handleApplyPreset(preset.id)}
|
|
657
|
+
>
|
|
658
|
+
<CardBody className="p-2">
|
|
659
|
+
<div className="w-full h-full rounded overflow-hidden bg-viji-pattern">
|
|
660
|
+
{preset.thumbnail ? (
|
|
661
|
+
<img
|
|
662
|
+
src={getThumbnailUrl(preset.thumbnail)}
|
|
663
|
+
alt={preset.name}
|
|
664
|
+
className="w-full h-full object-cover"
|
|
665
|
+
onError={(e) => {
|
|
666
|
+
const target = e.currentTarget as HTMLImageElement;
|
|
667
|
+
target.style.display = 'none';
|
|
668
|
+
}}
|
|
669
|
+
onLoad={() => {
|
|
670
|
+
console.debug('🖼️ [PRESET] Thumbnail loaded successfully:', {
|
|
671
|
+
presetName: preset.name,
|
|
672
|
+
originalThumbnail: preset.thumbnail,
|
|
673
|
+
resolvedUrl: getThumbnailUrl(preset.thumbnail)
|
|
674
|
+
});
|
|
675
|
+
}}
|
|
676
|
+
/>
|
|
677
|
+
) : null}
|
|
678
|
+
</div>
|
|
679
|
+
</CardBody>
|
|
680
|
+
</Card>
|
|
681
|
+
</Tooltip>
|
|
682
|
+
))}
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
|
|
689
|
+
{/* Parameters Panel - Like the example */}
|
|
690
|
+
{(!isPresetManagementOpen || hidePresetsControls) && (
|
|
691
|
+
<div className="flex-1 p-4 flex flex-col relative">
|
|
692
|
+
{processedGroups.length === 0 ? (
|
|
693
|
+
<div className="text-center py-8 text-white/60">
|
|
694
|
+
<p className="text-sm">No parameters available</p>
|
|
695
|
+
<p className="text-xs mt-1">Scene may still be loading...</p>
|
|
696
|
+
</div>
|
|
697
|
+
) : (
|
|
698
|
+
<div className="flex-1 overflow-y-auto">
|
|
699
|
+
<div className="space-y-4">
|
|
700
|
+
{/* Capabilities Info (like in the example) */}
|
|
701
|
+
{renderCapabilitiesInfo()}
|
|
702
|
+
|
|
703
|
+
{/* General Group Parameters (rendered individually, not in a group) */}
|
|
704
|
+
{(() => {
|
|
705
|
+
const generalGroup = processedGroups.find(group => group.groupName === 'general');
|
|
706
|
+
if (generalGroup && generalGroup.processedParameters.length > 0) {
|
|
707
|
+
return (
|
|
708
|
+
<div className="mb-6">
|
|
709
|
+
<div className="mb-3 pb-2 border-b border-white/10">
|
|
710
|
+
<span className="text-white font-medium text-sm">General Controls</span>
|
|
711
|
+
</div>
|
|
712
|
+
<div className="space-y-3">
|
|
713
|
+
{generalGroup.processedParameters.map(renderParameterControl)}
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
})()}
|
|
720
|
+
|
|
721
|
+
{/* Other Groups (excluding general) */}
|
|
722
|
+
{processedGroups
|
|
723
|
+
.filter((group: any) => group.groupName !== 'general')
|
|
724
|
+
.map(renderParameterGroup)}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
)}
|
|
728
|
+
{/* Pinned Save Area (non-scrolling) */}
|
|
729
|
+
{hasUnsavedChanges && !hidePresetsControls && (
|
|
730
|
+
<div className="p-3">
|
|
731
|
+
<Button
|
|
732
|
+
color="primary"
|
|
733
|
+
variant="flat"
|
|
734
|
+
className="w-full"
|
|
735
|
+
startContent={<PlusIcon className="w-4 h-4" />}
|
|
736
|
+
onPress={handleSaveAsPreset}
|
|
737
|
+
isDisabled={!currentUser}
|
|
738
|
+
isLoading={isSavingPreset}
|
|
739
|
+
>
|
|
740
|
+
Save as Preset
|
|
741
|
+
</Button>
|
|
742
|
+
{!currentUser && (
|
|
743
|
+
<p className="text-white/60 text-xs text-center mt-2">
|
|
744
|
+
Login required to save presets
|
|
745
|
+
</p>
|
|
746
|
+
)}
|
|
747
|
+
</div>
|
|
748
|
+
)}
|
|
749
|
+
{isSavingPreset && (
|
|
750
|
+
<div className="absolute inset-0 z-20 flex items-center justify-center">
|
|
751
|
+
<Spinner color="primary" size="lg" />
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
)}
|
|
756
|
+
|
|
757
|
+
{/* No create preset modal (auto-named) */}
|
|
758
|
+
|
|
759
|
+
{/* Delete Preset Modal */}
|
|
760
|
+
{!hidePresetsControls && (
|
|
761
|
+
<Modal isOpen={isDeleteModalOpen} onClose={onDeleteModalClose}>
|
|
762
|
+
<ModalContent className="bg-black border border-white/10">
|
|
763
|
+
<ModalHeader>
|
|
764
|
+
<h3 className="text-white">Delete Preset</h3>
|
|
765
|
+
</ModalHeader>
|
|
766
|
+
<ModalBody>
|
|
767
|
+
<p className="text-white/80">
|
|
768
|
+
{(() => {
|
|
769
|
+
const p = availablePresets.find((x: any) => x.id === presetToDelete);
|
|
770
|
+
const name = p?.name ? `"${p.name}"` : 'this preset';
|
|
771
|
+
return `This will permanently delete ${name}. This action cannot be undone.`;
|
|
772
|
+
})()}
|
|
773
|
+
</p>
|
|
774
|
+
</ModalBody>
|
|
775
|
+
<ModalFooter>
|
|
776
|
+
<Button
|
|
777
|
+
variant="flat"
|
|
778
|
+
onPress={onDeleteModalClose}
|
|
779
|
+
>
|
|
780
|
+
Cancel
|
|
781
|
+
</Button>
|
|
782
|
+
<Button
|
|
783
|
+
color="danger"
|
|
784
|
+
onPress={handleDeletePreset}
|
|
785
|
+
isLoading={isSavingPreset}
|
|
786
|
+
>
|
|
787
|
+
Delete
|
|
788
|
+
</Button>
|
|
789
|
+
</ModalFooter>
|
|
790
|
+
</ModalContent>
|
|
791
|
+
</Modal>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
);
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
export default ParametersMenu;
|