domma-cms 1.3.0 → 1.5.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/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/dashboards-arrange.js +3 -0
- package/admin/js/lib/dashboards-strip.js +1 -0
- package/admin/js/lib/plugins-arrange.js +1 -1
- package/admin/js/lib/sidebar-frequent.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +7 -4
- package/admin/js/templates/dashboards.html +2 -0
- package/admin/js/templates/docs/usage-dashboard.html +16 -12
- package/admin/js/templates/docs/usage-plugins.html +9 -0
- package/admin/js/templates/docs/usage-tools.html +10 -0
- package/admin/js/views/dashboard-board.js +18 -19
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/dashboards.js +1 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugins.js +9 -9
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +23 -2
- package/plugins/_lib/admin/mail/keyboard.js +14 -0
- package/plugins/_lib/admin/mail/scheduling.js +22 -0
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/plugin-marketplace.js +9 -3
- package/server/routes/api/sidebar.js +17 -6
- package/server/services/dashboardSettings.js +8 -4
- package/server/services/managerClient.js +28 -0
- package/server/services/widgets.js +5 -11
- package/admin/js/templates/my-dashboard.html +0 -3
- package/admin/js/views/my-dashboard.js +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as k,getUser as
|
|
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>`,
|
|
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>`,
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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&&
|
|
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
|
|
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(
|
|
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.
|
|
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=
|
|
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
|
@@ -69,7 +69,8 @@ function formatSize(bytes) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identity.js';
|
|
72
|
-
import {describeWhen, quickTimes} from './scheduling.js';
|
|
72
|
+
import {describeWhen, quickTimes, sendQuestion} from './scheduling.js';
|
|
73
|
+
import {isSendShortcut} from './keyboard.js';
|
|
73
74
|
import {fmtDateTime} from '../ui/dates.js';
|
|
74
75
|
import {applyTemplate, expandTemplate} from './templates.js';
|
|
75
76
|
import {makeResizable} from './resizable.js';
|
|
@@ -300,7 +301,7 @@ const FORM = `
|
|
|
300
301
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="later" title="Send this at a time you choose">Later…</button>
|
|
301
302
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="send"
|
|
302
303
|
data-bind-disabled="cannotSend.value" data-bind-text="sendLabel.value" data-bind-title="sendTitle.value"></button>
|
|
303
|
-
<span data-help-title="Sending" data-help="Later… sends at a time you choose, and the button then says when. With an undo delay set in Mail Settings, Send holds the message for those seconds so you can take it back; it has not gone until they pass."></span>
|
|
304
|
+
<span data-help-title="Sending" data-help="Ctrl+Enter (Cmd+Enter on a Mac) sends too, after asking first. Later… sends at a time you choose, and the button then says when. With an undo delay set in Mail Settings, Send holds the message for those seconds so you can take it back; it has not gone until they pass."></span>
|
|
304
305
|
</div>
|
|
305
306
|
</div>`;
|
|
306
307
|
|
|
@@ -848,6 +849,25 @@ export function openCompose({
|
|
|
848
849
|
attachList.addEventListener('click', onAttachClick);
|
|
849
850
|
attachList.addEventListener('keydown', onAttachKey);
|
|
850
851
|
|
|
852
|
+
// Ctrl+Enter (Cmd+Enter): send, from anywhere in the window - the editor included - but only
|
|
853
|
+
// after "Are you sure?", because the combination is easy to hit while typing.
|
|
854
|
+
let askingSend = false;
|
|
855
|
+
const onSendKey = async (e) => {
|
|
856
|
+
if (!isSendShortcut(e)) return;
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
e.stopPropagation();
|
|
859
|
+
if (askingSend || closed || cannotSend.peek()) return;
|
|
860
|
+
askingSend = true;
|
|
861
|
+
try {
|
|
862
|
+
const q = sendQuestion({...collect(), sendAt: scheduledAt.peek(), undoSeconds});
|
|
863
|
+
const ok = await E.confirm(q.text, {title: q.title, confirmText: q.confirmText, cancelText: 'Keep writing'});
|
|
864
|
+
if (ok && !closed && !cannotSend.peek()) await send();
|
|
865
|
+
} finally {
|
|
866
|
+
askingSend = false;
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
slideover.element.addEventListener('keydown', onSendKey, true);
|
|
870
|
+
|
|
851
871
|
slideover.element.appendChild(form);
|
|
852
872
|
const stopResizing = makeResizable(slideover, {
|
|
853
873
|
storageKey: 'domma-mail.compose-width',
|
|
@@ -911,6 +931,7 @@ export function openCompose({
|
|
|
911
931
|
attachMenu?.destroy?.();
|
|
912
932
|
attachList.removeEventListener('click', onAttachClick);
|
|
913
933
|
attachList.removeEventListener('keydown', onAttachKey);
|
|
934
|
+
slideover.element?.removeEventListener('keydown', onSendKey, true);
|
|
914
935
|
try { stopHelp?.(); } catch { /* gone */ }
|
|
915
936
|
closedListeners.splice(0).forEach(fn => { try { fn(); } catch { /* ignore */ } });
|
|
916
937
|
// Domma hides a closed slideover rather than removing it: every message
|
|
@@ -126,6 +126,7 @@ export const SHORTCUTS = [
|
|
|
126
126
|
['r / Shift-R', 'Reply / reply all'],
|
|
127
127
|
['f', 'Forward'],
|
|
128
128
|
['c', 'New message'],
|
|
129
|
+
['Ctrl-Enter', 'Send the message you are writing (asks first; Cmd-Enter on a Mac)'],
|
|
129
130
|
['/', 'Search'],
|
|
130
131
|
['x', 'Select or deselect'],
|
|
131
132
|
['Shift-A', 'Select all on this page'],
|
|
@@ -134,3 +135,16 @@ export const SHORTCUTS = [
|
|
|
134
135
|
['Esc', 'Clear search or selection'],
|
|
135
136
|
['?', 'This list']
|
|
136
137
|
];
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Ctrl+Enter (Cmd+Enter on a Mac) - "send" in the compose window, as in most
|
|
141
|
+
* mail clients. The one combination this file claims, and only in compose,
|
|
142
|
+
* where it asks before anything goes (compose-window.js).
|
|
143
|
+
*
|
|
144
|
+
* @param {KeyboardEvent} event
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
export function isSendShortcut(event) {
|
|
148
|
+
if (!event || event.key !== 'Enter' || event.altKey || event.shiftKey || event.isComposing) return false;
|
|
149
|
+
return Boolean(event.ctrlKey || event.metaKey) && !(event.ctrlKey && event.metaKey);
|
|
150
|
+
}
|
|
@@ -203,3 +203,25 @@ export function dueNow(items, now = Date.now()) {
|
|
|
203
203
|
return Number.isFinite(when) && when <= at;
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The "Are you sure?" for Ctrl+Enter: who it goes to, what, and when - a
|
|
209
|
+
* keypress is easy to make by accident, so the question says what would happen.
|
|
210
|
+
*
|
|
211
|
+
* @param {{to?: Array<{name?: string, address?: string}|string>, subject?: string, sendAt?: string|null, undoSeconds?: number}} msg
|
|
212
|
+
* @param {Date} [now]
|
|
213
|
+
* @returns {{title: string, text: string, confirmText: string}}
|
|
214
|
+
*/
|
|
215
|
+
export function sendQuestion({to = [], subject = '', sendAt = null, undoSeconds = 0} = {}, now = new Date()) {
|
|
216
|
+
const names = (Array.isArray(to) ? to : []).map(r => (typeof r === 'string' ? r : (r?.name || r?.address || ''))).filter(Boolean);
|
|
217
|
+
const who = !names.length ? 'nobody yet'
|
|
218
|
+
: names.length <= 3 ? names.join(', ')
|
|
219
|
+
: `${names.slice(0, 2).join(', ')} and ${names.length - 2} more`;
|
|
220
|
+
const what = String(subject || '').trim() ? `"${String(subject).trim().slice(0, 80)}"` : 'this message (no subject)';
|
|
221
|
+
if (sendAt) {
|
|
222
|
+
return {title: 'Schedule this message?', confirmText: 'Schedule',
|
|
223
|
+
text: `Send ${what} to ${who} ${describeWhen(sendAt, now)}?`};
|
|
224
|
+
}
|
|
225
|
+
return {title: 'Send this message?', confirmText: 'Send',
|
|
226
|
+
text: `Send ${what} to ${who} now?${undoSeconds > 0 ? ` You will have ${undoSeconds} seconds to take it back.` : ''}`};
|
|
227
|
+
}
|
|
@@ -250,8 +250,8 @@ export async function authRoutes(fastify) {
|
|
|
250
250
|
menuItems: async () => (await getMenu('admin-sidebar').catch(() => null))?.items || [],
|
|
251
251
|
canSee: (visibility) => checkVisibility(request.user, visibility)
|
|
252
252
|
});
|
|
253
|
-
// Dashboard Pro's "Open
|
|
254
|
-
if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/
|
|
253
|
+
// Dashboard Pro's "Open on my boards" - for a user the admin does not confine.
|
|
254
|
+
if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/dashboards';
|
|
255
255
|
return {permissions, adminScope, adminHome};
|
|
256
256
|
});
|
|
257
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
|
-
|
|
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
|
}
|
|
@@ -26,6 +26,17 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
26
26
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
27
27
|
const signedIn = {preHandler: [authenticate]};
|
|
28
28
|
|
|
29
|
+
/** Dashboard Pro: the Dashboard entry (`#/`) is Dashboards, wearing the name this person (or the site) gave it. */
|
|
30
|
+
const dashboardsLabel = async (request) => (isDashboardPro()
|
|
31
|
+
? boardName(await readPrefs(request.user), await readSite())
|
|
32
|
+
: null);
|
|
33
|
+
/** Relabel the entry for `#/` - only while it still says "Dashboard", so a name an admin gave it stays. */
|
|
34
|
+
const relabel = (items, label) => (items || []).map((it) => {
|
|
35
|
+
if (!it || typeof it !== 'object') return it;
|
|
36
|
+
const next = it.url === '#/' && String(it.text || '').trim() === 'Dashboard' ? {...it, text: label} : it;
|
|
37
|
+
return Array.isArray(next.items) ? {...next, items: relabel(next.items, label)} : next;
|
|
38
|
+
});
|
|
39
|
+
|
|
29
40
|
fastify.get('/sidebar/menu', signedIn, async (request) => {
|
|
30
41
|
const menu = await getMenu(ADMIN_SIDEBAR_SLUG);
|
|
31
42
|
if (!menu) return {items: []};
|
|
@@ -33,16 +44,16 @@ export async function sidebarRoutes(fastify, opts = {}) {
|
|
|
33
44
|
// renderer needs them to place Tools before it drops them. An item
|
|
34
45
|
// this user may not see (`visibility`) arrives as a label-less hidden
|
|
35
46
|
// stub for the same reason - see restrictedStub() in menus.js.
|
|
36
|
-
|
|
47
|
+
const items = filterItemsForUser(menu.items || [], request.user, {keepHidden: true});
|
|
48
|
+
const label = await dashboardsLabel(request);
|
|
49
|
+
return {...menu, items: label ? relabel(items, label) : items};
|
|
37
50
|
});
|
|
38
51
|
|
|
39
52
|
fastify.get('/sidebar/registered-items', signedIn, async (request) => {
|
|
40
|
-
//
|
|
41
|
-
const boardLabel =
|
|
42
|
-
? boardName(await readPrefs(request.user), await readSite())
|
|
43
|
-
: null;
|
|
53
|
+
// A Tool that owns `#/` (the fleet manager's Dashboard) registers the entry itself.
|
|
54
|
+
const boardLabel = await dashboardsLabel(request);
|
|
44
55
|
return getRegisteredSidebarItems()
|
|
45
|
-
.map(entry => (boardLabel && entry?.item?.url === '#/
|
|
56
|
+
.map(entry => (boardLabel && entry?.item?.url === '#/' && String(entry.item.text || '').trim() === 'Dashboard'
|
|
46
57
|
? {...entry, item: {...entry.item, text: boardLabel}}
|
|
47
58
|
: entry))
|
|
48
59
|
.filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
|
|
@@ -24,7 +24,9 @@ export const UPDATES = ['live', 'slower', 'paused'];
|
|
|
24
24
|
export const NAME_MAX = 40;
|
|
25
25
|
|
|
26
26
|
export const DEFAULT_PREFS = {name: '', greeting: true, columns: 4, density: 'normal', updates: 'live', home: false};
|
|
27
|
-
export const DEFAULT_SITE = {name: '
|
|
27
|
+
export const DEFAULT_SITE = {name: 'Dashboards', locked: false};
|
|
28
|
+
/** The default before 1.5, when it named the board's own sidebar entry: read as no name at all. */
|
|
29
|
+
const OLD_DEFAULT = 'My Dashboard';
|
|
28
30
|
|
|
29
31
|
const cleanName = (v) => String(v ?? '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
|
|
30
32
|
|
|
@@ -41,10 +43,11 @@ export function normalisePrefs(input) {
|
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/** The site's settings, cleaned. A blank name falls back to "
|
|
46
|
+
/** The site's settings, cleaned. A blank name (or the old default) falls back to "Dashboards". */
|
|
45
47
|
export function normaliseSite(input) {
|
|
46
48
|
const s = input && typeof input === 'object' ? input : {};
|
|
47
|
-
|
|
49
|
+
const name = cleanName(s.name);
|
|
50
|
+
return {name: name && name !== OLD_DEFAULT ? name : DEFAULT_SITE.name, locked: s.locked === true};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/** What is wrong with submitted settings, as messages ([] when they are fine). */
|
|
@@ -66,7 +69,8 @@ export function siteProblems(input) {
|
|
|
66
69
|
|
|
67
70
|
/** What the board is called for this person: their own name, else the site's. */
|
|
68
71
|
export function boardName(prefs, site) {
|
|
69
|
-
|
|
72
|
+
const mine = cleanName(prefs?.name);
|
|
73
|
+
return mine && mine !== OLD_DEFAULT ? mine : normaliseSite(site).name;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
// ---- Files -------------------------------------------------------------------
|
|
@@ -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
|
+
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* @module services/widgets
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {getRegisteredSidebarItems
|
|
35
|
+
import {getRegisteredSidebarItems} from './hooks.js';
|
|
36
36
|
|
|
37
37
|
/** Widths on the board's four-column grid. */
|
|
38
38
|
export const WIDGET_SIZES = ['s', 'm', 'l', 'xl'];
|
|
@@ -226,7 +226,6 @@ export function layoutProblems(input) {
|
|
|
226
226
|
// ---------------------------------------------------------------------------
|
|
227
227
|
|
|
228
228
|
const owners = new Set();
|
|
229
|
-
let sidebarRegistered = false;
|
|
230
229
|
|
|
231
230
|
/**
|
|
232
231
|
* A Pro Tool switches the board on (plugins.js hands it to each plugin as
|
|
@@ -239,14 +238,8 @@ let sidebarRegistered = false;
|
|
|
239
238
|
export function enableDashboardPro({owner, tier, closedSource} = {}) {
|
|
240
239
|
if (typeof owner !== 'string' || !owner.trim()) return false;
|
|
241
240
|
if (tier !== 'paid' || closedSource !== true) return false;
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
if (!sidebarRegistered) {
|
|
245
|
-
sidebarRegistered = true;
|
|
246
|
-
try {
|
|
247
|
-
registerSidebarItem({folder: 'overview', item: {source: 'Dashboard Pro', text: 'My Dashboard', url: '#/my-dashboard', icon: 'layout'}});
|
|
248
|
-
} catch { /* a sidebar without folders still has the route */ }
|
|
249
|
-
}
|
|
241
|
+
// No sidebar entry of its own: the Dashboard entry becomes Dashboards (routes/api/sidebar.js),
|
|
242
|
+
// whose first tab is the primary Dashboard and the rest the boards (admin lib/dashboards-arrange.js).
|
|
250
243
|
owners.add(owner.trim());
|
|
251
244
|
return true;
|
|
252
245
|
}
|
|
@@ -274,7 +267,8 @@ export function _resetWidgetsForTests() {
|
|
|
274
267
|
|
|
275
268
|
export const MAX_BOARDS = 12;
|
|
276
269
|
export const BOARD_NAME_MAX = 40;
|
|
277
|
-
|
|
270
|
+
// `overview` is the primary Dashboard's tab (admin lib/dashboards-arrange.js), never a board.
|
|
271
|
+
const BOARD_ID_RE = /^(?!overview$)[a-z0-9][a-z0-9-]{0,39}$/;
|
|
278
272
|
|
|
279
273
|
const boardName = (v, fallback) => String(v ?? '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, BOARD_NAME_MAX) || fallback;
|
|
280
274
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{apiRequest as r}from"../api.js";import{viewLifetime as m}from"../lib/tool-kit.js";const o=/^#\/my-dashboard\/?(\?.*)?$/,a=m(o);export const myDashboardView={templateUrl:"/admin/js/templates/my-dashboard.html",async onMount(t){a.reset();const e=await r("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!o.test(location.hash))return;const i=t.get(0).querySelector(".dbx")||t.get(0);if(!e?.enabled){i.innerHTML='<p class="dbp-empty">My Dashboard comes with Dashboard Pro, which is not switched on for this site. Find it in the Marketplace.</p>';return}const{mountBoard:s}=await import("./dashboard-board.js");await s(t,a,{isAdmin:e.isAdmin===!0})}};
|