@signalk/app-dock 0.1.7 → 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/eslint.config.js CHANGED
@@ -40,6 +40,7 @@ module.exports = [
40
40
  console: 'readonly',
41
41
  setTimeout: 'readonly',
42
42
  clearTimeout: 'readonly',
43
+ setInterval: 'readonly',
43
44
  requestAnimationFrame: 'readonly',
44
45
  Promise: 'readonly',
45
46
  Set: 'readonly',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalk/app-dock",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "macOS-style configurable app dock for switching between SignalK webapps on touch screens",
5
5
  "repository": {
6
6
  "url": "https://github.com/SignalK/app-dock"
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,51 +47,52 @@ module.exports = (app) => {
20
47
  name: 'App Dock',
21
48
 
22
49
  start(settings) {
23
- pluginSettings = settings
24
-
25
- setTimeout(() => {
26
- const discovered = getWebapps()
27
- if (discovered.length === 0) return
28
-
29
- const configured = settings.apps || []
30
- const configuredUrls = new Set(configured.map((a) => a.url))
31
-
32
- const merged = [...configured]
33
- let added = false
34
- for (const w of discovered) {
35
- if (!configuredUrls.has(w.url)) {
36
- merged.push({ enabled: true, url: w.url, label: '', icon: '', color: '' })
37
- added = true
38
- }
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
39
57
  }
40
-
41
- const discoveredByUrl = {}
42
- discovered.forEach((d) => {
43
- discoveredByUrl[d.url] = d
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')
44
62
  })
63
+ settings = seededSettings
64
+ }
45
65
 
46
- resolvedApps = merged
47
- .filter((a) => a.enabled !== false)
48
- .map((a) => {
49
- const match = discoveredByUrl[a.url]
50
- return {
51
- label: a.label || (match && match.label) || a.url,
52
- url: a.url,
53
- icon: a.icon || (match && match.icon) || null,
54
- color: a.color || null,
55
- autostart: a.autostart || false
56
- }
57
- })
66
+ pluginSettings = settings
58
67
 
59
- if (added) {
60
- app.savePluginOptions({ ...settings, apps: merged }, (err) => {
61
- if (err) app.error('Failed to save discovered apps: ' + err.message)
62
- else app.debug('Auto-discovered new webapps, saved to config')
63
- })
64
- }
68
+ if (settings.showNightModeButton) {
69
+ app.registerPutHandler(
70
+ 'vessels.self',
71
+ 'environment.mode',
72
+ (context, path, value, _callback) => {
73
+ if (value !== 'day' && value !== 'night') {
74
+ return { state: 'COMPLETED', statusCode: 400, message: 'Invalid mode' }
75
+ }
76
+ app.handleMessage(plugin.id, {
77
+ updates: [{ values: [{ path: 'environment.mode', value }] }]
78
+ })
79
+ return { state: 'COMPLETED', statusCode: 200 }
80
+ },
81
+ plugin.id
82
+ )
83
+ }
65
84
 
66
- app.debug('Dock apps: %s', resolvedApps.map((a) => a.label).join(', '))
67
- }, 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(', '))
68
96
  },
69
97
 
70
98
  stop() {},
@@ -80,6 +108,24 @@ module.exports = (app) => {
80
108
  router.get('/webapps', (req, res) => {
81
109
  res.json(getWebapps())
82
110
  })
111
+
112
+ router.get('/mode', (req, res) => {
113
+ const current = app.getSelfPath('environment.mode')
114
+ res.json({ value: (current && current.value) || 'day' })
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
+ })
83
129
  },
84
130
 
85
131
  schema: {
@@ -131,6 +177,28 @@ module.exports = (app) => {
131
177
  default: 1.7
132
178
  },
133
179
 
180
+ showNightModeButton: {
181
+ type: 'boolean',
182
+ title: 'Show night/day mode toggle',
183
+ description: 'Adds a sun/moon button to the dock that toggles environment.mode',
184
+ default: true
185
+ },
186
+
187
+ showExitButton: {
188
+ type: 'boolean',
189
+ title: 'Show exit button',
190
+ description: 'Adds an X button to the dock that returns to Signal K admin UI',
191
+ default: false
192
+ },
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
+
134
202
  apps: {
135
203
  type: 'array',
136
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,a){a.r(t),a.d(t,{default:()=>s});var l=a(231);const n={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:a,size:o}){const i=o||36,r={...n.iconBox,width:i,height:i,borderRadius:.22*i,...a?{background:a}:{}},c=e||"";return c.startsWith("/")||c.startsWith("http")?l.createElement("div",{style:r},l.createElement("img",{src:c,alt:t,style:{...n.iconImg,padding:.12*i},onError:e=>{e.target.style.display="none",e.target.parentNode.textContent=(t||"??").slice(0,2).toUpperCase()}})):l.createElement("div",{style:r},c||(t||"??").slice(0,2).toUpperCase())}function i({label:e,value:t,options:a,onChange:o,hint:i}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("select",{style:n.select,value:t,onChange:e=>o(e.target.value)},a.map(e=>l.createElement("option",{key:e.value,value:e.value},e.label))),i&&l.createElement("span",{style:n.hint},i))}function r({label:e,value:t,onChange:a,hint:o}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("input",{style:n.input,type:"number",value:t,onChange:e=>a(Number(e.target.value))}),o&&l.createElement("span",{style:n.hint},o))}function c({label:e,checked:t,onChange:a,hint:o}){return l.createElement("div",{style:n.fieldRow},l.createElement("span",{style:n.label},e),l.createElement("input",{type:"checkbox",style:n.checkboxField,checked:t,onChange:e=>a(e.target.checked)}),o&&l.createElement("span",{style:n.hint},o))}function s({configuration:e,save:t}){const a=e||{},[s,d]=(0,l.useState)(()=>a.apps||[]),[p,u]=(0,l.useState)(a.position||"bottom"),[g,m]=(0,l.useState)(a.triggerCorner||"bottom-right"),[b,f]=(0,l.useState)(a.iframeMode||"keep-alive"),[h,v]=(0,l.useState)(a.iconSize||56),[y,k]=(0,l.useState)(!1!==a.magnification),[E,S]=(0,l.useState)(a.magnificationScale||1.7),[x,C]=(0,l.useState)(""),[w,D]=(0,l.useState)(!1),[z,R]=(0,l.useState)(!1),[I,T]=(0,l.useState)(null),[A,B]=(0,l.useState)(null),W=(0,l.useRef)(s);W.current=s;const F=(0,l.useCallback)(e=>({position:p,triggerCorner:g,iframeMode:b,iconSize:h,magnification:y,magnificationScale:E,apps:e}),[p,g,b,h,y,E]),M=(0,l.useCallback)(()=>{t(F(s)),C("Saved!"),D(!1)},[s,F,t]),N=()=>B(null),$=()=>{T(null),B(null)},_=s.filter(e=>!1!==e.enabled);return l.createElement("div",{style:n.root},l.createElement("div",{style:n.sectionTitle},"Dock Settings"),l.createElement(i,{label:"Dock position",value:p,onChange:u,options:[{value:"bottom",label:"Bottom"},{value:"top",label:"Top"},{value:"left",label:"Left"},{value:"right",label:"Right"}]}),l.createElement(i,{label:"Double-tap corner",value:g,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"}]}),l.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)"}]}),l.createElement(r,{label:"Icon size",value:h,onChange:v,hint:"px"}),l.createElement(c,{label:"Magnification effect",checked:y,onChange:k}),y&&l.createElement(r,{label:"Magnification scale",value:E,onChange:S,hint:"1.0 - 2.5"}),l.createElement("div",{style:n.sectionTitle},"Webapps"),l.createElement("button",{style:{...n.btn,...n.btnPrimary,...z?{opacity:.6}:{}},onClick:async()=>{R(!0),C(""),D(!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 C("No webapps found. Is the server fully started?"),D(!0),void R(!1);const a=W.current,l=new Set(a.map(e=>e.url));let n=0;const o=[...a];for(const e of t)l.has(e.url)||(o.push({enabled:!0,autostart:!1,url:e.url,label:e.label||"",icon:e.icon||"",color:e.isAdmin?"#78788c":""}),n++);d(o),C(n>0?`Found ${t.length} webapps, added ${n} new.`:`All ${t.length} webapps already in list.`),D(!1)}catch(e){C("Discovery failed: "+e.message),D(!0)}R(!1)},disabled:z},z?"Discovering…":"Discover Installed Webapps"),x&&l.createElement("div",{style:{...n.status,color:w?"#ef4444":"#10b981"}},x),l.createElement("div",{style:n.sectionTitle},"Dock Apps (drag to reorder)"),0===s.length?l.createElement("div",{style:n.empty},"No apps yet. Click Discover above."):l.createElement("div",null,s.map((e,t)=>l.createElement("div",{key:e.url+t,draggable:!0,onDragStart:()=>(e=>T(e))(t),onDragOver:e=>((e,t)=>{e.preventDefault(),B(t)})(e,t),onDragLeave:N,onDrop:e=>((e,t)=>{if(e.preventDefault(),B(null),null!==I&&I!==t){const e=[...s],[a]=e.splice(I,1);e.splice(t,0,a),d(e)}T(null)})(e,t),onDragEnd:$,style:{...n.item,...!1===e.enabled?n.itemDisabled:{},...A===t?n.dragOver:{},...I===t?{opacity:.4}:{}}},l.createElement("span",{style:n.handle},"≡"),l.createElement(o,{icon:e.icon,label:e.label||e.url,color:e.color}),l.createElement("div",{style:n.info},l.createElement("div",{style:n.name},e.label||e.url),l.createElement("div",{style:n.url},e.url)),l.createElement("div",{style:n.toggle,title:"Enabled"},l.createElement("input",{type:"checkbox",checked:!1!==e.enabled,onChange:()=>(e=>d(s.map((t,a)=>a===e?{...t,enabled:!t.enabled}:t)))(t),style:n.checkbox})),l.createElement("div",{style:{...n.toggle,cursor:"pointer",fontSize:16,opacity:e.autostart?1:.25},onClick:()=>(e=>d(s.map((t,a)=>a===e?{...t,autostart:!t.autostart}:{...t,autostart:!1})))(t),title:e.autostart?"Autostart: ON (click to disable)":"Click to set as autostart app"},"▶"),l.createElement("button",{style:{...n.btn,...n.btnDanger},onClick:()=>(e=>d(s.filter((t,a)=>a!==e)))(t)},"Remove")))),_.length>0&&l.createElement(l.Fragment,null,l.createElement("div",{style:n.sectionTitle},"Preview"),l.createElement("div",{style:n.preview},_.map((e,t)=>l.createElement(o,{key:t,icon:e.icon,label:e.label||e.url,color:e.color,size:40})))),l.createElement("div",{style:n.actions},l.createElement("button",{style:{...n.btn,...n.btnSave},onClick:M},"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)))}}}]);
@@ -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="#1e3a5f"/>
5
- <stop offset="100%" stop-color="#0a1628"/>
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.1" stroke="#ffffff" stroke-opacity="0.2" stroke-width="0.5"/>
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: 20;
123
+ z-index: 40;
124
124
  opacity: 0;
125
125
  pointer-events: none;
126
126
  transition:
@@ -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;
@@ -410,3 +447,115 @@ body {
410
447
  transform: rotate(360deg);
411
448
  }
412
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
@@ -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}`)
@@ -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 ───────────────────────────────────────────────────────────
@@ -311,6 +420,7 @@
311
420
 
312
421
  function hideDock() {
313
422
  if (!dockVisible) return
423
+ if ($tourOverlay && $tourOverlay.classList.contains('visible')) return
314
424
  dockVisible = false
315
425
  $dock.classList.remove('visible')
316
426
  $backdrop.classList.remove('visible')
@@ -447,6 +557,88 @@
447
557
  positionCornerZone()
448
558
  buildDock()
449
559
 
560
+ if (cfg.showNightModeButton) {
561
+ fetchCurrentMode()
562
+ setInterval(fetchCurrentMode, 5000)
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
+
450
642
  const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
451
643
  if (autostartIdx >= 0) {
452
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: {
@@ -180,16 +180,26 @@ 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)
186
188
  const [discovering, setDiscovering] = useState(false)
189
+ const [saving, setSaving] = useState(false)
187
190
  const [dragIdx, setDragIdx] = useState(null)
188
191
  const [overIdx, setOverIdx] = useState(null)
189
192
 
190
193
  const appsRef = useRef(apps)
191
194
  appsRef.current = apps
192
195
 
196
+ const statusTimeoutRef = useRef(null)
197
+ useEffect(() => {
198
+ return () => {
199
+ if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
200
+ }
201
+ }, [])
202
+
193
203
  const buildConfig = useCallback(
194
204
  (appsList) => ({
195
205
  position,
@@ -198,15 +208,41 @@ export default function PluginConfigurationPanel({ configuration, save }) {
198
208
  iconSize,
199
209
  magnification,
200
210
  magnificationScale,
211
+ showNightModeButton,
212
+ showExitButton,
201
213
  apps: appsList
202
214
  }),
203
- [position, triggerCorner, iframeMode, iconSize, magnification, magnificationScale]
215
+ [
216
+ position,
217
+ triggerCorner,
218
+ iframeMode,
219
+ iconSize,
220
+ magnification,
221
+ magnificationScale,
222
+ showNightModeButton,
223
+ showExitButton
224
+ ]
204
225
  )
205
226
 
206
- const doSave = useCallback(() => {
207
- save(buildConfig(apps))
208
- setStatus('Saved!')
227
+ const doSave = useCallback(async () => {
228
+ if (statusTimeoutRef.current) clearTimeout(statusTimeoutRef.current)
229
+ setSaving(true)
230
+ setStatus('Saving\u2026')
209
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
+ }
210
246
  }, [apps, buildConfig, save])
211
247
 
212
248
  const discover = async () => {
@@ -355,6 +391,22 @@ export default function PluginConfigurationPanel({ configuration, save }) {
355
391
  />
356
392
  )}
357
393
 
394
+ <div style={S.sectionTitle}>Utility Buttons</div>
395
+
396
+ <CheckboxField
397
+ label="Night/Day mode toggle"
398
+ checked={showNightModeButton}
399
+ onChange={setShowNightModeButton}
400
+ hint="Sun/moon button to toggle environment.mode"
401
+ />
402
+
403
+ <CheckboxField
404
+ label="Exit button"
405
+ checked={showExitButton}
406
+ onChange={setShowExitButton}
407
+ hint="X button to return to Signal K admin UI"
408
+ />
409
+
358
410
  <div style={S.sectionTitle}>Webapps</div>
359
411
  <button
360
412
  style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
@@ -420,21 +472,63 @@ export default function PluginConfigurationPanel({ configuration, save }) {
420
472
  </div>
421
473
  )}
422
474
 
423
- {enabledApps.length > 0 && (
475
+ {(enabledApps.length > 0 || showNightModeButton || showExitButton) && (
424
476
  <>
425
477
  <div style={S.sectionTitle}>Preview</div>
426
478
  <div style={S.preview}>
479
+ {showNightModeButton && (
480
+ <>
481
+ <div
482
+ style={{
483
+ ...S.iconBox,
484
+ width: 40,
485
+ height: 40,
486
+ background: 'rgba(255,255,255,0.08)',
487
+ border: '1px solid rgba(255,255,255,0.12)',
488
+ fontSize: 20,
489
+ borderRadius: 10
490
+ }}
491
+ >
492
+ {'\u2600\uFE0F'}
493
+ </div>
494
+ <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
495
+ </>
496
+ )}
427
497
  {enabledApps.map((app, i) => (
428
498
  <IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
429
499
  ))}
500
+ {showExitButton && (
501
+ <>
502
+ <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
503
+ <div
504
+ style={{
505
+ ...S.iconBox,
506
+ width: 40,
507
+ height: 40,
508
+ background: 'rgba(255,255,255,0.08)',
509
+ border: '1px solid rgba(255,255,255,0.12)',
510
+ fontSize: 16,
511
+ borderRadius: 10,
512
+ color: '#fff'
513
+ }}
514
+ >
515
+ {'\u2715'}
516
+ </div>
517
+ </>
518
+ )}
430
519
  </div>
431
520
  </>
432
521
  )}
433
522
 
434
523
  <div style={S.actions}>
435
- <button style={{ ...S.btn, ...S.btnSave }} onClick={doSave}>
436
- Save Configuration
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'}
437
530
  </button>
531
+ {status && <div style={{ ...S.status, color: statusError ? '#ef4444' : '#10b981' }}>{status}</div>}
438
532
  </div>
439
533
  </div>
440
534
  )
@@ -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
 
@@ -74,6 +77,32 @@ 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 true', () => {
82
+ const plugin = pluginFactory(createMockApp())
83
+ const prop = plugin.schema.properties.showNightModeButton
84
+ assert.equal(prop.type, 'boolean')
85
+ assert.equal(prop.default, true)
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
+ })
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
+ })
77
106
  })
78
107
 
79
108
  describe('webapp discovery', () => {
@@ -84,7 +113,8 @@ describe('webapp discovery', () => {
84
113
  const router = {
85
114
  get: (path, handler) => {
86
115
  routes[path] = handler
87
- }
116
+ },
117
+ post: () => {}
88
118
  }
89
119
  plugin.registerWithRouter(router)
90
120
 
@@ -106,7 +136,8 @@ describe('webapp discovery', () => {
106
136
  const router = {
107
137
  get: (path, handler) => {
108
138
  routes[path] = handler
109
- }
139
+ },
140
+ post: () => {}
110
141
  }
111
142
  plugin.registerWithRouter(router)
112
143
 
@@ -129,7 +160,8 @@ describe('webapp discovery', () => {
129
160
  const router = {
130
161
  get: (path, handler) => {
131
162
  routes[path] = handler
132
- }
163
+ },
164
+ post: () => {}
133
165
  }
134
166
  plugin.registerWithRouter(router)
135
167
 
@@ -152,7 +184,8 @@ describe('webapp discovery', () => {
152
184
  const router = {
153
185
  get: (path, handler) => {
154
186
  routes[path] = handler
155
- }
187
+ },
188
+ post: () => {}
156
189
  }
157
190
  plugin.registerWithRouter(router)
158
191
 
@@ -176,7 +209,8 @@ describe('settings endpoint', () => {
176
209
  const router = {
177
210
  get: (path, handler) => {
178
211
  routes[path] = handler
179
- }
212
+ },
213
+ post: () => {}
180
214
  }
181
215
  plugin.registerWithRouter(router)
182
216
 
@@ -192,9 +226,9 @@ describe('settings endpoint', () => {
192
226
  })
193
227
  })
194
228
 
195
- describe('auto-discovery on start', () => {
196
- it('merges discovered webapps into configured apps after delay', (t, done) => {
197
- const app = createMockApp(MOCK_WEBAPPS)
229
+ describe('default apps seeding', () => {
230
+ it('seeds default apps when config has no apps key', () => {
231
+ const app = createMockApp()
198
232
  let savedOptions = null
199
233
  app.savePluginOptions = (opts, cb) => {
200
234
  savedOptions = opts
@@ -202,73 +236,46 @@ describe('auto-discovery on start', () => {
202
236
  }
203
237
 
204
238
  const plugin = pluginFactory(app)
205
-
206
- const originalSetTimeout = globalThis.setTimeout
207
- globalThis.setTimeout = (fn) => fn()
208
- try {
209
- plugin.start({ apps: [] })
210
- } finally {
211
- globalThis.setTimeout = originalSetTimeout
212
- }
239
+ plugin.start({})
213
240
 
214
241
  assert.ok(savedOptions)
215
- assert.ok(savedOptions.apps.length >= 2)
216
- assert.ok(savedOptions.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
217
- assert.ok(savedOptions.apps.some((a) => a.url === '/@mxtommy/kip/'))
218
- done()
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')
219
248
  })
220
249
 
221
- it('does not duplicate already-configured apps', (t, done) => {
222
- const app = createMockApp(MOCK_WEBAPPS)
223
- let savedOptions = null
224
- app.savePluginOptions = (opts, cb) => {
225
- savedOptions = opts
226
- 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
227
255
  }
228
256
 
229
257
  const plugin = pluginFactory(app)
230
- const existing = [{ enabled: true, url: '/@signalk/freeboard-sk/', label: 'FB', icon: '', color: '' }]
231
-
232
- const originalSetTimeout = globalThis.setTimeout
233
- globalThis.setTimeout = (fn) => fn()
234
- try {
235
- plugin.start({ apps: existing })
236
- } finally {
237
- globalThis.setTimeout = originalSetTimeout
238
- }
258
+ plugin.start({ apps: [] })
239
259
 
240
- const fbEntries = savedOptions.apps.filter((a) => a.url === '/@signalk/freeboard-sk/')
241
- assert.equal(fbEntries.length, 1)
242
- assert.equal(fbEntries[0].label, 'FB')
243
- done()
260
+ assert.equal(saveCalled, false)
244
261
  })
245
262
 
246
- it('skips save when no new apps are discovered', () => {
247
- const app = createMockApp(MOCK_WEBAPPS)
263
+ it('preserves existing configured apps', () => {
264
+ const app = createMockApp()
248
265
  let saveCalled = false
249
266
  app.savePluginOptions = () => {
250
267
  saveCalled = true
251
268
  }
252
269
 
253
270
  const plugin = pluginFactory(app)
254
- const allConfigured = [
255
- { enabled: true, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
256
- { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
257
- ]
258
-
259
- const originalSetTimeout = globalThis.setTimeout
260
- globalThis.setTimeout = (fn) => fn()
261
- try {
262
- plugin.start({ apps: allConfigured })
263
- } finally {
264
- globalThis.setTimeout = originalSetTimeout
265
- }
271
+ const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
272
+ plugin.start({ apps: existing })
266
273
 
267
274
  assert.equal(saveCalled, false)
268
275
  })
269
276
 
270
277
  it('respects enabled:false when resolving apps', () => {
271
- const app = createMockApp(MOCK_WEBAPPS)
278
+ const app = createMockApp()
272
279
  app.savePluginOptions = (_, cb) => cb(null)
273
280
  const plugin = pluginFactory(app)
274
281
 
@@ -276,20 +283,14 @@ describe('auto-discovery on start', () => {
276
283
  { enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
277
284
  { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
278
285
  ]
279
-
280
- const originalSetTimeout = globalThis.setTimeout
281
- globalThis.setTimeout = (fn) => fn()
282
- try {
283
- plugin.start({ apps: configured })
284
- } finally {
285
- globalThis.setTimeout = originalSetTimeout
286
- }
286
+ plugin.start({ apps: configured })
287
287
 
288
288
  const routes = {}
289
289
  const router = {
290
290
  get: (path, handler) => {
291
291
  routes[path] = handler
292
- }
292
+ },
293
+ post: () => {}
293
294
  }
294
295
  plugin.registerWithRouter(router)
295
296
 
@@ -305,63 +306,196 @@ describe('auto-discovery on start', () => {
305
306
  assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
306
307
  assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
307
308
  })
309
+ })
310
+
311
+ describe('package.json', () => {
312
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'))
313
+
314
+ it('has signalk-node-server-plugin keyword', () => {
315
+ assert.ok(pkg.keywords.includes('signalk-node-server-plugin'))
316
+ })
317
+
318
+ it('has signalk-webapp keyword', () => {
319
+ assert.ok(pkg.keywords.includes('signalk-webapp'))
320
+ })
321
+
322
+ it('has signalk-plugin-configurator keyword', () => {
323
+ assert.ok(pkg.keywords.includes('signalk-plugin-configurator'))
324
+ })
325
+
326
+ it('main points to plugin/index.js', () => {
327
+ assert.equal(pkg.main, 'plugin/index.js')
328
+ assert.ok(fs.existsSync(path.join(__dirname, '..', pkg.main)))
329
+ })
330
+ })
331
+
332
+ describe('night mode PUT handler', () => {
333
+ it('registers PUT handler when showNightModeButton is true', () => {
334
+ let registered = false
335
+ const app = createMockApp()
336
+ app.registerPutHandler = (context, path, callback, sourceId) => {
337
+ registered = true
338
+ assert.equal(context, 'vessels.self')
339
+ assert.equal(path, 'environment.mode')
340
+ assert.equal(sourceId, 'signalk-app-dock')
341
+ }
308
342
 
309
- it('uses discovered label/icon when config overrides are empty', () => {
310
- const app = createMockApp(MOCK_WEBAPPS)
311
- app.savePluginOptions = (_, cb) => cb(null)
312
343
  const plugin = pluginFactory(app)
344
+ const originalSetTimeout = globalThis.setTimeout
345
+ globalThis.setTimeout = (fn) => fn()
346
+ try {
347
+ plugin.start({ showNightModeButton: true, apps: [] })
348
+ } finally {
349
+ globalThis.setTimeout = originalSetTimeout
350
+ }
351
+ assert.ok(registered)
352
+ })
313
353
 
314
- const configured = [{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }]
354
+ it('does not register PUT handler when showNightModeButton is false', () => {
355
+ let registered = false
356
+ const app = createMockApp()
357
+ app.registerPutHandler = () => {
358
+ registered = true
359
+ }
315
360
 
361
+ const plugin = pluginFactory(app)
316
362
  const originalSetTimeout = globalThis.setTimeout
317
363
  globalThis.setTimeout = (fn) => fn()
318
364
  try {
319
- plugin.start({ apps: configured })
365
+ plugin.start({ showNightModeButton: false, apps: [] })
320
366
  } finally {
321
367
  globalThis.setTimeout = originalSetTimeout
322
368
  }
369
+ assert.equal(registered, false)
370
+ })
371
+
372
+ it('PUT handler accepts valid mode values and emits delta', () => {
373
+ const app = createMockApp()
374
+ let handlerCallback
375
+ let emittedDelta = null
376
+ app.registerPutHandler = (ctx, path, cb) => {
377
+ handlerCallback = cb
378
+ }
379
+ app.handleMessage = (id, delta) => {
380
+ emittedDelta = delta
381
+ }
323
382
 
324
- const routes = {}
383
+ const plugin = pluginFactory(app)
384
+ const originalSetTimeout = globalThis.setTimeout
385
+ globalThis.setTimeout = (fn) => fn()
386
+ try {
387
+ plugin.start({ showNightModeButton: true, apps: [] })
388
+ } finally {
389
+ globalThis.setTimeout = originalSetTimeout
390
+ }
391
+
392
+ const result = handlerCallback('vessels.self', 'environment.mode', 'night', () => {})
393
+ assert.equal(result.state, 'COMPLETED')
394
+ assert.equal(result.statusCode, 200)
395
+ assert.equal(emittedDelta.updates[0].values[0].path, 'environment.mode')
396
+ assert.equal(emittedDelta.updates[0].values[0].value, 'night')
397
+ })
398
+
399
+ it('PUT handler rejects invalid mode values', () => {
400
+ const app = createMockApp()
401
+ let handlerCallback
402
+ app.registerPutHandler = (ctx, path, cb) => {
403
+ handlerCallback = cb
404
+ }
405
+
406
+ const plugin = pluginFactory(app)
407
+ const originalSetTimeout = globalThis.setTimeout
408
+ globalThis.setTimeout = (fn) => fn()
409
+ try {
410
+ plugin.start({ showNightModeButton: true, apps: [] })
411
+ } finally {
412
+ globalThis.setTimeout = originalSetTimeout
413
+ }
414
+
415
+ const result = handlerCallback('vessels.self', 'environment.mode', 'invalid', () => {})
416
+ assert.equal(result.statusCode, 400)
417
+ })
418
+ })
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: {} }
325
432
  const router = {
326
433
  get: (path, handler) => {
327
- routes[path] = handler
434
+ routes.get[path] = handler
435
+ },
436
+ post: (path, handler) => {
437
+ routes.post[path] = handler
328
438
  }
329
439
  }
330
440
  plugin.registerWithRouter(router)
331
441
 
332
- let resolved
333
- routes['/settings'](
334
- {},
335
- {
336
- json: (d) => {
337
- resolved = d
338
- }
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()
339
448
  }
340
- )
341
- const kip = resolved.apps.find((a) => a.url === '/@mxtommy/kip/')
342
- assert.equal(kip.label, 'KIP Instrument MFD')
343
- assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
449
+ }
450
+ routes.post['/dismiss-tour']({}, res)
344
451
  })
345
452
  })
346
453
 
347
- describe('package.json', () => {
348
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'))
349
-
350
- it('has signalk-node-server-plugin keyword', () => {
351
- assert.ok(pkg.keywords.includes('signalk-node-server-plugin'))
352
- })
454
+ describe('mode endpoint', () => {
455
+ it('returns day when no mode is set', (t, done) => {
456
+ const app = createMockApp()
457
+ const plugin = pluginFactory(app)
458
+ const routes = {}
459
+ const router = {
460
+ get: (path, handler) => {
461
+ routes[path] = handler
462
+ },
463
+ post: () => {}
464
+ }
465
+ plugin.registerWithRouter(router)
353
466
 
354
- it('has signalk-webapp keyword', () => {
355
- assert.ok(pkg.keywords.includes('signalk-webapp'))
467
+ const res = {
468
+ json: (data) => {
469
+ assert.equal(data.value, 'day')
470
+ done()
471
+ }
472
+ }
473
+ routes['/mode']({}, res)
356
474
  })
357
475
 
358
- it('has signalk-plugin-configurator keyword', () => {
359
- assert.ok(pkg.keywords.includes('signalk-plugin-configurator'))
360
- })
476
+ it('returns current mode when set', (t, done) => {
477
+ const app = createMockApp()
478
+ app.getSelfPath = (path) => {
479
+ if (path === 'environment.mode') return { value: 'night' }
480
+ return null
481
+ }
482
+ const plugin = pluginFactory(app)
483
+ const routes = {}
484
+ const router = {
485
+ get: (path, handler) => {
486
+ routes[path] = handler
487
+ },
488
+ post: () => {}
489
+ }
490
+ plugin.registerWithRouter(router)
361
491
 
362
- it('main points to plugin/index.js', () => {
363
- assert.equal(pkg.main, 'plugin/index.js')
364
- assert.ok(fs.existsSync(path.join(__dirname, '..', pkg.main)))
492
+ const res = {
493
+ json: (data) => {
494
+ assert.equal(data.value, 'night')
495
+ done()
496
+ }
497
+ }
498
+ routes['/mode']({}, res)
365
499
  })
366
500
  })
367
501