brainstorm-companion 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 +66 -0
- package/bin/brainstorm.js +2 -0
- package/package.json +39 -0
- package/skill/SKILL.md +71 -0
- package/skill/visual-companion.md +331 -0
- package/src/cli.js +441 -0
- package/src/content-detect.js +52 -0
- package/src/mcp.js +331 -0
- package/src/server.js +624 -0
- package/src/session.js +215 -0
- package/src/templates/comparison-helper.js +277 -0
- package/src/templates/comparison.html +277 -0
- package/src/templates/frame.html +283 -0
- package/src/templates/helper.js +78 -0
- package/src/templates/waiting.html +8 -0
- package/src/ws-protocol.js +69 -0
package/src/session.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
class SessionManager {
|
|
7
|
+
constructor(projectDir) {
|
|
8
|
+
this.baseDir = projectDir
|
|
9
|
+
? `${projectDir}/.superpowers/brainstorm/`
|
|
10
|
+
: `/tmp/brainstorm-companion/`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
create() {
|
|
14
|
+
const sessionId = `${process.pid}-${Date.now()}`;
|
|
15
|
+
const sessionDir = path.join(this.baseDir, sessionId);
|
|
16
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
17
|
+
return { sessionId, sessionDir };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getActive() {
|
|
21
|
+
if (!fs.existsSync(this.baseDir)) return null;
|
|
22
|
+
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Collect session dirs with their mtime for sorting (most recent first)
|
|
31
|
+
const sessions = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
const sessionDir = path.join(this.baseDir, entry.name);
|
|
35
|
+
try {
|
|
36
|
+
const stat = fs.statSync(sessionDir);
|
|
37
|
+
sessions.push({ name: entry.name, sessionDir, mtime: stat.mtimeMs });
|
|
38
|
+
} catch {
|
|
39
|
+
// skip unreadable dirs
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sort most recent first
|
|
44
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
45
|
+
|
|
46
|
+
for (const { name: sessionId, sessionDir } of sessions) {
|
|
47
|
+
const serverInfoPath = path.join(sessionDir, '.server-info');
|
|
48
|
+
if (!fs.existsSync(serverInfoPath)) continue;
|
|
49
|
+
|
|
50
|
+
let serverInfo;
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(serverInfoPath, 'utf8');
|
|
53
|
+
serverInfo = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Verify server PID is alive
|
|
59
|
+
const pid = serverInfo.pid || serverInfo.serverPid;
|
|
60
|
+
if (pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
} catch {
|
|
64
|
+
// PID is dead — stale session
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// No PID in server-info — can't verify, skip
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { sessionId, sessionDir, serverInfo };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pushScreen(html, { slot, filename, label } = {}) {
|
|
79
|
+
const active = this.getActive();
|
|
80
|
+
if (!active) throw new Error('No active session found');
|
|
81
|
+
const { sessionDir } = active;
|
|
82
|
+
|
|
83
|
+
let filePath;
|
|
84
|
+
if (slot !== undefined) {
|
|
85
|
+
const slotDir = path.join(sessionDir, `slot-${slot}`);
|
|
86
|
+
fs.mkdirSync(slotDir, { recursive: true });
|
|
87
|
+
filePath = path.join(slotDir, 'current.html');
|
|
88
|
+
if (label !== undefined) {
|
|
89
|
+
fs.writeFileSync(path.join(slotDir, '.label'), String(label), 'utf8');
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
filePath = path.join(sessionDir, filename || `screen-${Date.now()}.html`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(filePath, html, 'utf8');
|
|
96
|
+
return { path: filePath };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
readEvents() {
|
|
100
|
+
const active = this.getActive();
|
|
101
|
+
if (!active) return [];
|
|
102
|
+
const eventsPath = path.join(active.sessionDir, '.events');
|
|
103
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(eventsPath, 'utf8');
|
|
107
|
+
return raw
|
|
108
|
+
.split('\n')
|
|
109
|
+
.filter(line => line.trim())
|
|
110
|
+
.map(line => JSON.parse(line));
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clearEvents() {
|
|
117
|
+
const active = this.getActive();
|
|
118
|
+
if (!active) return;
|
|
119
|
+
const eventsPath = path.join(active.sessionDir, '.events');
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(eventsPath, '', 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore if file doesn't exist
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
clearSlot(slot) {
|
|
128
|
+
const active = this.getActive();
|
|
129
|
+
if (!active) return;
|
|
130
|
+
const slotFile = path.join(active.sessionDir, `slot-${slot}`, 'current.html');
|
|
131
|
+
try {
|
|
132
|
+
fs.rmSync(slotFile);
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore if not found
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clearAll() {
|
|
139
|
+
const active = this.getActive();
|
|
140
|
+
if (!active) return;
|
|
141
|
+
const { sessionDir } = active;
|
|
142
|
+
|
|
143
|
+
// Remove top-level .html files
|
|
144
|
+
try {
|
|
145
|
+
const entries = fs.readdirSync(sessionDir, { withFileTypes: true });
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
148
|
+
fs.rmSync(path.join(sessionDir, entry.name));
|
|
149
|
+
}
|
|
150
|
+
// Remove slot-*/current.html
|
|
151
|
+
if (entry.isDirectory() && entry.name.startsWith('slot-')) {
|
|
152
|
+
const slotCurrent = path.join(sessionDir, entry.name, 'current.html');
|
|
153
|
+
try {
|
|
154
|
+
fs.rmSync(slotCurrent);
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getStatus() {
|
|
166
|
+
const active = this.getActive();
|
|
167
|
+
if (!active) return null;
|
|
168
|
+
const { sessionId, sessionDir, serverInfo } = active;
|
|
169
|
+
|
|
170
|
+
// Gather slots
|
|
171
|
+
const slots = [];
|
|
172
|
+
try {
|
|
173
|
+
const entries = fs.readdirSync(sessionDir, { withFileTypes: true });
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (entry.isDirectory() && entry.name.startsWith('slot-')) {
|
|
176
|
+
const slotId = entry.name.replace(/^slot-/, '');
|
|
177
|
+
const slotDir = path.join(sessionDir, entry.name);
|
|
178
|
+
const labelPath = path.join(slotDir, '.label');
|
|
179
|
+
const hasContent = fs.existsSync(path.join(slotDir, 'current.html'));
|
|
180
|
+
let label = null;
|
|
181
|
+
if (fs.existsSync(labelPath)) {
|
|
182
|
+
try { label = fs.readFileSync(labelPath, 'utf8'); } catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
slots.push({ slot: slotId, label, hasContent });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// ignore
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Count events
|
|
192
|
+
const events = this.readEvents();
|
|
193
|
+
const eventCount = events.length;
|
|
194
|
+
|
|
195
|
+
// Uptime and URL from serverInfo
|
|
196
|
+
const uptime = serverInfo && serverInfo.startedAt
|
|
197
|
+
? Date.now() - serverInfo.startedAt
|
|
198
|
+
: null;
|
|
199
|
+
const url = serverInfo && serverInfo.url ? serverInfo.url : null;
|
|
200
|
+
|
|
201
|
+
return { sessionId, sessionDir, slots, eventCount, uptime, url };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
cleanup() {
|
|
205
|
+
const active = this.getActive();
|
|
206
|
+
if (!active) return;
|
|
207
|
+
try {
|
|
208
|
+
fs.rmSync(active.sessionDir, { recursive: true, force: true });
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { SessionManager };
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const WS_URL = 'ws://' + window.location.host;
|
|
3
|
+
let ws = null;
|
|
4
|
+
let eventQueue = [];
|
|
5
|
+
let currentView = 'side-by-side';
|
|
6
|
+
let activeSlot = null;
|
|
7
|
+
let preferred = null;
|
|
8
|
+
let slots = [];
|
|
9
|
+
|
|
10
|
+
// -------------------------------------------------------------------------
|
|
11
|
+
// WebSocket
|
|
12
|
+
// -------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function connect() {
|
|
15
|
+
ws = new WebSocket(WS_URL);
|
|
16
|
+
ws.onopen = () => {
|
|
17
|
+
document.getElementById('status').textContent = 'Connected';
|
|
18
|
+
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
|
19
|
+
eventQueue = [];
|
|
20
|
+
};
|
|
21
|
+
ws.onmessage = (msg) => {
|
|
22
|
+
let data;
|
|
23
|
+
try { data = JSON.parse(msg.data); } catch { return; }
|
|
24
|
+
|
|
25
|
+
if (data.type === 'reload') {
|
|
26
|
+
// Reload all iframes
|
|
27
|
+
reloadAllIframes();
|
|
28
|
+
} else if (data.type === 'slot-content') {
|
|
29
|
+
// Reload a specific slot iframe
|
|
30
|
+
reloadSlotIframe(data.slot);
|
|
31
|
+
} else if (data.type === 'slots-update') {
|
|
32
|
+
// Re-initialize slots
|
|
33
|
+
if (data.slots) initSlots(data.slots);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
ws.onclose = () => {
|
|
37
|
+
document.getElementById('status').textContent = 'Reconnecting…';
|
|
38
|
+
setTimeout(connect, 1000);
|
|
39
|
+
};
|
|
40
|
+
ws.onerror = () => {
|
|
41
|
+
document.getElementById('status').textContent = 'Disconnected';
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sendEvent(event) {
|
|
46
|
+
event.timestamp = Date.now();
|
|
47
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
48
|
+
ws.send(JSON.stringify(event));
|
|
49
|
+
} else {
|
|
50
|
+
eventQueue.push(event);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -------------------------------------------------------------------------
|
|
55
|
+
// Iframe helpers
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function reloadAllIframes() {
|
|
59
|
+
slots.forEach(s => reloadSlotIframe(s.id));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function reloadSlotIframe(slotId) {
|
|
63
|
+
const iframe = document.getElementById('iframe-' + slotId);
|
|
64
|
+
if (iframe) {
|
|
65
|
+
iframe.src = '/slot/' + slotId + '?t=' + Date.now();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
// Slot initialization
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function initSlots(newSlots) {
|
|
74
|
+
slots = newSlots;
|
|
75
|
+
buildTabBar();
|
|
76
|
+
buildPanels();
|
|
77
|
+
buildPrefButtons();
|
|
78
|
+
|
|
79
|
+
if (slots.length > 0) {
|
|
80
|
+
if (!activeSlot || !slots.find(s => s.id === activeSlot)) {
|
|
81
|
+
activeSlot = slots[0].id;
|
|
82
|
+
}
|
|
83
|
+
applyView();
|
|
84
|
+
updateTabHighlight();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildTabBar() {
|
|
89
|
+
const tabBar = document.getElementById('tab-bar');
|
|
90
|
+
tabBar.innerHTML = '';
|
|
91
|
+
slots.forEach((slot, i) => {
|
|
92
|
+
const btn = document.createElement('button');
|
|
93
|
+
btn.className = 'tab' + (slot.id === activeSlot ? ' active' : '');
|
|
94
|
+
btn.dataset.slot = slot.id;
|
|
95
|
+
btn.innerHTML =
|
|
96
|
+
'<span class="slot-letter">' + slot.id.toUpperCase() + '</span>' +
|
|
97
|
+
(slot.label ? '<span class="slot-label">' + escapeHtml(slot.label) + '</span>' : '');
|
|
98
|
+
btn.title = 'Slot ' + slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '') + ' (key: ' + (i + 1) + ' or ' + slot.id + ')';
|
|
99
|
+
btn.addEventListener('click', () => switchSlot(slot.id));
|
|
100
|
+
tabBar.appendChild(btn);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildPanels() {
|
|
105
|
+
const panelsEl = document.getElementById('panels');
|
|
106
|
+
panelsEl.innerHTML = '';
|
|
107
|
+
slots.forEach(slot => {
|
|
108
|
+
const panel = document.createElement('div');
|
|
109
|
+
panel.className = 'panel';
|
|
110
|
+
panel.id = 'panel-' + slot.id;
|
|
111
|
+
|
|
112
|
+
const header = document.createElement('div');
|
|
113
|
+
header.className = 'panel-header';
|
|
114
|
+
header.textContent = 'Slot ' + slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '');
|
|
115
|
+
|
|
116
|
+
const iframe = document.createElement('iframe');
|
|
117
|
+
iframe.id = 'iframe-' + slot.id;
|
|
118
|
+
iframe.src = '/slot/' + slot.id;
|
|
119
|
+
iframe.title = 'Slot ' + slot.id.toUpperCase();
|
|
120
|
+
|
|
121
|
+
panel.appendChild(header);
|
|
122
|
+
panel.appendChild(iframe);
|
|
123
|
+
panelsEl.appendChild(panel);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildPrefButtons() {
|
|
128
|
+
const bar = document.getElementById('preference-bar');
|
|
129
|
+
// Remove old pref buttons (keep the label span and keyboard hint)
|
|
130
|
+
const oldBtns = bar.querySelectorAll('.pref-btn, .pref-result');
|
|
131
|
+
oldBtns.forEach(el => el.remove());
|
|
132
|
+
|
|
133
|
+
// Insert buttons after the label span
|
|
134
|
+
const label = bar.querySelector('span');
|
|
135
|
+
slots.forEach(slot => {
|
|
136
|
+
const btn = document.createElement('button');
|
|
137
|
+
btn.className = 'pref-btn' + (preferred === slot.id ? ' selected' : '');
|
|
138
|
+
btn.dataset.slot = slot.id;
|
|
139
|
+
btn.textContent = slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '');
|
|
140
|
+
btn.addEventListener('click', () => setPreferred(slot.id));
|
|
141
|
+
label.after(btn);
|
|
142
|
+
label.parentNode.insertBefore(btn, label.nextSibling);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
// View / slot switching
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function switchSlot(slotId) {
|
|
151
|
+
activeSlot = slotId;
|
|
152
|
+
updateTabHighlight();
|
|
153
|
+
if (currentView === 'single') {
|
|
154
|
+
applyView();
|
|
155
|
+
}
|
|
156
|
+
sendEvent({ type: 'tab-switch', slot: slotId });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function switchToSlotByIndex(index) {
|
|
160
|
+
if (index >= 0 && index < slots.length) {
|
|
161
|
+
switchSlot(slots[index].id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function updateTabHighlight() {
|
|
166
|
+
document.querySelectorAll('#tab-bar .tab').forEach(tab => {
|
|
167
|
+
tab.classList.toggle('active', tab.dataset.slot === activeSlot);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setView(mode) {
|
|
172
|
+
currentView = mode;
|
|
173
|
+
document.querySelectorAll('.view-btn').forEach(btn => {
|
|
174
|
+
btn.classList.toggle('active', btn.dataset.view === mode);
|
|
175
|
+
});
|
|
176
|
+
applyView();
|
|
177
|
+
sendEvent({ type: 'view-change', mode });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function toggleView() {
|
|
181
|
+
setView(currentView === 'side-by-side' ? 'single' : 'side-by-side');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function applyView() {
|
|
185
|
+
if (currentView === 'side-by-side') {
|
|
186
|
+
document.querySelectorAll('#panels .panel').forEach(panel => {
|
|
187
|
+
panel.classList.remove('hidden');
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
// Single view: show only active slot
|
|
191
|
+
document.querySelectorAll('#panels .panel').forEach(panel => {
|
|
192
|
+
const isActive = panel.id === 'panel-' + activeSlot;
|
|
193
|
+
panel.classList.toggle('hidden', !isActive);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setPreferred(slotId) {
|
|
199
|
+
preferred = slotId;
|
|
200
|
+
document.querySelectorAll('.pref-btn').forEach(btn => {
|
|
201
|
+
btn.classList.toggle('selected', btn.dataset.slot === slotId);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Show result text
|
|
205
|
+
const bar = document.getElementById('preference-bar');
|
|
206
|
+
let resultEl = bar.querySelector('.pref-result');
|
|
207
|
+
if (!resultEl) {
|
|
208
|
+
resultEl = document.createElement('div');
|
|
209
|
+
resultEl.className = 'pref-result';
|
|
210
|
+
const hint = bar.querySelector('.keyboard-hint');
|
|
211
|
+
if (hint) bar.insertBefore(resultEl, hint);
|
|
212
|
+
else bar.appendChild(resultEl);
|
|
213
|
+
}
|
|
214
|
+
const slot = slots.find(s => s.id === slotId);
|
|
215
|
+
const label = slot && slot.label ? slot.label : 'Slot ' + slotId.toUpperCase();
|
|
216
|
+
resultEl.textContent = label + ' preferred — return to terminal to continue';
|
|
217
|
+
|
|
218
|
+
sendEvent({ type: 'preference', choice: slotId, label });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
// Keyboard shortcuts
|
|
223
|
+
// -------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
document.addEventListener('keydown', (e) => {
|
|
226
|
+
// Don't intercept when typing in inputs
|
|
227
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
228
|
+
|
|
229
|
+
if (e.key === 'Tab') {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
toggleView();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (e.key >= '1' && e.key <= '9') {
|
|
235
|
+
switchToSlotByIndex(parseInt(e.key, 10) - 1);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const lower = e.key.toLowerCase();
|
|
239
|
+
if ('abcdefghijklmnopqrstuvwxyz'.includes(lower) && lower.length === 1) {
|
|
240
|
+
if (slots.find(s => s.id === lower)) {
|
|
241
|
+
switchSlot(lower);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
// View toggle buttons
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
document.addEventListener('click', (e) => {
|
|
251
|
+
const btn = e.target.closest('.view-btn');
|
|
252
|
+
if (btn) setView(btn.dataset.view);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
// Utility
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function escapeHtml(str) {
|
|
260
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
// Boot: fetch slots from /api/status, then connect WebSocket
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
fetch('/api/status')
|
|
268
|
+
.then(r => r.json())
|
|
269
|
+
.then(data => {
|
|
270
|
+
if (data.slots && data.slots.length > 0) {
|
|
271
|
+
initSlots(data.slots);
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
.catch(() => {});
|
|
275
|
+
|
|
276
|
+
connect();
|
|
277
|
+
})();
|