@signalk/app-dock 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # signalk-app-dock
1
+ # @signalk/app-dock
2
2
 
3
3
  A macOS-style app dock for switching between Signal K webapps on touch screens.
4
4
 
5
- ![CI](https://github.com/dirkwa/signalk-app-dock/actions/workflows/ci.yml/badge.svg)
5
+ ![CI](https://github.com/SignalK/app-dock/actions/workflows/ci.yml/badge.svg)
6
6
 
7
7
  ## Features
8
8
 
@@ -20,12 +20,12 @@ A macOS-style app dock for switching between Signal K webapps on touch screens.
20
20
 
21
21
  ```bash
22
22
  cd ~/.signalk
23
- npm install signalk-app-dock
23
+ npm install @signalk/app-dock
24
24
  ```
25
25
 
26
26
  Restart Signal K, enable in **Plugin Config > App Dock**, click **Discover Installed Webapps**.
27
27
 
28
- Open: `http://your-sk-server:3000/signalk-app-dock/`
28
+ Open: `http://your-sk-server:3000/@signalk/app-dock/`
29
29
 
30
30
  ## Usage
31
31
 
@@ -58,7 +58,7 @@ Open **Plugin Config > App Dock** in the admin UI. The embedded configurator pro
58
58
 
59
59
  ```bash
60
60
  cd ~/.signalk
61
- npm link /path/to/signalk-app-dock
61
+ npm link /path/to/app-dock
62
62
  ```
63
63
 
64
64
  Edit files in `public/`, reload the browser. No build step needed for the dock itself.
package/eslint.config.js CHANGED
@@ -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.6",
3
+ "version": "0.2.0",
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
@@ -6,7 +6,7 @@ module.exports = (app) => {
6
6
 
7
7
  function getWebapps() {
8
8
  return (app.webapps || [])
9
- .filter((w) => w.name !== 'signalk-app-dock' && w.name !== '@signalk/server-admin-ui')
9
+ .filter((w) => w.name !== '@signalk/app-dock' && w.name !== '@signalk/server-admin-ui')
10
10
  .map((w) => ({
11
11
  name: w.name,
12
12
  label: w.signalk?.displayName || w.name,
@@ -22,6 +22,23 @@ module.exports = (app) => {
22
22
  start(settings) {
23
23
  pluginSettings = settings
24
24
 
25
+ if (settings.showNightModeButton) {
26
+ app.registerPutHandler(
27
+ 'vessels.self',
28
+ 'environment.mode',
29
+ (context, path, value, _callback) => {
30
+ if (value !== 'day' && value !== 'night') {
31
+ return { state: 'COMPLETED', statusCode: 400, message: 'Invalid mode' }
32
+ }
33
+ app.handleMessage(plugin.id, {
34
+ updates: [{ values: [{ path: 'environment.mode', value }] }]
35
+ })
36
+ return { state: 'COMPLETED', statusCode: 200 }
37
+ },
38
+ plugin.id
39
+ )
40
+ }
41
+
25
42
  setTimeout(() => {
26
43
  const discovered = getWebapps()
27
44
  if (discovered.length === 0) return
@@ -80,12 +97,17 @@ module.exports = (app) => {
80
97
  router.get('/webapps', (req, res) => {
81
98
  res.json(getWebapps())
82
99
  })
100
+
101
+ router.get('/mode', (req, res) => {
102
+ const current = app.getSelfPath('environment.mode')
103
+ res.json({ value: (current && current.value) || 'day' })
104
+ })
83
105
  },
84
106
 
85
107
  schema: {
86
108
  type: 'object',
87
109
  description:
88
- 'Open /signalk-app-dock/config.html for the visual configurator with discover button and drag-to-reorder.',
110
+ 'Open /@signalk/app-dock/config.html for the visual configurator with discover button and drag-to-reorder.',
89
111
  required: [],
90
112
  properties: {
91
113
  position: {
@@ -131,6 +153,20 @@ module.exports = (app) => {
131
153
  default: 1.7
132
154
  },
133
155
 
156
+ showNightModeButton: {
157
+ type: 'boolean',
158
+ title: 'Show night/day mode toggle',
159
+ description: 'Adds a sun/moon button to the dock that toggles environment.mode',
160
+ default: false
161
+ },
162
+
163
+ showExitButton: {
164
+ type: 'boolean',
165
+ title: 'Show exit button',
166
+ description: 'Adds an X button to the dock that returns to Signal K admin UI',
167
+ default: false
168
+ },
169
+
134
170
  apps: {
135
171
  type: 'array',
136
172
  title: 'Dock Apps',
package/public/805.js CHANGED
@@ -1 +1 @@
1
- "use strict";(self.webpackChunk_signalk_app_dock=self.webpackChunk_signalk_app_dock||[]).push([[805],{805(e,t,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||[]),[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")))}}}]);
@@ -284,7 +284,7 @@
284
284
  const res = await fetch('/skServer/webapps')
285
285
  const allWebapps = await res.json()
286
286
  const webapps = allWebapps
287
- .filter((w) => w.name !== 'signalk-app-dock' && w.name !== '@signalk/server-admin-ui')
287
+ .filter((w) => w.name !== '@signalk/app-dock' && w.name !== '@signalk/server-admin-ui')
288
288
  .map((w) => ({
289
289
  name: w.name,
290
290
  label: w.signalk?.displayName || w.name,
package/public/dock.css CHANGED
@@ -359,6 +359,43 @@ body {
359
359
  transform: translateY(-50%);
360
360
  }
361
361
 
362
+ /* ─── Dock separator ───────────────────────────────────────────────────────── */
363
+ .dock-separator {
364
+ width: 1px;
365
+ height: 32px;
366
+ background: rgba(255, 255, 255, 0.15);
367
+ flex-shrink: 0;
368
+ align-self: center;
369
+ margin: 0 2px;
370
+ border-radius: 1px;
371
+ }
372
+
373
+ .layout-vertical .dock-separator {
374
+ width: 32px;
375
+ height: 1px;
376
+ margin: 2px 0;
377
+ }
378
+
379
+ /* ─── Utility buttons (night mode, exit) ───────────────────────────────────── */
380
+ .dock-icon-utility {
381
+ background: rgba(255, 255, 255, 0.08);
382
+ border: 1px solid rgba(255, 255, 255, 0.12);
383
+ box-shadow: none;
384
+ }
385
+
386
+ .dock-item-utility .dock-dot {
387
+ display: none;
388
+ }
389
+
390
+ .dock-item-utility:hover .dock-icon-utility,
391
+ .dock-item-utility.pressing .dock-icon-utility {
392
+ background: rgba(255, 255, 255, 0.15);
393
+ }
394
+
395
+ .dock-item-exit:hover .dock-icon-utility {
396
+ background: rgba(239, 68, 68, 0.25);
397
+ }
398
+
362
399
  /* ─── Invisible corner trigger zone ────────────────────────────────────────── */
363
400
  #trigger-corner {
364
401
  position: fixed;
package/public/dock.js CHANGED
@@ -8,6 +8,8 @@
8
8
  iconSize: 56,
9
9
  magnification: true,
10
10
  magnificationScale: 1.7,
11
+ showNightModeButton: false,
12
+ showExitButton: false,
11
13
  apps: []
12
14
  }
13
15
 
@@ -52,6 +54,48 @@
52
54
  let dockVisible = false
53
55
  const iframes = {}
54
56
 
57
+ // ─── Night mode state ────────────────────────────────────────────────────────
58
+ let currentMode = 'day'
59
+
60
+ async function fetchCurrentMode() {
61
+ try {
62
+ const res = await fetch('/plugins/signalk-app-dock/mode')
63
+ if (res.ok) {
64
+ const data = await res.json()
65
+ currentMode = data.value || 'day'
66
+ updateNightModeIcon()
67
+ }
68
+ } catch (e) {
69
+ console.warn('[Dock] Could not fetch environment.mode', e)
70
+ }
71
+ }
72
+
73
+ function updateNightModeIcon() {
74
+ const icon = document.querySelector('.dock-item-nightmode .dock-icon')
75
+ if (!icon) return
76
+ icon.textContent = currentMode === 'night' ? '\u{1F319}' : '\u2600\uFE0F'
77
+ const label = document.querySelector('.dock-item-nightmode .dock-label')
78
+ if (label) label.textContent = currentMode === 'night' ? 'Day mode' : 'Night mode'
79
+ }
80
+
81
+ async function toggleNightMode() {
82
+ await fetchCurrentMode()
83
+ const newMode = currentMode === 'night' ? 'day' : 'night'
84
+ try {
85
+ const res = await fetch('/signalk/v1/api/vessels/self/environment/mode', {
86
+ method: 'PUT',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ value: newMode })
89
+ })
90
+ if (res.ok) {
91
+ currentMode = newMode
92
+ updateNightModeIcon()
93
+ }
94
+ } catch (e) {
95
+ console.warn('[Dock] Failed to toggle mode', e)
96
+ }
97
+ }
98
+
55
99
  // ─── Apply dock position class & alignment ───────────────────────────────────
56
100
  const pos = cfg.position
57
101
  $dock.classList.add(`pos-${pos}`)
@@ -72,7 +116,7 @@
72
116
  fetch('/skServer/webapps')
73
117
  .then((r) => r.json())
74
118
  .then((apps) => {
75
- const me = apps.find((a) => a.name === 'signalk-app-dock')
119
+ const me = apps.find((a) => a.name === '@signalk/app-dock')
76
120
  if (me) $version.textContent = 'v' + me.version
77
121
  })
78
122
  .catch(() => {})
@@ -119,6 +163,55 @@
119
163
  }
120
164
  }
121
165
 
166
+ // ─── Utility item builder ─────────────────────────────────────────────────────
167
+ function createUtilityItem(className, icon, label, onClick) {
168
+ const sz = cfg.iconSize
169
+ const radius = Math.round(sz * 0.25) + 'px'
170
+ const lblCls = labelClass()
171
+
172
+ const $item = document.createElement('div')
173
+ $item.className = 'dock-item dock-item-utility ' + className
174
+
175
+ const $icon = document.createElement('div')
176
+ $icon.className = 'dock-icon dock-icon-utility'
177
+ $icon.style.width = sz + 'px'
178
+ $icon.style.height = sz + 'px'
179
+ $icon.style.borderRadius = radius
180
+ $icon.style.fontSize = Math.round(sz * 0.48) + 'px'
181
+ $icon.textContent = icon
182
+
183
+ const $dot = document.createElement('div')
184
+ $dot.className = 'dock-dot'
185
+
186
+ const $label = document.createElement('div')
187
+ $label.className = 'dock-label' + (lblCls ? ' ' + lblCls : '')
188
+ $label.textContent = label
189
+
190
+ $item.appendChild($icon)
191
+ $item.appendChild($dot)
192
+ $item.appendChild($label)
193
+
194
+ $item.addEventListener('touchstart', () => $item.classList.add('pressing'), { passive: true })
195
+ $item.addEventListener('touchend', () => $item.classList.remove('pressing'), { passive: true })
196
+ $item.addEventListener('touchcancel', () => $item.classList.remove('pressing'), { passive: true })
197
+
198
+ $item.addEventListener('click', (e) => {
199
+ if (magScrubbed) {
200
+ e.preventDefault()
201
+ return
202
+ }
203
+ onClick()
204
+ })
205
+
206
+ return $item
207
+ }
208
+
209
+ function createSeparator() {
210
+ const $sep = document.createElement('div')
211
+ $sep.className = 'dock-separator'
212
+ return $sep
213
+ }
214
+
122
215
  // ─── Build dock items ─────────────────────────────────────────────────────────
123
216
  function buildDock() {
124
217
  $dockInner.innerHTML = ''
@@ -126,6 +219,11 @@
126
219
  const radius = Math.round(sz * 0.25) + 'px'
127
220
  const lblCls = labelClass()
128
221
 
222
+ if (cfg.showNightModeButton) {
223
+ $dockInner.appendChild(createUtilityItem('dock-item-nightmode', '\u2600\uFE0F', 'Night mode', toggleNightMode))
224
+ $dockInner.appendChild(createSeparator())
225
+ }
226
+
129
227
  cfg.apps.forEach((app, i) => {
130
228
  const $item = document.createElement('div')
131
229
  $item.className = 'dock-item'
@@ -179,6 +277,17 @@
179
277
 
180
278
  $dockInner.appendChild($item)
181
279
  })
280
+
281
+ if (cfg.showExitButton) {
282
+ $dockInner.appendChild(createSeparator())
283
+ $dockInner.appendChild(
284
+ createUtilityItem('dock-item-exit', '\u2715', 'Exit to Admin', () => {
285
+ window.location.href = '/admin/'
286
+ })
287
+ )
288
+ }
289
+
290
+ if (cfg.showNightModeButton) updateNightModeIcon()
182
291
  }
183
292
 
184
293
  // ─── Magnification ───────────────────────────────────────────────────────────
@@ -447,6 +556,11 @@
447
556
  positionCornerZone()
448
557
  buildDock()
449
558
 
559
+ if (cfg.showNightModeButton) {
560
+ fetchCurrentMode()
561
+ setInterval(fetchCurrentMode, 5000)
562
+ }
563
+
450
564
  const autostartIdx = cfg.apps.findIndex((a) => a.autostart)
451
565
  if (autostartIdx >= 0) {
452
566
  switchToApp(autostartIdx)
@@ -180,6 +180,8 @@ export default function PluginConfigurationPanel({ configuration, save }) {
180
180
  const [iconSize, setIconSize] = useState(cfg.iconSize || 56)
181
181
  const [magnification, setMagnification] = useState(cfg.magnification !== false)
182
182
  const [magnificationScale, setMagnificationScale] = useState(cfg.magnificationScale || 1.7)
183
+ const [showNightModeButton, setShowNightModeButton] = useState(cfg.showNightModeButton || false)
184
+ const [showExitButton, setShowExitButton] = useState(cfg.showExitButton || false)
183
185
 
184
186
  const [status, setStatus] = useState('')
185
187
  const [statusError, setStatusError] = useState(false)
@@ -198,9 +200,20 @@ export default function PluginConfigurationPanel({ configuration, save }) {
198
200
  iconSize,
199
201
  magnification,
200
202
  magnificationScale,
203
+ showNightModeButton,
204
+ showExitButton,
201
205
  apps: appsList
202
206
  }),
203
- [position, triggerCorner, iframeMode, iconSize, magnification, magnificationScale]
207
+ [
208
+ position,
209
+ triggerCorner,
210
+ iframeMode,
211
+ iconSize,
212
+ magnification,
213
+ magnificationScale,
214
+ showNightModeButton,
215
+ showExitButton
216
+ ]
204
217
  )
205
218
 
206
219
  const doSave = useCallback(() => {
@@ -217,14 +230,14 @@ export default function PluginConfigurationPanel({ configuration, save }) {
217
230
  const res = await fetch('/skServer/webapps')
218
231
  const allWebapps = await res.json()
219
232
  const webapps = allWebapps
220
- .filter((w) => w.name !== 'signalk-app-dock')
233
+ .filter((w) => w.name !== '@signalk/app-dock')
221
234
  .map((w) => {
222
235
  if (w.name === '@signalk/server-admin-ui') {
223
236
  return {
224
237
  name: w.name,
225
238
  label: 'Settings',
226
239
  url: '/admin/',
227
- icon: '/signalk-app-dock/icon-settings.svg',
240
+ icon: '/@signalk/app-dock/icon-settings.svg',
228
241
  isAdmin: true
229
242
  }
230
243
  }
@@ -355,6 +368,22 @@ export default function PluginConfigurationPanel({ configuration, save }) {
355
368
  />
356
369
  )}
357
370
 
371
+ <div style={S.sectionTitle}>Utility Buttons</div>
372
+
373
+ <CheckboxField
374
+ label="Night/Day mode toggle"
375
+ checked={showNightModeButton}
376
+ onChange={setShowNightModeButton}
377
+ hint="Sun/moon button to toggle environment.mode"
378
+ />
379
+
380
+ <CheckboxField
381
+ label="Exit button"
382
+ checked={showExitButton}
383
+ onChange={setShowExitButton}
384
+ hint="X button to return to Signal K admin UI"
385
+ />
386
+
358
387
  <div style={S.sectionTitle}>Webapps</div>
359
388
  <button
360
389
  style={{ ...S.btn, ...S.btnPrimary, ...(discovering ? { opacity: 0.6 } : {}) }}
@@ -420,13 +449,50 @@ export default function PluginConfigurationPanel({ configuration, save }) {
420
449
  </div>
421
450
  )}
422
451
 
423
- {enabledApps.length > 0 && (
452
+ {(enabledApps.length > 0 || showNightModeButton || showExitButton) && (
424
453
  <>
425
454
  <div style={S.sectionTitle}>Preview</div>
426
455
  <div style={S.preview}>
456
+ {showNightModeButton && (
457
+ <>
458
+ <div
459
+ style={{
460
+ ...S.iconBox,
461
+ width: 40,
462
+ height: 40,
463
+ background: 'rgba(255,255,255,0.08)',
464
+ border: '1px solid rgba(255,255,255,0.12)',
465
+ fontSize: 20,
466
+ borderRadius: 10
467
+ }}
468
+ >
469
+ {'\u2600\uFE0F'}
470
+ </div>
471
+ <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
472
+ </>
473
+ )}
427
474
  {enabledApps.map((app, i) => (
428
475
  <IconPreview key={i} icon={app.icon} label={app.label || app.url} color={app.color} size={40} />
429
476
  ))}
477
+ {showExitButton && (
478
+ <>
479
+ <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.15)', alignSelf: 'center' }} />
480
+ <div
481
+ style={{
482
+ ...S.iconBox,
483
+ width: 40,
484
+ height: 40,
485
+ background: 'rgba(255,255,255,0.08)',
486
+ border: '1px solid rgba(255,255,255,0.12)',
487
+ fontSize: 16,
488
+ borderRadius: 10,
489
+ color: '#fff'
490
+ }}
491
+ >
492
+ {'\u2715'}
493
+ </div>
494
+ </>
495
+ )}
430
496
  </div>
431
497
  </>
432
498
  )}
@@ -12,7 +12,10 @@ function createMockApp(webapps = []) {
12
12
  webapps,
13
13
  debug: () => {},
14
14
  error: () => {},
15
- savePluginOptions: (opts, cb) => cb(null)
15
+ savePluginOptions: (opts, cb) => cb(null),
16
+ registerPutHandler: () => {},
17
+ handleMessage: () => {},
18
+ getSelfPath: () => null
16
19
  }
17
20
  }
18
21
 
@@ -26,7 +29,7 @@ const MOCK_WEBAPPS = [
26
29
  signalk: { displayName: 'KIP Instrument MFD', appIcon: 'assets/icon-72x72.png' }
27
30
  },
28
31
  {
29
- name: 'signalk-app-dock',
32
+ name: '@signalk/app-dock',
30
33
  signalk: { displayName: 'App Dock' }
31
34
  },
32
35
  {
@@ -74,10 +77,24 @@ describe('schema', () => {
74
77
  const items = plugin.schema.properties.apps.items
75
78
  assert.deepEqual(items.required, ['url'])
76
79
  })
80
+
81
+ it('defines showNightModeButton with default false', () => {
82
+ const plugin = pluginFactory(createMockApp())
83
+ const prop = plugin.schema.properties.showNightModeButton
84
+ assert.equal(prop.type, 'boolean')
85
+ assert.equal(prop.default, false)
86
+ })
87
+
88
+ it('defines showExitButton with default false', () => {
89
+ const plugin = pluginFactory(createMockApp())
90
+ const prop = plugin.schema.properties.showExitButton
91
+ assert.equal(prop.type, 'boolean')
92
+ assert.equal(prop.default, false)
93
+ })
77
94
  })
78
95
 
79
96
  describe('webapp discovery', () => {
80
- it('filters out signalk-app-dock and admin-ui from /webapps', (t, done) => {
97
+ it('filters out @signalk/app-dock and admin-ui from /webapps', (t, done) => {
81
98
  const app = createMockApp(MOCK_WEBAPPS)
82
99
  const plugin = pluginFactory(app)
83
100
  const routes = {}
@@ -91,7 +108,7 @@ describe('webapp discovery', () => {
91
108
  const res = {
92
109
  json: (data) => {
93
110
  assert.equal(data.length, 2)
94
- assert.ok(data.every((w) => w.name !== 'signalk-app-dock'))
111
+ assert.ok(data.every((w) => w.name !== '@signalk/app-dock'))
95
112
  assert.ok(data.every((w) => w.name !== '@signalk/server-admin-ui'))
96
113
  done()
97
114
  }
@@ -365,6 +382,140 @@ describe('package.json', () => {
365
382
  })
366
383
  })
367
384
 
385
+ describe('night mode PUT handler', () => {
386
+ it('registers PUT handler when showNightModeButton is true', () => {
387
+ let registered = false
388
+ const app = createMockApp()
389
+ app.registerPutHandler = (context, path, callback, sourceId) => {
390
+ registered = true
391
+ assert.equal(context, 'vessels.self')
392
+ assert.equal(path, 'environment.mode')
393
+ assert.equal(sourceId, 'signalk-app-dock')
394
+ }
395
+
396
+ const plugin = pluginFactory(app)
397
+ const originalSetTimeout = globalThis.setTimeout
398
+ globalThis.setTimeout = (fn) => fn()
399
+ try {
400
+ plugin.start({ showNightModeButton: true, apps: [] })
401
+ } finally {
402
+ globalThis.setTimeout = originalSetTimeout
403
+ }
404
+ assert.ok(registered)
405
+ })
406
+
407
+ it('does not register PUT handler when showNightModeButton is false', () => {
408
+ let registered = false
409
+ const app = createMockApp()
410
+ app.registerPutHandler = () => {
411
+ registered = true
412
+ }
413
+
414
+ const plugin = pluginFactory(app)
415
+ const originalSetTimeout = globalThis.setTimeout
416
+ globalThis.setTimeout = (fn) => fn()
417
+ try {
418
+ plugin.start({ showNightModeButton: false, apps: [] })
419
+ } finally {
420
+ globalThis.setTimeout = originalSetTimeout
421
+ }
422
+ assert.equal(registered, false)
423
+ })
424
+
425
+ it('PUT handler accepts valid mode values and emits delta', () => {
426
+ const app = createMockApp()
427
+ let handlerCallback
428
+ let emittedDelta = null
429
+ app.registerPutHandler = (ctx, path, cb) => {
430
+ handlerCallback = cb
431
+ }
432
+ app.handleMessage = (id, delta) => {
433
+ emittedDelta = delta
434
+ }
435
+
436
+ const plugin = pluginFactory(app)
437
+ const originalSetTimeout = globalThis.setTimeout
438
+ globalThis.setTimeout = (fn) => fn()
439
+ try {
440
+ plugin.start({ showNightModeButton: true, apps: [] })
441
+ } finally {
442
+ globalThis.setTimeout = originalSetTimeout
443
+ }
444
+
445
+ const result = handlerCallback('vessels.self', 'environment.mode', 'night', () => {})
446
+ assert.equal(result.state, 'COMPLETED')
447
+ assert.equal(result.statusCode, 200)
448
+ assert.equal(emittedDelta.updates[0].values[0].path, 'environment.mode')
449
+ assert.equal(emittedDelta.updates[0].values[0].value, 'night')
450
+ })
451
+
452
+ it('PUT handler rejects invalid mode values', () => {
453
+ const app = createMockApp()
454
+ let handlerCallback
455
+ app.registerPutHandler = (ctx, path, cb) => {
456
+ handlerCallback = cb
457
+ }
458
+
459
+ const plugin = pluginFactory(app)
460
+ const originalSetTimeout = globalThis.setTimeout
461
+ globalThis.setTimeout = (fn) => fn()
462
+ try {
463
+ plugin.start({ showNightModeButton: true, apps: [] })
464
+ } finally {
465
+ globalThis.setTimeout = originalSetTimeout
466
+ }
467
+
468
+ const result = handlerCallback('vessels.self', 'environment.mode', 'invalid', () => {})
469
+ assert.equal(result.statusCode, 400)
470
+ })
471
+ })
472
+
473
+ describe('mode endpoint', () => {
474
+ it('returns day when no mode is set', (t, done) => {
475
+ const app = createMockApp()
476
+ const plugin = pluginFactory(app)
477
+ const routes = {}
478
+ const router = {
479
+ get: (path, handler) => {
480
+ routes[path] = handler
481
+ }
482
+ }
483
+ plugin.registerWithRouter(router)
484
+
485
+ const res = {
486
+ json: (data) => {
487
+ assert.equal(data.value, 'day')
488
+ done()
489
+ }
490
+ }
491
+ routes['/mode']({}, res)
492
+ })
493
+
494
+ it('returns current mode when set', (t, done) => {
495
+ const app = createMockApp()
496
+ app.getSelfPath = (path) => {
497
+ if (path === 'environment.mode') return { value: 'night' }
498
+ return null
499
+ }
500
+ const plugin = pluginFactory(app)
501
+ const routes = {}
502
+ const router = {
503
+ get: (path, handler) => {
504
+ routes[path] = handler
505
+ }
506
+ }
507
+ plugin.registerWithRouter(router)
508
+
509
+ const res = {
510
+ json: (data) => {
511
+ assert.equal(data.value, 'night')
512
+ done()
513
+ }
514
+ }
515
+ routes['/mode']({}, res)
516
+ })
517
+ })
518
+
368
519
  describe('public files', () => {
369
520
  const publicDir = path.join(__dirname, '..', 'public')
370
521