@signalk/app-dock 0.1.6 → 0.2.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 +5 -5
- package/eslint.config.js +1 -0
- package/package.json +1 -1
- package/plugin/index.js +38 -2
- package/public/805.js +1 -1
- package/public/config.html +1 -1
- package/public/dock.css +37 -0
- package/public/dock.js +115 -1
- package/src/configpanel/PluginConfigurationPanel.js +70 -4
- package/test/plugin.test.js +155 -4
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# signalk
|
|
1
|
+
# @signalk/app-dock
|
|
2
2
|
|
|
3
3
|
A macOS-style app dock for switching between Signal K webapps on touch screens.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -20,12 +20,12 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
cd ~/.signalk
|
|
23
|
-
npm install signalk
|
|
23
|
+
npm install @signalk/app-dock
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Restart Signal K, enable in **Plugin Config > App Dock**, click **Discover Installed Webapps**.
|
|
27
27
|
|
|
28
|
-
Open: `http://your-sk-server:3000/
|
|
28
|
+
Open: `http://your-sk-server:3000/@signalk/app-dock/`
|
|
29
29
|
|
|
30
30
|
## Usage
|
|
31
31
|
|
|
@@ -58,7 +58,7 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
|
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
60
|
cd ~/.signalk
|
|
61
|
-
npm link /path/to/
|
|
61
|
+
npm link /path/to/app-dock
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
Edit files in `public/`, reload the browser. No build step needed for the dock itself.
|
package/eslint.config.js
CHANGED
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -6,7 +6,7 @@ module.exports = (app) => {
|
|
|
6
6
|
|
|
7
7
|
function getWebapps() {
|
|
8
8
|
return (app.webapps || [])
|
|
9
|
-
.filter((w) => w.name !== 'signalk
|
|
9
|
+
.filter((w) => w.name !== '@signalk/app-dock' && w.name !== '@signalk/server-admin-ui')
|
|
10
10
|
.map((w) => ({
|
|
11
11
|
name: w.name,
|
|
12
12
|
label: w.signalk?.displayName || w.name,
|
|
@@ -22,6 +22,23 @@ module.exports = (app) => {
|
|
|
22
22
|
start(settings) {
|
|
23
23
|
pluginSettings = settings
|
|
24
24
|
|
|
25
|
+
if (settings.showNightModeButton) {
|
|
26
|
+
app.registerPutHandler(
|
|
27
|
+
'vessels.self',
|
|
28
|
+
'environment.mode',
|
|
29
|
+
(context, path, value, _callback) => {
|
|
30
|
+
if (value !== 'day' && value !== 'night') {
|
|
31
|
+
return { state: 'COMPLETED', statusCode: 400, message: 'Invalid mode' }
|
|
32
|
+
}
|
|
33
|
+
app.handleMessage(plugin.id, {
|
|
34
|
+
updates: [{ values: [{ path: 'environment.mode', value }] }]
|
|
35
|
+
})
|
|
36
|
+
return { state: 'COMPLETED', statusCode: 200 }
|
|
37
|
+
},
|
|
38
|
+
plugin.id
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
setTimeout(() => {
|
|
26
43
|
const discovered = getWebapps()
|
|
27
44
|
if (discovered.length === 0) return
|
|
@@ -80,12 +97,17 @@ module.exports = (app) => {
|
|
|
80
97
|
router.get('/webapps', (req, res) => {
|
|
81
98
|
res.json(getWebapps())
|
|
82
99
|
})
|
|
100
|
+
|
|
101
|
+
router.get('/mode', (req, res) => {
|
|
102
|
+
const current = app.getSelfPath('environment.mode')
|
|
103
|
+
res.json({ value: (current && current.value) || 'day' })
|
|
104
|
+
})
|
|
83
105
|
},
|
|
84
106
|
|
|
85
107
|
schema: {
|
|
86
108
|
type: 'object',
|
|
87
109
|
description:
|
|
88
|
-
'Open /
|
|
110
|
+
'Open /@signalk/app-dock/config.html for the visual configurator with discover button and drag-to-reorder.',
|
|
89
111
|
required: [],
|
|
90
112
|
properties: {
|
|
91
113
|
position: {
|
|
@@ -131,6 +153,20 @@ module.exports = (app) => {
|
|
|
131
153
|
default: 1.7
|
|
132
154
|
},
|
|
133
155
|
|
|
156
|
+
showNightModeButton: {
|
|
157
|
+
type: 'boolean',
|
|
158
|
+
title: 'Show night/day mode toggle',
|
|
159
|
+
description: 'Adds a sun/moon button to the dock that toggles environment.mode',
|
|
160
|
+
default: false
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
showExitButton: {
|
|
164
|
+
type: 'boolean',
|
|
165
|
+
title: 'Show exit button',
|
|
166
|
+
description: 'Adds an X button to the dock that returns to Signal K admin UI',
|
|
167
|
+
default: false
|
|
168
|
+
},
|
|
169
|
+
|
|
134
170
|
apps: {
|
|
135
171
|
type: 'array',
|
|
136
172
|
title: 'Dock Apps',
|
package/public/805.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,
|
|
1
|
+
"use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,n){n.r(t),n.d(t,{default:()=>s});var a=n(231);const l={root:{fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',color:"#333",padding:"16px 0"},sectionTitle:{fontSize:13,fontWeight:600,color:"#888",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:10,marginTop:24},btn:{display:"inline-flex",alignItems:"center",gap:8,padding:"8px 16px",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},btnPrimary:{background:"#3b82f6",color:"#fff"},btnSave:{background:"#10b981",color:"#fff"},btnDanger:{background:"#ef4444",color:"#fff",padding:"4px 10px",fontSize:11},status:{marginTop:8,fontSize:12,minHeight:18},item:{display:"flex",alignItems:"center",gap:10,padding:"8px 12px",background:"#f8f9fa",border:"1px solid #e0e0e0",borderRadius:8,marginBottom:6,cursor:"grab"},itemDisabled:{opacity:.4},dragOver:{borderColor:"#3b82f6",background:"#eef4ff"},handle:{color:"#bbb",fontSize:16,cursor:"grab",userSelect:"none",flexShrink:0,width:18,textAlign:"center"},iconBox:{width:36,height:36,borderRadius:8,display:"flex",alignItems:"center",justifyContent:"center",background:"#e2e8f0",overflow:"hidden",flexShrink:0,fontSize:18},iconImg:{width:"100%",height:"100%",objectFit:"contain",padding:5},info:{flex:1,minWidth:0},name:{fontSize:13,fontWeight:600,color:"#333"},url:{fontSize:10,color:"#999",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},toggle:{flexShrink:0},checkbox:{width:16,height:16,cursor:"pointer",accentColor:"#3b82f6"},preview:{display:"flex",alignItems:"flex-end",justifyContent:"center",gap:6,padding:"12px 18px",background:"rgba(30,30,32,0.85)",border:"1px solid rgba(255,255,255,0.18)",borderRadius:18,margin:"16px auto",width:"fit-content"},actions:{display:"flex",gap:10,alignItems:"center",marginTop:16},empty:{textAlign:"center",padding:"30px 16px",color:"#999",fontSize:13},fieldRow:{display:"flex",alignItems:"center",gap:12,marginBottom:10},label:{fontSize:13,fontWeight:500,color:"#555",width:160,flexShrink:0},select:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,background:"#fff",color:"#333"},input:{padding:"6px 10px",borderRadius:6,border:"1px solid #ccc",fontSize:13,width:80,background:"#fff",color:"#333"},checkboxField:{width:16,height:16,accentColor:"#3b82f6"},hint:{fontSize:11,color:"#aaa",marginLeft:8}};function o({icon:e,label:t,color:n,size:o}){const i=o||36,r={...l.iconBox,width:i,height:i,borderRadius:.22*i,...n?{background:n}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?a.createElement("div",{style:r},a.createElement("img",{src:c,alt:t,style:{...l.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):a.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:n,onChange:o,hint:i}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("select",{style:l.select,value:t,onChange:e=>o(e.target.value)},n.map(e=>a.createElement("option",{key:e.value,value:e.value},e.label))),i&&a.createElement("span",{style:l.hint},i))}function r({label:e,value:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{style:l.input,type:"number",value:t,onChange:e=>n(Number(e.target.value))}),o&&a.createElement("span",{style:l.hint},o))}function c({label:e,checked:t,onChange:n,hint:o}){return a.createElement("div",{style:l.fieldRow},a.createElement("span",{style:l.label},e),a.createElement("input",{type:"checkbox",style:l.checkboxField,checked:t,onChange:e=>n(e.target.checked)}),o&&a.createElement("span",{style:l.hint},o))}function s({configuration:e,save:t}){const n=e||{},[s,d]=(0,a.useState)(()=>n.apps||[]),[g,u]=(0,a.useState)(n.position||"bottom"),[p,m]=(0,a.useState)(n.triggerCorner||"bottom-right"),[b,f]=(0,a.useState)(n.iframeMode||"keep-alive"),[h,v]=(0,a.useState)(n.iconSize||56),[y,k]=(0,a.useState)(!1!==n.magnification),[E,S]=(0,a.useState)(n.magnificationScale||1.7),[x,w]=(0,a.useState)(n.showNightModeButton||!1),[C,z]=(0,a.useState)(n.showExitButton||!1),[D,R]=(0,a.useState)(""),[B,I]=(0,a.useState)(!1),[T,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(null),[N,W]=(0,a.useState)(null),$=(0,a.useRef)(s);$.current=s;const _=(0,a.useCallback)(e=>({position:g,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[g,p,b,h,y,E,x,C]),O=(0,a.useCallback)(()=>{t(_(s)),R("Saved!"),I(!1)},[s,_,t]),U=()=>W(null),j=()=>{M(null),W(null)},L=s.filter(e=>!1!==e.enabled);return a.createElement("div",{style:l.root},a.createElement("div",{style:l.sectionTitle},"Dock Settings"),a.createElement(i,{label:"Dock position",value:g,onChange:u,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),a.createElement(i,{label:"Double-tap corner",value:p,onChange:m,options:[{value:"bottom-right",label:"Bottom-right"},{value:"bottom-left",label:"Bottom-left"},{value:"top-right",label:"Top-right"},{value:"top-left",label:"Top-left"}]}),a.createElement(i,{label:"iFrame lifecycle",value:b,onChange:f,options:[{value:"keep-alive",label:"Keep-alive (faster switching, more RAM)"},{value:"destroy",label:"Destroy (reload each time, less RAM)"}]}),a.createElement(r,{label:"Icon size",value:h,onChange:v,hint:"px"}),a.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&a.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),a.createElement("div",{style:l.sectionTitle},"Utility Buttons"),a.createElement(c,{label:"Night/Day mode toggle",checked:x,onChange:w,hint:"Sun/moon button to toggle environment.mode"}),a.createElement(c,{label:"Exit button",checked:C,onChange:z,hint:"X button to return to Signal K admin UI"}),a.createElement("div",{style:l.sectionTitle},"Webapps"),a.createElement("button",{style:{...l.btn,...l.btnPrimary,...T?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),I(!1);try{const e=await fetch("/skServer/webapps"),t=(await e.json()).filter(e=>"@signalk/app-dock"!==e.name).map(e=>"@signalk/server-admin-ui"===e.name?{name:e.name,label:"Settings",url:"/admin/",icon:"/@signalk/app-dock/icon-settings.svg",isAdmin:!0}:{name:e.name,label:e.signalk?.displayName||e.name,url:`/${e.name}/`,icon:e.signalk?.appIcon?`/${e.name}/${e.signalk.appIcon}`:null});if(0===t.length)return R("No webapps found. Is the server fully started?"),I(!0),void F(!1);const n=$.current,a=new Set(n.map(e=>e.url));let l=0;const o=[...n];for(const e of t)a.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),l++);d(o),R(l>0?`Found ${t.length} webapps, added ${l} new.`:`All ${t.length} webapps already in list.`),I(!1)}catch(e){R("Discovery failed: "+e.message),I(!0)}F(!1)},disabled:T},T?"Discovering…":"Discover Installed Webapps"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D),a.createElement("div",{style:l.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?a.createElement("div",{style:l.empty},"No apps yet. Click Discover above."):a.createElement("div",null,s.map((e,t)=>a.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>M(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),W(t)})(e,t),onDragLeave:U,onDrop:e=>((e,t)=>{if(e.preventDefault(),W(null),null!==A&&A!==t){const e=[...s],[n]=e.splice(A,1);e.splice(t,0,n),d(e)}M(null)})(e,t),onDragEnd:j,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...N===t?l.dragOver:{},...A===t?{opacity:.4}:{}}},a.createElement("span",{style:l.handle},"≡"),a.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),a.createElement("div",{style:l.info},a.createElement("div",{style:l.name},e.label||e.url),a.createElement("div",{style:l.url},e.url)),a.createElement("div",{style:l.toggle,title:"Enabled"},a.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,n)=>n===e?{...t,enabled:!t.enabled}:t)))(t),style:l.checkbox})),a.createElement("div",{style:{...l.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,n)=>n===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),a.createElement("button",{style:{...l.btn,...l.btnDanger},onClick:()=>(e=>d(s.filter((t,n)=>n!==e)))(t)},"Remove")))),(L.length>0||x||C)&&a.createElement(a.Fragment,null,a.createElement("div",{style:l.sectionTitle},"Preview"),a.createElement("div",{style:l.preview},x&&a.createElement(a.Fragment,null,a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:20,borderRadius:10}},"☀️"),a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}})),L.map((e,t)=>a.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})),C&&a.createElement(a.Fragment,null,a.createElement("div",{style:{width:1,height:24,background:"rgba(255,255,255,0.15)",alignSelf:"center"}}),a.createElement("div",{style:{...l.iconBox,width:40,height:40,background:"rgba(255,255,255,0.08)",border:"1px solid rgba(255,255,255,0.12)",fontSize:16,borderRadius:10,color:"#fff"}},"✕")))),a.createElement("div",{style:l.actions},a.createElement("button",{style:{...l.btn,...l.btnSave},onClick:O},"Save Configuration")))}}}]);
|
package/public/config.html
CHANGED
|
@@ -284,7 +284,7 @@
|
|
|
284
284
|
const res = await fetch('/skServer/webapps')
|
|
285
285
|
const allWebapps = await res.json()
|
|
286
286
|
const webapps = allWebapps
|
|
287
|
-
.filter((w) => w.name !== 'signalk
|
|
287
|
+
.filter((w) => w.name !== '@signalk/app-dock' && w.name !== '@signalk/server-admin-ui')
|
|
288
288
|
.map((w) => ({
|
|
289
289
|
name: w.name,
|
|
290
290
|
label: w.signalk?.displayName || w.name,
|
package/public/dock.css
CHANGED
|
@@ -359,6 +359,43 @@ body {
|
|
|
359
359
|
transform: translateY(-50%);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/* ─── Dock separator ───────────────────────────────────────────────────────── */
|
|
363
|
+
.dock-separator {
|
|
364
|
+
width: 1px;
|
|
365
|
+
height: 32px;
|
|
366
|
+
background: rgba(255, 255, 255, 0.15);
|
|
367
|
+
flex-shrink: 0;
|
|
368
|
+
align-self: center;
|
|
369
|
+
margin: 0 2px;
|
|
370
|
+
border-radius: 1px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.layout-vertical .dock-separator {
|
|
374
|
+
width: 32px;
|
|
375
|
+
height: 1px;
|
|
376
|
+
margin: 2px 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ─── Utility buttons (night mode, exit) ───────────────────────────────────── */
|
|
380
|
+
.dock-icon-utility {
|
|
381
|
+
background: rgba(255, 255, 255, 0.08);
|
|
382
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
383
|
+
box-shadow: none;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.dock-item-utility .dock-dot {
|
|
387
|
+
display: none;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.dock-item-utility:hover .dock-icon-utility,
|
|
391
|
+
.dock-item-utility.pressing .dock-icon-utility {
|
|
392
|
+
background: rgba(255, 255, 255, 0.15);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.dock-item-exit:hover .dock-icon-utility {
|
|
396
|
+
background: rgba(239, 68, 68, 0.25);
|
|
397
|
+
}
|
|
398
|
+
|
|
362
399
|
/* ─── Invisible corner trigger zone ────────────────────────────────────────── */
|
|
363
400
|
#trigger-corner {
|
|
364
401
|
position: fixed;
|
package/public/dock.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
iconSize: 56,
|
|
9
9
|
magnification: true,
|
|
10
10
|
magnificationScale: 1.7,
|
|
11
|
+
showNightModeButton: false,
|
|
12
|
+
showExitButton: false,
|
|
11
13
|
apps: []
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -52,6 +54,48 @@
|
|
|
52
54
|
let dockVisible = false
|
|
53
55
|
const iframes = {}
|
|
54
56
|
|
|
57
|
+
// ─── Night mode state ────────────────────────────────────────────────────────
|
|
58
|
+
let currentMode = 'day'
|
|
59
|
+
|
|
60
|
+
async function fetchCurrentMode() {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch('/plugins/signalk-app-dock/mode')
|
|
63
|
+
if (res.ok) {
|
|
64
|
+
const data = await res.json()
|
|
65
|
+
currentMode = data.value || 'day'
|
|
66
|
+
updateNightModeIcon()
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[Dock] Could not fetch environment.mode', e)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updateNightModeIcon() {
|
|
74
|
+
const icon = document.querySelector('.dock-item-nightmode .dock-icon')
|
|
75
|
+
if (!icon) return
|
|
76
|
+
icon.textContent = currentMode === 'night' ? '\u{1F319}' : '\u2600\uFE0F'
|
|
77
|
+
const label = document.querySelector('.dock-item-nightmode .dock-label')
|
|
78
|
+
if (label) label.textContent = currentMode === 'night' ? 'Day mode' : 'Night mode'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function toggleNightMode() {
|
|
82
|
+
await fetchCurrentMode()
|
|
83
|
+
const newMode = currentMode === 'night' ? 'day' : 'night'
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch('/signalk/v1/api/vessels/self/environment/mode', {
|
|
86
|
+
method: 'PUT',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ value: newMode })
|
|
89
|
+
})
|
|
90
|
+
if (res.ok) {
|
|
91
|
+
currentMode = newMode
|
|
92
|
+
updateNightModeIcon()
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('[Dock] Failed to toggle mode', e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
// ─── Apply dock position class & alignment ───────────────────────────────────
|
|
56
100
|
const pos = cfg.position
|
|
57
101
|
$dock.classList.add(`pos-${pos}`)
|
|
@@ -72,7 +116,7 @@
|
|
|
72
116
|
fetch('/skServer/webapps')
|
|
73
117
|
.then((r) => r.json())
|
|
74
118
|
.then((apps) => {
|
|
75
|
-
const me = apps.find((a) => a.name === 'signalk
|
|
119
|
+
const me = apps.find((a) => a.name === '@signalk/app-dock')
|
|
76
120
|
if (me) $version.textContent = 'v' + me.version
|
|
77
121
|
})
|
|
78
122
|
.catch(() => {})
|
|
@@ -119,6 +163,55 @@
|
|
|
119
163
|
}
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
// ─── Utility item builder ─────────────────────────────────────────────────────
|
|
167
|
+
function createUtilityItem(className, icon, label, onClick) {
|
|
168
|
+
const sz = cfg.iconSize
|
|
169
|
+
const radius = Math.round(sz * 0.25) + 'px'
|
|
170
|
+
const lblCls = labelClass()
|
|
171
|
+
|
|
172
|
+
const $item = document.createElement('div')
|
|
173
|
+
$item.className = 'dock-item dock-item-utility ' + className
|
|
174
|
+
|
|
175
|
+
const $icon = document.createElement('div')
|
|
176
|
+
$icon.className = 'dock-icon dock-icon-utility'
|
|
177
|
+
$icon.style.width = sz + 'px'
|
|
178
|
+
$icon.style.height = sz + 'px'
|
|
179
|
+
$icon.style.borderRadius = radius
|
|
180
|
+
$icon.style.fontSize = Math.round(sz * 0.48) + 'px'
|
|
181
|
+
$icon.textContent = icon
|
|
182
|
+
|
|
183
|
+
const $dot = document.createElement('div')
|
|
184
|
+
$dot.className = 'dock-dot'
|
|
185
|
+
|
|
186
|
+
const $label = document.createElement('div')
|
|
187
|
+
$label.className = 'dock-label' + (lblCls ? ' ' + lblCls : '')
|
|
188
|
+
$label.textContent = label
|
|
189
|
+
|
|
190
|
+
$item.appendChild($icon)
|
|
191
|
+
$item.appendChild($dot)
|
|
192
|
+
$item.appendChild($label)
|
|
193
|
+
|
|
194
|
+
$item.addEventListener('touchstart', () => $item.classList.add('pressing'), { passive: true })
|
|
195
|
+
$item.addEventListener('touchend', () => $item.classList.remove('pressing'), { passive: true })
|
|
196
|
+
$item.addEventListener('touchcancel', () => $item.classList.remove('pressing'), { passive: true })
|
|
197
|
+
|
|
198
|
+
$item.addEventListener('click', (e) => {
|
|
199
|
+
if (magScrubbed) {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
onClick()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return $item
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createSeparator() {
|
|
210
|
+
const $sep = document.createElement('div')
|
|
211
|
+
$sep.className = 'dock-separator'
|
|
212
|
+
return $sep
|
|
213
|
+
}
|
|
214
|
+
|
|
122
215
|
// ─── Build dock items ─────────────────────────────────────────────────────────
|
|
123
216
|
function buildDock() {
|
|
124
217
|
$dockInner.innerHTML = ''
|
|
@@ -126,6 +219,11 @@
|
|
|
126
219
|
const radius = Math.round(sz * 0.25) + 'px'
|
|
127
220
|
const lblCls = labelClass()
|
|
128
221
|
|
|
222
|
+
if (cfg.showNightModeButton) {
|
|
223
|
+
$dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
|
|
224
|
+
$dockInner.appendChild(createSeparator())
|
|
225
|
+
}
|
|
226
|
+
|
|
129
227
|
cfg.apps.forEach((app, i) => {
|
|
130
228
|
const $item = document.createElement('div')
|
|
131
229
|
$item.className = 'dock-item'
|
|
@@ -179,6 +277,17 @@
|
|
|
179
277
|
|
|
180
278
|
$dockInner.appendChild($item)
|
|
181
279
|
})
|
|
280
|
+
|
|
281
|
+
if (cfg.showExitButton) {
|
|
282
|
+
$dockInner.appendChild(createSeparator())
|
|
283
|
+
$dockInner.appendChild(
|
|
284
|
+
createUtilityItem('dock-item-exit', '\u2715', 'Exit to Admin', () => {
|
|
285
|
+
window.location.href = '/admin/'
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (cfg.showNightModeButton) updateNightModeIcon()
|
|
182
291
|
}
|
|
183
292
|
|
|
184
293
|
// ─── Magnification ───────────────────────────────────────────────────────────
|
|
@@ -447,6 +556,11 @@
|
|
|
447
556
|
positionCornerZone()
|
|
448
557
|
buildDock()
|
|
449
558
|
|
|
559
|
+
if (cfg.showNightModeButton) {
|
|
560
|
+
fetchCurrentMode()
|
|
561
|
+
setInterval(fetchCurrentMode, 5000)
|
|
562
|
+
}
|
|
563
|
+
|
|
450
564
|
const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
|
|
451
565
|
if (autostartIdx >= 0) {
|
|
452
566
|
switchToApp(autostartIdx)
|
|
@@ -180,6 +180,8 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
180
180
|
const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
|
|
181
181
|
const [magnification, setMagnification] = useState(cfg.magnification !== false)
|
|
182
182
|
const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
|
|
183
|
+
const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
|
|
184
|
+
const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
|
|
183
185
|
|
|
184
186
|
const [status, setStatus] = useState('')
|
|
185
187
|
const [statusError, setStatusError] = useState(false)
|
|
@@ -198,9 +200,20 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
198
200
|
iconSize,
|
|
199
201
|
magnification,
|
|
200
202
|
magnificationScale,
|
|
203
|
+
showNightModeButton,
|
|
204
|
+
showExitButton,
|
|
201
205
|
apps: appsList
|
|
202
206
|
}),
|
|
203
|
-
[
|
|
207
|
+
[
|
|
208
|
+
position,
|
|
209
|
+
triggerCorner,
|
|
210
|
+
iframeMode,
|
|
211
|
+
iconSize,
|
|
212
|
+
magnification,
|
|
213
|
+
magnificationScale,
|
|
214
|
+
showNightModeButton,
|
|
215
|
+
showExitButton
|
|
216
|
+
]
|
|
204
217
|
)
|
|
205
218
|
|
|
206
219
|
const doSave = useCallback(() => {
|
|
@@ -217,14 +230,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
217
230
|
const res = await fetch('/skServer/webapps')
|
|
218
231
|
const allWebapps = await res.json()
|
|
219
232
|
const webapps = allWebapps
|
|
220
|
-
.filter((w) => w.name !== 'signalk
|
|
233
|
+
.filter((w) => w.name !== '@signalk/app-dock')
|
|
221
234
|
.map((w) => {
|
|
222
235
|
if (w.name === '@signalk/server-admin-ui') {
|
|
223
236
|
return {
|
|
224
237
|
name: w.name,
|
|
225
238
|
label: 'Settings',
|
|
226
239
|
url: '/admin/',
|
|
227
|
-
icon: '/
|
|
240
|
+
icon: '/@signalk/app-dock/icon-settings.svg',
|
|
228
241
|
isAdmin: true
|
|
229
242
|
}
|
|
230
243
|
}
|
|
@@ -355,6 +368,22 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
355
368
|
/>
|
|
356
369
|
)}
|
|
357
370
|
|
|
371
|
+
<div style={S.sectionTitle}>Utility Buttons</div>
|
|
372
|
+
|
|
373
|
+
<CheckboxField
|
|
374
|
+
label="Night/Day mode toggle"
|
|
375
|
+
checked={showNightModeButton}
|
|
376
|
+
onChange={setShowNightModeButton}
|
|
377
|
+
hint="Sun/moon button to toggle environment.mode"
|
|
378
|
+
/>
|
|
379
|
+
|
|
380
|
+
<CheckboxField
|
|
381
|
+
label="Exit button"
|
|
382
|
+
checked={showExitButton}
|
|
383
|
+
onChange={setShowExitButton}
|
|
384
|
+
hint="X button to return to Signal K admin UI"
|
|
385
|
+
/>
|
|
386
|
+
|
|
358
387
|
<div style={S.sectionTitle}>Webapps</div>
|
|
359
388
|
<button
|
|
360
389
|
style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
|
|
@@ -420,13 +449,50 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
420
449
|
</div>
|
|
421
450
|
)}
|
|
422
451
|
|
|
423
|
-
{enabledApps.length > 0 && (
|
|
452
|
+
{(enabledApps.length > 0 || showNightModeButton || showExitButton) && (
|
|
424
453
|
<>
|
|
425
454
|
<div style={S.sectionTitle}>Preview</div>
|
|
426
455
|
<div style={S.preview}>
|
|
456
|
+
{showNightModeButton && (
|
|
457
|
+
<>
|
|
458
|
+
<div
|
|
459
|
+
style={{
|
|
460
|
+
...S.iconBox,
|
|
461
|
+
width: 40,
|
|
462
|
+
height: 40,
|
|
463
|
+
background: 'rgba(255,255,255,0.08)',
|
|
464
|
+
border: '1px solid rgba(255,255,255,0.12)',
|
|
465
|
+
fontSize: 20,
|
|
466
|
+
borderRadius: 10
|
|
467
|
+
}}
|
|
468
|
+
>
|
|
469
|
+
{'\u2600\uFE0F'}
|
|
470
|
+
</div>
|
|
471
|
+
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
|
|
472
|
+
</>
|
|
473
|
+
)}
|
|
427
474
|
{enabledApps.map((app, i) => (
|
|
428
475
|
<IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
|
|
429
476
|
))}
|
|
477
|
+
{showExitButton && (
|
|
478
|
+
<>
|
|
479
|
+
<div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
|
|
480
|
+
<div
|
|
481
|
+
style={{
|
|
482
|
+
...S.iconBox,
|
|
483
|
+
width: 40,
|
|
484
|
+
height: 40,
|
|
485
|
+
background: 'rgba(255,255,255,0.08)',
|
|
486
|
+
border: '1px solid rgba(255,255,255,0.12)',
|
|
487
|
+
fontSize: 16,
|
|
488
|
+
borderRadius: 10,
|
|
489
|
+
color: '#fff'
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
{'\u2715'}
|
|
493
|
+
</div>
|
|
494
|
+
</>
|
|
495
|
+
)}
|
|
430
496
|
</div>
|
|
431
497
|
</>
|
|
432
498
|
)}
|
package/test/plugin.test.js
CHANGED
|
@@ -12,7 +12,10 @@ function createMockApp(webapps = []) {
|
|
|
12
12
|
webapps,
|
|
13
13
|
debug: () => {},
|
|
14
14
|
error: () => {},
|
|
15
|
-
savePluginOptions: (opts, cb) => cb(null)
|
|
15
|
+
savePluginOptions: (opts, cb) => cb(null),
|
|
16
|
+
registerPutHandler: () => {},
|
|
17
|
+
handleMessage: () => {},
|
|
18
|
+
getSelfPath: () => null
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -26,7 +29,7 @@ const MOCK_WEBAPPS = [
|
|
|
26
29
|
signalk: { displayName: 'KIP Instrument MFD', appIcon: 'assets/icon-72x72.png' }
|
|
27
30
|
},
|
|
28
31
|
{
|
|
29
|
-
name: 'signalk
|
|
32
|
+
name: '@signalk/app-dock',
|
|
30
33
|
signalk: { displayName: 'App Dock' }
|
|
31
34
|
},
|
|
32
35
|
{
|
|
@@ -74,10 +77,24 @@ describe('schema', () => {
|
|
|
74
77
|
const items = plugin.schema.properties.apps.items
|
|
75
78
|
assert.deepEqual(items.required, ['url'])
|
|
76
79
|
})
|
|
80
|
+
|
|
81
|
+
it('defines showNightModeButton with default false', () => {
|
|
82
|
+
const plugin = pluginFactory(createMockApp())
|
|
83
|
+
const prop = plugin.schema.properties.showNightModeButton
|
|
84
|
+
assert.equal(prop.type, 'boolean')
|
|
85
|
+
assert.equal(prop.default, false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('defines showExitButton with default false', () => {
|
|
89
|
+
const plugin = pluginFactory(createMockApp())
|
|
90
|
+
const prop = plugin.schema.properties.showExitButton
|
|
91
|
+
assert.equal(prop.type, 'boolean')
|
|
92
|
+
assert.equal(prop.default, false)
|
|
93
|
+
})
|
|
77
94
|
})
|
|
78
95
|
|
|
79
96
|
describe('webapp discovery', () => {
|
|
80
|
-
it('filters out signalk
|
|
97
|
+
it('filters out @signalk/app-dock and admin-ui from /webapps', (t, done) => {
|
|
81
98
|
const app = createMockApp(MOCK_WEBAPPS)
|
|
82
99
|
const plugin = pluginFactory(app)
|
|
83
100
|
const routes = {}
|
|
@@ -91,7 +108,7 @@ describe('webapp discovery', () => {
|
|
|
91
108
|
const res = {
|
|
92
109
|
json: (data) => {
|
|
93
110
|
assert.equal(data.length, 2)
|
|
94
|
-
assert.ok(data.every((w) => w.name !== 'signalk
|
|
111
|
+
assert.ok(data.every((w) => w.name !== '@signalk/app-dock'))
|
|
95
112
|
assert.ok(data.every((w) => w.name !== '@signalk/server-admin-ui'))
|
|
96
113
|
done()
|
|
97
114
|
}
|
|
@@ -365,6 +382,140 @@ describe('package.json', () => {
|
|
|
365
382
|
})
|
|
366
383
|
})
|
|
367
384
|
|
|
385
|
+
describe('night mode PUT handler', () => {
|
|
386
|
+
it('registers PUT handler when showNightModeButton is true', () => {
|
|
387
|
+
let registered = false
|
|
388
|
+
const app = createMockApp()
|
|
389
|
+
app.registerPutHandler = (context, path, callback, sourceId) => {
|
|
390
|
+
registered = true
|
|
391
|
+
assert.equal(context, 'vessels.self')
|
|
392
|
+
assert.equal(path, 'environment.mode')
|
|
393
|
+
assert.equal(sourceId, 'signalk-app-dock')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const plugin = pluginFactory(app)
|
|
397
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
398
|
+
globalThis.setTimeout = (fn) => fn()
|
|
399
|
+
try {
|
|
400
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
401
|
+
} finally {
|
|
402
|
+
globalThis.setTimeout = originalSetTimeout
|
|
403
|
+
}
|
|
404
|
+
assert.ok(registered)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('does not register PUT handler when showNightModeButton is false', () => {
|
|
408
|
+
let registered = false
|
|
409
|
+
const app = createMockApp()
|
|
410
|
+
app.registerPutHandler = () => {
|
|
411
|
+
registered = true
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const plugin = pluginFactory(app)
|
|
415
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
416
|
+
globalThis.setTimeout = (fn) => fn()
|
|
417
|
+
try {
|
|
418
|
+
plugin.start({ showNightModeButton: false, apps: [] })
|
|
419
|
+
} finally {
|
|
420
|
+
globalThis.setTimeout = originalSetTimeout
|
|
421
|
+
}
|
|
422
|
+
assert.equal(registered, false)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('PUT handler accepts valid mode values and emits delta', () => {
|
|
426
|
+
const app = createMockApp()
|
|
427
|
+
let handlerCallback
|
|
428
|
+
let emittedDelta = null
|
|
429
|
+
app.registerPutHandler = (ctx, path, cb) => {
|
|
430
|
+
handlerCallback = cb
|
|
431
|
+
}
|
|
432
|
+
app.handleMessage = (id, delta) => {
|
|
433
|
+
emittedDelta = delta
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const plugin = pluginFactory(app)
|
|
437
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
438
|
+
globalThis.setTimeout = (fn) => fn()
|
|
439
|
+
try {
|
|
440
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
441
|
+
} finally {
|
|
442
|
+
globalThis.setTimeout = originalSetTimeout
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const result = handlerCallback('vessels.self', 'environment.mode', 'night', () => {})
|
|
446
|
+
assert.equal(result.state, 'COMPLETED')
|
|
447
|
+
assert.equal(result.statusCode, 200)
|
|
448
|
+
assert.equal(emittedDelta.updates[0].values[0].path, 'environment.mode')
|
|
449
|
+
assert.equal(emittedDelta.updates[0].values[0].value, 'night')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('PUT handler rejects invalid mode values', () => {
|
|
453
|
+
const app = createMockApp()
|
|
454
|
+
let handlerCallback
|
|
455
|
+
app.registerPutHandler = (ctx, path, cb) => {
|
|
456
|
+
handlerCallback = cb
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const plugin = pluginFactory(app)
|
|
460
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
461
|
+
globalThis.setTimeout = (fn) => fn()
|
|
462
|
+
try {
|
|
463
|
+
plugin.start({ showNightModeButton: true, apps: [] })
|
|
464
|
+
} finally {
|
|
465
|
+
globalThis.setTimeout = originalSetTimeout
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const result = handlerCallback('vessels.self', 'environment.mode', 'invalid', () => {})
|
|
469
|
+
assert.equal(result.statusCode, 400)
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
describe('mode endpoint', () => {
|
|
474
|
+
it('returns day when no mode is set', (t, done) => {
|
|
475
|
+
const app = createMockApp()
|
|
476
|
+
const plugin = pluginFactory(app)
|
|
477
|
+
const routes = {}
|
|
478
|
+
const router = {
|
|
479
|
+
get: (path, handler) => {
|
|
480
|
+
routes[path] = handler
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
plugin.registerWithRouter(router)
|
|
484
|
+
|
|
485
|
+
const res = {
|
|
486
|
+
json: (data) => {
|
|
487
|
+
assert.equal(data.value, 'day')
|
|
488
|
+
done()
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
routes['/mode']({}, res)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('returns current mode when set', (t, done) => {
|
|
495
|
+
const app = createMockApp()
|
|
496
|
+
app.getSelfPath = (path) => {
|
|
497
|
+
if (path === 'environment.mode') return { value: 'night' }
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
const plugin = pluginFactory(app)
|
|
501
|
+
const routes = {}
|
|
502
|
+
const router = {
|
|
503
|
+
get: (path, handler) => {
|
|
504
|
+
routes[path] = handler
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
plugin.registerWithRouter(router)
|
|
508
|
+
|
|
509
|
+
const res = {
|
|
510
|
+
json: (data) => {
|
|
511
|
+
assert.equal(data.value, 'night')
|
|
512
|
+
done()
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
routes['/mode']({}, res)
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
368
519
|
describe('public files', () => {
|
|
369
520
|
const publicDir = path.join(__dirname, '..', 'public')
|
|
370
521
|
|