domma-cms 1.2.0 → 1.4.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.
@@ -1,4 +1,4 @@
1
- import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{SHAPES as Xe,whileLoading as Ze}from"../lib/loading.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as et,openMenuAt as Ae,sidebarCount as tt,viewLifetime as at}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as nt}from"../lib/server-restart.js";import{FILTERS as st,arrangeCatalogue as lt,arrangePlugins as it,catalogueAction as ee,catalogueRow as ot,checkScaffold as rt,fmtReleased as ct,needsAttention as dt,planLines as ut,pluginRow as pt,pluginStatus as Ue,readSettings as mt,settingsFields as Re,slugifyPlugin as bt,updateFor as je}from"../lib/plugins-arrange.js";const Be=/^#\/plugins\/?(\?.*)?$/,vt="#/plugins",de="plugins.filter",te="plugins.draft",ft=`
1
+ import{api as k,getUser as Ze}from"../api.js";import{addBannerAction as Ae}from"../lib/plugin-chrome.js";import{SHAPES as et,whileLoading as tt}from"../lib/loading.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as at,openMenuAt as Ue,sidebarCount as nt,viewLifetime as st}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as lt}from"../lib/server-restart.js";import{FILTERS as it,arrangeCatalogue as ot,arrangePlugins as rt,catalogueAction as ee,catalogueRow as ct,checkScaffold as dt,formatPrice as Re,fmtReleased as ut,needsAttention as pt,planLines as mt,pluginRow as bt,pluginStatus as je,readSettings as ft,settingsFields as Be,slugifyPlugin as vt,updateFor as De}from"../lib/plugins-arrange.js";const Fe=/^#\/plugins\/?(\?.*)?$/,ht="#/plugins",de="plugins.filter",te="plugins.draft",gt=`
2
2
  <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
3
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
4
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -10,7 +10,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
10
10
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
11
11
  </span>
12
12
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
13
- </div>`,ht=`
13
+ </div>`,yt=`
14
14
  <div class="plx-row" data-tool="{{name}}" tabindex="0" data-bind-class="rowCls">
15
15
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
16
16
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -19,7 +19,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
19
19
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
20
20
  </span>
21
21
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
- </div>`,gt=`
22
+ </div>`,wt=`
23
23
  <div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
24
24
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
25
25
  <span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
@@ -29,7 +29,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
29
29
  </span>
30
30
  <button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
