command_deck 0.1.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +17 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +141 -0
- data/Rakefile +12 -0
- data/TAGS +521 -0
- data/app/controllers/command_deck/actions_controller.rb +18 -0
- data/app/controllers/command_deck/assets_controller.rb +48 -0
- data/app/controllers/command_deck/base_controller.rb +17 -0
- data/app/controllers/command_deck/panels_controller.rb +108 -0
- data/config/routes.rb +9 -0
- data/lib/command_deck/assets/css/main.css +393 -0
- data/lib/command_deck/assets/js/core/api.js +20 -0
- data/lib/command_deck/assets/js/core/dom.js +33 -0
- data/lib/command_deck/assets/js/core/store.js +13 -0
- data/lib/command_deck/assets/js/main.js +39 -0
- data/lib/command_deck/assets/js/ui/overlay.js +305 -0
- data/lib/command_deck/assets/js/ui/panel_selector.js +32 -0
- data/lib/command_deck/assets/js/ui/position_manager.js +26 -0
- data/lib/command_deck/assets/js/ui/settings_dropdown.js +64 -0
- data/lib/command_deck/assets/js/ui/theme_manager.js +34 -0
- data/lib/command_deck/engine.rb +28 -0
- data/lib/command_deck/executor.rb +70 -0
- data/lib/command_deck/injector.rb +28 -0
- data/lib/command_deck/middleware.rb +57 -0
- data/lib/command_deck/railtie.rb +13 -0
- data/lib/command_deck/registry.rb +103 -0
- data/lib/command_deck/version.rb +5 -0
- data/lib/command_deck.rb +14 -0
- data/public/img/demo.png +0 -0
- data/sig/command_deck.rbs +4 -0
- metadata +110 -0
@@ -0,0 +1,305 @@
|
|
1
|
+
import { el, jsonPretty } from '../core/dom.js';
|
2
|
+
import { store } from '../core/store.js';
|
3
|
+
import { PositionManager } from './position_manager.js';
|
4
|
+
import { ThemeManager } from './theme_manager.js';
|
5
|
+
|
6
|
+
export class Overlay {
|
7
|
+
constructor({ mount, panelsApi, actionsApi }) {
|
8
|
+
this.mount = mount;
|
9
|
+
this.panelsApi = panelsApi;
|
10
|
+
this.actionsApi = actionsApi;
|
11
|
+
this._loaded = false;
|
12
|
+
}
|
13
|
+
|
14
|
+
refreshCurrentTab() {
|
15
|
+
// Re-fetch panels and re-render current panel/tab without changing selection
|
16
|
+
this.panelsApi.fetchPanels().then((data) => {
|
17
|
+
const panels = (data && data.panels) || [];
|
18
|
+
this.panels = panels;
|
19
|
+
if (this.panelSelector) this.panelSelector.setPanels(this.panels);
|
20
|
+
const panel = this.panels.find(p => p.key === this.currentPanelKey) || this.panels[0];
|
21
|
+
if (!panel) return;
|
22
|
+
if (this.panelSelector) this.panelSelector.setActiveKey(panel.key);
|
23
|
+
this.renderTabs(panel, this.currentTabIndex || 0);
|
24
|
+
}).catch(() => {/* ignore refresh errors */});
|
25
|
+
}
|
26
|
+
|
27
|
+
init() {
|
28
|
+
if (this._loaded) return; this._loaded = true;
|
29
|
+
// Root pieces
|
30
|
+
this.toggle = el('div', { id: 'command-deck-toggle', title: 'Command Deck' }, ['CD']);
|
31
|
+
this.panel = el('div', { id: 'command-deck-panel', style: { display: 'none' } });
|
32
|
+
// Keep these nodes across Turbo Drive navigations
|
33
|
+
this.toggle.setAttribute('data-turbo-permanent', '');
|
34
|
+
this.panel.setAttribute('data-turbo-permanent', '');
|
35
|
+
|
36
|
+
const headerWrap = el('div', { id: 'cd-header' });
|
37
|
+
const title = el('h4', null, ['Command Deck']);
|
38
|
+
headerWrap.appendChild(title);
|
39
|
+
const headerRight = el('div', { id: 'cd-header-right' });
|
40
|
+
headerWrap.appendChild(headerRight);
|
41
|
+
|
42
|
+
this.tabsWrap = el('div', { id: 'cd-tabs-wrap' });
|
43
|
+
this.actionsWrap = el('div', { id: 'cd-actions-wrap' });
|
44
|
+
this.resultPre = el('pre', { id: 'command-deck-output' });
|
45
|
+
|
46
|
+
this.panel.appendChild(headerWrap);
|
47
|
+
this.panel.appendChild(this.tabsWrap);
|
48
|
+
this.panel.appendChild(this.actionsWrap);
|
49
|
+
this.panel.appendChild(this.resultPre);
|
50
|
+
|
51
|
+
document.body.appendChild(this.toggle);
|
52
|
+
document.body.appendChild(this.panel);
|
53
|
+
|
54
|
+
this.toggle.addEventListener('click', () => {
|
55
|
+
this.panel.style.display = (this.panel.style.display === 'none' || !this.panel.style.display) ? 'block' : 'none';
|
56
|
+
});
|
57
|
+
|
58
|
+
this.position = new PositionManager(this.toggle, this.panel);
|
59
|
+
this.position.set(this.position.load());
|
60
|
+
|
61
|
+
this.theme = new ThemeManager(this.toggle, this.panel);
|
62
|
+
this.theme.set(this.theme.load());
|
63
|
+
}
|
64
|
+
|
65
|
+
attachSettings(dropdown) {
|
66
|
+
// Append settings into right side of header
|
67
|
+
const headerRight = this.panel.querySelector('#cd-header-right');
|
68
|
+
dropdown.mount(headerRight);
|
69
|
+
if (this.theme) dropdown.setTheme && dropdown.setTheme(this.theme.load());
|
70
|
+
}
|
71
|
+
|
72
|
+
attachPanelSelector(selector) {
|
73
|
+
this.panelSelector = selector;
|
74
|
+
const headerRight = this.panel.querySelector('#cd-header-right');
|
75
|
+
selector.mount(headerRight);
|
76
|
+
}
|
77
|
+
|
78
|
+
loadPanels() {
|
79
|
+
return this.panelsApi.fetchPanels().then(data => this.setPanels(data));
|
80
|
+
}
|
81
|
+
|
82
|
+
setPanels(data) {
|
83
|
+
this.tabsWrap.textContent = '';
|
84
|
+
this.actionsWrap.textContent = '';
|
85
|
+
this.panels = (data && data.panels) || [];
|
86
|
+
if (!this.panels.length) {
|
87
|
+
this.tabsWrap.appendChild(el('div', null, ['No panels defined. Add files under app/command_deck/**/*.rb']))
|
88
|
+
return;
|
89
|
+
}
|
90
|
+
// Initialize selector
|
91
|
+
if (this.panelSelector) {
|
92
|
+
this.panelSelector.setPanels(this.panels);
|
93
|
+
}
|
94
|
+
const saved = store.get('panel-key');
|
95
|
+
const exists = this.panels.find(p => p.key === saved);
|
96
|
+
const key = exists ? saved : this.panels[0].key;
|
97
|
+
this.selectPanel(key);
|
98
|
+
}
|
99
|
+
|
100
|
+
selectPanel(key) {
|
101
|
+
const panel = (this.panels || []).find(p => p.key === key);
|
102
|
+
if (!panel) return;
|
103
|
+
this.currentPanelKey = key;
|
104
|
+
store.set('panel-key', key);
|
105
|
+
if (this.panelSelector) this.panelSelector.setActiveKey(key);
|
106
|
+
|
107
|
+
const headerTitle = this.panel.querySelector('#cd-header h4');
|
108
|
+
headerTitle.textContent = 'Command Deck';
|
109
|
+
this.renderTabs(panel, this.currentTabIndex || 0);
|
110
|
+
}
|
111
|
+
|
112
|
+
renderTabs(panel, selectedIndex = 0) {
|
113
|
+
this.tabsWrap.textContent = '';
|
114
|
+
this.actionsWrap.textContent = '';
|
115
|
+
this.currentTabIndex = selectedIndex;
|
116
|
+
(panel.tabs || []).forEach((tab, i) => {
|
117
|
+
const btn = el('button', { class: 'cd-tab-btn' }, [tab.title]);
|
118
|
+
btn.addEventListener('click', () => { this.currentTabIndex = i; this.renderActions(tab); });
|
119
|
+
this.tabsWrap.appendChild(btn);
|
120
|
+
if (i === selectedIndex) this.renderActions(tab);
|
121
|
+
});
|
122
|
+
}
|
123
|
+
|
124
|
+
renderActions(tab) {
|
125
|
+
this.actionsWrap.textContent = '';
|
126
|
+
(tab.actions || []).forEach(action => {
|
127
|
+
const box = el('div', { class: 'cd-action' });
|
128
|
+
box.appendChild(el('div', { class: 'cd-action-title' }, [action.title]));
|
129
|
+
const form = el('div', { class: 'cd-form' });
|
130
|
+
|
131
|
+
const inputs = {};
|
132
|
+
const paramsMeta = {};
|
133
|
+
(action.params || []).forEach(p => {
|
134
|
+
const labelChildren = [p.label || p.name];
|
135
|
+
if (p.required === true) {
|
136
|
+
labelChildren.push(el('span', { class: 'cd-required', title: 'Required' }, ['*']));
|
137
|
+
}
|
138
|
+
const label = el('label', null, labelChildren);
|
139
|
+
let input;
|
140
|
+
if (p.type === 'boolean') {
|
141
|
+
input = el('input', { type: 'checkbox' });
|
142
|
+
} else if (p.type === 'integer') {
|
143
|
+
input = el('input', { type: 'number', step: '1' });
|
144
|
+
} else if (p.type === 'selector' || (p.choices && p.choices.length)) {
|
145
|
+
input = el('select');
|
146
|
+
if (p.include_blank) {
|
147
|
+
input.appendChild(el('option', { value: '' }, ['']));
|
148
|
+
}
|
149
|
+
(p.choices || []).forEach(ch => {
|
150
|
+
const attrs = { 'data-val': JSON.stringify(ch.value) };
|
151
|
+
if (ch.meta != null) attrs['data-meta'] = JSON.stringify(ch.meta);
|
152
|
+
const opt = el('option', attrs, [ch.label != null ? String(ch.label) : String(ch.value)]);
|
153
|
+
input.appendChild(opt);
|
154
|
+
});
|
155
|
+
|
156
|
+
// Restore last selection (per panel/action/param)
|
157
|
+
const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${p.name}`;
|
158
|
+
const savedRaw = store.get(selKey);
|
159
|
+
if (savedRaw != null) {
|
160
|
+
let matched = false;
|
161
|
+
for (let i = 0; i < input.options.length; i++) {
|
162
|
+
const o = input.options[i];
|
163
|
+
const raw = o.getAttribute('data-val');
|
164
|
+
if (raw === savedRaw) {
|
165
|
+
input.selectedIndex = i;
|
166
|
+
matched = true;
|
167
|
+
break;
|
168
|
+
}
|
169
|
+
}
|
170
|
+
if (!matched && p.include_blank && savedRaw === '') {
|
171
|
+
for (let i = 0; i < input.options.length; i++) {
|
172
|
+
if (input.options[i].value === '') { input.selectedIndex = i; break; }
|
173
|
+
}
|
174
|
+
}
|
175
|
+
}
|
176
|
+
} else {
|
177
|
+
input = el('input', { type: 'text' });
|
178
|
+
}
|
179
|
+
if (p.required === true && input) {
|
180
|
+
input.setAttribute('aria-required', 'true');
|
181
|
+
input.setAttribute('required', '');
|
182
|
+
}
|
183
|
+
form.appendChild(label);
|
184
|
+
form.appendChild(input);
|
185
|
+
|
186
|
+
// Optional hint below inputs (used for selector current value display)
|
187
|
+
let hint;
|
188
|
+
if (input.tagName === 'SELECT') {
|
189
|
+
hint = el('div', { class: 'cd-param-hint' });
|
190
|
+
const updateHint = () => {
|
191
|
+
const sel = input.options[input.selectedIndex];
|
192
|
+
if (!sel) { hint.textContent = ''; return; }
|
193
|
+
const metaRaw = sel.getAttribute('data-meta');
|
194
|
+
if (!metaRaw) { hint.textContent = ''; return; }
|
195
|
+
try {
|
196
|
+
const meta = JSON.parse(metaRaw);
|
197
|
+
if (typeof meta.enabled === 'boolean') {
|
198
|
+
const status = meta.enabled ? 'ON' : 'OFF';
|
199
|
+
hint.textContent = 'Current: ';
|
200
|
+
const badge = el('span', { class: `cd-badge ${meta.enabled ? 'cd-on' : 'cd-off'}` }, [status]);
|
201
|
+
hint.appendChild(badge);
|
202
|
+
} else {
|
203
|
+
hint.textContent = '';
|
204
|
+
}
|
205
|
+
} catch(_) { hint.textContent = ''; }
|
206
|
+
};
|
207
|
+
input.addEventListener('change', () => {
|
208
|
+
// Persist selection
|
209
|
+
const sel = input.options[input.selectedIndex];
|
210
|
+
const raw = sel ? sel.getAttribute('data-val') : '';
|
211
|
+
const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${p.name}`;
|
212
|
+
store.set(selKey, raw || '');
|
213
|
+
updateHint();
|
214
|
+
validate();
|
215
|
+
});
|
216
|
+
// Initialize after options are appended
|
217
|
+
setTimeout(updateHint, 0);
|
218
|
+
form.appendChild(hint);
|
219
|
+
}
|
220
|
+
|
221
|
+
inputs[p.name] = input;
|
222
|
+
paramsMeta[p.name] = p;
|
223
|
+
});
|
224
|
+
|
225
|
+
// Validation helpers
|
226
|
+
const requiredParams = (action.params || []).filter(p => p.required === true);
|
227
|
+
const isFilled = (p) => {
|
228
|
+
const inp = inputs[p.name];
|
229
|
+
if (!inp) return false;
|
230
|
+
if (p.type === 'integer') return inp.value !== '';
|
231
|
+
if (p.type === 'selector' || (p.choices && p.choices.length)) {
|
232
|
+
if (p.include_blank) return inp.value !== '' && inp.selectedIndex > -1;
|
233
|
+
return inp.selectedIndex > -1; // any selection counts
|
234
|
+
}
|
235
|
+
if (p.type === 'boolean') return true; // checkbox always provides a value
|
236
|
+
// string or others
|
237
|
+
return String(inp.value || '').trim() !== '';
|
238
|
+
};
|
239
|
+
const validate = () => {
|
240
|
+
const ok = requiredParams.every(isFilled);
|
241
|
+
const running = run.classList.contains('loading');
|
242
|
+
run.disabled = !ok || running;
|
243
|
+
};
|
244
|
+
|
245
|
+
const run = el('button', { class: 'cd-run', title: 'Run' }, ['\u25B6']); // ▶
|
246
|
+
run.addEventListener('click', () => {
|
247
|
+
if (run.disabled) return;
|
248
|
+
run.classList.add('loading');
|
249
|
+
run.setAttribute('aria-busy', 'true');
|
250
|
+
run.disabled = true;
|
251
|
+
const payload = {};
|
252
|
+
Object.keys(inputs).forEach(k => {
|
253
|
+
const inp = inputs[k];
|
254
|
+
const meta = paramsMeta[k] || {};
|
255
|
+
if (inp.tagName === 'SELECT') {
|
256
|
+
const sel = inp.options[inp.selectedIndex];
|
257
|
+
const raw = sel && sel.getAttribute('data-val');
|
258
|
+
const label = sel ? sel.textContent : '';
|
259
|
+
let value;
|
260
|
+
try { value = raw != null ? JSON.parse(raw) : inp.value; } catch(_) { value = inp.value; }
|
261
|
+
|
262
|
+
if (meta.return === 'label') {
|
263
|
+
payload[k] = label;
|
264
|
+
} else if (meta.return === 'both' || meta.return === 'object') {
|
265
|
+
payload[k] = { label: label, value: value };
|
266
|
+
} else {
|
267
|
+
payload[k] = value; // default: value
|
268
|
+
}
|
269
|
+
|
270
|
+
// Persist selection on run as well
|
271
|
+
const selKey = `sel:${this.currentPanelKey || ''}:${action.key}:${k}`;
|
272
|
+
store.set(selKey, raw || '');
|
273
|
+
} else {
|
274
|
+
payload[k] = (inp.type === 'checkbox') ? !!inp.checked : inp.value;
|
275
|
+
}
|
276
|
+
});
|
277
|
+
this.setRunning();
|
278
|
+
this.actionsApi
|
279
|
+
.submit(action.key, payload)
|
280
|
+
.then((res) => { this.setResult(res); this.refreshCurrentTab(); })
|
281
|
+
.catch((e) => this.setResult({ ok:false, error: String(e) }))
|
282
|
+
.finally(() => { run.classList.remove('loading'); run.removeAttribute('aria-busy'); validate(); });
|
283
|
+
});
|
284
|
+
const actionsRow = el('div', { class: 'cd-actions-row' });
|
285
|
+
actionsRow.appendChild(run);
|
286
|
+
form.appendChild(actionsRow);
|
287
|
+
|
288
|
+
box.appendChild(form);
|
289
|
+
this.actionsWrap.appendChild(box);
|
290
|
+
|
291
|
+
// Hook up validation listeners
|
292
|
+
(action.params || []).forEach(p => {
|
293
|
+
const inp = inputs[p.name];
|
294
|
+
if (!inp) return;
|
295
|
+
const evt = (inp.tagName === 'SELECT' || inp.type === 'checkbox') ? 'change' : 'input';
|
296
|
+
inp.addEventListener(evt, validate);
|
297
|
+
});
|
298
|
+
// Initial state
|
299
|
+
validate();
|
300
|
+
});
|
301
|
+
}
|
302
|
+
|
303
|
+
setRunning(){ this.resultPre.textContent = 'Running...'; }
|
304
|
+
setResult(obj){ this.resultPre.textContent = jsonPretty(obj); }
|
305
|
+
}
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import { el } from '../core/dom.js';
|
2
|
+
|
3
|
+
export class PanelSelector {
|
4
|
+
constructor(onSelect) {
|
5
|
+
this.onSelect = onSelect;
|
6
|
+
this.root = el('div', { id: 'cd-panel-selector' });
|
7
|
+
this.select = el('select');
|
8
|
+
this.root.appendChild(this.select);
|
9
|
+
|
10
|
+
this.select.addEventListener('change', () => {
|
11
|
+
const key = this.select.value;
|
12
|
+
this.onSelect && this.onSelect(key);
|
13
|
+
});
|
14
|
+
}
|
15
|
+
|
16
|
+
mount(container) { container.appendChild(this.root); }
|
17
|
+
|
18
|
+
setPanels(panels) {
|
19
|
+
// panels: [{ key, title, owner, group }]
|
20
|
+
this.select.textContent = '';
|
21
|
+
(panels || []).forEach(p => {
|
22
|
+
const label = [p.group, p.title].filter(Boolean).join(' • ');
|
23
|
+
const opt = el('option', { value: p.key }, [label || p.title || p.key]);
|
24
|
+
this.select.appendChild(opt);
|
25
|
+
});
|
26
|
+
}
|
27
|
+
|
28
|
+
setActiveKey(key) {
|
29
|
+
if (!key) return;
|
30
|
+
this.select.value = key;
|
31
|
+
}
|
32
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import { store } from '../core/store.js';
|
2
|
+
|
3
|
+
const POSITIONS = ['tl','tr','bl','br'];
|
4
|
+
|
5
|
+
export class PositionManager {
|
6
|
+
constructor(toggle, panel) {
|
7
|
+
this.toggle = toggle;
|
8
|
+
this.panel = panel;
|
9
|
+
}
|
10
|
+
load() { return store.get('position', 'br'); }
|
11
|
+
save(pos) { store.set('position', pos); }
|
12
|
+
apply(pos) {
|
13
|
+
['cd-pos-tl','cd-pos-tr','cd-pos-bl','cd-pos-br'].forEach(c => {
|
14
|
+
this.toggle.classList.remove(c);
|
15
|
+
this.panel.classList.remove(c);
|
16
|
+
});
|
17
|
+
const cls = 'cd-pos-' + pos;
|
18
|
+
this.toggle.classList.add(cls);
|
19
|
+
this.panel.classList.add(cls);
|
20
|
+
}
|
21
|
+
set(pos) {
|
22
|
+
if (!POSITIONS.includes(pos)) return;
|
23
|
+
this.save(pos);
|
24
|
+
this.apply(pos);
|
25
|
+
}
|
26
|
+
}
|
@@ -0,0 +1,64 @@
|
|
1
|
+
import { el } from '../core/dom.js';
|
2
|
+
|
3
|
+
export class SettingsDropdown {
|
4
|
+
constructor(onSelectPosition, onThemeChange) {
|
5
|
+
this.onSelectPosition = onSelectPosition;
|
6
|
+
this.onThemeChange = onThemeChange;
|
7
|
+
|
8
|
+
this.root = el('div', { id: 'cd-settings-panel', style: { display: 'none' } });
|
9
|
+
this.button = el('button', { id: 'cd-settings-btn', title: 'Settings' }, ['\u2699']);
|
10
|
+
|
11
|
+
// Position section
|
12
|
+
const titlePos = el('div', { class: 'cd-settings-title' }, ['Position']);
|
13
|
+
const menu = el('div', { class: 'cd-menu' });
|
14
|
+
const items = [
|
15
|
+
['tl','\u2196 Top-left'],
|
16
|
+
['tr','\u2197 Top-right'],
|
17
|
+
['bl','\u2199 Bottom-left'],
|
18
|
+
['br','\u2198 Bottom-right']
|
19
|
+
];
|
20
|
+
items.forEach(([key, label]) => {
|
21
|
+
const item = el('button', { class: 'cd-menu-item', 'data-pos': key }, [label]);
|
22
|
+
item.addEventListener('click', (e) => { e.stopPropagation(); this.onSelectPosition(key); this.hide(); });
|
23
|
+
menu.appendChild(item);
|
24
|
+
});
|
25
|
+
|
26
|
+
// Theme toggle (no title/label; just icon button)
|
27
|
+
this.themeBtn = el('button', { id: 'cd-theme-toggle', title: 'Toggle theme' }, ['\u2600']); // sun
|
28
|
+
this.themeBtn.addEventListener('click', (e) => {
|
29
|
+
e.stopPropagation();
|
30
|
+
const next = (this.currentTheme === 'dark') ? 'light' : 'dark';
|
31
|
+
this.setTheme(next);
|
32
|
+
this.onThemeChange && this.onThemeChange(next);
|
33
|
+
});
|
34
|
+
|
35
|
+
this.root.appendChild(titlePos);
|
36
|
+
this.root.appendChild(menu);
|
37
|
+
this.root.appendChild(this.themeBtn);
|
38
|
+
|
39
|
+
this.button.addEventListener('click', (e) => {
|
40
|
+
e.stopPropagation();
|
41
|
+
this.toggle();
|
42
|
+
});
|
43
|
+
this.root.addEventListener('click', (e) => e.stopPropagation());
|
44
|
+
document.addEventListener('click', () => this.hide());
|
45
|
+
}
|
46
|
+
|
47
|
+
setTheme(theme) {
|
48
|
+
this.currentTheme = theme === 'dark' ? 'dark' : 'light';
|
49
|
+
if (this.themeBtn) this.themeBtn.textContent = (this.currentTheme === 'dark') ? '\u263D' : '\u2600';
|
50
|
+
// ☽ (U+263D) for moon, ☀ (U+2600) for sun
|
51
|
+
}
|
52
|
+
|
53
|
+
mount(container) {
|
54
|
+
container.appendChild(this.button);
|
55
|
+
container.appendChild(this.root);
|
56
|
+
}
|
57
|
+
|
58
|
+
toggle() {
|
59
|
+
const willOpen = (this.root.style.display === 'none' || !this.root.style.display);
|
60
|
+
this.root.style.display = willOpen ? 'block' : 'none';
|
61
|
+
this.button.classList.toggle('open', willOpen);
|
62
|
+
}
|
63
|
+
hide() { this.root.style.display = 'none'; this.button.classList.remove('open'); }
|
64
|
+
}
|
@@ -0,0 +1,34 @@
|
|
1
|
+
import { store } from '../core/store.js';
|
2
|
+
|
3
|
+
export class ThemeManager {
|
4
|
+
constructor(toggle, panel) {
|
5
|
+
this.toggle = toggle;
|
6
|
+
this.panel = panel;
|
7
|
+
}
|
8
|
+
|
9
|
+
load() {
|
10
|
+
const saved = store.get('theme');
|
11
|
+
if (saved) return saved;
|
12
|
+
try {
|
13
|
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
14
|
+
return 'dark';
|
15
|
+
}
|
16
|
+
} catch(_) {}
|
17
|
+
return 'light';
|
18
|
+
}
|
19
|
+
|
20
|
+
save(theme) { store.set('theme', theme); }
|
21
|
+
|
22
|
+
apply(theme) {
|
23
|
+
const isDark = theme === 'dark';
|
24
|
+
[this.toggle, this.panel].forEach(node => {
|
25
|
+
node.classList.toggle('cd-theme-dark', isDark);
|
26
|
+
});
|
27
|
+
}
|
28
|
+
|
29
|
+
set(theme) {
|
30
|
+
if (theme !== 'light' && theme !== 'dark') return;
|
31
|
+
this.save(theme);
|
32
|
+
this.apply(theme);
|
33
|
+
}
|
34
|
+
}
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
require_relative "middleware"
|
5
|
+
|
6
|
+
module CommandDeck
|
7
|
+
# Rails engine for Command Deck
|
8
|
+
class Engine < ::Rails::Engine
|
9
|
+
isolate_namespace CommandDeck
|
10
|
+
|
11
|
+
initializer "command_deck.mount_point" do
|
12
|
+
# Default mount point. If the host app has a relative_url_root, respect it.
|
13
|
+
mp = "/command_deck"
|
14
|
+
if defined?(Rails) && Rails.application.config.respond_to?(:relative_url_root)
|
15
|
+
root = Rails.application.config.relative_url_root
|
16
|
+
mp = File.join(root, mp) if root.present?
|
17
|
+
end
|
18
|
+
CommandDeck::Middleware.mount_point = mp
|
19
|
+
end
|
20
|
+
|
21
|
+
initializer "command_deck.middleware" do |app|
|
22
|
+
# Dev-only injection. Safe no-op in other environments.
|
23
|
+
if defined?(Rails) && Rails.env.development?
|
24
|
+
app.middleware.insert_after ActionDispatch::DebugExceptions, CommandDeck::Middleware
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CommandDeck
|
4
|
+
# Executor for running actions
|
5
|
+
class Executor
|
6
|
+
def self.call(key:, params:)
|
7
|
+
action = Registry.find_action(key)
|
8
|
+
raise ArgumentError, "Unknown action #{key}" unless action
|
9
|
+
|
10
|
+
coerced = coerce(action.params, params || {})
|
11
|
+
action.block.call(coerced, {})
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.coerce(schema, raw)
|
15
|
+
return {} if schema.nil? || schema.empty?
|
16
|
+
|
17
|
+
schema.each_with_object({}) do |param, out|
|
18
|
+
name = param[:name]
|
19
|
+
value = raw[name] || raw[name.to_s]
|
20
|
+
out[name] = coerce_value(param[:type], value)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.coerce_value(type, value)
|
25
|
+
case type
|
26
|
+
when :boolean then coerce_boolean(value)
|
27
|
+
when :integer then coerce_integer(value)
|
28
|
+
when :selector then coerce_selector(value)
|
29
|
+
else coerce_string(value)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.coerce_boolean(value)
|
34
|
+
boolean_true?(value)
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.coerce_integer(value)
|
38
|
+
integer_or_nil(value)
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.coerce_selector(value)
|
42
|
+
return symbolize_keys_shallow(value) if value.is_a?(Hash)
|
43
|
+
|
44
|
+
value
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.coerce_string(value)
|
48
|
+
value&.to_s
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.symbolize_keys_shallow(hash)
|
52
|
+
hash.each_with_object({}) do |(k, v), out|
|
53
|
+
key = k.respond_to?(:to_sym) ? k.to_sym : k
|
54
|
+
out[key] = v
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.boolean_true?(value)
|
59
|
+
return false if value.nil?
|
60
|
+
|
61
|
+
value == true || %w[true 1 on].include?(value.to_s.strip.downcase)
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.integer_or_nil(value)
|
65
|
+
return nil if value.nil? || value == ""
|
66
|
+
|
67
|
+
value.to_i
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Minimal response body injector adapted from the web-console project (MIT License).
|
4
|
+
# It safely appends content before </body> when present and adjusts Content-Length.
|
5
|
+
module CommandDeck
|
6
|
+
# Tiny middleware that injects a tiny floating UI into HTML responses.
|
7
|
+
class Injector
|
8
|
+
def initialize(body, headers)
|
9
|
+
@body = "".dup
|
10
|
+
body.each { |part| @body << part }
|
11
|
+
body.close if body.respond_to?(:close)
|
12
|
+
@headers = headers
|
13
|
+
end
|
14
|
+
|
15
|
+
def inject(content)
|
16
|
+
@headers[Rack::CONTENT_LENGTH] = (@body.bytesize + content.bytesize).to_s if @headers[Rack::CONTENT_LENGTH]
|
17
|
+
|
18
|
+
[
|
19
|
+
if (position = @body.rindex("</body>"))
|
20
|
+
[@body.insert(position, content)]
|
21
|
+
else
|
22
|
+
[@body << content]
|
23
|
+
end,
|
24
|
+
@headers
|
25
|
+
]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
require_relative "injector"
|
5
|
+
|
6
|
+
module CommandDeck
|
7
|
+
# Dev-only middleware that injects a tiny floating UI into HTML responses.
|
8
|
+
class Middleware
|
9
|
+
class << self
|
10
|
+
attr_accessor :mount_point
|
11
|
+
end
|
12
|
+
self.mount_point = "/command_deck"
|
13
|
+
|
14
|
+
def initialize(app)
|
15
|
+
@app = app
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(env)
|
19
|
+
status, headers, body = @app.call(env)
|
20
|
+
|
21
|
+
begin
|
22
|
+
return [status, headers, body] unless html_response?(headers)
|
23
|
+
return [status, headers, body] if engine_request?(env)
|
24
|
+
|
25
|
+
body, headers = Injector.new(body, headers).inject(overlay_snippet)
|
26
|
+
[status, headers, body]
|
27
|
+
rescue StandardError
|
28
|
+
# Fail open: on any injection error, return original response
|
29
|
+
[status, headers, body]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def html_response?(headers)
|
36
|
+
ctype = headers[Rack::CONTENT_TYPE].to_s
|
37
|
+
return true if ctype.empty?
|
38
|
+
|
39
|
+
ctype.include?("html")
|
40
|
+
end
|
41
|
+
|
42
|
+
def engine_request?(env)
|
43
|
+
path = env["PATH_INFO"].to_s
|
44
|
+
path.start_with?(self.class.mount_point)
|
45
|
+
end
|
46
|
+
|
47
|
+
def overlay_snippet
|
48
|
+
mp = self.class.mount_point
|
49
|
+
<<~HTML
|
50
|
+
<!-- Command Deck assets (ESM) -->
|
51
|
+
<link rel="stylesheet" href="#{mp}/assets/css/main.css" />
|
52
|
+
<script type="module" src="#{mp}/assets/js/main.js" data-mount="#{mp}"></script>
|
53
|
+
<!-- /Command Deck assets -->
|
54
|
+
HTML
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/railtie"
|
4
|
+
|
5
|
+
module CommandDeck
|
6
|
+
# Rails engine for Command Deck
|
7
|
+
class Railtie < ::Rails::Railtie
|
8
|
+
initializer "command_deck.load_panels", after: :load_config_initializers do
|
9
|
+
path = Rails.root.join("app/command_deck")
|
10
|
+
Dir[path.join("**/*.rb")].sort.each { |f| load f } if Dir.exist?(path)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|