ai-agent-session-center 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/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
// settingsManager.js — Settings persistence and event system
|
|
2
|
+
import * as db from './browserDb.js';
|
|
3
|
+
|
|
4
|
+
const defaults = {
|
|
5
|
+
theme: 'command-center',
|
|
6
|
+
fontSize: '13',
|
|
7
|
+
soundEnabled: 'true',
|
|
8
|
+
soundVolume: '0.5',
|
|
9
|
+
soundActions: '',
|
|
10
|
+
scanlineEnabled: 'true',
|
|
11
|
+
cardSize: 'small',
|
|
12
|
+
activityFeedVisible: 'true',
|
|
13
|
+
characterModel: 'robot',
|
|
14
|
+
animationIntensity: '100',
|
|
15
|
+
animationSpeed: '100',
|
|
16
|
+
movementActions: '',
|
|
17
|
+
hookDensity: 'off',
|
|
18
|
+
labelSettings: JSON.stringify({
|
|
19
|
+
ONEOFF: { sound: 'alarm', movement: 'shake', frame: 'fire' },
|
|
20
|
+
HEAVY: { sound: 'urgentAlarm', movement: 'flash', frame: 'electric' },
|
|
21
|
+
IMPORTANT: { sound: 'fanfare', movement: 'bounce', frame: 'liquid' },
|
|
22
|
+
})
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let settings = { ...defaults };
|
|
26
|
+
const listeners = new Map(); // key -> Set of callbacks
|
|
27
|
+
|
|
28
|
+
export async function loadSettings() {
|
|
29
|
+
try {
|
|
30
|
+
const data = await db.getAllSettings();
|
|
31
|
+
settings = { ...defaults, ...data };
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error('[settings] Failed to load:', e.message);
|
|
34
|
+
}
|
|
35
|
+
return settings;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function get(key) {
|
|
39
|
+
return settings[key] ?? defaults[key];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAll() {
|
|
43
|
+
return { ...settings };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function set(key, value) {
|
|
47
|
+
settings[key] = value;
|
|
48
|
+
try {
|
|
49
|
+
await db.setSetting(key, String(value));
|
|
50
|
+
flashAutosave();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('[settings] Failed to save:', e.message);
|
|
53
|
+
}
|
|
54
|
+
// Notify listeners
|
|
55
|
+
const cbs = listeners.get(key);
|
|
56
|
+
if (cbs) cbs.forEach(cb => cb(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function onChange(key, callback) {
|
|
60
|
+
if (!listeners.has(key)) listeners.set(key, new Set());
|
|
61
|
+
listeners.get(key).add(callback);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Apply theme to body
|
|
65
|
+
export function applyTheme(themeName) {
|
|
66
|
+
if (themeName === 'command-center') {
|
|
67
|
+
document.body.removeAttribute('data-theme');
|
|
68
|
+
} else {
|
|
69
|
+
document.body.setAttribute('data-theme', themeName);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Apply font size
|
|
74
|
+
export function applyFontSize(size) {
|
|
75
|
+
document.documentElement.style.fontSize = size + 'px';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Apply scanline setting
|
|
79
|
+
export function applyScanline(enabled) {
|
|
80
|
+
document.body.classList.toggle('no-scanlines', enabled !== 'true');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Apply animation intensity (0-200, default 100)
|
|
84
|
+
export function applyAnimationIntensity(value) {
|
|
85
|
+
const v = parseFloat(value) / 100;
|
|
86
|
+
document.documentElement.style.setProperty('--anim-intensity', v);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Apply animation speed (30-200, default 100 → lower = faster)
|
|
90
|
+
export function applyAnimationSpeed(value) {
|
|
91
|
+
const v = parseFloat(value) / 100;
|
|
92
|
+
document.documentElement.style.setProperty('--anim-speed', v);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Apply activity feed visibility
|
|
96
|
+
export function applyActivityFeed(visible) {
|
|
97
|
+
const feed = document.getElementById('activity-feed');
|
|
98
|
+
if (feed) feed.style.display = visible === 'true' ? '' : 'none';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- Hook Density ----
|
|
102
|
+
|
|
103
|
+
const DENSITY_EVENTS = {
|
|
104
|
+
high: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'SessionEnd'],
|
|
105
|
+
medium: ['SessionStart', 'UserPromptSubmit', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'SessionEnd'],
|
|
106
|
+
low: ['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Fetch live status from ~/.claude/settings.json and sync UI
|
|
110
|
+
export async function syncHookDensityUI() {
|
|
111
|
+
const statusEl = document.getElementById('hook-density-status');
|
|
112
|
+
const btns = document.querySelectorAll('.density-btn');
|
|
113
|
+
const installBtn = document.getElementById('hook-install-btn');
|
|
114
|
+
const uninstallBtn = document.getElementById('hook-uninstall-btn');
|
|
115
|
+
if (!statusEl) return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const resp = await fetch('/api/hooks/status');
|
|
119
|
+
const data = await resp.json();
|
|
120
|
+
|
|
121
|
+
// Update button active state
|
|
122
|
+
btns.forEach(b => b.classList.toggle('active', b.dataset.density === data.density));
|
|
123
|
+
|
|
124
|
+
// Update status text
|
|
125
|
+
if (data.installed) {
|
|
126
|
+
statusEl.innerHTML = `<span class="hook-status-dot installed"></span> Installed: <strong>${data.density}</strong> (${data.events.length} events)`;
|
|
127
|
+
statusEl.title = data.events.join(', ');
|
|
128
|
+
} else {
|
|
129
|
+
statusEl.innerHTML = `<span class="hook-status-dot"></span> Not installed`;
|
|
130
|
+
statusEl.title = '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (installBtn) installBtn.textContent = data.installed ? 'Re-install' : 'Install';
|
|
134
|
+
if (uninstallBtn) uninstallBtn.classList.toggle('hidden', !data.installed);
|
|
135
|
+
} catch {
|
|
136
|
+
statusEl.innerHTML = '<span class="hook-status-dot"></span> Unable to check';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function installHookDensity(density) {
|
|
141
|
+
const statusEl = document.getElementById('hook-density-status');
|
|
142
|
+
const installBtn = document.getElementById('hook-install-btn');
|
|
143
|
+
if (installBtn) { installBtn.disabled = true; installBtn.textContent = 'Installing...'; }
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const resp = await fetch('/api/hooks/install', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ density })
|
|
150
|
+
});
|
|
151
|
+
const data = await resp.json();
|
|
152
|
+
if (!resp.ok) throw new Error(data.error || 'Install failed');
|
|
153
|
+
await set('hookDensity', density);
|
|
154
|
+
await syncHookDensityUI();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (statusEl) statusEl.innerHTML = `<span class="hook-status-dot"></span> Error: ${err.message}`;
|
|
157
|
+
} finally {
|
|
158
|
+
if (installBtn) installBtn.disabled = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function uninstallHooks() {
|
|
163
|
+
const statusEl = document.getElementById('hook-density-status');
|
|
164
|
+
const uninstallBtn = document.getElementById('hook-uninstall-btn');
|
|
165
|
+
if (uninstallBtn) { uninstallBtn.disabled = true; uninstallBtn.textContent = 'Removing...'; }
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const resp = await fetch('/api/hooks/uninstall', { method: 'POST' });
|
|
169
|
+
const data = await resp.json();
|
|
170
|
+
if (!resp.ok) throw new Error(data.error || 'Uninstall failed');
|
|
171
|
+
await set('hookDensity', 'off');
|
|
172
|
+
await syncHookDensityUI();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (statusEl) statusEl.innerHTML = `<span class="hook-status-dot"></span> Error: ${err.message}`;
|
|
175
|
+
} finally {
|
|
176
|
+
if (uninstallBtn) { uninstallBtn.disabled = false; uninstallBtn.textContent = 'Uninstall'; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Flash autosave indicator
|
|
181
|
+
function flashAutosave() {
|
|
182
|
+
const el = document.getElementById('settings-autosave');
|
|
183
|
+
if (!el) return;
|
|
184
|
+
el.classList.remove('visible');
|
|
185
|
+
void el.offsetWidth; // force reflow
|
|
186
|
+
el.classList.add('visible');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Export all settings as JSON file download
|
|
190
|
+
export function exportSettings() {
|
|
191
|
+
const data = JSON.stringify(getAll(), null, 2);
|
|
192
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
193
|
+
const url = URL.createObjectURL(blob);
|
|
194
|
+
const a = document.createElement('a');
|
|
195
|
+
a.href = url;
|
|
196
|
+
a.download = 'claude-dashboard-settings.json';
|
|
197
|
+
a.click();
|
|
198
|
+
URL.revokeObjectURL(url);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Import settings from JSON file
|
|
202
|
+
export async function importSettings(file) {
|
|
203
|
+
const text = await file.text();
|
|
204
|
+
const imported = JSON.parse(text);
|
|
205
|
+
for (const [key, value] of Object.entries(imported)) {
|
|
206
|
+
await set(key, value);
|
|
207
|
+
}
|
|
208
|
+
// Re-apply all visual settings
|
|
209
|
+
applyTheme(get('theme'));
|
|
210
|
+
applyFontSize(get('fontSize'));
|
|
211
|
+
applyScanline(get('scanlineEnabled'));
|
|
212
|
+
applyActivityFeed(get('activityFeedVisible'));
|
|
213
|
+
applyAnimationIntensity(get('animationIntensity'));
|
|
214
|
+
applyAnimationSpeed(get('animationSpeed'));
|
|
215
|
+
// Refresh the UI to reflect imported values
|
|
216
|
+
syncUIToSettings();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Reset all settings to defaults
|
|
220
|
+
export async function resetDefaults() {
|
|
221
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
222
|
+
await set(key, value);
|
|
223
|
+
}
|
|
224
|
+
applyTheme(defaults.theme);
|
|
225
|
+
applyFontSize(defaults.fontSize);
|
|
226
|
+
applyScanline(defaults.scanlineEnabled);
|
|
227
|
+
applyActivityFeed(defaults.activityFeedVisible);
|
|
228
|
+
applyAnimationIntensity(defaults.animationIntensity);
|
|
229
|
+
applyAnimationSpeed(defaults.animationSpeed);
|
|
230
|
+
syncUIToSettings();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Sync all UI controls to current settings state
|
|
234
|
+
function syncUIToSettings() {
|
|
235
|
+
const currentTheme = get('theme');
|
|
236
|
+
applyTheme(currentTheme);
|
|
237
|
+
document.querySelectorAll('.theme-swatch').forEach(s => {
|
|
238
|
+
s.classList.toggle('active', s.dataset.theme === currentTheme);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const fontSize = get('fontSize');
|
|
242
|
+
applyFontSize(fontSize);
|
|
243
|
+
const slider = document.getElementById('font-size-slider');
|
|
244
|
+
const display = document.getElementById('font-size-display');
|
|
245
|
+
if (slider) slider.value = fontSize;
|
|
246
|
+
if (display) display.textContent = fontSize + 'px';
|
|
247
|
+
|
|
248
|
+
const soundEn = document.getElementById('sound-enabled');
|
|
249
|
+
if (soundEn) soundEn.checked = get('soundEnabled') === 'true';
|
|
250
|
+
const soundVol = document.getElementById('sound-volume');
|
|
251
|
+
if (soundVol) soundVol.value = get('soundVolume');
|
|
252
|
+
const volDisplay = document.getElementById('volume-display');
|
|
253
|
+
if (volDisplay) volDisplay.textContent = Math.round(parseFloat(get('soundVolume')) * 100) + '%';
|
|
254
|
+
|
|
255
|
+
const scanlineEl = document.getElementById('scanline-enabled');
|
|
256
|
+
if (scanlineEl) scanlineEl.checked = get('scanlineEnabled') === 'true';
|
|
257
|
+
applyScanline(get('scanlineEnabled'));
|
|
258
|
+
|
|
259
|
+
const feedEl = document.getElementById('activity-feed-visible');
|
|
260
|
+
if (feedEl) feedEl.checked = get('activityFeedVisible') === 'true';
|
|
261
|
+
applyActivityFeed(get('activityFeedVisible'));
|
|
262
|
+
|
|
263
|
+
// Character model
|
|
264
|
+
const currentModel = get('characterModel');
|
|
265
|
+
document.querySelectorAll('.char-swatch').forEach(s => {
|
|
266
|
+
s.classList.toggle('active', s.dataset.model === currentModel);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Animation intensity
|
|
270
|
+
const animIntensity = get('animationIntensity');
|
|
271
|
+
applyAnimationIntensity(animIntensity);
|
|
272
|
+
const intSlider = document.getElementById('anim-intensity-slider');
|
|
273
|
+
const intDisplay = document.getElementById('anim-intensity-display');
|
|
274
|
+
if (intSlider) intSlider.value = animIntensity;
|
|
275
|
+
if (intDisplay) intDisplay.textContent = animIntensity + '%';
|
|
276
|
+
|
|
277
|
+
// Animation speed
|
|
278
|
+
const animSpeed = get('animationSpeed');
|
|
279
|
+
applyAnimationSpeed(animSpeed);
|
|
280
|
+
const spdSlider = document.getElementById('anim-speed-slider');
|
|
281
|
+
const spdDisplay = document.getElementById('anim-speed-display');
|
|
282
|
+
if (spdSlider) spdSlider.value = animSpeed;
|
|
283
|
+
if (spdDisplay) spdDisplay.textContent = animSpeed + '%';
|
|
284
|
+
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Reusable API key field wiring (toggle, save, load)
|
|
288
|
+
function setupApiKeyField(inputId, settingKey) {
|
|
289
|
+
const input = document.getElementById(inputId);
|
|
290
|
+
const toggle = document.getElementById(inputId + '-toggle');
|
|
291
|
+
const saveBtn = document.getElementById(inputId + '-save');
|
|
292
|
+
const status = document.getElementById(inputId + '-status');
|
|
293
|
+
if (!input) return;
|
|
294
|
+
|
|
295
|
+
// Load stored value
|
|
296
|
+
const stored = get(settingKey);
|
|
297
|
+
if (stored) {
|
|
298
|
+
input.value = stored;
|
|
299
|
+
if (status) status.textContent = 'Key saved in browser';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Toggle show/hide
|
|
303
|
+
if (toggle) {
|
|
304
|
+
toggle.addEventListener('click', () => {
|
|
305
|
+
const isPassword = input.type === 'password';
|
|
306
|
+
input.type = isPassword ? 'text' : 'password';
|
|
307
|
+
toggle.textContent = isPassword ? 'HIDE' : 'SHOW';
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Save
|
|
312
|
+
if (saveBtn) {
|
|
313
|
+
saveBtn.addEventListener('click', async () => {
|
|
314
|
+
const val = input.value.trim();
|
|
315
|
+
if (val) {
|
|
316
|
+
await set(settingKey, val);
|
|
317
|
+
if (status) { status.textContent = 'Saved'; status.style.color = 'var(--accent-green, #4caf50)'; }
|
|
318
|
+
} else {
|
|
319
|
+
await set(settingKey, '');
|
|
320
|
+
if (status) { status.textContent = 'Cleared'; status.style.color = 'var(--text-dim)'; }
|
|
321
|
+
}
|
|
322
|
+
setTimeout(() => { if (status) { status.textContent = val ? 'Key saved in browser' : ''; status.style.color = ''; } }, 2000);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Initialize settings UI bindings
|
|
328
|
+
export function initSettingsUI() {
|
|
329
|
+
// Open/close settings
|
|
330
|
+
document.getElementById('open-settings').addEventListener('click', () => {
|
|
331
|
+
document.getElementById('settings-modal').classList.remove('hidden');
|
|
332
|
+
});
|
|
333
|
+
document.getElementById('close-settings').addEventListener('click', () => {
|
|
334
|
+
document.getElementById('settings-modal').classList.add('hidden');
|
|
335
|
+
});
|
|
336
|
+
document.getElementById('settings-modal').addEventListener('click', (e) => {
|
|
337
|
+
if (e.target.id === 'settings-modal') {
|
|
338
|
+
document.getElementById('settings-modal').classList.add('hidden');
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// --- Settings tab switching ---
|
|
343
|
+
let lastSettingsTab = 'appearance';
|
|
344
|
+
document.querySelector('.settings-tabs').addEventListener('click', (e) => {
|
|
345
|
+
const tab = e.target.closest('.settings-tab');
|
|
346
|
+
if (!tab) return;
|
|
347
|
+
const tabName = tab.dataset.settingsTab;
|
|
348
|
+
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
|
349
|
+
document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
|
|
350
|
+
tab.classList.add('active');
|
|
351
|
+
const content = document.getElementById('settings-tab-' + tabName);
|
|
352
|
+
if (content) content.classList.add('active');
|
|
353
|
+
lastSettingsTab = tabName;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Theme selection
|
|
357
|
+
document.getElementById('theme-grid').addEventListener('click', (e) => {
|
|
358
|
+
const swatch = e.target.closest('.theme-swatch');
|
|
359
|
+
if (!swatch) return;
|
|
360
|
+
const theme = swatch.dataset.theme;
|
|
361
|
+
document.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
|
|
362
|
+
swatch.classList.add('active');
|
|
363
|
+
applyTheme(theme);
|
|
364
|
+
set('theme', theme);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Font size controls
|
|
368
|
+
const slider = document.getElementById('font-size-slider');
|
|
369
|
+
const display = document.getElementById('font-size-display');
|
|
370
|
+
slider.addEventListener('input', () => {
|
|
371
|
+
const val = slider.value;
|
|
372
|
+
display.textContent = val + 'px';
|
|
373
|
+
applyFontSize(val);
|
|
374
|
+
set('fontSize', val);
|
|
375
|
+
});
|
|
376
|
+
document.getElementById('font-decrease').addEventListener('click', () => {
|
|
377
|
+
const val = Math.max(10, parseInt(slider.value) - 1);
|
|
378
|
+
slider.value = val;
|
|
379
|
+
display.textContent = val + 'px';
|
|
380
|
+
applyFontSize(val);
|
|
381
|
+
set('fontSize', String(val));
|
|
382
|
+
});
|
|
383
|
+
document.getElementById('font-increase').addEventListener('click', () => {
|
|
384
|
+
const val = Math.min(20, parseInt(slider.value) + 1);
|
|
385
|
+
slider.value = val;
|
|
386
|
+
display.textContent = val + 'px';
|
|
387
|
+
applyFontSize(val);
|
|
388
|
+
set('fontSize', String(val));
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// --- Scanline toggle ---
|
|
392
|
+
const scanlineCheckbox = document.getElementById('scanline-enabled');
|
|
393
|
+
if (scanlineCheckbox) {
|
|
394
|
+
scanlineCheckbox.addEventListener('change', (e) => {
|
|
395
|
+
const enabled = String(e.target.checked);
|
|
396
|
+
applyScanline(enabled);
|
|
397
|
+
set('scanlineEnabled', enabled);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Sound controls
|
|
402
|
+
document.getElementById('sound-enabled').addEventListener('change', (e) => {
|
|
403
|
+
set('soundEnabled', String(e.target.checked));
|
|
404
|
+
});
|
|
405
|
+
document.getElementById('sound-volume').addEventListener('input', (e) => {
|
|
406
|
+
const vol = e.target.value;
|
|
407
|
+
document.getElementById('volume-display').textContent = Math.round(vol * 100) + '%';
|
|
408
|
+
set('soundVolume', vol);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// --- Import / Export ---
|
|
412
|
+
const exportBtn = document.getElementById('export-settings');
|
|
413
|
+
if (exportBtn) {
|
|
414
|
+
exportBtn.addEventListener('click', () => exportSettings());
|
|
415
|
+
}
|
|
416
|
+
const importBtn = document.getElementById('import-settings-btn');
|
|
417
|
+
const importFile = document.getElementById('import-settings-file');
|
|
418
|
+
if (importBtn && importFile) {
|
|
419
|
+
importBtn.addEventListener('click', () => importFile.click());
|
|
420
|
+
importFile.addEventListener('change', async (e) => {
|
|
421
|
+
const file = e.target.files[0];
|
|
422
|
+
if (!file) return;
|
|
423
|
+
try {
|
|
424
|
+
await importSettings(file);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.error('[settings] Import failed:', err.message);
|
|
427
|
+
}
|
|
428
|
+
importFile.value = '';
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- Reset to defaults ---
|
|
433
|
+
const resetBtn = document.getElementById('reset-defaults');
|
|
434
|
+
if (resetBtn) {
|
|
435
|
+
resetBtn.addEventListener('click', async () => {
|
|
436
|
+
await resetDefaults();
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Animation intensity slider ---
|
|
441
|
+
const intensitySlider = document.getElementById('anim-intensity-slider');
|
|
442
|
+
const intensityDisplay = document.getElementById('anim-intensity-display');
|
|
443
|
+
if (intensitySlider && intensityDisplay) {
|
|
444
|
+
intensitySlider.addEventListener('input', () => {
|
|
445
|
+
const val = intensitySlider.value;
|
|
446
|
+
intensityDisplay.textContent = val + '%';
|
|
447
|
+
applyAnimationIntensity(val);
|
|
448
|
+
set('animationIntensity', val);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- Animation speed slider ---
|
|
453
|
+
const speedSlider = document.getElementById('anim-speed-slider');
|
|
454
|
+
const speedDisplay = document.getElementById('anim-speed-display');
|
|
455
|
+
if (speedSlider && speedDisplay) {
|
|
456
|
+
speedSlider.addEventListener('input', () => {
|
|
457
|
+
const val = speedSlider.value;
|
|
458
|
+
speedDisplay.textContent = val + '%';
|
|
459
|
+
applyAnimationSpeed(val);
|
|
460
|
+
set('animationSpeed', val);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- Character model ---
|
|
465
|
+
const charGrid = document.getElementById('char-model-grid');
|
|
466
|
+
if (charGrid) {
|
|
467
|
+
charGrid.addEventListener('click', async (e) => {
|
|
468
|
+
const swatch = e.target.closest('.char-swatch');
|
|
469
|
+
if (!swatch) return;
|
|
470
|
+
const model = swatch.dataset.model;
|
|
471
|
+
document.querySelectorAll('.char-swatch').forEach(s => s.classList.remove('active'));
|
|
472
|
+
swatch.classList.add('active');
|
|
473
|
+
await set('characterModel', model);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- Activity feed toggle ---
|
|
478
|
+
const feedToggle = document.getElementById('activity-feed-visible');
|
|
479
|
+
if (feedToggle) {
|
|
480
|
+
feedToggle.addEventListener('change', (e) => {
|
|
481
|
+
const visible = String(e.target.checked);
|
|
482
|
+
applyActivityFeed(visible);
|
|
483
|
+
set('activityFeedVisible', visible);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Hook density controls ---
|
|
488
|
+
const densityControl = document.getElementById('hook-density-control');
|
|
489
|
+
if (densityControl) {
|
|
490
|
+
densityControl.addEventListener('click', (e) => {
|
|
491
|
+
const btn = e.target.closest('.density-btn');
|
|
492
|
+
if (!btn) return;
|
|
493
|
+
document.querySelectorAll('.density-btn').forEach(b => b.classList.remove('active'));
|
|
494
|
+
btn.classList.add('active');
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const installBtn = document.getElementById('hook-install-btn');
|
|
498
|
+
if (installBtn) {
|
|
499
|
+
installBtn.addEventListener('click', () => {
|
|
500
|
+
const activeBtn = document.querySelector('.density-btn.active');
|
|
501
|
+
const density = activeBtn?.dataset.density || 'medium';
|
|
502
|
+
installHookDensity(density);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
const uninstallBtn = document.getElementById('hook-uninstall-btn');
|
|
506
|
+
if (uninstallBtn) {
|
|
507
|
+
uninstallBtn.addEventListener('click', () => uninstallHooks());
|
|
508
|
+
}
|
|
509
|
+
// Fetch live hook status on settings init
|
|
510
|
+
syncHookDensityUI();
|
|
511
|
+
|
|
512
|
+
// --- API Keys (Anthropic, OpenAI, Gemini) ---
|
|
513
|
+
setupApiKeyField('settings-api-key', 'anthropicApiKey');
|
|
514
|
+
setupApiKeyField('settings-openai-key', 'openaiApiKey');
|
|
515
|
+
setupApiKeyField('settings-gemini-key', 'geminiApiKey');
|
|
516
|
+
|
|
517
|
+
// --- Apply all current settings to UI ---
|
|
518
|
+
syncUIToSettings();
|
|
519
|
+
|
|
520
|
+
// Build per-action sound config grid (deferred import to avoid circular)
|
|
521
|
+
initSoundGrid();
|
|
522
|
+
|
|
523
|
+
// Build per-action movement effect grid
|
|
524
|
+
initMovementGrid();
|
|
525
|
+
|
|
526
|
+
// Build label completion alerts grid
|
|
527
|
+
initLabelGrid();
|
|
528
|
+
|
|
529
|
+
// Build summary prompt template management
|
|
530
|
+
initSummaryPromptSettings();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Shared per-action config grid component.
|
|
535
|
+
* Used by both sound and movement settings.
|
|
536
|
+
*
|
|
537
|
+
* @param {HTMLElement} grid - container element
|
|
538
|
+
* @param {Object} opts
|
|
539
|
+
* @param {Object|string[]} opts.library - available options: { key: label } or [name, ...]
|
|
540
|
+
* @param {Object} opts.currentMapping - action -> current value
|
|
541
|
+
* @param {Object} opts.labels - action -> display label
|
|
542
|
+
* @param {Object} opts.categories - category -> [action, ...]
|
|
543
|
+
* @param {Function} opts.onChange - (action, value) => void
|
|
544
|
+
* @param {Function} [opts.onPreview] - (value) => void (adds preview button if provided)
|
|
545
|
+
*/
|
|
546
|
+
function buildActionGrid(grid, opts) {
|
|
547
|
+
const { library, currentMapping, labels, categories, onChange, onPreview } = opts;
|
|
548
|
+
|
|
549
|
+
// Normalize library to [{ value, label }]
|
|
550
|
+
let options;
|
|
551
|
+
if (Array.isArray(library)) {
|
|
552
|
+
options = library.map(s => ({ value: s, label: s }));
|
|
553
|
+
} else {
|
|
554
|
+
options = Object.entries(library).map(([val, lbl]) => ({ value: val, label: lbl }));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let html = '';
|
|
558
|
+
for (const [category, actions] of Object.entries(categories)) {
|
|
559
|
+
html += `<div class="sound-category-label">${category}</div>`;
|
|
560
|
+
for (const action of actions) {
|
|
561
|
+
const current = currentMapping[action] || 'none';
|
|
562
|
+
const optionsHtml = options.map(o =>
|
|
563
|
+
`<option value="${o.value}"${o.value === current ? ' selected' : ''}>${o.label}</option>`
|
|
564
|
+
).join('');
|
|
565
|
+
html += `<div class="sound-action-row">` +
|
|
566
|
+
`<span class="sound-action-label">${labels[action] || action}</span>` +
|
|
567
|
+
`<select class="sound-action-select" data-action="${action}">${optionsHtml}</select>` +
|
|
568
|
+
(onPreview ? `<button class="sound-preview-btn" data-action="${action}" title="Preview">▶</button>` : '') +
|
|
569
|
+
`</div>`;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
grid.innerHTML = html;
|
|
573
|
+
|
|
574
|
+
grid.addEventListener('change', (e) => {
|
|
575
|
+
const sel = e.target.closest('.sound-action-select');
|
|
576
|
+
if (!sel) return;
|
|
577
|
+
onChange(sel.dataset.action, sel.value);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (onPreview) {
|
|
581
|
+
grid.addEventListener('click', (e) => {
|
|
582
|
+
const btn = e.target.closest('.sound-preview-btn');
|
|
583
|
+
if (!btn) return;
|
|
584
|
+
const action = btn.dataset.action;
|
|
585
|
+
const sel = grid.querySelector(`.sound-action-select[data-action="${action}"]`);
|
|
586
|
+
if (sel) onPreview(sel.value);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function initMovementGrid() {
|
|
592
|
+
const movementManager = await import('./movementManager.js');
|
|
593
|
+
const robotManager = await import('./robotManager.js');
|
|
594
|
+
const grid = document.getElementById('movement-action-grid');
|
|
595
|
+
if (!grid) return;
|
|
596
|
+
|
|
597
|
+
// Render preview character in the viewport
|
|
598
|
+
const viewport = document.getElementById('movement-preview-viewport');
|
|
599
|
+
if (viewport) {
|
|
600
|
+
const templates = robotManager._getTemplates();
|
|
601
|
+
const currentModel = get('characterModel') || 'robot';
|
|
602
|
+
const templateFn = templates[currentModel] || templates.robot;
|
|
603
|
+
|
|
604
|
+
// Clear placeholder label, render character
|
|
605
|
+
viewport.innerHTML = '';
|
|
606
|
+
const previewChar = document.createElement('div');
|
|
607
|
+
previewChar.className = `css-robot char-${currentModel}`;
|
|
608
|
+
previewChar.dataset.status = 'idle';
|
|
609
|
+
previewChar.style.setProperty('--robot-color', '#00e5ff');
|
|
610
|
+
previewChar.innerHTML = templateFn('#00e5ff');
|
|
611
|
+
viewport.appendChild(previewChar);
|
|
612
|
+
|
|
613
|
+
// Effect name label
|
|
614
|
+
const nameLabel = document.createElement('div');
|
|
615
|
+
nameLabel.className = 'movement-preview-effect-name';
|
|
616
|
+
viewport.appendChild(nameLabel);
|
|
617
|
+
|
|
618
|
+
// Store ref for preview callback
|
|
619
|
+
viewport._previewChar = previewChar;
|
|
620
|
+
viewport._nameLabel = nameLabel;
|
|
621
|
+
viewport._clearTimer = null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
buildActionGrid(grid, {
|
|
625
|
+
library: movementManager.getEffectLibrary(),
|
|
626
|
+
currentMapping: movementManager.getActionEffects(),
|
|
627
|
+
labels: movementManager.getActionLabels(),
|
|
628
|
+
categories: movementManager.getActionCategories(),
|
|
629
|
+
onChange: (action, value) => movementManager.setActionEffect(action, value),
|
|
630
|
+
onPreview: (effectName) => {
|
|
631
|
+
if (!viewport || !viewport._previewChar) return;
|
|
632
|
+
const char = viewport._previewChar;
|
|
633
|
+
const label = viewport._nameLabel;
|
|
634
|
+
|
|
635
|
+
// Clear previous
|
|
636
|
+
if (viewport._clearTimer) clearTimeout(viewport._clearTimer);
|
|
637
|
+
char.removeAttribute('data-movement');
|
|
638
|
+
void char.offsetWidth; // force reflow for re-trigger
|
|
639
|
+
|
|
640
|
+
if (effectName === 'none') {
|
|
641
|
+
label.textContent = '';
|
|
642
|
+
label.classList.remove('visible');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Apply effect
|
|
647
|
+
char.setAttribute('data-movement', effectName);
|
|
648
|
+
const lib = movementManager.getEffectLibrary();
|
|
649
|
+
label.textContent = lib[effectName] || effectName;
|
|
650
|
+
label.classList.add('visible');
|
|
651
|
+
|
|
652
|
+
// Auto-clear after 3.5s
|
|
653
|
+
viewport._clearTimer = setTimeout(() => {
|
|
654
|
+
char.removeAttribute('data-movement');
|
|
655
|
+
label.classList.remove('visible');
|
|
656
|
+
}, 3500);
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function initSoundGrid() {
|
|
662
|
+
const soundManager = await import('./soundManager.js');
|
|
663
|
+
const grid = document.getElementById('sound-action-grid');
|
|
664
|
+
if (!grid) return;
|
|
665
|
+
|
|
666
|
+
buildActionGrid(grid, {
|
|
667
|
+
library: soundManager.getSoundLibrary(),
|
|
668
|
+
currentMapping: soundManager.getActionSounds(),
|
|
669
|
+
labels: soundManager.getActionLabels(),
|
|
670
|
+
categories: soundManager.getActionCategories(),
|
|
671
|
+
onChange: (action, value) => soundManager.setActionSound(action, value),
|
|
672
|
+
onPreview: (value) => soundManager.previewSound(value),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---- Summary Prompt Template Settings ----
|
|
677
|
+
|
|
678
|
+
async function initSummaryPromptSettings() {
|
|
679
|
+
const list = document.getElementById('settings-prompt-list');
|
|
680
|
+
const nameInput = document.getElementById('settings-prompt-name');
|
|
681
|
+
const textInput = document.getElementById('settings-prompt-text');
|
|
682
|
+
const saveBtn = document.getElementById('settings-prompt-save');
|
|
683
|
+
const cancelEditBtn = document.getElementById('settings-prompt-cancel-edit');
|
|
684
|
+
if (!list || !nameInput || !textInput || !saveBtn) return;
|
|
685
|
+
|
|
686
|
+
let editingId = null;
|
|
687
|
+
|
|
688
|
+
async function loadAndRender() {
|
|
689
|
+
try {
|
|
690
|
+
const all = await db.getAll('summaryPrompts');
|
|
691
|
+
const prompts = all.map(p => ({ ...p, is_default: p.isDefault }));
|
|
692
|
+
renderList(prompts);
|
|
693
|
+
} catch(e) {
|
|
694
|
+
list.innerHTML = '<div style="color:var(--text-dim);padding:8px">Failed to load prompts</div>';
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function renderList(prompts) {
|
|
699
|
+
list.innerHTML = prompts.map(p => `
|
|
700
|
+
<div class="settings-prompt-item${p.is_default ? ' default' : ''}" data-id="${p.id}">
|
|
701
|
+
<div class="settings-prompt-item-row">
|
|
702
|
+
<button class="settings-prompt-star${p.is_default ? ' active' : ''}" data-id="${p.id}" title="${p.is_default ? 'Default' : 'Set as default'}">★</button>
|
|
703
|
+
<span class="settings-prompt-item-name">${escapeHtml(p.name)}</span>
|
|
704
|
+
${p.is_default ? '<span class="settings-prompt-badge">DEFAULT</span>' : ''}
|
|
705
|
+
<button class="settings-prompt-edit" data-id="${p.id}" title="Edit">✎</button>
|
|
706
|
+
<button class="settings-prompt-del" data-id="${p.id}" title="Delete">×</button>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="settings-prompt-item-preview">${escapeHtml(p.prompt).substring(0, 120)}${p.prompt.length > 120 ? '...' : ''}</div>
|
|
709
|
+
</div>
|
|
710
|
+
`).join('') || '<div style="color:var(--text-dim);padding:8px">No prompt templates</div>';
|
|
711
|
+
|
|
712
|
+
// Star (set default)
|
|
713
|
+
list.querySelectorAll('.settings-prompt-star').forEach(btn => {
|
|
714
|
+
btn.addEventListener('click', async () => {
|
|
715
|
+
const id = parseInt(btn.dataset.id, 10);
|
|
716
|
+
// Clear all defaults first, then set the selected one
|
|
717
|
+
const all = await db.getAll('summaryPrompts');
|
|
718
|
+
for (const p of all) {
|
|
719
|
+
if (p.isDefault && p.id !== id) {
|
|
720
|
+
await db.put('summaryPrompts', { ...p, isDefault: 0, updatedAt: Date.now() });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const target = all.find(p => p.id === id);
|
|
724
|
+
if (target) {
|
|
725
|
+
await db.put('summaryPrompts', { ...target, isDefault: 1, updatedAt: Date.now() });
|
|
726
|
+
}
|
|
727
|
+
loadAndRender();
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Edit
|
|
732
|
+
list.querySelectorAll('.settings-prompt-edit').forEach(btn => {
|
|
733
|
+
btn.addEventListener('click', async () => {
|
|
734
|
+
const id = parseInt(btn.dataset.id, 10);
|
|
735
|
+
const p = await db.get('summaryPrompts', id);
|
|
736
|
+
if (!p) return;
|
|
737
|
+
editingId = id;
|
|
738
|
+
nameInput.value = p.name;
|
|
739
|
+
textInput.value = p.prompt;
|
|
740
|
+
saveBtn.textContent = 'Update Template';
|
|
741
|
+
cancelEditBtn.classList.remove('hidden');
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Delete
|
|
746
|
+
list.querySelectorAll('.settings-prompt-del').forEach(btn => {
|
|
747
|
+
btn.addEventListener('click', async () => {
|
|
748
|
+
await db.del('summaryPrompts', parseInt(btn.dataset.id, 10));
|
|
749
|
+
loadAndRender();
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Save / Update
|
|
755
|
+
saveBtn.addEventListener('click', async () => {
|
|
756
|
+
const name = nameInput.value.trim();
|
|
757
|
+
const prompt = textInput.value.trim();
|
|
758
|
+
if (!name || !prompt) return;
|
|
759
|
+
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
if (editingId) {
|
|
762
|
+
const existing = await db.get('summaryPrompts', editingId);
|
|
763
|
+
await db.put('summaryPrompts', { ...existing, name, prompt, updatedAt: now });
|
|
764
|
+
} else {
|
|
765
|
+
await db.put('summaryPrompts', { name, prompt, isDefault: 0, createdAt: now, updatedAt: now });
|
|
766
|
+
}
|
|
767
|
+
nameInput.value = '';
|
|
768
|
+
textInput.value = '';
|
|
769
|
+
editingId = null;
|
|
770
|
+
saveBtn.textContent = 'Add Template';
|
|
771
|
+
cancelEditBtn.classList.add('hidden');
|
|
772
|
+
loadAndRender();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// Cancel edit
|
|
776
|
+
cancelEditBtn.addEventListener('click', () => {
|
|
777
|
+
editingId = null;
|
|
778
|
+
nameInput.value = '';
|
|
779
|
+
textInput.value = '';
|
|
780
|
+
saveBtn.textContent = 'Add Template';
|
|
781
|
+
cancelEditBtn.classList.add('hidden');
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
loadAndRender();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---- Label Settings (per-label completion alerts) ----
|
|
788
|
+
|
|
789
|
+
export function getLabelSettings() {
|
|
790
|
+
const raw = get('labelSettings');
|
|
791
|
+
try {
|
|
792
|
+
return JSON.parse(raw);
|
|
793
|
+
} catch {
|
|
794
|
+
return JSON.parse(defaults.labelSettings);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export async function setLabelSetting(label, field, value) {
|
|
799
|
+
const current = getLabelSettings();
|
|
800
|
+
if (!current[label]) current[label] = { sound: 'none', movement: 'none' };
|
|
801
|
+
current[label] = { ...current[label], [field]: value };
|
|
802
|
+
await set('labelSettings', JSON.stringify(current));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Frame effect library for label cards
|
|
806
|
+
const FRAME_EFFECTS = {
|
|
807
|
+
none: 'None',
|
|
808
|
+
fire: 'Burning Fire',
|
|
809
|
+
electric: 'Electric Current',
|
|
810
|
+
chains: 'Golden Chains',
|
|
811
|
+
liquid: 'Liquid Flow',
|
|
812
|
+
plasma: 'Plasma Ring',
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
export function getFrameEffects() {
|
|
816
|
+
return { ...FRAME_EFFECTS };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function initLabelGrid() {
|
|
820
|
+
const container = document.getElementById('label-settings-grid');
|
|
821
|
+
if (!container) return;
|
|
822
|
+
|
|
823
|
+
const soundManager = await import('./soundManager.js');
|
|
824
|
+
const movementManager = await import('./movementManager.js');
|
|
825
|
+
|
|
826
|
+
const sounds = soundManager.getSoundLibrary(); // string[]
|
|
827
|
+
const effects = movementManager.getEffectLibrary(); // { key: label }
|
|
828
|
+
const labelConfig = getLabelSettings();
|
|
829
|
+
|
|
830
|
+
const LABELS = ['ONEOFF', 'HEAVY', 'IMPORTANT'];
|
|
831
|
+
const LABEL_COLORS = { ONEOFF: '#ff9100', HEAVY: '#ff3355', IMPORTANT: '#aa66ff' };
|
|
832
|
+
const LABEL_ICONS = { ONEOFF: '🔥', HEAVY: '★', IMPORTANT: '⚠' };
|
|
833
|
+
|
|
834
|
+
let html = '';
|
|
835
|
+
for (const label of LABELS) {
|
|
836
|
+
const cfg = labelConfig[label] || { sound: 'none', movement: 'none', frame: 'none' };
|
|
837
|
+
const color = LABEL_COLORS[label];
|
|
838
|
+
|
|
839
|
+
const soundOpts = sounds.map(s =>
|
|
840
|
+
`<option value="${s}"${s === cfg.sound ? ' selected' : ''}>${s}</option>`
|
|
841
|
+
).join('');
|
|
842
|
+
|
|
843
|
+
const effectOpts = Object.entries(effects).map(([key, name]) =>
|
|
844
|
+
`<option value="${key}"${key === cfg.movement ? ' selected' : ''}>${name}</option>`
|
|
845
|
+
).join('');
|
|
846
|
+
|
|
847
|
+
const frameOpts = Object.entries(FRAME_EFFECTS).map(([key, name]) =>
|
|
848
|
+
`<option value="${key}"${key === (cfg.frame || 'none') ? ' selected' : ''}>${name}</option>`
|
|
849
|
+
).join('');
|
|
850
|
+
|
|
851
|
+
html += `
|
|
852
|
+
<div class="label-config-card" style="--label-color: ${color}" data-frame="${cfg.frame || 'none'}">
|
|
853
|
+
<div class="label-config-header">
|
|
854
|
+
<span class="label-config-icon">${LABEL_ICONS[label]}</span>
|
|
855
|
+
<span class="label-config-name">${label}</span>
|
|
856
|
+
</div>
|
|
857
|
+
<div class="label-config-row">
|
|
858
|
+
<span class="label-config-field">Card Frame</span>
|
|
859
|
+
<select class="label-config-select" data-label="${label}" data-field="frame">${frameOpts}</select>
|
|
860
|
+
</div>
|
|
861
|
+
<div class="label-config-row">
|
|
862
|
+
<span class="label-config-field">Sound</span>
|
|
863
|
+
<select class="label-config-select" data-label="${label}" data-field="sound">${soundOpts}</select>
|
|
864
|
+
<button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="sound" title="Preview">▶</button>
|
|
865
|
+
</div>
|
|
866
|
+
<div class="label-config-row">
|
|
867
|
+
<span class="label-config-field">Movement</span>
|
|
868
|
+
<select class="label-config-select" data-label="${label}" data-field="movement">${effectOpts}</select>
|
|
869
|
+
<button class="sound-preview-btn label-preview-btn" data-label="${label}" data-field="movement" title="Preview">▶</button>
|
|
870
|
+
</div>
|
|
871
|
+
</div>`;
|
|
872
|
+
}
|
|
873
|
+
container.innerHTML = html;
|
|
874
|
+
|
|
875
|
+
// Change handler
|
|
876
|
+
container.addEventListener('change', (e) => {
|
|
877
|
+
const sel = e.target.closest('.label-config-select');
|
|
878
|
+
if (!sel) return;
|
|
879
|
+
const field = sel.dataset.field;
|
|
880
|
+
const label = sel.dataset.label;
|
|
881
|
+
setLabelSetting(label, field, sel.value);
|
|
882
|
+
|
|
883
|
+
// Live-preview frame effect on the config card itself
|
|
884
|
+
if (field === 'frame') {
|
|
885
|
+
const configCard = sel.closest('.label-config-card');
|
|
886
|
+
if (configCard) configCard.dataset.frame = sel.value;
|
|
887
|
+
// Also update any live session cards with this label
|
|
888
|
+
document.querySelectorAll(`.session-card.${label.toLowerCase()}-session`).forEach(card => {
|
|
889
|
+
if (sel.value && sel.value !== 'none') {
|
|
890
|
+
card.dataset.frame = sel.value;
|
|
891
|
+
} else {
|
|
892
|
+
delete card.dataset.frame;
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Preview handler
|
|
899
|
+
container.addEventListener('click', (e) => {
|
|
900
|
+
const btn = e.target.closest('.label-preview-btn');
|
|
901
|
+
if (!btn) return;
|
|
902
|
+
const label = btn.dataset.label;
|
|
903
|
+
const field = btn.dataset.field;
|
|
904
|
+
const sel = container.querySelector(`.label-config-select[data-label="${label}"][data-field="${field}"]`);
|
|
905
|
+
if (!sel) return;
|
|
906
|
+
if (field === 'sound') {
|
|
907
|
+
soundManager.previewSound(sel.value);
|
|
908
|
+
} else {
|
|
909
|
+
// Preview movement on the first session card character
|
|
910
|
+
const card = document.querySelector('.session-card .css-robot');
|
|
911
|
+
if (card && sel.value !== 'none') {
|
|
912
|
+
card.removeAttribute('data-movement');
|
|
913
|
+
void card.offsetWidth;
|
|
914
|
+
card.setAttribute('data-movement', sel.value);
|
|
915
|
+
setTimeout(() => card.removeAttribute('data-movement'), 3500);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function escapeHtml(str) {
|
|
922
|
+
if (!str) return '';
|
|
923
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
924
|
+
}
|