31
31
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
32
- </div>`,r=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ue(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const g=at(Be);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){g.reset();const b=A.get(0).querySelector(".plx")||A.get(0),$=()=>Be.test(location.hash)&&b.isConnected,U=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),v=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),ae=M.observable(""),m=M.observable("");let be="all";try{be=st.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),V=M.observable(""),R=M.observable("all"),ve=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const W=M.computed(()=>new Map((v.value.items||[]).map(e=>[e.slug,e.version]))),_=e=>(v.peek().items||[]).find(t=>t.slug===e),ne=M.observable([]);g.add(M.effect(()=>{const e=W.value;ne.value=it(U.value,{filter:T.value,query:V.value,latest:e}).map(t=>({...pt(t,e),accent:Le(t.name)}))}));const De=M.computed(()=>{const e=U.value,t=W.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>dt(n,t)).length}}),Y=M.computed(()=>(v.value.items||[]).filter(e=>ee(e)==="update").length),Fe=M.computed(()=>U.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),se=M.observable([]);g.add(M.effect(()=>{se.value=lt(v.value.items,{filter:R.value,query:ve.value}).map(e=>{const t=ot(e),a=t.action==="update"||t.action==="install";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const qe=M.computed(()=>{const e=v.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>ee(t)==="install").length,updates:Y.value}}),J=e=>U.peek().find(t=>t.name===e);async function le(){try{const[e,t]=await Ze(b.querySelector(".dm-loading-slot"),Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]),Xe.iconRows);if(!$())return;U.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],ae.value=""}catch(e){ae.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function fe(){try{const e=await k.marketplace.catalogue();if(!$())return;v.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{v.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([le(),fe()]),tt(vt)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${r(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=o=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:o}):k.plugins.update(e.name,{enabled:t,cascade:o});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let o;try{o=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ut(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${r(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(i=>`<li>${r(i)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,o=await l(!0)}await nt(o,{onStatus:c=>{m.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(o){E.toast(`Not switched ${n}: ${o.message||o}`,{type:"error"})}finally{m.value="",$()&&await P()}}async function B(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),o=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${o} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await P()}}async function he(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await k.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}$()&&await P()}}async function ge(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ye=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=b.querySelector(".plx-file");async function Pe(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await P()}}const xe=async()=>{const e=z.files?.[0];z.value="",e&&await Pe(e)};z.addEventListener("change",xe),g.add(()=>z.removeEventListener("change",xe));const Q=new Set;let L=null,we=!1;function ke({title:e,size:t="md",panel:a,model:n,methods:l,dirty:o=()=>!1,form:c=null,cleanup:u=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const C=Oe(a),x=M.applyBindings(n,a,{methods:l});c&&(L=c),Q.add(i),et(i,()=>{u(),x.dispose(),C(),Q.delete(i),L===c&&(L=null)});const w=i.close.bind(i);let f=!1,N=!1;return i.close=async D=>{if(!f&&D!==!0&&o()){if(N)return;N=!0;const F=await E.confirm("Discard the changes you have not saved?");if(N=!1,!F)return}return f=!0,w()},I.scan(a),i.open(),i}function ie(e,t=null){const a=J(e);if(!a)return;const n=Ue(a),l=Re(a),o=_(a.name),c=je(a,W.peek()),u=!!(c&&o?.entitled&&!o?.needsCms),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),w=M.computed(()=>mt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,D=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ct(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),F=(s,p)=>{const O=`plx-set-${p}`,X=s.help?` <span data-help="${r(s.help)}"></span>`:"",K=`<label class="form-label" for="${O}">${r(s.label)}${X}</label>`,H=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${K}<input id="${O}" type="checkbox" class="form-switch" ${H}></div>`;if(s.type==="select")return`<div>${K}<select id="${O}" class="form-input" ${H}>${s.options.map(Ne=>`<option value="${r(Ne.value)}">${r(Ne.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${K}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${H}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${K}<textarea id="${O}" class="form-input" rows="3" ${H}></textarea>${Z}</div>`;const Je=s.type==="number"?"number":s.secret?"password":"text",Qe=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${K}<input id="${O}" type="${Je}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Qe} ${H}>${Z}</div>`},d={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,x[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},h=ue(`
32
+ </div>`,r=O=>String(O??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ue(O){const b=document.createElement("template");return b.innerHTML=O.trim(),b.content.firstElementChild}const g=st(Fe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(O){g.reset();const b=O.get(0).querySelector(".plx")||O.get(0),$=()=>Fe.test(location.hash)&&b.isConnected,L=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),f=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),ae=M.observable(""),m=M.observable("");let be="all";try{be=it.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),H=M.observable(""),R=M.observable("all"),fe=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const V=M.computed(()=>new Map((f.value.items||[]).map(e=>[e.slug,e.version]))),P=e=>(f.peek().items||[]).find(t=>t.slug===e),ne=M.observable([]);g.add(M.effect(()=>{const e=V.value;ne.value=rt(L.value,{filter:T.value,query:H.value,latest:e}).map(t=>({...bt(t,e),accent:Le(t.name)}))}));const qe=M.computed(()=>{const e=L.value,t=V.value,a=e.filter(n=>je(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>pt(n,t)).length}}),Y=M.computed(()=>(f.value.items||[]).filter(e=>ee(e)==="update").length),Pe=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),se=M.observable([]);g.add(M.effect(()=>{se.value=ot(f.value.items,{filter:R.value,query:fe.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=f.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>ee(t)==="install").length,updates:Y.value}}),J=e=>L.peek().find(t=>t.name===e);async function le(){try{const[e,t]=await tt(b.querySelector(".dm-loading-slot"),Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]),et.iconRows);if(!$())return;L.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],ae.value=""}catch(e){ae.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function ve(){try{const e=await k.marketplace.catalogue();if(!$())return;f.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{f.value={available:!1,site:null,items:[],failed:!0}}}const z=async()=>{await Promise.all([le(),ve()]),nt(ht)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${r(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=i=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:i}):k.plugins.update(e.name,{enabled:t,cascade:i});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let i;try{i=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=mt(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${r(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${r(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await lt(i,{onStatus:c=>{m.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(i){E.toast(`Not switched ${n}: ${i.message||i}`,{type:"error"})}finally{m.value="",$()&&await z()}}async function B(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),i=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${i} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await z()}}function he(e){e?.buyUrl&&(window.open(e.buyUrl,"_blank","noopener"),E.toast(`Manage my site opened in a new tab. We email you a sign-in link; ${e.displayName||e.slug} is ticked ready once you are in. Install it here when it is paid for.`,{type:"info",duration:9e3}))}async function ge(e,t){const n=P(e)?.tier==="paid"?` <p class="text-muted">It is a paid Tool: your site's manager is told, and if you pay for it on its own it comes off your monthly bill (the rest of this month is credited).</p>`:"";if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>${n}`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{const i=(await k.marketplace.uninstall(e))?.release?.removed?" It is off your bill.":"";E.toast(`${t||e} uninstalled.${i} Restart the server to apply.`,{type:"success",duration:6e3})}catch(l){E.toast(`Uninstall failed: ${l.message||l}`,{type:"error"})}$()&&await z()}}async function ye(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const we=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},K=b.querySelector(".plx-file");async function Ke(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await z()}}const xe=async()=>{const e=K.files?.[0];K.value="",e&&await Ke(e)};K.addEventListener("change",xe),g.add(()=>K.removeEventListener("change",xe));const Q=new Set;let U=null,ke=!1;function $e({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:r(e),size:t,position:"right"});o.element.appendChild(a);const C=Oe(a),w=M.applyBindings(n,a,{methods:l});c&&(U=c),Q.add(o),at(o,()=>{u(),w.dispose(),C(),Q.delete(o),U===c&&(U=null)});const x=o.close.bind(o);let v=!1,N=!1;return o.close=async D=>{if(!v&&D!==!0&&i()){if(N)return;N=!0;const F=await E.confirm("Discard the changes you have not saved?");if(N=!1,!F)return}return v=!0,x()},I.scan(a),o.open(),o}function ie(e,t=null){const a=J(e);if(!a)return;const n=je(a),l=Be(a),i=P(a.name),c=De(a,V.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},w=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),x=M.computed(()=>ft(l,Object.fromEntries(l.map(s=>[s.key,w[s.key].value])))),v=M.observable(!1),N=()=>Object.keys(x.peek().changed).length>0||Object.keys(x.peek().errors).length>0,D=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ut(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),F=(s,p)=>{const A=`plx-set-${p}`,X=s.help?` <span data-help="${r(s.help)}"></span>`:"",W=`<label class="form-label" for="${A}">${r(s.label)}${X}</label>`,_=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${W}<input id="${A}" type="checkbox" class="form-switch" ${_}></div>`;if(s.type==="select")return`<div>${W}<select id="${A}" class="form-input" ${_}>${s.options.map(Ie=>`<option value="${r(Ie.value)}">${r(Ie.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${W}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${_}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${W}<textarea id="${A}" class="form-input" rows="3" ${_}></textarea>${Z}</div>`;const Ge=s.type==="number"?"number":s.secret?"password":"text",Xe=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${W}<input id="${A}" type="${Ge}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Xe} ${_}>${Z}</div>`},d={saving:v,hasFields:l.length>0,cannot:M.computed(()=>v.value||!Object.keys(x.value.changed).length||Object.keys(x.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,w[s.key]],[`err${p}`,M.computed(()=>x.value.errors[s.key]||"")]]))},h=ue(`
33
33
  <div class="plx-form">
34
34
  <p class="plx-desc"></p>
35
35
  <dl class="plx-facts"></dl>
@@ -42,7 +42,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
42
42
  <div class="plx-actions">
43
43
  ${a.core?"":`<button type="button" class="btn btn-sm btn-ghost" data-on-click="toggle"><span data-icon="${a.enabled?"pause":"play"}" data-icon-size="13"></span> ${a.enabled?"Switch off":"Switch on"}</button>`}
44
44
  ${u?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${r(c)}</button>`:""}
45
- ${c&&o?.needsCms?`<span class="plx-hint">v${r(c)}: ${r(o.needsCms)}</span>`:""}
45
+ ${c&&i?.needsCms?`<span class="plx-hint">v${r(c)}: ${r(i.needsCms)}</span>`:""}
46
46
  ${a.sourceEditable!==!1?'<button type="button" class="btn btn-sm btn-ghost" data-on-click="source"><span data-icon="code" data-icon-size="13"></span> Source</button>':""}
47
47
  ${a.closedSource?"":'<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportIt" title="Download as a .dcmsplugin file"><span data-icon="download" data-icon-size="13"></span> Export</button>'}
48
48
  <span class="plx-spacer"></span>
@@ -50,7 +50,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
50
50
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
51
51
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
52
52
  </div>
53
- </div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Ye=h.querySelector(".plx-facts");for(const[s,p]of D){const O=document.createElement("dt");O.textContent=s;const X=document.createElement("dd");X.textContent=p,Ye.append(O,X)}async function Te(){if(d.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await le()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await G.close(),Q.has(G)||s()},G=ke({title:a.displayName||a.name,size:"md",panel:h,model:d,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=x[p.key].peek());return s}}:null,methods:{save:Te,cancel:()=>G.close(),toggle:ce(()=>j(a,!a.enabled)),update:ce(()=>B(o,{update:!0})),source:ce(()=>ye(a)),exportIt:()=>ge(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Te())})}function $e(e=null){const t=Ge(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()])),o=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const d=a.displayName.value;c||(a.slug.value=bt(d))}),i=M.observable(!1),C=M.computed(()=>rt(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.value])),U.value.map(d=>d.name))),x=d=>M.computed(()=>C.value.errors[d]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys(C.value.errors).length>0)},f=ue(`
53
+ </div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Qe=h.querySelector(".plx-facts");for(const[s,p]of D){const A=document.createElement("dt");A.textContent=s;const X=document.createElement("dd");X.textContent=p,Qe.append(A,X)}async function Ne(){if(d.cannot.peek())return;const{changed:s}=x.peek();v.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await le()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{v.value=!1}}const ce=s=>async()=>{await G.close(),Q.has(G)||s()},G=$e({title:a.displayName||a.name,size:"md",panel:h,model:d,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=w[p.key].peek());return s}}:null,methods:{save:Ne,cancel:()=>G.close(),toggle:ce(()=>j(a,!a.enabled)),update:ce(()=>B(i,{update:!0})),source:ce(()=>we(a)),exportIt:()=>ye(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Ne())})}function Ee(e=null){const t=Ze(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const d=a.displayName.value;c||(a.slug.value=vt(d))}),o=M.observable(!1),C=M.computed(()=>dt(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.value])),L.value.map(d=>d.name))),w=d=>M.computed(()=>C.value.errors[d]||""),x={...a,saving:o,nameErr:w("displayName"),slugErr:w("slug"),descErr:w("description"),authorErr:w("author"),iconErr:w("icon"),cannot:M.computed(()=>o.value||Object.keys(C.value.errors).length>0)},v=ue(`
54
54
  <div class="plx-form">
55
55
  <div>
56
56
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -86,7 +86,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
86
86
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
87
87
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
88
88
  </div>
89
- </div>`);async function N(){if(w.cannot.peek())return;i.value=!0;const{payload:d}=C.peek();try{await k.plugins.scaffold(d),E.toast(`"${d.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),D.close(!0),$()&&await P()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const D=ke({title:"New plugin",size:"sm",panel:f,model:w,dirty:o,form:{kind:"new",name:null,dirty:o,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>D.close()}}),F=f.querySelector("#plx-np-slug");F.addEventListener("input",()=>{c=!!F.value}),f.addEventListener("keydown",d=>{(d.ctrlKey||d.metaKey)&&d.key==="Enter"&&(d.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function ze(e){const t=J(e);if(!t)return[];const a=_(t.name),n=je(t,W.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Re(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>ie(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ye(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ge(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>he(t.name,t.displayName)}]]}function Ke(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const He=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function Ve(e){const t=_(e);if(!t)return[];const a=ee(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,T.value="all",Ce.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>he(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function We(e){const t=_(e);if(!t)return;const a=ee(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,T.value="all",Ce.activate?.(0))}function oe(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const o=u=>{const i=u.target.closest(".plx-row");if(!i)return;const C=u.target.closest(".plx-kebab");if(C){Ae(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},c=u=>{const i=u.target.closest(".plx-row");i&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ae(i)):u.key==="Enter"&&u.target===i&&n(i.dataset[t]))};e.addEventListener("click",o),e.addEventListener("keydown",c),g.add(()=>{e.removeEventListener("click",o),e.removeEventListener("keydown",c)})}const re=b.querySelector(".plx-browse");re.appendChild(ue(`
89
+ </div>`);async function N(){if(x.cannot.peek())return;o.value=!0;const{payload:d}=C.peek();try{await k.plugins.scaffold(d),E.toast(`"${d.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),D.close(!0),$()&&await z()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const D=$e({title:"New plugin",size:"sm",panel:v,model:x,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>D.close()}}),F=v.querySelector("#plx-np-slug");F.addEventListener("input",()=>{c=!!F.value}),v.addEventListener("keydown",d=>{(d.ctrlKey||d.metaKey)&&d.key==="Enter"&&(d.preventDefault(),N())}),v.querySelector("#plx-np-name")?.focus()}function We(e){const t=J(e);if(!t)return[];const a=P(t.name),n=De(t,V.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Be(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>ie(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>we(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ye(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>ge(t.name,t.displayName)}]]}function _e(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const He=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function Ve(e){const t=P(e);if(!t)return[];const a=ee(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{H.value=t.slug,T.value="all",Ce.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>ge(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${Re(t)!=="Paid"?` - ${Re(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>he(t)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function Ye(e){const t=P(e);if(!t)return;const a=ee(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):a==="buy"?he(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(H.value=t.slug,T.value="all",Ce.activate?.(0))}function oe(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const C=u.target.closest(".plx-kebab");if(C){Ue(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(o.dataset[t]);return}n(o.dataset[t])},c=u=>{const o=u.target.closest(".plx-row");o&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ue(o)):u.key==="Enter"&&u.target===o&&n(o.dataset[t]))};e.addEventListener("click",i),e.addEventListener("keydown",c),g.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const re=b.querySelector(".plx-browse");re.appendChild(ue(`
90
90
  <div class="plx-card">
91
91
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
92
92
  <p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
@@ -103,10 +103,10 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
103
103
  <button type="button" data-on-click="bUpdates" data-bind-class="browseFilter.value === 'updates' && 'is-on'" data-bind-hidden="!browseCounts.value.updates">Updates <b data-bind-text="browseCounts.value.updates"></b></button>
104
104
  </div>
105
105
  <input type="search" class="form-input plx-search" placeholder="Search the catalogue\u2026" aria-label="Search the catalogue" data-model="browseQuery.value">
106
- <span data-help-title="Browse" data-help="What this site's manager offers it. Install brings a plugin in licensed to this site; Update replaces the installed version and keeps its data. One not licensed yet is listed so you can see it exists - ask for it to be added. Either takes effect after a server restart."></span>
106
+ <span data-help-title="Browse" data-help="What this site's manager offers it. Install brings a plugin in licensed to this site; Update replaces the installed version and keeps its data. A paid Tool you can buy shows Buy\u2026 (Renew\u2026 once expired): it opens Manage my site in a new tab with that Tool ticked. One shown as Not licensed has no online billing - ask for it to be added. Install and Update take effect after a server restart."></span>
107
107
  </div>
108
108
  <p class="plx-note" data-bind-text="siteText.value" data-bind-hidden="!siteText.value"></p>
109
109
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
110
110
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
111
111
  </div>
112
- </div>`)),re.querySelector(".plx-catalogue").innerHTML=gt;const Ee=b.querySelector(".plx-installed");Ee.innerHTML=ft;const Se=b.querySelector(".plx-tools");Se.innerHTML=ht;const Ce=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),_e=M.applyBindings({plugins:U,tools:q,toolRows:pe,catalogue:v,loading:me,loadError:ae,restarting:m,filter:T,query:V,rows:ne,counts:De,emptyText:Fe,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:ve,browseRows:se,browseCounts:qe,siteText:M.computed(()=>v.value.site?`Licensed to ${v.value.site}.`:""),browseState:M.computed(()=>v.value.available===null?"loading":v.value.failed?"error":v.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>_e.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ne.value,pe.value,se.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),oe(Ee,"name",ze,e=>ie(e)),oe(Se,"tool",Ke,He),oe(re.querySelector(".plx-catalogue"),"slug",Ve,We),Ie(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),Ie(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>$e()});const Me=e=>{(m.peek()||L?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Me),g.add(()=>window.removeEventListener("beforeunload",Me)),g.add(()=>{if(L?.dirty())try{S.set(te,{kind:L.kind,name:L.name,values:L.values(),at:new Date().toISOString()})}catch{}we=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([le(),fe()]),!$())return;let y=null;try{y=S.get(te)}catch{}if(y?.values&&!we){try{S.remove?S.remove(te):S.set(te,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?$e(y.values):ie(y.name,y.values))}}};
112
+ </div>`)),re.querySelector(".plx-catalogue").innerHTML=wt;const Se=b.querySelector(".plx-installed");Se.innerHTML=gt;const Me=b.querySelector(".plx-tools");Me.innerHTML=yt;const Ce=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Je=M.applyBindings({plugins:L,tools:q,toolRows:pe,catalogue:f,loading:me,loadError:ae,restarting:m,filter:T,query:H,rows:ne,counts:qe,emptyText:Pe,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:fe,browseRows:se,browseCounts:ze,siteText:M.computed(()=>f.value.site?`Licensed to ${f.value.site}.`:""),browseState:M.computed(()=>f.value.available===null?"loading":f.value.failed?"error":f.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>Je.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ne.value,pe.value,se.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),oe(Se,"name",We,e=>ie(e)),oe(Me,"tool",_e,He),oe(re.querySelector(".plx-catalogue"),"slug",Ve,Ye),Ae(O,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>K.click()}),Ae(O,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Ee()});const Te=e=>{(m.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Te),g.add(()=>window.removeEventListener("beforeunload",Te)),g.add(()=>{if(U?.dirty())try{S.set(te,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}ke=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([le(),ve()]),!$())return;let y=null;try{y=S.get(te)}catch{}if(y?.values&&!ke){try{S.remove?S.remove(te):S.set(te,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?Ee(y.values):ie(y.name,y.values))}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -48,6 +48,8 @@ import {isSmtpConfigured} from '../../services/email.js';
48
48
  import {loginFailed, loginRateLimited} from '../../services/notification-sources.js';
49
49
  import {afterIndexOf, checkPassword, runLoginChecks, verifyChallenge} from '../../services/authChecks.js';
50
50
  import {createSession, isLegacyTokenRevoked, listSessions, revokeLegacyToken, revokeSession, revokeUserSessions, useSession} from '../../services/sessions.js';
51
+ import {isDashboardPro} from '../../services/widgets.js';
52
+ import {readPrefs} from '../../services/dashboardSettings.js';
51
53
 
52
54
  const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
53
55
 
@@ -240,7 +242,7 @@ export async function authRoutes(fastify) {
240
242
  const adminScope = getAdminScope(roles);
241
243
  // adminHome: the screen `#/` opens for this user - see services/adminHome.js.
242
244
  // The menu is read only when a site home has to be checked against it.
243
- const adminHome = await resolveAdminHome({
245
+ let adminHome = await resolveAdminHome({
244
246
  scope: adminScope,
245
247
  permissions,
246
248
  siteHome: getConfig('site')?.adminHome,
@@ -248,6 +250,8 @@ export async function authRoutes(fastify) {
248
250
  menuItems: async () => (await getMenu('admin-sidebar').catch(() => null))?.items || [],
249
251
  canSee: (visibility) => checkVisibility(request.user, visibility)
250
252
  });
253
+ // Dashboard Pro's "Open the admin on my board" - for a user the admin does not confine.
254
+ if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/my-dashboard';
251
255
  return {permissions, adminScope, adminHome};
252
256
  });
253
257
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * GET /api/plugins/marketplace - the catalogue as this site sees it
5
5
  * POST /api/plugins/marketplace/install - fetch a bundle and install it
6
- * DELETE /api/plugins/marketplace/:slug - uninstall
6
+ * DELETE /api/plugins/marketplace/:slug - uninstall (a paid plugin also tells the manager, for billing)
7
7
  *
8
8
  * This is delivery path A: a site the manager forked, buying from the fleet's
9
9
  * own catalogue over loopback. The standalone path - buy, download one file,
@@ -16,7 +16,7 @@
16
16
  * All routes require authentication + admin role.
17
17
  */
18
18
  import {authenticate, requireAdmin} from '../../middleware/auth.js';
19
- import {fetchCatalogue, fetchPluginBundle, isManaged, siteSlug} from '../../services/managerClient.js';
19
+ import {fetchCatalogue, fetchPluginBundle, isManaged, releasePlugin, siteSlug} from '../../services/managerClient.js';
20
20
  import {cmsVersionShortfall, compareVersions, installedCmsVersion, installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
21
21
  import {discoverPlugins} from '../../services/plugins.js';
22
22
 
@@ -127,6 +127,9 @@ export async function pluginMarketplaceRoutes(fastify) {
127
127
  return reply.code(400).send({ error: 'Invalid plugin slug.' });
128
128
  }
129
129
 
130
+ // Read before the files go: only a paid plugin has a bill to change.
131
+ const paid = isManaged() && (await discoverPlugins()).some(m => m.name === slug && m.tier === 'paid');
132
+
130
133
  try {
131
134
  await uninstallPlugin(slug);
132
135
  } catch (err) {
@@ -134,6 +137,9 @@ export async function pluginMarketplaceRoutes(fastify) {
134
137
  return reply.code(400).send({ error: 'Uninstall failed.' });
135
138
  }
136
139
 
137
- return reply.send({ success: true });
140
+ // The files are gone either way; a manager that cannot be reached is
141
+ // logged, not a failed uninstall.
142
+ const release = paid ? await releasePlugin(slug) : null;
143
+ return reply.send({ success: true, release });
138
144
  });
139
145
  }
@@ -17,6 +17,8 @@
17
17
  import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
18
18
  import {getRegisteredSidebarItems} from '../../services/hooks.js';
19
19
  import {filterItemsForUser, getMenu} from '../../services/menus.js';
20
+ import {isDashboardPro} from '../../services/widgets.js';
21
+ import {boardName, readPrefs, readSite} from '../../services/dashboardSettings.js';
20
22
 
21
23
  export const ADMIN_SIDEBAR_SLUG = 'admin-sidebar';
22
24
 
@@ -35,7 +37,14 @@ export async function sidebarRoutes(fastify, opts = {}) {
35
37
  });
36
38
 
37
39
  fastify.get('/sidebar/registered-items', signedIn, async (request) => {
40
+ // Dashboard Pro: My Dashboard wears the name this person (or the site) gave it.
41
+ const boardLabel = isDashboardPro()
42
+ ? boardName(await readPrefs(request.user), await readSite())
43
+ : null;
38
44
  return getRegisteredSidebarItems()
45
+ .map(entry => (boardLabel && entry?.item?.url === '#/my-dashboard'
46
+ ? {...entry, item: {...entry.item, text: boardLabel}}
47
+ : entry))
39
48
  .filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
40
49
  .map(entry => Array.isArray(entry.item?.items)
41
50
  ? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
@@ -3,12 +3,20 @@
3
3
  *
4
4
  * GET /api/dashboard/pro {enabled} - whether this site has the board (any signed-in user)
5
5
  * GET /api/widgets the widgets this user may add (Pro)
6
- * GET /api/dashboard/layout {layout, source: mine|site|built-in} (Pro)
6
+ * GET /api/dashboard/boards {boards: [{id, name, widgets}], active, source} (Pro)
7
+ * PUT /api/dashboard/boards save all of mine (Pro)
8
+ * GET /api/dashboard/layout the active board's layout {layout, source: mine|site|built-in} (Pro)
7
9
  * PUT /api/dashboard/layout save mine (Pro)
8
10
  * DELETE /api/dashboard/layout go back to the site's default (Pro)
9
11
  * GET /api/dashboard/layout/default the site default (Pro, admins)
10
12
  * PUT /api/dashboard/layout/default set it (Pro, admins)
13
+ * GET /api/dashboard/settings {mine, site, name, isAdmin, locked} - the cog (Pro)
14
+ * PUT /api/dashboard/settings save mine (Pro)
15
+ * PUT /api/dashboard/settings/site the name for everyone, and the layout lock (Pro, admins)
11
16
  *
17
+ * A LOCKED site (settings/site `locked`) holds everyone but administrators to
18
+ * the site default: their GET answers it with source "locked", and their
19
+ * PUT / DELETE answer 423.
12
20
  * Every route but /dashboard/pro answers 404 while no Pro Tool has switched the
13
21
  * board on (services/widgets.js) - a free site has none of this.
14
22
  *
@@ -23,8 +31,10 @@ import {getPermissionsForRoles} from '../../services/roles.js';
23
31
  import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
24
32
  import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
25
33
  import {
26
- DEFAULT_LAYOUT, getWidgets, isDashboardPro, layoutProblems, normaliseLayout, widgetsFor
34
+ DEFAULT_LAYOUT, activeBoard, boardsProblems, getWidgets, isDashboardPro, layoutProblems, normaliseBoards,
35
+ normaliseLayout, widgetsFor
27
36
  } from '../../services/widgets.js';
37
+ import {boardName, prefsProblems, readPrefs, readSite, siteProblems, writePrefs, writeSite} from '../../services/dashboardSettings.js';
28
38
 
29
39
  const DIR = path.resolve('content', 'dashboard');
30
40
  const LAYOUTS = path.join(DIR, 'layouts');
@@ -72,23 +82,63 @@ export async function widgetRoutes(fastify, opts = {}) {
72
82
 
73
83
  fastify.get('/widgets', {preHandler: [authenticate, pro]}, async (request) => widgetsFor(getWidgets(), who(request.user)));
74
84
 
75
- fastify.get('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
76
- const file = userFile(request.user);
85
+ /** Whether this user is held to the site default (the lock, and not an admin). */
86
+ const heldToDefault = async (user) => !who(user).isAdmin && (await readSite()).locked;
87
+
88
+ /**
89
+ * This person's boards, and where they came from: their own (mine), or -
90
+ * before they have arranged anything - one board from the site default
91
+ * (site / built-in). A locked site gives a non-admin that one board only.
92
+ */
93
+ async function boardsFor(user) {
94
+ if (await heldToDefault(user)) {
95
+ const d = await siteDefault();
96
+ return {...normaliseBoards({boards: [{id: 'main', name: 'Main', widgets: d.layout.widgets}]}), source: 'locked'};
97
+ }
98
+ const file = userFile(user);
77
99
  const mine = file ? await readJson(file) : null;
78
- return mine ? {layout: normaliseLayout(mine), source: 'mine'} : siteDefault();
100
+ if (mine) return {...normaliseBoards(mine), source: 'mine'};
101
+ const d = await siteDefault();
102
+ return {...normaliseBoards({boards: [{id: 'main', name: 'Main', widgets: d.layout.widgets}]}), source: d.source};
103
+ }
104
+ const locked423 = (reply) => reply.status(423).send({error: 'Your administrator has locked the Dashboard layout.'});
105
+
106
+ fastify.get('/dashboard/boards', {preHandler: [authenticate, pro]}, async (request) => boardsFor(request.user));
107
+
108
+ fastify.put('/dashboard/boards', {preHandler: [authenticate, pro]}, async (request, reply) => {
109
+ if (await heldToDefault(request.user)) return locked423(reply);
110
+ const problems = boardsProblems(request.body);
111
+ if (problems.length) return reply.status(400).send({error: problems[0]});
112
+ const file = userFile(request.user);
113
+ if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
114
+ const set = normaliseBoards(request.body);
115
+ await writeJson(file, set);
116
+ return {...set, source: 'mine'};
117
+ });
118
+
119
+ // The single-layout routes work on the ACTIVE board (what the board drew
120
+ // before there were several, and what a script may still call).
121
+ fastify.get('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
122
+ const set = await boardsFor(request.user);
123
+ return {layout: {version: 1, widgets: activeBoard(set).widgets}, source: set.source};
79
124
  });
80
125
 
81
126
  fastify.put('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
127
+ if (await heldToDefault(request.user)) return locked423(reply);
82
128
  const problems = layoutProblems(request.body);
83
129
  if (problems.length) return reply.status(400).send({error: problems[0]});
84
130
  const file = userFile(request.user);
85
131
  if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
132
+ const set = await boardsFor(request.user);
86
133
  const layout = normaliseLayout(request.body);
87
- await writeJson(file, layout);
134
+ activeBoard(set).widgets = layout.widgets;
135
+ await writeJson(file, normaliseBoards(set));
88
136
  return {layout, source: 'mine'};
89
137
  });
90
138
 
91
- fastify.delete('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
139
+ // Reset: every board goes, and the person starts again from the site default.
140
+ fastify.delete('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
141
+ if (await heldToDefault(request.user)) return locked423(reply);
92
142
  const file = userFile(request.user);
93
143
  if (file) await fs.rm(file, {force: true});
94
144
  return siteDefault();
@@ -103,4 +153,29 @@ export async function widgetRoutes(fastify, opts = {}) {
103
153
  await writeJson(DEFAULT_FILE, layout);
104
154
  return {layout, source: 'site'};
105
155
  });
156
+
157
+ // ---- The cog ----
158
+ fastify.get('/dashboard/settings', {preHandler: [authenticate, pro]}, async (request) => {
159
+ const [mine, site] = await Promise.all([readPrefs(request.user), readSite()]);
160
+ const isAdmin = who(request.user).isAdmin;
161
+ return {mine, site, name: boardName(mine, site), isAdmin, locked: site.locked && !isAdmin};
162
+ });
163
+
164
+ fastify.put('/dashboard/settings', {preHandler: [authenticate, pro]}, async (request, reply) => {
165
+ const problems = prefsProblems(request.body);
166
+ if (problems.length) return reply.status(400).send({error: problems[0]});
167
+ try {
168
+ const mine = await writePrefs(request.user, request.body);
169
+ const site = await readSite();
170
+ return {mine, site, name: boardName(mine, site)};
171
+ } catch (err) {
172
+ return reply.status(400).send({error: err.message});
173
+ }
174
+ });
175
+
176
+ fastify.put('/dashboard/settings/site', {preHandler: [authenticate, pro, requireAdmin]}, async (request, reply) => {
177
+ const problems = siteProblems(request.body);
178
+ if (problems.length) return reply.status(400).send({error: problems[0]});
179
+ return {site: await writeSite(request.body)};
180
+ });
106
181
  }
package/server/server.js CHANGED
@@ -670,6 +670,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content
670
670
  // sub-folders have no key in older menus; findFolder() falls back to their name.
671
671
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
672
672
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
673
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Dashboard', url: '#/docs/usage/dashboard', icon: 'home'}});
673
674
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
674
675
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
675
676
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Dashboard Pro settings - the board's cog (admin/js/views/dashboard-board.js).
3
+ *
4
+ * Two layers:
5
+ *
6
+ * mine - each person's own: what their board is called, a greeting, how many
7
+ * columns and how dense, how often widgets update, and whether the
8
+ * admin opens on their board. content/dashboard/prefs/<user id>.json -
9
+ * per user, so ignored in a site repo like the layouts beside it.
10
+ * site - an administrator's, for everyone: the board's default name and a
11
+ * lock that holds everyone (but administrators) to the default
12
+ * layout. content/dashboard/site.json, tracked.
13
+ *
14
+ * Pure normalisers plus small file helpers; the routes are in
15
+ * routes/api/widgets.js. Tested in tests/services/dashboard-settings.test.js.
16
+ */
17
+ import fs from 'fs/promises';
18
+ import path from 'path';
19
+
20
+ export const COLUMNS = [3, 4, 6];
21
+ export const DENSITIES = ['normal', 'compact'];
22
+ /** live = each widget's own interval; slower = three times as long; paused = only by hand. */
23
+ export const UPDATES = ['live', 'slower', 'paused'];
24
+ export const NAME_MAX = 40;
25
+
26
+ export const DEFAULT_PREFS = {name: '', greeting: true, columns: 4, density: 'normal', updates: 'live', home: false};
27
+ export const DEFAULT_SITE = {name: 'My Dashboard', locked: false};
28
+
29
+ const cleanName = (v) => String(v ?? '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
30
+
31
+ /** A person's settings, cleaned: unknown values fall back to the defaults. */
32
+ export function normalisePrefs(input) {
33
+ const p = input && typeof input === 'object' ? input : {};
34
+ return {
35
+ name: cleanName(p.name),
36
+ greeting: p.greeting !== false,
37
+ columns: COLUMNS.includes(Number(p.columns)) ? Number(p.columns) : DEFAULT_PREFS.columns,
38
+ density: DENSITIES.includes(p.density) ? p.density : DEFAULT_PREFS.density,
39
+ updates: UPDATES.includes(p.updates) ? p.updates : DEFAULT_PREFS.updates,
40
+ home: p.home === true
41
+ };
42
+ }
43
+
44
+ /** The site's settings, cleaned. A blank name falls back to "My Dashboard". */
45
+ export function normaliseSite(input) {
46
+ const s = input && typeof input === 'object' ? input : {};
47
+ return {name: cleanName(s.name) || DEFAULT_SITE.name, locked: s.locked === true};
48
+ }
49
+
50
+ /** What is wrong with submitted settings, as messages ([] when they are fine). */
51
+ export function prefsProblems(input) {
52
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Settings must be an object.'];
53
+ const out = [];
54
+ if (input.name != null && String(input.name).length > NAME_MAX) out.push(`The name can be at most ${NAME_MAX} characters.`);
55
+ if (input.columns != null && !COLUMNS.includes(Number(input.columns))) out.push(`Columns must be one of ${COLUMNS.join(', ')}.`);
56
+ if (input.density != null && !DENSITIES.includes(input.density)) out.push(`Density must be ${DENSITIES.join(' or ')}.`);
57
+ if (input.updates != null && !UPDATES.includes(input.updates)) out.push(`Updates must be one of ${UPDATES.join(', ')}.`);
58
+ return out;
59
+ }
60
+
61
+ export function siteProblems(input) {
62
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return ['Settings must be an object.'];
63
+ if (input.name != null && String(input.name).length > NAME_MAX) return [`The name can be at most ${NAME_MAX} characters.`];
64
+ return [];
65
+ }
66
+
67
+ /** What the board is called for this person: their own name, else the site's. */
68
+ export function boardName(prefs, site) {
69
+ return cleanName(prefs?.name) || normaliseSite(site).name;
70
+ }
71
+
72
+ // ---- Files -------------------------------------------------------------------
73
+
74
+ const DIR = () => path.resolve('content', 'dashboard');
75
+ const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
76
+
77
+ async function readJson(file) {
78
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
79
+ }
80
+
81
+ async function writeJson(file, data) {
82
+ await fs.mkdir(path.dirname(file), {recursive: true});
83
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
84
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
85
+ await fs.rename(tmp, file);
86
+ }
87
+
88
+ const prefsFile = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(DIR(), 'prefs', `${user.id}.json`) : null);
89
+
90
+ export async function readPrefs(user) {
91
+ const file = prefsFile(user);
92
+ return normalisePrefs(file ? await readJson(file) : null);
93
+ }
94
+
95
+ export async function writePrefs(user, input) {
96
+ const file = prefsFile(user);
97
+ if (!file) throw new Error('This account cannot keep settings.');
98
+ const prefs = normalisePrefs(input);
99
+ await writeJson(file, prefs);
100
+ return prefs;
101
+ }
102
+
103
+ export async function readSite() {
104
+ return normaliseSite(await readJson(path.join(DIR(), 'site.json')));
105
+ }
106
+
107
+ export async function writeSite(input) {
108
+ const site = normaliseSite(input);
109
+ await writeJson(path.join(DIR(), 'site.json'), site);
110
+ return site;
111
+ }
@@ -58,6 +58,7 @@ const MAP = [
58
58
  ['usage-editions', 'usage/editions', 'Editions & Licences'],
59
59
  ['usage-collections', 'usage/collections', 'Collections & Forms'],
60
60
  ['usage-tools', 'usage/tools', 'Built-in Tools'],
61
+ ['usage-dashboard', 'usage/dashboard', 'Dashboard'],
61
62
  ['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
62
63
  ['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
63
64
  ['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
@@ -116,3 +116,31 @@ export async function fetchPluginBundle(slug, version) {
116
116
  version: json.version ?? version,
117
117
  };
118
118
  }
119
+
120
+ /**
121
+ * Tell the manager this site uninstalled a paid plugin, so billing and the
122
+ * licence can follow: a Tool the site pays for on its own line comes off its
123
+ * subscription; anything else is left to an admin there, who is told.
124
+ *
125
+ * @param {string} slug
126
+ * @returns {Promise<{billed: boolean, removed?: boolean, cancelling?: boolean, reason?: string}|null>}
127
+ * null when unmanaged or the manager could not be reached
128
+ */
129
+ export async function releasePlugin(slug) {
130
+ if (!isManaged()) return null;
131
+ try {
132
+ const res = await fetch(`${process.env.MANAGER_URL}/api/plugins/release/${slug}`, {
133
+ method: 'POST',
134
+ signal: AbortSignal.timeout(20_000),
135
+ headers: fleetHeaders(),
136
+ });
137
+ if (!res.ok) {
138
+ console.warn(`[managerClient] releasePlugin failed: ${res.status} ${res.statusText}`);
139
+ return null;
140
+ }
141
+ return await res.json();
142
+ } catch (err) {
143
+ console.warn('[managerClient] releasePlugin error:', err.message);
144
+ return null;
145
+ }
146
+ }
@@ -319,6 +319,7 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
319
319
  '/content/preview-links.json',
320
320
  // Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
321
321
  '/content/dashboard/layouts/',
322
+ '/content/dashboard/prefs/',
322
323
  '/config/connections.json*',
323
324
  '/config/keys/',
324
325
  '/plugins/*/data/*.jsonl',