@signalk/app-dock 0.2.0 → 0.2.1
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/package.json +1 -1
- package/plugin/index.js +76 -44
- package/public/805.js +1 -1
- package/public/app-icon.svg +5 -3
- package/public/dock.css +113 -1
- package/public/dock.js +78 -0
- package/public/index.html +23 -0
- package/src/configpanel/PluginConfigurationPanel.js +34 -6
- package/test/plugin.test.js +87 -104
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const DEFAULT_APPS = [
|
|
4
|
+
{
|
|
5
|
+
enabled: true,
|
|
6
|
+
autostart: true,
|
|
7
|
+
url: '/@signalk/freeboard-sk/',
|
|
8
|
+
label: 'Freeboard-SK',
|
|
9
|
+
icon: '/@signalk/freeboard-sk/assets/icons/icon-72x72.png',
|
|
10
|
+
color: ''
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
enabled: true,
|
|
14
|
+
autostart: false,
|
|
15
|
+
url: '/@mxtommy/kip/',
|
|
16
|
+
label: 'KIP',
|
|
17
|
+
icon: '/@mxtommy/kip/assets/icon-72x72.png',
|
|
18
|
+
color: ''
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
enabled: true,
|
|
22
|
+
autostart: false,
|
|
23
|
+
url: '/admin/',
|
|
24
|
+
label: 'Settings',
|
|
25
|
+
icon: '/@signalk/app-dock/icon-settings.svg',
|
|
26
|
+
color: '#78788c'
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
|
|
3
30
|
module.exports = (app) => {
|
|
4
31
|
let pluginSettings = {}
|
|
5
32
|
let resolvedApps = []
|
|
@@ -20,6 +47,22 @@ module.exports = (app) => {
|
|
|
20
47
|
name: 'App Dock',
|
|
21
48
|
|
|
22
49
|
start(settings) {
|
|
50
|
+
if (!Array.isArray(settings.apps)) {
|
|
51
|
+
const seeded = DEFAULT_APPS.map((a) => ({ ...a }))
|
|
52
|
+
const seededSettings = {
|
|
53
|
+
showNightModeButton: true,
|
|
54
|
+
showExitButton: false,
|
|
55
|
+
...settings,
|
|
56
|
+
apps: seeded
|
|
57
|
+
}
|
|
58
|
+
app.debug('App Dock: first run, seeding %d default apps', seeded.length)
|
|
59
|
+
app.savePluginOptions(seededSettings, (err) => {
|
|
60
|
+
if (err) app.error('App Dock: failed to save default apps: ' + err.message)
|
|
61
|
+
else app.debug('App Dock: saved default apps to config')
|
|
62
|
+
})
|
|
63
|
+
settings = seededSettings
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
pluginSettings = settings
|
|
24
67
|
|
|
25
68
|
if (settings.showNightModeButton) {
|
|
@@ -39,49 +82,17 @@ module.exports = (app) => {
|
|
|
39
82
|
)
|
|
40
83
|
}
|
|
41
84
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
merged.push({ enabled: true, url: w.url, label: '', icon: '', color: '' })
|
|
54
|
-
added = true
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const discoveredByUrl = {}
|
|
59
|
-
discovered.forEach((d) => {
|
|
60
|
-
discoveredByUrl[d.url] = d
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
resolvedApps = merged
|
|
64
|
-
.filter((a) => a.enabled !== false)
|
|
65
|
-
.map((a) => {
|
|
66
|
-
const match = discoveredByUrl[a.url]
|
|
67
|
-
return {
|
|
68
|
-
label: a.label || (match && match.label) || a.url,
|
|
69
|
-
url: a.url,
|
|
70
|
-
icon: a.icon || (match && match.icon) || null,
|
|
71
|
-
color: a.color || null,
|
|
72
|
-
autostart: a.autostart || false
|
|
73
|
-
}
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
if (added) {
|
|
77
|
-
app.savePluginOptions({ ...settings, apps: merged }, (err) => {
|
|
78
|
-
if (err) app.error('Failed to save discovered apps: ' + err.message)
|
|
79
|
-
else app.debug('Auto-discovered new webapps, saved to config')
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
app.debug('Dock apps: %s', resolvedApps.map((a) => a.label).join(', '))
|
|
84
|
-
}, 5000)
|
|
85
|
+
resolvedApps = (settings.apps || [])
|
|
86
|
+
.filter((a) => a.enabled !== false)
|
|
87
|
+
.map((a) => ({
|
|
88
|
+
label: a.label || a.url,
|
|
89
|
+
url: a.url,
|
|
90
|
+
icon: a.icon || null,
|
|
91
|
+
color: a.color || null,
|
|
92
|
+
autostart: a.autostart || false
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
app.debug('App Dock: resolved %d apps: %s', resolvedApps.length, resolvedApps.map((a) => a.label).join(', '))
|
|
85
96
|
},
|
|
86
97
|
|
|
87
98
|
stop() {},
|
|
@@ -102,6 +113,19 @@ module.exports = (app) => {
|
|
|
102
113
|
const current = app.getSelfPath('environment.mode')
|
|
103
114
|
res.json({ value: (current && current.value) || 'day' })
|
|
104
115
|
})
|
|
116
|
+
|
|
117
|
+
router.post('/dismiss-tour', (req, res) => {
|
|
118
|
+
const updated = { ...pluginSettings, tourDismissed: true }
|
|
119
|
+
app.savePluginOptions(updated, (err) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
app.error('App Dock: failed to save tourDismissed: ' + err.message)
|
|
122
|
+
res.status(500).json({ error: err.message })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
pluginSettings = updated
|
|
126
|
+
res.json({ tourDismissed: true })
|
|
127
|
+
})
|
|
128
|
+
})
|
|
105
129
|
},
|
|
106
130
|
|
|
107
131
|
schema: {
|
|
@@ -157,7 +181,7 @@ module.exports = (app) => {
|
|
|
157
181
|
type: 'boolean',
|
|
158
182
|
title: 'Show night/day mode toggle',
|
|
159
183
|
description: 'Adds a sun/moon button to the dock that toggles environment.mode',
|
|
160
|
-
default:
|
|
184
|
+
default: true
|
|
161
185
|
},
|
|
162
186
|
|
|
163
187
|
showExitButton: {
|
|
@@ -167,6 +191,14 @@ module.exports = (app) => {
|
|
|
167
191
|
default: false
|
|
168
192
|
},
|
|
169
193
|
|
|
194
|
+
tourDismissed: {
|
|
195
|
+
type: 'boolean',
|
|
196
|
+
title: 'Welcome tour dismissed',
|
|
197
|
+
description:
|
|
198
|
+
'Set automatically when a user clicks "Don\'t show again" in the welcome tour. Uncheck to show the tour again on all devices.',
|
|
199
|
+
default: false
|
|
200
|
+
},
|
|
201
|
+
|
|
170
202
|
apps: {
|
|
171
203
|
type: 'array',
|
|
172
204
|
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,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")))}}}]);
|
|
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||[]),[u,g]=(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,T]=(0,a.useState)(!1),[I,F]=(0,a.useState)(!1),[A,M]=(0,a.useState)(!1),[N,W]=(0,a.useState)(null),[$,_]=(0,a.useState)(null),O=(0,a.useRef)(s);O.current=s;const U=(0,a.useRef)(null);(0,a.useEffect)(()=>()=>{U.current&&clearTimeout(U.current)},[]);const j=(0,a.useCallback)(e=>({position:u,triggerCorner:p,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,showNightModeButton:x,showExitButton:C,apps:e}),[u,p,b,h,y,E,x,C]),L=(0,a.useCallback)(async()=>{U.current&&clearTimeout(U.current),M(!0),R("Saving…"),T(!1);try{const e=t(j(s));e&&"function"==typeof e.then&&await e,R("Configuration saved — plugin will restart"),T(!1),U.current=setTimeout(()=>R(""),5e3)}catch(e){R("Save failed: "+(e.message||e)),T(!0)}finally{M(!1)}},[s,j,t]),P=()=>_(null),K=()=>{W(null),_(null)},H=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:u,onChange:g,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,...I?{opacity:.6}:{}},onClick:async()=>{F(!0),R(""),T(!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?"),T(!0),void F(!1);const n=O.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.`),T(!1)}catch(e){R("Discovery failed: "+e.message),T(!0)}F(!1)},disabled:I},I?"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=>W(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),_(t)})(e,t),onDragLeave:P,onDrop:e=>((e,t)=>{if(e.preventDefault(),_(null),null!==N&&N!==t){const e=[...s],[n]=e.splice(N,1);e.splice(t,0,n),d(e)}W(null)})(e,t),onDragEnd:K,style:{...l.item,...!1===e.enabled?l.itemDisabled:{},...$===t?l.dragOver:{},...N===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")))),(H.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"}})),H.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,...A?{opacity:.6}:{}},onClick:L,disabled:A},A?"Saving…":"Save Configuration"),D&&a.createElement("div",{style:{...l.status,color:B?"#ef4444":"#10b981"}},D)))}}}]);
|
package/public/app-icon.svg
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="72" height="72">
|
|
2
2
|
<defs>
|
|
3
3
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
-
<stop offset="0%" stop-color="#
|
|
5
|
-
<stop offset="100%" stop-color="#
|
|
4
|
+
<stop offset="0%" stop-color="#60a5fa"/>
|
|
5
|
+
<stop offset="100%" stop-color="#2563eb"/>
|
|
6
6
|
</linearGradient>
|
|
7
7
|
</defs>
|
|
8
|
+
<!-- White base -->
|
|
9
|
+
<rect width="72" height="72" fill="#ffffff"/>
|
|
8
10
|
<!-- Background -->
|
|
9
11
|
<rect width="72" height="72" rx="16" fill="url(#bg)"/>
|
|
10
12
|
<!-- Dock pill -->
|
|
11
|
-
<rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.
|
|
13
|
+
<rect x="8" y="48" width="56" height="16" rx="8" fill="#ffffff" fill-opacity="0.25" stroke="#ffffff" stroke-opacity="0.4" stroke-width="0.5"/>
|
|
12
14
|
<!-- Dock icons - 3 squares in the pill -->
|
|
13
15
|
<rect x="14" y="51" width="10" height="10" rx="2.5" fill="#3b82f6" opacity="0.9"/>
|
|
14
16
|
<rect x="31" y="51" width="10" height="10" rx="2.5" fill="#10b981" opacity="0.9"/>
|
package/public/dock.css
CHANGED
|
@@ -120,7 +120,7 @@ body {
|
|
|
120
120
|
/* ─── Dock shell ────────────────────────────────────────────────────────────── */
|
|
121
121
|
#dock {
|
|
122
122
|
position: fixed;
|
|
123
|
-
z-index:
|
|
123
|
+
z-index: 40;
|
|
124
124
|
opacity: 0;
|
|
125
125
|
pointer-events: none;
|
|
126
126
|
transition:
|
|
@@ -447,3 +447,115 @@ body {
|
|
|
447
447
|
transform: rotate(360deg);
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
+
|
|
451
|
+
/* ─── Welcome tour ─────────────────────────────────────────────────────────── */
|
|
452
|
+
#tour-overlay {
|
|
453
|
+
position: fixed;
|
|
454
|
+
inset: 0;
|
|
455
|
+
z-index: 30;
|
|
456
|
+
display: flex;
|
|
457
|
+
align-items: center;
|
|
458
|
+
justify-content: center;
|
|
459
|
+
background: rgba(0, 0, 0, 0.6);
|
|
460
|
+
backdrop-filter: blur(4px);
|
|
461
|
+
-webkit-backdrop-filter: blur(4px);
|
|
462
|
+
opacity: 0;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
transition: opacity 0.3s ease;
|
|
465
|
+
padding: 24px;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#tour-overlay.visible {
|
|
469
|
+
opacity: 1;
|
|
470
|
+
pointer-events: auto;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#tour-card {
|
|
474
|
+
max-width: 520px;
|
|
475
|
+
width: 100%;
|
|
476
|
+
background: rgba(30, 30, 32, 0.9);
|
|
477
|
+
backdrop-filter: blur(40px) saturate(180%);
|
|
478
|
+
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
479
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
480
|
+
border-radius: 22px;
|
|
481
|
+
padding: 32px;
|
|
482
|
+
color: rgba(255, 255, 255, 0.92);
|
|
483
|
+
box-shadow:
|
|
484
|
+
0 24px 64px rgba(0, 0, 0, 0.6),
|
|
485
|
+
inset 0 0.5px 0 rgba(255, 255, 255, 0.15);
|
|
486
|
+
/* Position above the dock so user can see both */
|
|
487
|
+
margin-bottom: 180px;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#tour-title {
|
|
491
|
+
font-size: 22px;
|
|
492
|
+
font-weight: 700;
|
|
493
|
+
letter-spacing: 0.01em;
|
|
494
|
+
margin-bottom: 16px;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#tour-body {
|
|
498
|
+
font-size: 15px;
|
|
499
|
+
line-height: 1.55;
|
|
500
|
+
color: rgba(255, 255, 255, 0.78);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#tour-body p {
|
|
504
|
+
margin-bottom: 12px;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#tour-body p:last-child {
|
|
508
|
+
margin-bottom: 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#tour-link {
|
|
512
|
+
color: #60a5fa;
|
|
513
|
+
text-decoration: none;
|
|
514
|
+
font-weight: 600;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#tour-link:hover {
|
|
518
|
+
text-decoration: underline;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#tour-buttons {
|
|
522
|
+
display: flex;
|
|
523
|
+
gap: 10px;
|
|
524
|
+
margin-top: 24px;
|
|
525
|
+
justify-content: flex-end;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.tour-btn {
|
|
529
|
+
padding: 10px 20px;
|
|
530
|
+
border: none;
|
|
531
|
+
border-radius: 10px;
|
|
532
|
+
font-size: 14px;
|
|
533
|
+
font-weight: 600;
|
|
534
|
+
cursor: pointer;
|
|
535
|
+
font-family: inherit;
|
|
536
|
+
transition:
|
|
537
|
+
background 0.15s ease,
|
|
538
|
+
transform 0.1s ease;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.tour-btn-primary {
|
|
542
|
+
background: #3b82f6;
|
|
543
|
+
color: #fff;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.tour-btn-primary:hover {
|
|
547
|
+
background: #2563eb;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.tour-btn-dismiss {
|
|
551
|
+
background: rgba(255, 255, 255, 0.08);
|
|
552
|
+
color: rgba(255, 255, 255, 0.8);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.tour-btn-dismiss:hover {
|
|
556
|
+
background: rgba(255, 255, 255, 0.15);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.tour-btn:active {
|
|
560
|
+
transform: scale(0.97);
|
|
561
|
+
}
|
package/public/dock.js
CHANGED
|
@@ -420,6 +420,7 @@
|
|
|
420
420
|
|
|
421
421
|
function hideDock() {
|
|
422
422
|
if (!dockVisible) return
|
|
423
|
+
if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
|
|
423
424
|
dockVisible = false
|
|
424
425
|
$dock.classList.remove('visible')
|
|
425
426
|
$backdrop.classList.remove('visible')
|
|
@@ -561,6 +562,83 @@
|
|
|
561
562
|
setInterval(fetchCurrentMode, 5000)
|
|
562
563
|
}
|
|
563
564
|
|
|
565
|
+
async function refreshConfig() {
|
|
566
|
+
try {
|
|
567
|
+
const [configRes, settingsRes] = await Promise.all([
|
|
568
|
+
fetch('/plugins/signalk-app-dock/config'),
|
|
569
|
+
fetch('/plugins/signalk-app-dock/settings')
|
|
570
|
+
])
|
|
571
|
+
const prev = JSON.stringify(cfg)
|
|
572
|
+
if (configRes.ok) {
|
|
573
|
+
const data = await configRes.json()
|
|
574
|
+
const pluginCfg = data.configuration || data
|
|
575
|
+
cfg = { ...DEFAULTS, ...pluginCfg }
|
|
576
|
+
}
|
|
577
|
+
if (settingsRes.ok) {
|
|
578
|
+
const data = await settingsRes.json()
|
|
579
|
+
if (Array.isArray(data.apps) && data.apps.length > 0) {
|
|
580
|
+
cfg.apps = data.apps
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (JSON.stringify(cfg) !== prev) {
|
|
584
|
+
buildDock()
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// ignore transient errors (e.g. during plugin restart)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
setInterval(refreshConfig, 5000)
|
|
592
|
+
|
|
593
|
+
// ─── Welcome tour ────────────────────────────────────────────────────────────
|
|
594
|
+
const $tourOverlay = document.getElementById('tour-overlay')
|
|
595
|
+
const $tourGotIt = document.getElementById('tour-btn-got-it')
|
|
596
|
+
const $tourDismiss = document.getElementById('tour-btn-dismiss')
|
|
597
|
+
|
|
598
|
+
function hideTour() {
|
|
599
|
+
$tourOverlay.classList.remove('visible')
|
|
600
|
+
hideDock()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function showTour() {
|
|
604
|
+
$tourOverlay.classList.add('visible')
|
|
605
|
+
showDock()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if ($tourGotIt) $tourGotIt.addEventListener('click', hideTour)
|
|
609
|
+
if ($tourDismiss) {
|
|
610
|
+
$tourDismiss.addEventListener('click', async () => {
|
|
611
|
+
hideTour()
|
|
612
|
+
try {
|
|
613
|
+
await fetch('/plugins/signalk-app-dock/dismiss-tour', { method: 'POST' })
|
|
614
|
+
} catch {
|
|
615
|
+
// ignore network errors; worst case user sees the tour again next reload
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const $tourLink = document.getElementById('tour-link')
|
|
621
|
+
if ($tourLink) {
|
|
622
|
+
$tourLink.addEventListener('click', (e) => {
|
|
623
|
+
e.preventDefault()
|
|
624
|
+
const adminUrl = '/admin/#/serverConfiguration/plugins/signalk-app-dock'
|
|
625
|
+
hideIdleHint()
|
|
626
|
+
const $frame = document.createElement('iframe')
|
|
627
|
+
$frame.allow = 'fullscreen; geolocation'
|
|
628
|
+
$frame.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock'
|
|
629
|
+
$frame.style.touchAction = 'auto'
|
|
630
|
+
$frame.classList.add('active')
|
|
631
|
+
$iframeContainer.innerHTML = ''
|
|
632
|
+
Object.keys(iframes).forEach((k) => delete iframes[k])
|
|
633
|
+
$iframeContainer.appendChild($frame)
|
|
634
|
+
$frame.src = adminUrl
|
|
635
|
+
iframes[adminUrl] = $frame
|
|
636
|
+
hideTour()
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!cfg.tourDismissed) showTour()
|
|
641
|
+
|
|
564
642
|
const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
|
|
565
643
|
if (autostartIdx >= 0) {
|
|
566
644
|
switchToApp(autostartIdx)
|
package/public/index.html
CHANGED
|
@@ -36,6 +36,29 @@
|
|
|
36
36
|
<span id="loading-label"></span>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
+
<!-- First-run welcome tour -->
|
|
40
|
+
<div id="tour-overlay" aria-hidden="true">
|
|
41
|
+
<div id="tour-card" role="dialog" aria-labelledby="tour-title">
|
|
42
|
+
<div id="tour-title">Welcome to App Dock</div>
|
|
43
|
+
<div id="tour-body">
|
|
44
|
+
<p>App Dock gives you a macOS-style dock to quickly switch between Signal K webapps on touch screens.</p>
|
|
45
|
+
<p>
|
|
46
|
+
<strong>Double-tap the bottom-right corner</strong> of the screen to open the dock, then tap an icon to
|
|
47
|
+
launch that app.
|
|
48
|
+
</p>
|
|
49
|
+
<p>
|
|
50
|
+
Freeboard-SK is loading in the background and KIP is one tap away. Customize the apps, dock position, and
|
|
51
|
+
more in
|
|
52
|
+
<a id="tour-link" href="#">Plugin Config</a>.
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
<div id="tour-buttons">
|
|
56
|
+
<button id="tour-btn-got-it" class="tour-btn tour-btn-primary">Got it</button>
|
|
57
|
+
<button id="tour-btn-dismiss" class="tour-btn tour-btn-dismiss">Don't show again</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
39
62
|
<script src="dock.js"></script>
|
|
40
63
|
</body>
|
|
41
64
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback } from 'react'
|
|
1
|
+
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
|
2
2
|
|
|
3
3
|
const S = {
|
|
4
4
|
root: {
|
|
@@ -186,12 +186,20 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
186
186
|
const [status, setStatus] = useState('')
|
|
187
187
|
const [statusError, setStatusError] = useState(false)
|
|
188
188
|
const [discovering, setDiscovering] = useState(false)
|
|
189
|
+
const [saving, setSaving] = useState(false)
|
|
189
190
|
const [dragIdx, setDragIdx] = useState(null)
|
|
190
191
|
const [overIdx, setOverIdx] = useState(null)
|
|
191
192
|
|
|
192
193
|
const appsRef = useRef(apps)
|
|
193
194
|
appsRef.current = apps
|
|
194
195
|
|
|
196
|
+
const statusTimeoutRef = useRef(null)
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
return () => {
|
|
199
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
200
|
+
}
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
195
203
|
const buildConfig = useCallback(
|
|
196
204
|
(appsList) => ({
|
|
197
205
|
position,
|
|
@@ -216,10 +224,25 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
216
224
|
]
|
|
217
225
|
)
|
|
218
226
|
|
|
219
|
-
const doSave = useCallback(() => {
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
const doSave = useCallback(async () => {
|
|
228
|
+
if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
|
|
229
|
+
setSaving(true)
|
|
230
|
+
setStatus('Saving\u2026')
|
|
222
231
|
setStatusError(false)
|
|
232
|
+
try {
|
|
233
|
+
const result = save(buildConfig(apps))
|
|
234
|
+
if (result && typeof result.then === 'function') {
|
|
235
|
+
await result
|
|
236
|
+
}
|
|
237
|
+
setStatus('Configuration saved \u2014 plugin will restart')
|
|
238
|
+
setStatusError(false)
|
|
239
|
+
statusTimeoutRef.current = setTimeout(() => setStatus(''), 5000)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
setStatus('Save failed: ' + (e.message || e))
|
|
242
|
+
setStatusError(true)
|
|
243
|
+
} finally {
|
|
244
|
+
setSaving(false)
|
|
245
|
+
}
|
|
223
246
|
}, [apps, buildConfig, save])
|
|
224
247
|
|
|
225
248
|
const discover = async () => {
|
|
@@ -498,9 +521,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
|
|
|
498
521
|
)}
|
|
499
522
|
|
|
500
523
|
<div style={S.actions}>
|
|
501
|
-
<button
|
|
502
|
-
|
|
524
|
+
<button
|
|
525
|
+
style={{ ...S.btn, ...S.btnSave, ...(saving ? { opacity: 0.6 } : {}) }}
|
|
526
|
+
onClick={doSave}
|
|
527
|
+
disabled={saving}
|
|
528
|
+
>
|
|
529
|
+
{saving ? 'Saving\u2026' : 'Save Configuration'}
|
|
503
530
|
</button>
|
|
531
|
+
{status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
|
|
504
532
|
</div>
|
|
505
533
|
</div>
|
|
506
534
|
)
|
package/test/plugin.test.js
CHANGED
|
@@ -78,11 +78,11 @@ describe('schema', () => {
|
|
|
78
78
|
assert.deepEqual(items.required, ['url'])
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
it('defines showNightModeButton with default
|
|
81
|
+
it('defines showNightModeButton with default true', () => {
|
|
82
82
|
const plugin = pluginFactory(createMockApp())
|
|
83
83
|
const prop = plugin.schema.properties.showNightModeButton
|
|
84
84
|
assert.equal(prop.type, 'boolean')
|
|
85
|
-
assert.equal(prop.default,
|
|
85
|
+
assert.equal(prop.default, true)
|
|
86
86
|
})
|
|
87
87
|
|
|
88
88
|
it('defines showExitButton with default false', () => {
|
|
@@ -91,6 +91,18 @@ describe('schema', () => {
|
|
|
91
91
|
assert.equal(prop.type, 'boolean')
|
|
92
92
|
assert.equal(prop.default, false)
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
it('triggerCorner defaults to bottom-right', () => {
|
|
96
|
+
const plugin = pluginFactory(createMockApp())
|
|
97
|
+
assert.equal(plugin.schema.properties.triggerCorner.default, 'bottom-right')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('defines tourDismissed with default false', () => {
|
|
101
|
+
const plugin = pluginFactory(createMockApp())
|
|
102
|
+
const prop = plugin.schema.properties.tourDismissed
|
|
103
|
+
assert.equal(prop.type, 'boolean')
|
|
104
|
+
assert.equal(prop.default, false)
|
|
105
|
+
})
|
|
94
106
|
})
|
|
95
107
|
|
|
96
108
|
describe('webapp discovery', () => {
|
|
@@ -101,7 +113,8 @@ describe('webapp discovery', () => {
|
|
|
101
113
|
const router = {
|
|
102
114
|
get: (path, handler) => {
|
|
103
115
|
routes[path] = handler
|
|
104
|
-
}
|
|
116
|
+
},
|
|
117
|
+
post: () => {}
|
|
105
118
|
}
|
|
106
119
|
plugin.registerWithRouter(router)
|
|
107
120
|
|
|
@@ -123,7 +136,8 @@ describe('webapp discovery', () => {
|
|
|
123
136
|
const router = {
|
|
124
137
|
get: (path, handler) => {
|
|
125
138
|
routes[path] = handler
|
|
126
|
-
}
|
|
139
|
+
},
|
|
140
|
+
post: () => {}
|
|
127
141
|
}
|
|
128
142
|
plugin.registerWithRouter(router)
|
|
129
143
|
|
|
@@ -146,7 +160,8 @@ describe('webapp discovery', () => {
|
|
|
146
160
|
const router = {
|
|
147
161
|
get: (path, handler) => {
|
|
148
162
|
routes[path] = handler
|
|
149
|
-
}
|
|
163
|
+
},
|
|
164
|
+
post: () => {}
|
|
150
165
|
}
|
|
151
166
|
plugin.registerWithRouter(router)
|
|
152
167
|
|
|
@@ -169,7 +184,8 @@ describe('webapp discovery', () => {
|
|
|
169
184
|
const router = {
|
|
170
185
|
get: (path, handler) => {
|
|
171
186
|
routes[path] = handler
|
|
172
|
-
}
|
|
187
|
+
},
|
|
188
|
+
post: () => {}
|
|
173
189
|
}
|
|
174
190
|
plugin.registerWithRouter(router)
|
|
175
191
|
|
|
@@ -193,7 +209,8 @@ describe('settings endpoint', () => {
|
|
|
193
209
|
const router = {
|
|
194
210
|
get: (path, handler) => {
|
|
195
211
|
routes[path] = handler
|
|
196
|
-
}
|
|
212
|
+
},
|
|
213
|
+
post: () => {}
|
|
197
214
|
}
|
|
198
215
|
plugin.registerWithRouter(router)
|
|
199
216
|
|
|
@@ -209,9 +226,9 @@ describe('settings endpoint', () => {
|
|
|
209
226
|
})
|
|
210
227
|
})
|
|
211
228
|
|
|
212
|
-
describe('
|
|
213
|
-
it('
|
|
214
|
-
const app = createMockApp(
|
|
229
|
+
describe('default apps seeding', () => {
|
|
230
|
+
it('seeds default apps when config has no apps key', () => {
|
|
231
|
+
const app = createMockApp()
|
|
215
232
|
let savedOptions = null
|
|
216
233
|
app.savePluginOptions = (opts, cb) => {
|
|
217
234
|
savedOptions = opts
|
|
@@ -219,73 +236,46 @@ describe('auto-discovery on start', () => {
|
|
|
219
236
|
}
|
|
220
237
|
|
|
221
238
|
const plugin = pluginFactory(app)
|
|
222
|
-
|
|
223
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
224
|
-
globalThis.setTimeout = (fn) => fn()
|
|
225
|
-
try {
|
|
226
|
-
plugin.start({ apps: [] })
|
|
227
|
-
} finally {
|
|
228
|
-
globalThis.setTimeout = originalSetTimeout
|
|
229
|
-
}
|
|
239
|
+
plugin.start({})
|
|
230
240
|
|
|
231
241
|
assert.ok(savedOptions)
|
|
232
|
-
assert.
|
|
233
|
-
assert.
|
|
234
|
-
assert.
|
|
235
|
-
|
|
242
|
+
assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
|
|
243
|
+
assert.equal(savedOptions.apps[0].autostart, true)
|
|
244
|
+
assert.equal(savedOptions.apps[1].url, '/@mxtommy/kip/')
|
|
245
|
+
assert.equal(savedOptions.apps[1].autostart, false)
|
|
246
|
+
assert.equal(savedOptions.apps[2].url, '/admin/')
|
|
247
|
+
assert.equal(savedOptions.apps[2].label, 'Settings')
|
|
236
248
|
})
|
|
237
249
|
|
|
238
|
-
it('does not
|
|
239
|
-
const app = createMockApp(
|
|
240
|
-
let
|
|
241
|
-
app.savePluginOptions = (
|
|
242
|
-
|
|
243
|
-
cb(null)
|
|
250
|
+
it('does not seed when apps array already exists (even if empty)', () => {
|
|
251
|
+
const app = createMockApp()
|
|
252
|
+
let saveCalled = false
|
|
253
|
+
app.savePluginOptions = () => {
|
|
254
|
+
saveCalled = true
|
|
244
255
|
}
|
|
245
256
|
|
|
246
257
|
const plugin = pluginFactory(app)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
250
|
-
globalThis.setTimeout = (fn) => fn()
|
|
251
|
-
try {
|
|
252
|
-
plugin.start({ apps: existing })
|
|
253
|
-
} finally {
|
|
254
|
-
globalThis.setTimeout = originalSetTimeout
|
|
255
|
-
}
|
|
258
|
+
plugin.start({ apps: [] })
|
|
256
259
|
|
|
257
|
-
|
|
258
|
-
assert.equal(fbEntries.length, 1)
|
|
259
|
-
assert.equal(fbEntries[0].label, 'FB')
|
|
260
|
-
done()
|
|
260
|
+
assert.equal(saveCalled, false)
|
|
261
261
|
})
|
|
262
262
|
|
|
263
|
-
it('
|
|
264
|
-
const app = createMockApp(
|
|
263
|
+
it('preserves existing configured apps', () => {
|
|
264
|
+
const app = createMockApp()
|
|
265
265
|
let saveCalled = false
|
|
266
266
|
app.savePluginOptions = () => {
|
|
267
267
|
saveCalled = true
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const plugin = pluginFactory(app)
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
274
|
-
]
|
|
275
|
-
|
|
276
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
277
|
-
globalThis.setTimeout = (fn) => fn()
|
|
278
|
-
try {
|
|
279
|
-
plugin.start({ apps: allConfigured })
|
|
280
|
-
} finally {
|
|
281
|
-
globalThis.setTimeout = originalSetTimeout
|
|
282
|
-
}
|
|
271
|
+
const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
|
|
272
|
+
plugin.start({ apps: existing })
|
|
283
273
|
|
|
284
274
|
assert.equal(saveCalled, false)
|
|
285
275
|
})
|
|
286
276
|
|
|
287
277
|
it('respects enabled:false when resolving apps', () => {
|
|
288
|
-
const app = createMockApp(
|
|
278
|
+
const app = createMockApp()
|
|
289
279
|
app.savePluginOptions = (_, cb) => cb(null)
|
|
290
280
|
const plugin = pluginFactory(app)
|
|
291
281
|
|
|
@@ -293,20 +283,14 @@ describe('auto-discovery on start', () => {
|
|
|
293
283
|
{ enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
|
|
294
284
|
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
295
285
|
]
|
|
296
|
-
|
|
297
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
298
|
-
globalThis.setTimeout = (fn) => fn()
|
|
299
|
-
try {
|
|
300
|
-
plugin.start({ apps: configured })
|
|
301
|
-
} finally {
|
|
302
|
-
globalThis.setTimeout = originalSetTimeout
|
|
303
|
-
}
|
|
286
|
+
plugin.start({ apps: configured })
|
|
304
287
|
|
|
305
288
|
const routes = {}
|
|
306
289
|
const router = {
|
|
307
290
|
get: (path, handler) => {
|
|
308
291
|
routes[path] = handler
|
|
309
|
-
}
|
|
292
|
+
},
|
|
293
|
+
post: () => {}
|
|
310
294
|
}
|
|
311
295
|
plugin.registerWithRouter(router)
|
|
312
296
|
|
|
@@ -322,43 +306,6 @@ describe('auto-discovery on start', () => {
|
|
|
322
306
|
assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
|
|
323
307
|
assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
|
|
324
308
|
})
|
|
325
|
-
|
|
326
|
-
it('uses discovered label/icon when config overrides are empty', () => {
|
|
327
|
-
const app = createMockApp(MOCK_WEBAPPS)
|
|
328
|
-
app.savePluginOptions = (_, cb) => cb(null)
|
|
329
|
-
const plugin = pluginFactory(app)
|
|
330
|
-
|
|
331
|
-
const configured = [{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }]
|
|
332
|
-
|
|
333
|
-
const originalSetTimeout = globalThis.setTimeout
|
|
334
|
-
globalThis.setTimeout = (fn) => fn()
|
|
335
|
-
try {
|
|
336
|
-
plugin.start({ apps: configured })
|
|
337
|
-
} finally {
|
|
338
|
-
globalThis.setTimeout = originalSetTimeout
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const routes = {}
|
|
342
|
-
const router = {
|
|
343
|
-
get: (path, handler) => {
|
|
344
|
-
routes[path] = handler
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
plugin.registerWithRouter(router)
|
|
348
|
-
|
|
349
|
-
let resolved
|
|
350
|
-
routes['/settings'](
|
|
351
|
-
{},
|
|
352
|
-
{
|
|
353
|
-
json: (d) => {
|
|
354
|
-
resolved = d
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
)
|
|
358
|
-
const kip = resolved.apps.find((a) => a.url === '/@mxtommy/kip/')
|
|
359
|
-
assert.equal(kip.label, 'KIP Instrument MFD')
|
|
360
|
-
assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
|
|
361
|
-
})
|
|
362
309
|
})
|
|
363
310
|
|
|
364
311
|
describe('package.json', () => {
|
|
@@ -470,6 +417,40 @@ describe('night mode PUT handler', () => {
|
|
|
470
417
|
})
|
|
471
418
|
})
|
|
472
419
|
|
|
420
|
+
describe('dismiss-tour endpoint', () => {
|
|
421
|
+
it('saves tourDismissed: true and responds', (t, done) => {
|
|
422
|
+
const app = createMockApp()
|
|
423
|
+
let saved = null
|
|
424
|
+
app.savePluginOptions = (opts, cb) => {
|
|
425
|
+
saved = opts
|
|
426
|
+
cb(null)
|
|
427
|
+
}
|
|
428
|
+
const plugin = pluginFactory(app)
|
|
429
|
+
plugin.start({ apps: [], position: 'left' })
|
|
430
|
+
|
|
431
|
+
const routes = { get: {}, post: {} }
|
|
432
|
+
const router = {
|
|
433
|
+
get: (path, handler) => {
|
|
434
|
+
routes.get[path] = handler
|
|
435
|
+
},
|
|
436
|
+
post: (path, handler) => {
|
|
437
|
+
routes.post[path] = handler
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
plugin.registerWithRouter(router)
|
|
441
|
+
|
|
442
|
+
const res = {
|
|
443
|
+
json: (data) => {
|
|
444
|
+
assert.equal(data.tourDismissed, true)
|
|
445
|
+
assert.equal(saved.tourDismissed, true)
|
|
446
|
+
assert.equal(saved.position, 'left')
|
|
447
|
+
done()
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
routes.post['/dismiss-tour']({}, res)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
473
454
|
describe('mode endpoint', () => {
|
|
474
455
|
it('returns day when no mode is set', (t, done) => {
|
|
475
456
|
const app = createMockApp()
|
|
@@ -478,7 +459,8 @@ describe('mode endpoint', () => {
|
|
|
478
459
|
const router = {
|
|
479
460
|
get: (path, handler) => {
|
|
480
461
|
routes[path] = handler
|
|
481
|
-
}
|
|
462
|
+
},
|
|
463
|
+
post: () => {}
|
|
482
464
|
}
|
|
483
465
|
plugin.registerWithRouter(router)
|
|
484
466
|
|
|
@@ -502,7 +484,8 @@ describe('mode endpoint', () => {
|
|
|
502
484
|
const router = {
|
|
503
485
|
get: (path, handler) => {
|
|
504
486
|
routes[path] = handler
|
|
505
|
-
}
|
|
487
|
+
},
|
|
488
|
+
post: () => {}
|
|
506
489
|
}
|
|
507
490
|
plugin.registerWithRouter(router)
|
|
508
491
|
|