@signalk/app-dock 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,441 @@
1
+ import React, { useState, useRef, useCallback } 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 [triggerCorner, setTriggerCorner] = useState(cfg.triggerCorner || 'bottom-right')
179
+ const [iframeMode, setIframeMode] = useState(cfg.iframeMode || 'keep-alive')
180
+ const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
181
+ const [magnification, setMagnification] = useState(cfg.magnification !== false)
182
+ const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
183
+
184
+ const [status, setStatus] = useState('')
185
+ const [statusError, setStatusError] = useState(false)
186
+ const [discovering, setDiscovering] = useState(false)
187
+ const [dragIdx, setDragIdx] = useState(null)
188
+ const [overIdx, setOverIdx] = useState(null)
189
+
190
+ const appsRef = useRef(apps)
191
+ appsRef.current = apps
192
+
193
+ const buildConfig = useCallback(
194
+ (appsList) => ({
195
+ position,
196
+ triggerCorner,
197
+ iframeMode,
198
+ iconSize,
199
+ magnification,
200
+ magnificationScale,
201
+ apps: appsList
202
+ }),
203
+ [position, triggerCorner, iframeMode, iconSize, magnification, magnificationScale]
204
+ )
205
+
206
+ const doSave = useCallback(() => {
207
+ save(buildConfig(apps))
208
+ setStatus('Saved!')
209
+ setStatusError(false)
210
+ }, [apps, buildConfig, save])
211
+
212
+ const discover = async () => {
213
+ setDiscovering(true)
214
+ setStatus('')
215
+ setStatusError(false)
216
+ try {
217
+ const res = await fetch('/skServer/webapps')
218
+ const allWebapps = await res.json()
219
+ const webapps = allWebapps
220
+ .filter((w) => w.name !== 'signalk-app-dock')
221
+ .map((w) => {
222
+ if (w.name === '@signalk/server-admin-ui') {
223
+ return {
224
+ name: w.name,
225
+ label: 'Settings',
226
+ url: '/admin/',
227
+ icon: '/signalk-app-dock/icon-settings.svg',
228
+ isAdmin: true
229
+ }
230
+ }
231
+ return {
232
+ name: w.name,
233
+ label: w.signalk?.displayName || w.name,
234
+ url: `/${w.name}/`,
235
+ icon: w.signalk?.appIcon ? `/${w.name}/${w.signalk.appIcon}` : null
236
+ }
237
+ })
238
+
239
+ if (webapps.length === 0) {
240
+ setStatus('No webapps found. Is the server fully started?')
241
+ setStatusError(true)
242
+ setDiscovering(false)
243
+ return
244
+ }
245
+
246
+ const current = appsRef.current
247
+ const existingUrls = new Set(current.map((a) => a.url))
248
+ let added = 0
249
+ const merged = [...current]
250
+ for (const w of webapps) {
251
+ if (!existingUrls.has(w.url)) {
252
+ merged.push({
253
+ enabled: true,
254
+ autostart: false,
255
+ url: w.url,
256
+ label: w.label || '',
257
+ icon: w.icon || '',
258
+ color: w.isAdmin ? '#78788c' : ''
259
+ })
260
+ added++
261
+ }
262
+ }
263
+
264
+ setApps(merged)
265
+ setStatus(
266
+ added > 0
267
+ ? `Found ${webapps.length} webapps, added ${added} new.`
268
+ : `All ${webapps.length} webapps already in list.`
269
+ )
270
+ setStatusError(false)
271
+ } catch (e) {
272
+ setStatus('Discovery failed: ' + e.message)
273
+ setStatusError(true)
274
+ }
275
+ setDiscovering(false)
276
+ }
277
+
278
+ const toggleApp = (i) => setApps(apps.map((a, j) => (j === i ? { ...a, enabled: !a.enabled } : a)))
279
+ const toggleAutostart = (i) =>
280
+ setApps(apps.map((a, j) => (j === i ? { ...a, autostart: !a.autostart } : { ...a, autostart: false })))
281
+ const removeApp = (i) => setApps(apps.filter((_, j) => j !== i))
282
+
283
+ const onDragStart = (i) => setDragIdx(i)
284
+ const onDragOver = (e, i) => {
285
+ e.preventDefault()
286
+ setOverIdx(i)
287
+ }
288
+ const onDragLeave = () => setOverIdx(null)
289
+ const onDrop = (e, dropIdx) => {
290
+ e.preventDefault()
291
+ setOverIdx(null)
292
+ if (dragIdx !== null && dragIdx !== dropIdx) {
293
+ const next = [...apps]
294
+ const [moved] = next.splice(dragIdx, 1)
295
+ next.splice(dropIdx, 0, moved)
296
+ setApps(next)
297
+ }
298
+ setDragIdx(null)
299
+ }
300
+ const onDragEnd = () => {
301
+ setDragIdx(null)
302
+ setOverIdx(null)
303
+ }
304
+
305
+ const enabledApps = apps.filter((a) => a.enabled !== false)
306
+
307
+ return (
308
+ <div style={S.root}>
309
+ <div style={S.sectionTitle}>Dock Settings</div>
310
+
311
+ <SelectField
312
+ label="Dock position"
313
+ value={position}
314
+ onChange={setPosition}
315
+ options={[
316
+ { value: 'bottom', label: 'Bottom' },
317
+ { value: 'top', label: 'Top' },
318
+ { value: 'left', label: 'Left' },
319
+ { value: 'right', label: 'Right' }
320
+ ]}
321
+ />
322
+
323
+ <SelectField
324
+ label="Double-tap corner"
325
+ value={triggerCorner}
326
+ onChange={setTriggerCorner}
327
+ options={[
328
+ { value: 'bottom-right', label: 'Bottom-right' },
329
+ { value: 'bottom-left', label: 'Bottom-left' },
330
+ { value: 'top-right', label: 'Top-right' },
331
+ { value: 'top-left', label: 'Top-left' }
332
+ ]}
333
+ />
334
+
335
+ <SelectField
336
+ label="iFrame lifecycle"
337
+ value={iframeMode}
338
+ onChange={setIframeMode}
339
+ options={[
340
+ { value: 'keep-alive', label: 'Keep-alive (faster switching, more RAM)' },
341
+ { value: 'destroy', label: 'Destroy (reload each time, less RAM)' }
342
+ ]}
343
+ />
344
+
345
+ <NumberField label="Icon size" value={iconSize} onChange={setIconSize} hint="px" />
346
+
347
+ <CheckboxField label="Magnification effect" checked={magnification} onChange={setMagnification} />
348
+
349
+ {magnification && (
350
+ <NumberField
351
+ label="Magnification scale"
352
+ value={magnificationScale}
353
+ onChange={setMagnificationScale}
354
+ hint="1.0 - 2.5"
355
+ />
356
+ )}
357
+
358
+ <div style={S.sectionTitle}>Webapps</div>
359
+ <button
360
+ style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
361
+ onClick={discover}
362
+ disabled={discovering}
363
+ >
364
+ {discovering ? 'Discovering\u2026' : 'Discover Installed Webapps'}
365
+ </button>
366
+ {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
367
+
368
+ <div style={S.sectionTitle}>Dock Apps (drag to reorder)</div>
369
+ {apps.length === 0 ? (
370
+ <div style={S.empty}>No apps yet. Click Discover above.</div>
371
+ ) : (
372
+ <div>
373
+ {apps.map((app, i) => (
374
+ <div
375
+ key={app.url + i}
376
+ draggable
377
+ onDragStart={() => onDragStart(i)}
378
+ onDragOver={(e) => onDragOver(e, i)}
379
+ onDragLeave={onDragLeave}
380
+ onDrop={(e) => onDrop(e, i)}
381
+ onDragEnd={onDragEnd}
382
+ style={{
383
+ ...S.item,
384
+ ...(app.enabled === false ? S.itemDisabled : {}),
385
+ ...(overIdx === i ? S.dragOver : {}),
386
+ ...(dragIdx === i ? { opacity: 0.4 } : {})
387
+ }}
388
+ >
389
+ <span style={S.handle}>{'\u2261'}</span>
390
+ <IconPreview icon={app.icon} label={app.label || app.url} color={app.color} />
391
+ <div style={S.info}>
392
+ <div style={S.name}>{app.label || app.url}</div>
393
+ <div style={S.url}>{app.url}</div>
394
+ </div>
395
+ <div style={S.toggle} title="Enabled">
396
+ <input
397
+ type="checkbox"
398
+ checked={app.enabled !== false}
399
+ onChange={() => toggleApp(i)}
400
+ style={S.checkbox}
401
+ />
402
+ </div>
403
+ <div
404
+ style={{
405
+ ...S.toggle,
406
+ cursor: 'pointer',
407
+ fontSize: 16,
408
+ opacity: app.autostart ? 1 : 0.25
409
+ }}
410
+ onClick={() => toggleAutostart(i)}
411
+ title={app.autostart ? 'Autostart: ON (click to disable)' : 'Click to set as autostart app'}
412
+ >
413
+ {'\u25b6'}
414
+ </div>
415
+ <button style={{ ...S.btn, ...S.btnDanger }} onClick={() => removeApp(i)}>
416
+ Remove
417
+ </button>
418
+ </div>
419
+ ))}
420
+ </div>
421
+ )}
422
+
423
+ {enabledApps.length > 0 && (
424
+ <>
425
+ <div style={S.sectionTitle}>Preview</div>
426
+ <div style={S.preview}>
427
+ {enabledApps.map((app, i) => (
428
+ <IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
429
+ ))}
430
+ </div>
431
+ </>
432
+ )}
433
+
434
+ <div style={S.actions}>
435
+ <button style={{ ...S.btn, ...S.btnSave }} onClick={doSave}>
436
+ Save Configuration
437
+ </button>
438
+ </div>
439
+ </div>
440
+ )
441
+ }
@@ -0,0 +1 @@
1
+ // Vite entry point — the actual component is loaded via Module Federation