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.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandDeck
4
+ # Controller for serving panels
5
+ class PanelsController < BaseController
6
+ def index
7
+ render json: { panels: serialize_panels }
8
+ end
9
+
10
+ private
11
+
12
+ def serialize_panels
13
+ Registry.panels.map { |panel| serialize_panel(panel) }
14
+ end
15
+
16
+ def serialize_panel(panel)
17
+ {
18
+ key: panel.key,
19
+ title: panel.title,
20
+ owner: panel.owner,
21
+ group: panel.group,
22
+ tabs: serialize_tabs(panel.tabs)
23
+ }
24
+ end
25
+
26
+ def serialize_tabs(tabs)
27
+ tabs.map { |tab| serialize_tab(tab) }
28
+ end
29
+
30
+ def serialize_tab(tab)
31
+ { title: tab.title, actions: serialize_actions(tab.actions) }
32
+ end
33
+
34
+ def serialize_actions(actions)
35
+ actions.map { |action| serialize_action(action) }
36
+ end
37
+
38
+ def serialize_action(action)
39
+ { title: action.title, key: action.key, params: serialize_params(action.params) }
40
+ end
41
+
42
+ def serialize_params(params)
43
+ (params || []).map { |p| serialize_param(p) }
44
+ end
45
+
46
+ def serialize_param(param)
47
+ base = base_param(param)
48
+
49
+ choices = extract_choices(param)
50
+ if choices
51
+ base[:choices] = normalize_choices(choices)
52
+ base[:include_blank] = param[:include_blank] if param.key?(:include_blank)
53
+ end
54
+
55
+ base[:return] = param[:return] if param.key?(:return)
56
+ base
57
+ end
58
+
59
+ def normalize_choices(choices)
60
+ arrayify(choices).map { |item| normalize_choice_item(item) }
61
+ end
62
+
63
+ def base_param(param)
64
+ {
65
+ name: param[:name],
66
+ type: param[:type].to_s,
67
+ label: param[:label] || param[:name].to_s.tr("_", " ").capitalize,
68
+ required: param.key?(:required) ? param[:required] : false
69
+ }
70
+ end
71
+
72
+ def extract_choices(param)
73
+ return param[:options] if param.key?(:options)
74
+
75
+ collection = param[:collection]
76
+ collection.respond_to?(:call) ? collection.call : nil
77
+ end
78
+
79
+ def arrayify(obj)
80
+ obj.is_a?(Array) ? obj : obj.to_a
81
+ end
82
+
83
+ def normalize_choice_item(item)
84
+ return normalize_choice_hash(item) if item.is_a?(Hash)
85
+ return normalize_choice_array(item) if item.is_a?(Array)
86
+
87
+ normalize_choice_default(item)
88
+ end
89
+
90
+ def normalize_choice_hash(item)
91
+ out = {}
92
+ out[:label] = item[:label].to_s if item.key?(:label)
93
+ out[:value] = item[:value] if item.key?(:value)
94
+ out[:meta] = item[:meta] if item.key?(:meta)
95
+ out
96
+ end
97
+
98
+ def normalize_choice_array(item)
99
+ return normalize_choice_default(item) if item.size < 2
100
+
101
+ { label: item[0].to_s, value: item[1] }
102
+ end
103
+
104
+ def normalize_choice_default(item)
105
+ { label: item.to_s, value: item }
106
+ end
107
+ end
108
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ CommandDeck::Engine.routes.draw do
4
+ get "/assets/js/*path", to: "assets#js"
5
+ get "/assets/css/*path", to: "assets#css"
6
+
7
+ get "/panels", to: "panels#index"
8
+ resources :actions, only: [:create]
9
+ end
@@ -0,0 +1,393 @@
1
+ /* ==========================================
2
+ Command Deck • main.css (scoped)
3
+ ========================================== */
4
+
5
+ /* ---------- Base & Panel ---------- */
6
+ #command-deck-toggle {
7
+ position: fixed;
8
+ z-index: 2147483000;
9
+ right: 16px;
10
+ bottom: 16px;
11
+ width: 44px;
12
+ height: 44px;
13
+ border-radius: 22px;
14
+ background: #111;
15
+ color: #fff;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ font: 600 14px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
20
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .25);
21
+ cursor: pointer;
22
+ }
23
+
24
+ /* Dark theme for toggle */
25
+ #command-deck-toggle.cd-theme-dark {
26
+ background: #000; /* full black */
27
+ color: #fff;
28
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .6);
29
+ }
30
+
31
+ #command-deck-panel {
32
+ position: fixed;
33
+ z-index: 2147483000;
34
+ right: 16px;
35
+ bottom: 72px;
36
+ width: 360px;
37
+ max-width: 92vw;
38
+ background: #fff;
39
+ color: #111;
40
+ border-radius: 12px;
41
+ box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
42
+ padding: 12px;
43
+ border: 1px solid rgba(0, 0, 0, .06);
44
+ }
45
+
46
+ /* Dark theme for panel */
47
+ #command-deck-panel.cd-theme-dark {
48
+ background: #000; /* full black */
49
+ color: #f1f1f1;
50
+ border-color: #222;
51
+ box-shadow: 0 12px 40px rgba(0,0,0,.7);
52
+ }
53
+
54
+ #command-deck-panel.cd-theme-dark #cd-tabs-wrap .cd-tab-btn,
55
+ #command-deck-panel.cd-theme-dark #cd-settings-btn,
56
+ #command-deck-panel.cd-theme-dark .cd-menu-item,
57
+ #command-deck-panel.cd-theme-dark .cd-form input[type="text"],
58
+ #command-deck-panel.cd-theme-dark .cd-form input[type="number"],
59
+ #command-deck-panel.cd-theme-dark .cd-form select,
60
+ #command-deck-panel.cd-theme-dark #cd-panel-selector select {
61
+ background: #111; /* near-black */
62
+ color: #eee;
63
+ border-color: #333;
64
+ }
65
+
66
+ #command-deck-panel.cd-theme-dark .cd-action { border-color: #222; }
67
+
68
+ /* Dark mode: improve readability of labels and placeholders */
69
+ #command-deck-panel.cd-theme-dark .cd-form label {
70
+ color: #cfcfcf;
71
+ }
72
+
73
+ #command-deck-panel.cd-theme-dark .cd-form input[type="text"],
74
+ #command-deck-panel.cd-theme-dark .cd-form input[type="number"] {
75
+ caret-color: #fff;
76
+ }
77
+
78
+ #command-deck-panel.cd-theme-dark .cd-form input[type="text"]::placeholder,
79
+ #command-deck-panel.cd-theme-dark .cd-form input[type="number"]::placeholder {
80
+ color: #9a9a9a;
81
+ opacity: 1; /* Ensure consistent placeholder contrast */
82
+ }
83
+
84
+ #command-deck-panel.cd-theme-dark #command-deck-output {
85
+ background: #0d0d0d;
86
+ color: #eee;
87
+ }
88
+
89
+ /* Theme toggle button */
90
+ #cd-theme-toggle {
91
+ border: 1px solid #ddd;
92
+ background: #f9f9f9;
93
+ border-radius: 6px;
94
+ padding: 4px 8px;
95
+ cursor: pointer;
96
+ margin-top: 8px;
97
+ }
98
+
99
+ #command-deck-panel.cd-theme-dark #cd-theme-toggle {
100
+ background: #111827;
101
+ color: #e5e7eb;
102
+ border-color: #374151;
103
+ }
104
+
105
+ #command-deck-panel * {
106
+ font: 500 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
107
+ }
108
+
109
+ /* ---------- Header & Settings ---------- */
110
+ #cd-header {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ gap: 8px;
115
+ position: relative;
116
+ }
117
+
118
+ #command-deck-panel h4 {
119
+ margin: 0 0 8px;
120
+ font-size: 14px;
121
+ }
122
+
123
+ #cd-settings-btn {
124
+ border: 1px solid #ddd;
125
+ background: #f9f9f9;
126
+ border-radius: 6px;
127
+ padding: 4px 8px;
128
+ cursor: pointer;
129
+ }
130
+
131
+ #cd-settings-btn.open {
132
+ background: #eef4ff;
133
+ border-color: #cfe0ff;
134
+ }
135
+
136
+ #cd-header-right {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 8px;
140
+ }
141
+
142
+ #cd-panel-selector select {
143
+ padding: 4px 8px;
144
+ border: 1px solid #ddd;
145
+ border-radius: 6px;
146
+ background: #fff;
147
+ }
148
+
149
+ #cd-settings-panel {
150
+ position: absolute;
151
+ top: 32px;
152
+ right: 0;
153
+ background: #fff;
154
+ border: 1px solid #e5e5e5;
155
+ border-radius: 8px;
156
+ box-shadow: 0 10px 28px rgba(0,0,0,.12);
157
+ padding: 8px;
158
+ z-index: 2147483001;
159
+ min-width: 180px;
160
+ }
161
+
162
+ /* Dark theme for settings dropdown */
163
+ #command-deck-panel.cd-theme-dark #cd-settings-panel {
164
+ background: #0d0d0d;
165
+ border-color: #333;
166
+ box-shadow: 0 10px 28px rgba(0,0,0,.6);
167
+ }
168
+
169
+ .cd-settings-title {
170
+ font-weight: 600;
171
+ margin-bottom: 6px;
172
+ }
173
+
174
+ .cd-menu {
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 6px;
178
+ }
179
+
180
+ .cd-menu-item {
181
+ text-align: left;
182
+ padding: 6px 8px;
183
+ border: 1px solid #eee;
184
+ background: #f9f9f9;
185
+ border-radius: 6px;
186
+ cursor: pointer;
187
+ }
188
+
189
+ .cd-menu-item.active {
190
+ background: #0d6efd;
191
+ color: #fff;
192
+ border-color: #0d6efd;
193
+ }
194
+
195
+ /* ---------- Tabs & Actions ---------- */
196
+ #cd-tabs-wrap {
197
+ display: flex;
198
+ gap: 6px;
199
+ flex-wrap: wrap;
200
+ margin: 6px 0 8px;
201
+ }
202
+
203
+ #cd-tabs-wrap .cd-tab-btn {
204
+ padding: 6px 8px;
205
+ border: 1px solid #ddd;
206
+ background: #f9f9f9;
207
+ border-radius: 6px;
208
+ cursor: pointer;
209
+ }
210
+
211
+ #cd-actions-wrap {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 12px;
215
+ }
216
+
217
+ .cd-action {
218
+ border: 1px solid #eee;
219
+ border-radius: 8px;
220
+ padding: 10px;
221
+ }
222
+
223
+ .cd-action-title {
224
+ font-weight: 600;
225
+ margin-bottom: 8px;
226
+ }
227
+
228
+ .cd-form label {
229
+ display: block;
230
+ margin: 6px 0 2px;
231
+ color: #555;
232
+ }
233
+
234
+ .cd-form label .cd-required {
235
+ color: #d00;
236
+ margin-left: 4px;
237
+ font-weight: 700;
238
+ }
239
+
240
+ .cd-form input[type="text"],
241
+ .cd-form input[type="number"],
242
+ .cd-form select {
243
+ width: 100%;
244
+ box-sizing: border-box;
245
+ padding: 8px 10px;
246
+ border: 1px solid #ddd;
247
+ border-radius: 6px;
248
+ }
249
+
250
+ .cd-actions-row {
251
+ display: flex;
252
+ justify-content: flex-end;
253
+ margin-top: 8px;
254
+ }
255
+
256
+ .cd-param-hint {
257
+ margin-top: 6px;
258
+ color: #666;
259
+ font-size: 12px;
260
+ }
261
+
262
+ .cd-badge {
263
+ display: inline-block;
264
+ padding: 2px 6px;
265
+ border-radius: 999px;
266
+ font-size: 11px;
267
+ font-weight: 600;
268
+ margin-left: 4px;
269
+ }
270
+
271
+ .cd-badge.cd-on {
272
+ background: #e6f4ea;
273
+ color: #137333;
274
+ border: 1px solid #c8e6c9;
275
+ }
276
+
277
+ .cd-badge.cd-off {
278
+ background: #fde8e8;
279
+ color: #b91c1c;
280
+ border: 1px solid #fecaca;
281
+ }
282
+
283
+ #command-deck-panel.cd-theme-dark .cd-param-hint {
284
+ color: #bdbdbd;
285
+ }
286
+ #command-deck-panel.cd-theme-dark .cd-badge.cd-on {
287
+ background: #0b2f1c;
288
+ color: #a7f3d0;
289
+ border-color: #065f46;
290
+ }
291
+ #command-deck-panel.cd-theme-dark .cd-badge.cd-off {
292
+ background: #3b0c0c;
293
+ color: #fecaca;
294
+ border-color: #7f1d1d;
295
+ }
296
+
297
+ .cd-form .cd-run {
298
+ width: 36px;
299
+ height: 32px;
300
+ display: inline-flex;
301
+ align-items: center;
302
+ justify-content: center;
303
+ padding: 0;
304
+ border: 0;
305
+ border-radius: 6px;
306
+ background: #0d6efd;
307
+ color: #fff;
308
+ cursor: pointer;
309
+ font-size: 16px;
310
+ }
311
+
312
+ .cd-form .cd-run:hover:not(:disabled) {
313
+ background: #0b5ed7; /* darker */
314
+ }
315
+
316
+ .cd-form .cd-run:focus-visible {
317
+ outline: 2px solid #9ec5fe;
318
+ outline-offset: 2px;
319
+ }
320
+
321
+ .cd-form .cd-run:disabled,
322
+ .cd-form .cd-run.loading {
323
+ opacity: 0.6;
324
+ cursor: not-allowed;
325
+ }
326
+
327
+ #command-deck-output {
328
+ white-space: pre-wrap;
329
+ background: #0b102114;
330
+ color: #0b1d30;
331
+ border-radius: 6px;
332
+ padding: 8px;
333
+ margin-top: 10px;
334
+ max-height: 200px;
335
+ overflow: auto;
336
+ }
337
+
338
+ /* ---------- Corner Positioning ---------- */
339
+ #command-deck-toggle.cd-pos-tl {
340
+ left: 16px;
341
+ right: auto;
342
+ top: 16px;
343
+ bottom: auto;
344
+ }
345
+
346
+ #command-deck-toggle.cd-pos-tr {
347
+ right: 16px;
348
+ left: auto;
349
+ top: 16px;
350
+ bottom: auto;
351
+ }
352
+
353
+ #command-deck-toggle.cd-pos-bl {
354
+ left: 16px;
355
+ right: auto;
356
+ bottom: 16px;
357
+ top: auto;
358
+ }
359
+
360
+ #command-deck-toggle.cd-pos-br {
361
+ right: 16px;
362
+ left: auto;
363
+ bottom: 16px;
364
+ top: auto;
365
+ }
366
+
367
+ #command-deck-panel.cd-pos-tl {
368
+ left: 16px;
369
+ right: auto;
370
+ top: 72px;
371
+ bottom: auto;
372
+ }
373
+
374
+ #command-deck-panel.cd-pos-tr {
375
+ right: 16px;
376
+ left: auto;
377
+ top: 72px;
378
+ bottom: auto;
379
+ }
380
+
381
+ #command-deck-panel.cd-pos-bl {
382
+ left: 16px;
383
+ right: auto;
384
+ bottom: 72px;
385
+ top: auto;
386
+ }
387
+
388
+ #command-deck-panel.cd-pos-br {
389
+ right: 16px;
390
+ left: auto;
391
+ bottom: 72px;
392
+ top: auto;
393
+ }
@@ -0,0 +1,20 @@
1
+ export class PanelsApi {
2
+ constructor(mount) { this.mount = mount; }
3
+ fetchPanels() {
4
+ return fetch(this.mount + '/panels', { credentials: 'same-origin' }).then(r => r.json());
5
+ }
6
+ }
7
+
8
+ export class ActionsApi {
9
+ constructor(mount) { this.mount = mount; }
10
+ submit(key, params) {
11
+ const tokenEl = document.querySelector('meta[name="csrf-token"]');
12
+ const token = tokenEl ? tokenEl.getAttribute('content') : '';
13
+ return fetch(this.mount + '/actions', {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
16
+ body: JSON.stringify({ key, params }),
17
+ credentials: 'same-origin'
18
+ }).then(r => r.json());
19
+ }
20
+ }
@@ -0,0 +1,33 @@
1
+ // DOM helpers and ready hook
2
+ export function onReady(fn) {
3
+ const run = () => { try { fn(); } catch(_) {} };
4
+ if (document.readyState === 'loading') {
5
+ document.addEventListener('DOMContentLoaded', run);
6
+ } else {
7
+ run();
8
+ }
9
+ document.addEventListener('turbo:load', run);
10
+ document.addEventListener('turbo:render', run);
11
+ window.addEventListener('pageshow', run);
12
+ }
13
+
14
+ export function el(tag, attrs, children) {
15
+ const e = document.createElement(tag);
16
+ if (attrs) for (const k in attrs) {
17
+ if (k === 'style' && typeof attrs[k] === 'object') {
18
+ Object.assign(e.style, attrs[k]);
19
+ } else if (k === 'class') {
20
+ e.className = attrs[k];
21
+ } else if (k.startsWith('on') && typeof attrs[k] === 'function') {
22
+ e.addEventListener(k.slice(2), attrs[k]);
23
+ } else {
24
+ e.setAttribute(k, attrs[k]);
25
+ }
26
+ }
27
+ (children || []).forEach(c => e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c));
28
+ return e;
29
+ }
30
+
31
+ export function jsonPretty(obj) {
32
+ try { return JSON.stringify(obj, null, 2); } catch(_) { return String(obj); }
33
+ }
@@ -0,0 +1,13 @@
1
+ const KEY_PREFIX = 'command-deck-';
2
+
3
+ export const store = {
4
+ get(key, fallback = null) {
5
+ try {
6
+ const v = localStorage.getItem(KEY_PREFIX + key);
7
+ return v == null ? fallback : v;
8
+ } catch(_) { return fallback; }
9
+ },
10
+ set(key, value) {
11
+ try { localStorage.setItem(KEY_PREFIX + key, value); } catch(_) {}
12
+ }
13
+ };
@@ -0,0 +1,39 @@
1
+ import { onReady } from './core/dom.js';
2
+ import { PanelsApi, ActionsApi } from './core/api.js';
3
+ import { Overlay } from './ui/overlay.js';
4
+ import { SettingsDropdown } from './ui/settings_dropdown.js';
5
+ import { PanelSelector } from './ui/panel_selector.js';
6
+
7
+ function getMount() {
8
+ const s = document.querySelector('script[data-mount]') || document.currentScript;
9
+ const m = s && s.getAttribute('data-mount');
10
+ return m || '/command_deck';
11
+ }
12
+
13
+ onReady(() => {
14
+ function ensureOverlay() {
15
+ const existingToggle = document.getElementById('command-deck-toggle');
16
+ const existingPanel = document.getElementById('command-deck-panel');
17
+ if (existingToggle && existingPanel) return;
18
+
19
+ const mount = getMount();
20
+ const panelsApi = new PanelsApi(mount);
21
+ const actionsApi = new ActionsApi(mount);
22
+
23
+ const overlay = new Overlay({ mount, panelsApi, actionsApi });
24
+ overlay.init();
25
+
26
+ const dropdown = new SettingsDropdown(
27
+ (pos) => overlay.position.set(pos),
28
+ (theme) => overlay.theme && overlay.theme.set(theme)
29
+ );
30
+ overlay.attachSettings(dropdown);
31
+
32
+ const selector = new PanelSelector((key) => overlay.selectPanel(key));
33
+ overlay.attachPanelSelector(selector);
34
+
35
+ overlay.loadPanels();
36
+ }
37
+
38
+ ensureOverlay();
39
+ });