@signalk/app-dock 1.2.1 → 1.3.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.
@@ -1,549 +0,0 @@
1
- import React, { useState, useRef, useCallback, useEffect } from 'react'
2
-
3
- const S = {
4
- root: {
5
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
6
- color: '#333',
7
- padding: '16px 0'
8
- },
9
- sectionTitle: {
10
- fontSize: 13,
11
- fontWeight: 600,
12
- color: '#888',
13
- textTransform: 'uppercase',
14
- letterSpacing: '0.05em',
15
- marginBottom: 10,
16
- marginTop: 24
17
- },
18
- btn: {
19
- display: 'inline-flex',
20
- alignItems: 'center',
21
- gap: 8,
22
- padding: '8px 16px',
23
- border: 'none',
24
- borderRadius: 6,
25
- fontSize: 13,
26
- fontWeight: 600,
27
- cursor: 'pointer'
28
- },
29
- btnPrimary: { background: '#3b82f6', color: '#fff' },
30
- btnSave: { background: '#10b981', color: '#fff' },
31
- btnDanger: { background: '#ef4444', color: '#fff', padding: '4px 10px', fontSize: 11 },
32
- status: { marginTop: 8, fontSize: 12, minHeight: 18 },
33
- item: {
34
- display: 'flex',
35
- alignItems: 'center',
36
- gap: 10,
37
- padding: '8px 12px',
38
- background: '#f8f9fa',
39
- border: '1px solid #e0e0e0',
40
- borderRadius: 8,
41
- marginBottom: 6,
42
- cursor: 'grab'
43
- },
44
- itemDisabled: { opacity: 0.4 },
45
- dragOver: { borderColor: '#3b82f6', background: '#eef4ff' },
46
- handle: {
47
- color: '#bbb',
48
- fontSize: 16,
49
- cursor: 'grab',
50
- userSelect: 'none',
51
- flexShrink: 0,
52
- width: 18,
53
- textAlign: 'center'
54
- },
55
- iconBox: {
56
- width: 36,
57
- height: 36,
58
- borderRadius: 8,
59
- display: 'flex',
60
- alignItems: 'center',
61
- justifyContent: 'center',
62
- background: '#e2e8f0',
63
- overflow: 'hidden',
64
- flexShrink: 0,
65
- fontSize: 18
66
- },
67
- iconImg: { width: '100%', height: '100%', objectFit: 'contain', padding: 5 },
68
- info: { flex: 1, minWidth: 0 },
69
- name: { fontSize: 13, fontWeight: 600, color: '#333' },
70
- url: { fontSize: 10, color: '#999', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
71
- toggle: { flexShrink: 0 },
72
- checkbox: { width: 16, height: 16, cursor: 'pointer', accentColor: '#3b82f6' },
73
- preview: {
74
- display: 'flex',
75
- alignItems: 'flex-end',
76
- justifyContent: 'center',
77
- gap: 6,
78
- padding: '12px 18px',
79
- background: 'rgba(30,30,32,0.85)',
80
- border: '1px solid rgba(255,255,255,0.18)',
81
- borderRadius: 18,
82
- margin: '16px auto',
83
- width: 'fit-content'
84
- },
85
- actions: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 16 },
86
- empty: { textAlign: 'center', padding: '30px 16px', color: '#999', fontSize: 13 },
87
- fieldRow: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 },
88
- label: { fontSize: 13, fontWeight: 500, color: '#555', width: 160, flexShrink: 0 },
89
- select: {
90
- padding: '6px 10px',
91
- borderRadius: 6,
92
- border: '1px solid #ccc',
93
- fontSize: 13,
94
- background: '#fff',
95
- color: '#333'
96
- },
97
- input: {
98
- padding: '6px 10px',
99
- borderRadius: 6,
100
- border: '1px solid #ccc',
101
- fontSize: 13,
102
- width: 80,
103
- background: '#fff',
104
- color: '#333'
105
- },
106
- checkboxField: { width: 16, height: 16, accentColor: '#3b82f6' },
107
- hint: { fontSize: 11, color: '#aaa', marginLeft: 8 }
108
- }
109
-
110
- function IconPreview({ icon, label, color, size }) {
111
- const sz = size || 36
112
- const style = {
113
- ...S.iconBox,
114
- width: sz,
115
- height: sz,
116
- borderRadius: sz * 0.22,
117
- ...(color ? { background: color } : {})
118
- }
119
- const iconVal = icon || ''
120
- if (iconVal.startsWith('/') || iconVal.startsWith('http')) {
121
- return (
122
- <div style={style}>
123
- <img
124
- src={iconVal}
125
- alt={label}
126
- style={{ ...S.iconImg, padding: sz * 0.12 }}
127
- onError={(e) => {
128
- e.target.style.display = 'none'
129
- e.target.parentNode.textContent = (label || '??').slice(0, 2).toUpperCase()
130
- }}
131
- />
132
- </div>
133
- )
134
- }
135
- return <div style={style}>{iconVal || (label || '??').slice(0, 2).toUpperCase()}</div>
136
- }
137
-
138
- function SelectField({ label, value, options, onChange, hint }) {
139
- return (
140
- <div style={S.fieldRow}>
141
- <span style={S.label}>{label}</span>
142
- <select style={S.select} value={value} onChange={(e) => onChange(e.target.value)}>
143
- {options.map((o) => (
144
- <option key={o.value} value={o.value}>
145
- {o.label}
146
- </option>
147
- ))}
148
- </select>
149
- {hint && <span style={S.hint}>{hint}</span>}
150
- </div>
151
- )
152
- }
153
-
154
- function NumberField({ label, value, onChange, hint }) {
155
- return (
156
- <div style={S.fieldRow}>
157
- <span style={S.label}>{label}</span>
158
- <input style={S.input} type="number" value={value} onChange={(e) => onChange(Number(e.target.value))} />
159
- {hint && <span style={S.hint}>{hint}</span>}
160
- </div>
161
- )
162
- }
163
-
164
- function CheckboxField({ label, checked, onChange, hint }) {
165
- return (
166
- <div style={S.fieldRow}>
167
- <span style={S.label}>{label}</span>
168
- <input type="checkbox" style={S.checkboxField} checked={checked} onChange={(e) => onChange(e.target.checked)} />
169
- {hint && <span style={S.hint}>{hint}</span>}
170
- </div>
171
- )
172
- }
173
-
174
- export default function PluginConfigurationPanel({ configuration, save }) {
175
- const cfg = configuration || {}
176
- const [apps, setApps] = useState(() => cfg.apps || [])
177
- const [position, setPosition] = useState(cfg.position || 'bottom')
178
- const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
179
- const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
180
- const [magnification, setMagnification] = useState(cfg.magnification !== false)
181
- const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
182
- const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
183
- const [showFullscreenButton, setShowFullscreenButton] = useState(cfg.showFullscreenButton || false)
184
- const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
185
- const [tourDismissed, setTourDismissed] = useState(cfg.tourDismissed || false)
186
-
187
- const [status, setStatus] = useState('')
188
- const [statusError, setStatusError] = useState(false)
189
- const [discovering, setDiscovering] = useState(false)
190
- const [saving, setSaving] = useState(false)
191
- const [dragIdx, setDragIdx] = useState(null)
192
- const [overIdx, setOverIdx] = useState(null)
193
-
194
- const appsRef = useRef(apps)
195
- appsRef.current = apps
196
-
197
- const statusTimeoutRef = useRef(null)
198
- useEffect(() => {
199
- return () => {
200
- if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
201
- }
202
- }, [])
203
-
204
- const buildConfig = useCallback(
205
- (appsList) => ({
206
- position,
207
- iframeMode,
208
- iconSize,
209
- magnification,
210
- magnificationScale,
211
- showNightModeButton,
212
- showFullscreenButton,
213
- showExitButton,
214
- tourDismissed,
215
- apps: appsList
216
- }),
217
- [
218
- position,
219
- iframeMode,
220
- iconSize,
221
- magnification,
222
- magnificationScale,
223
- showNightModeButton,
224
- showFullscreenButton,
225
- showExitButton,
226
- tourDismissed
227
- ]
228
- )
229
-
230
- const doSave = useCallback(async () => {
231
- if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
232
- setSaving(true)
233
- setStatus('Saving\u2026')
234
- setStatusError(false)
235
- try {
236
- const result = save(buildConfig(apps))
237
- if (result && typeof result.then === 'function') {
238
- await result
239
- }
240
- setStatus('Configuration saved \u2014 plugin will restart')
241
- setStatusError(false)
242
- statusTimeoutRef.current = setTimeout(() => setStatus(''), 5000)
243
- } catch (e) {
244
- setStatus('Save failed: ' + (e.message || e))
245
- setStatusError(true)
246
- } finally {
247
- setSaving(false)
248
- }
249
- }, [apps, buildConfig, save])
250
-
251
- const discover = async () => {
252
- setDiscovering(true)
253
- setStatus('')
254
- setStatusError(false)
255
- try {
256
- const res = await fetch('/skServer/webapps')
257
- const allWebapps = await res.json()
258
- const webapps = allWebapps
259
- .filter((w) => w.name !== '@signalk/app-dock')
260
- .map((w) => {
261
- if (w.name === '@signalk/server-admin-ui') {
262
- return {
263
- name: w.name,
264
- label: 'Settings',
265
- url: '/admin/',
266
- icon: '/@signalk/app-dock/icon-settings.svg',
267
- isAdmin: true
268
- }
269
- }
270
- return {
271
- name: w.name,
272
- label: w.signalk?.displayName || w.name,
273
- url: `/${w.name}/`,
274
- icon: w.signalk?.appIcon ? `/${w.name}/${w.signalk.appIcon}` : null
275
- }
276
- })
277
-
278
- if (webapps.length === 0) {
279
- setStatus('No webapps found. Is the server fully started?')
280
- setStatusError(true)
281
- setDiscovering(false)
282
- return
283
- }
284
-
285
- const current = appsRef.current
286
- const existingUrls = new Set(current.map((a) => a.url))
287
- let added = 0
288
- const merged = [...current]
289
- for (const w of webapps) {
290
- if (!existingUrls.has(w.url)) {
291
- merged.push({
292
- enabled: true,
293
- autostart: false,
294
- url: w.url,
295
- label: w.label || '',
296
- icon: w.icon || '',
297
- color: w.isAdmin ? '#78788c' : ''
298
- })
299
- added++
300
- }
301
- }
302
-
303
- setApps(merged)
304
- setStatus(
305
- added > 0
306
- ? `Found ${webapps.length} webapps, added ${added} new.`
307
- : `All ${webapps.length} webapps already in list.`
308
- )
309
- setStatusError(false)
310
- } catch (e) {
311
- setStatus('Discovery failed: ' + e.message)
312
- setStatusError(true)
313
- }
314
- setDiscovering(false)
315
- }
316
-
317
- const toggleApp = (i) => setApps(apps.map((a, j) => (j === i ? { ...a, enabled: !a.enabled } : a)))
318
- const toggleAutostart = (i) =>
319
- setApps(apps.map((a, j) => (j === i ? { ...a, autostart: !a.autostart } : { ...a, autostart: false })))
320
- const removeApp = (i) => setApps(apps.filter((_, j) => j !== i))
321
-
322
- const onDragStart = (i) => setDragIdx(i)
323
- const onDragOver = (e, i) => {
324
- e.preventDefault()
325
- setOverIdx(i)
326
- }
327
- const onDragLeave = () => setOverIdx(null)
328
- const onDrop = (e, dropIdx) => {
329
- e.preventDefault()
330
- setOverIdx(null)
331
- if (dragIdx !== null && dragIdx !== dropIdx) {
332
- const next = [...apps]
333
- const [moved] = next.splice(dragIdx, 1)
334
- next.splice(dropIdx, 0, moved)
335
- setApps(next)
336
- }
337
- setDragIdx(null)
338
- }
339
- const onDragEnd = () => {
340
- setDragIdx(null)
341
- setOverIdx(null)
342
- }
343
-
344
- const enabledApps = apps.filter((a) => a.enabled !== false)
345
-
346
- return (
347
- <div style={S.root}>
348
- <div style={S.sectionTitle}>Dock Settings</div>
349
-
350
- <SelectField
351
- label="Dock position"
352
- value={position}
353
- onChange={setPosition}
354
- options={[
355
- { value: 'bottom', label: 'Bottom' },
356
- { value: 'top', label: 'Top' },
357
- { value: 'left', label: 'Left' },
358
- { value: 'right', label: 'Right' }
359
- ]}
360
- />
361
-
362
- <SelectField
363
- label="iFrame lifecycle"
364
- value={iframeMode}
365
- onChange={setIframeMode}
366
- options={[
367
- { value: 'keep-alive', label: 'Keep-alive (faster switching, more RAM)' },
368
- { value: 'destroy', label: 'Destroy (reload each time, less RAM)' }
369
- ]}
370
- />
371
-
372
- <NumberField label="Icon size" value={iconSize} onChange={setIconSize} hint="px" />
373
-
374
- <CheckboxField label="Magnification effect" checked={magnification} onChange={setMagnification} />
375
-
376
- {magnification && (
377
- <NumberField
378
- label="Magnification scale"
379
- value={magnificationScale}
380
- onChange={setMagnificationScale}
381
- hint="1.0 - 2.5"
382
- />
383
- )}
384
-
385
- <div style={S.sectionTitle}>Utility Buttons</div>
386
-
387
- <CheckboxField
388
- label="Night/Day mode toggle"
389
- checked={showNightModeButton}
390
- onChange={setShowNightModeButton}
391
- hint="Sun/moon button to toggle environment.mode"
392
- />
393
-
394
- <CheckboxField
395
- label="Fullscreen toggle"
396
- checked={showFullscreenButton}
397
- onChange={setShowFullscreenButton}
398
- hint="Toggles browser fullscreen. Hidden on devices without Fullscreen API support (e.g. iPadOS Safari)."
399
- />
400
-
401
- <CheckboxField
402
- label="Exit button"
403
- checked={showExitButton}
404
- onChange={setShowExitButton}
405
- hint="X button to return to Signal K admin UI"
406
- />
407
-
408
- <div style={S.sectionTitle}>Webapps</div>
409
- <button
410
- style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
411
- onClick={discover}
412
- disabled={discovering}
413
- >
414
- {discovering ? 'Discovering\u2026' : 'Discover Installed Webapps'}
415
- </button>
416
- {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
417
-
418
- <div style={S.sectionTitle}>Dock Apps (drag to reorder)</div>
419
- {apps.length === 0 ? (
420
- <div style={S.empty}>No apps yet. Click Discover above.</div>
421
- ) : (
422
- <div>
423
- {apps.map((app, i) => (
424
- <div
425
- key={app.url + i}
426
- draggable
427
- onDragStart={() => onDragStart(i)}
428
- onDragOver={(e) => onDragOver(e, i)}
429
- onDragLeave={onDragLeave}
430
- onDrop={(e) => onDrop(e, i)}
431
- onDragEnd={onDragEnd}
432
- style={{
433
- ...S.item,
434
- ...(app.enabled === false ? S.itemDisabled : {}),
435
- ...(overIdx === i ? S.dragOver : {}),
436
- ...(dragIdx === i ? { opacity: 0.4 } : {})
437
- }}
438
- >
439
- <span style={S.handle}>{'\u2261'}</span>
440
- <IconPreview icon={app.icon} label={app.label || app.url} color={app.color} />
441
- <div style={S.info}>
442
- <div style={S.name}>{app.label || app.url}</div>
443
- <div style={S.url}>{app.url}</div>
444
- </div>
445
- <div style={S.toggle} title="Enabled">
446
- <input
447
- type="checkbox"
448
- checked={app.enabled !== false}
449
- onChange={() => toggleApp(i)}
450
- style={S.checkbox}
451
- />
452
- </div>
453
- <div
454
- style={{
455
- ...S.toggle,
456
- cursor: 'pointer',
457
- fontSize: 16,
458
- opacity: app.autostart ? 1 : 0.25
459
- }}
460
- onClick={() => toggleAutostart(i)}
461
- title={app.autostart ? 'Autostart: ON (click to disable)' : 'Click to set as autostart app'}
462
- >
463
- {'\u25b6'}
464
- </div>
465
- <button style={{ ...S.btn, ...S.btnDanger }} onClick={() => removeApp(i)}>
466
- Remove
467
- </button>
468
- </div>
469
- ))}
470
- </div>
471
- )}
472
-
473
- {(enabledApps.length > 0 || showNightModeButton || showFullscreenButton || showExitButton) && (
474
- <>
475
- <div style={S.sectionTitle}>Preview</div>
476
- <div style={S.preview}>
477
- {showNightModeButton && (
478
- <div
479
- style={{
480
- ...S.iconBox,
481
- width: 40,
482
- height: 40,
483
- background: 'rgba(255,255,255,0.08)',
484
- border: '1px solid rgba(255,255,255,0.12)',
485
- fontSize: 20,
486
- borderRadius: 10
487
- }}
488
- >
489
- {'\u2600\uFE0F'}
490
- </div>
491
- )}
492
- {showFullscreenButton && (
493
- <div
494
- style={{
495
- ...S.iconBox,
496
- width: 40,
497
- height: 40,
498
- background: 'rgba(255,255,255,0.08)',
499
- border: '1px solid rgba(255,255,255,0.12)',
500
- fontSize: 20,
501
- borderRadius: 10,
502
- color: '#fff'
503
- }}
504
- >
505
- {'\u26F6'}
506
- </div>
507
- )}
508
- {(showNightModeButton || showFullscreenButton) && (
509
- <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
510
- )}
511
- {enabledApps.map((app, i) => (
512
- <IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
513
- ))}
514
- {showExitButton && (
515
- <>
516
- <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
517
- <div
518
- style={{
519
- ...S.iconBox,
520
- width: 40,
521
- height: 40,
522
- background: 'rgba(255,255,255,0.08)',
523
- border: '1px solid rgba(255,255,255,0.12)',
524
- fontSize: 16,
525
- borderRadius: 10,
526
- color: '#fff'
527
- }}
528
- >
529
- {'\u2715'}
530
- </div>
531
- </>
532
- )}
533
- </div>
534
- </>
535
- )}
536
-
537
- <div style={S.actions}>
538
- <button
539
- style={{ ...S.btn, ...S.btnSave, ...(saving ? { opacity: 0.6 } : {}) }}
540
- onClick={doSave}
541
- disabled={saving}
542
- >
543
- {saving ? 'Saving\u2026' : 'Save Configuration'}
544
- </button>
545
- {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
546
- </div>
547
- </div>
548
- )
549
- }
@@ -1 +0,0 @@
1
- // Vite entry point — the actual component is loaded via Module Federation