domma-cms 1.6.46 → 1.6.48

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.
@@ -58,6 +58,10 @@
58
58
  <div class="card-body"><h3 style="margin-top:0;"><span data-icon="users"></span> Users &amp; Roles</h3>
59
59
  <p class="text-muted" style="margin-bottom:0;">Accounts, roles, the role ladder, confined roles and resets.</p></div>
60
60
  </a>
61
+ <a href="#/docs/usage/blocks" class="card" style="text-decoration:none;display:block;">
62
+ <div class="card-body"><h3 style="margin-top:0;"><span data-icon="box"></span> Blocks</h3>
63
+ <p class="text-muted" style="margin-bottom:0;">Your own HTML templates for collection entries, views and one-off pieces.</p></div>
64
+ </a>
61
65
  <a href="#/docs/usage/tools" class="card" style="text-decoration:none;display:block;">
62
66
  <div class="card-body"><h3 style="margin-top:0;"><span data-icon="list"></span> Built-in Tools</h3>
63
67
  <p class="text-muted" style="margin-bottom:0;">Contacts, Notes, Todo, Analytics, SEO, notifications and search.</p></div>
@@ -101,7 +105,7 @@
101
105
  <p class="text-muted" style="margin-bottom:0;">Emails, webhooks, Actions and what happens after a submission.</p></div>
102
106
  </a>
103
107
  <a href="#/tutorials/plugin" class="card" style="text-decoration:none;display:block;">
104
- <div class="card-body"><h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Plugin</h3>
108
+ <div class="card-body"><h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Tool</h3>
105
109
  <p class="text-muted" style="margin-bottom:0;">Server routes, admin screens and page snippets of your own.</p></div>
106
110
  </a>
107
111
  </div>
@@ -26,7 +26,7 @@
26
26
 
27
27
  <a href="#/tutorials/plugin" class="card" style="text-decoration:none;display:block;">
28
28
  <div class="card-body">
29
- <h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Plugin</h3>
29
+ <h3 style="margin-top:0;"><span data-icon="package"></span> Writing a Tool</h3>
30
30
  <p class="text-muted" style="margin-bottom:0;">
31
31
  Extend the CMS with your own server routes, admin pages and page-injection snippets:
32
32
  the manifest, the Fastify entry point, switching it on and testing it.
@@ -1,14 +1,14 @@
1
- import{api as $}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{helpPopovers as at}from"../lib/help-popover.js";import{openMenuAt as Fe,sidebarCount as nt}from"../lib/tool-kit.js";import{createSimpleEditor as Ie}from"../lib/simple-editor.js";import{checkForm as st,draftKey as j,draftOffer as rt,escapeHtml as Ne,extractPlaceholders as ct,formFromBlock as lt,formatHtml as it,payloadFromForm as ut,previewMessage as dt,previewShell as pt,rawPlaceholders as We,renderPreview as mt,sampleValue as _e,scopeName as vt,shortcodeFor as ft,snapshot as de}from"../lib/block-editor-arrange.js";import{fmtDateTime as He}from"../lib/dates.js";const ht=/^\/blocks\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,J=u=>String(u||"").split("?")[0],pe="block-editor.mode",bt=["write","split","preview"],Be="block-editor.preview",Ve=["full","1024","390"],yt="/__block-preview",me="dm-block-preview-root",Ke={title:"Block preview",layout:"blank",breadcrumbs:!1},kt={ADD_ATTR:["target","loading"],ALLOW_DATA_ATTR:!0,FORCE_BODY:!0},wt=()=>window.DOMPurify?.sanitize?u=>window.DOMPurify.sanitize(u,kt):null,gt=`
1
+ import{api as $}from"../api.js";import{addBannerAction as J}from"../lib/plugin-chrome.js";import{helpPopovers as at}from"../lib/help-popover.js";import{openMenuAt as Fe,sidebarCount as nt}from"../lib/tool-kit.js";import{createSimpleEditor as Ie}from"../lib/simple-editor.js";import{checkForm as st,draftKey as j,draftOffer as rt,escapeHtml as Ne,extractPlaceholders as ct,formFromBlock as lt,formatHtml as it,payloadFromForm as ut,previewMessage as dt,previewShell as pt,rawPlaceholders as We,renderPreview as mt,sampleValue as _e,scopeName as vt,shortcodeFor as ht,snapshot as de}from"../lib/block-editor-arrange.js";import{fmtDateTime as Be}from"../lib/dates.js";const ft=/^\/blocks\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,G=u=>String(u||"").split("?")[0],pe="block-editor.mode",bt=["write","split","preview"],He="block-editor.preview",Ve=["full","1024","390"],yt="/__block-preview",me="dm-block-preview-root",Ke={title:"Block preview",layout:"blank",breadcrumbs:!1},kt={ADD_ATTR:["target","loading"],ALLOW_DATA_ATTR:!0,FORCE_BODY:!0},wt=()=>window.DOMPurify?.sanitize?u=>window.DOMPurify.sanitize(u,kt):null,gt=`
2
2
  <div class="bex-sample" data-key="{{key}}" tabindex="0">
3
3
  <label class="bex-sample-label" for="bex-sample-{{key}}">{{label}}</label>
4
4
  <input id="bex-sample-{{key}}" type="text" class="form-input form-input--sm" data-bind-value="value" data-sample="{{key}}" placeholder="Sample {{key}}">
5
5
  <button type="button" class="bex-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
6
- </div>`;let f=null,Ue=!1;function St(){Ue||!window.R?.use||(Ue=!0,R.use((u,p,s)=>{if(!(ht.test(p?.path||"")&&J(u?.path)!==J(p?.path))||!f?.dirty())return s();const c=f;return E.confirm(`You have unsaved changes to ${Ne(c.label())}. Leave anyway? They are kept as a draft for your next visit.`).then(B=>{B?s():history.replaceState(null,"",c.hash)})}),M.subscribe("router:afterChange",({to:u}={})=>{if(f&&J(u?.path)!==f.path){const p=f;f=null,p.dispose()}}))}function Et(u,p){const s=u.querySelector(".dm-plugin-banner-name"),g=s?.lastChild;g&&g.nodeType===3&&s.querySelector(".dm-plugin-banner-sep")&&(g.textContent=p)}function G(u){return navigator.clipboard?.writeText(u).then(()=>!0,()=>!1)??Promise.resolve(!1)}export const blockEditorView={templateUrl:"/admin/js/templates/block-editor.html",async onMount(u){if(St(),f){const e=f;f=null,e.dispose()}const p=u.get(0),s=p.querySelector(".bex")||p,g=[],c=e=>(g.push(e),e);let B=!1;const C=()=>!B&&s.isConnected,ve=location.hash.match(/^#\/blocks\/edit\/([^/?#]+)/),l=ve?decodeURIComponent(ve[1]):null,d=!l,Ye=location.hash,ze=J(location.hash.slice(1)),Je=d&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",fe=E.loader(p,{type:"dots"});let y=null,Q=[],X=[];try{[y,Q,X]=await Promise.all([l?$.blocks.get(l):Promise.resolve(null),$.blocks.list().catch(()=>[]),$.projects.list().catch(()=>[])])}catch(e){fe.destroy(),E.toast(e.message||"Block not found.",{type:"error"}),R.navigate("/blocks");return}if(fe.destroy(),!s.isConnected)return;const he=(Array.isArray(Q)?Q:[]).map(e=>e.name).sort((e,t)=>e.localeCompare(t));let be=y?.meta||{};const v=lt(y?{...y,name:l}:null,{project:Je}),a={};for(const[e,t]of Object.entries(v))a[e]=M.observable(t);const Z=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),ye=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),O=M.observable(de(v)),D=M.computed(()=>de(ye())!==O.value),q=M.observable(!1),V=M.observable(!1),K=M.computed(()=>st(ye(),{isNew:d,taken:he}));let ke="split";try{ke=bt.includes(S.get(pe))?S.get(pe):"split"}catch{}const k=M.observable(ke);c(M.effect(()=>{const e=k.value;try{S.set(pe,e)}catch{}}));const we=M.observable("Ln 1, Col 1");let L={};try{L=S.get(Be)||{}}catch{}const ee=M.observable(Ve.includes(L.width)?L.width:"full"),U=M.observable(String(L.repeat)==="3"?"3":"1"),m=M.observable(typeof L.theme=="string"?L.theme:""),x=M.observable("");c(M.effect(()=>{const e={width:ee.value,repeat:U.value,theme:m.value};try{S.set(Be,e)}catch{}}));const h={},P={},Y=M.observable(0),te=M.observable([]),ge=()=>We(a.content.peek());c(M.effect(()=>{const e=ct(a.content.value),t=We(a.content.value);let n=!1;for(const o of e){const w=t.has(o);if(!(o in h)||o in P&&P[o]!==w){const H=o in h;if(h[o]=_e(o,new Date,{raw:w}),P[o]=w,H){n=!0;const i=s.querySelector(`.bex-samples [data-sample="${CSS.escape(o)}"]`);i&&(i.value=h[o])}}}te.value=e.map(o=>({key:o,label:t.has(o)?`{{{${o}}}}`:`{{${o}}}`,value:h[o]})),n&&queueMicrotask(()=>Y.value++)}));const Ge={...a,dirty:D,saving:q,mode:k,cursor:we,sampleRows:te,isNew:d,notNew:!d,pvWidth:ee,pvRepeat:U,pvTheme:m,pvStatus:x,titleText:d?"New block":l,metaText:M.computed(()=>{if(d)return"Not saved yet";const e=[];return(y?.updatedAt||y?.meta?.updatedAt)&&e.push(`changed ${He(y.updatedAt||y.meta.updatedAt)}`),e.push(`${a.content.value.split(`
7
- `).length} lines`),a.css.value.trim()&&e.push(`${Math.ceil(a.css.value.length/1024)} KB CSS`),e.join(" \xB7 ")}),nameErr:M.computed(()=>(V.value||a.name.value)&&K.value.errors.name||""),cssErr:M.computed(()=>K.value.errors.css||""),scopeHint:M.computed(()=>`[data-block="${vt(a.name.value)}"]`),noSamples:M.computed(()=>te.value.length===0),noSamplesText:"No {{placeholders}} in the template yet.",modeCls:M.computed(()=>`bex-body is-${k.value}`),cannotSave:M.computed(()=>q.value||V.value&&Object.keys(K.value.errors).length>0)},oe=s.querySelector("#block-project");for(const e of Array.isArray(X)?X:[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,oe.appendChild(t)}if(v.project&&![...oe.options].some(e=>e.value===v.project)){const e=document.createElement("option");e.value=v.project,e.textContent=`${v.project} (no longer a project)`,oe.appendChild(e)}const F=s.querySelector("#block-quick-switch");for(const e of he){const t=document.createElement("option");t.value=e,t.textContent=e,e===l&&(t.selected=!0),F.appendChild(t)}const b=s.querySelector(".bex-samples");b.innerHTML=gt;let A=null,N=null,r=null;function Se(e){for(const[t,n]of Object.entries(e))a[t]&&(a[t].value=n);A.setValue(String(e.content??"")),N.setValue(String(e.css??""))}const ae=s.querySelector("#block-preview-frame");let T="none",W=0;const Qe="As your site shows it \xB7 not saved",Ee=()=>{if(!C()||k.peek()==="write"||T!=="ready")return;const e={content:a.content.peek(),css:a.css.peek(),name:a.name.peek()},t=wt();if(!t){x.value="The preview needs the HTML cleaner, which did not load - reload the admin.";return}const n=mt(e,h,{repeat:Number(U.peek()),sanitize:t});try{ae.contentWindow?.postMessage({source:"dm-block-editor",type:"paint",tick:W,html:n},"*")}catch{}},Me=e=>{const t=dt(e,ae.contentWindow,W);if(!(!t||!C())){if(t.type==="save"){z();return}if(T=t.host?"ready":"failed",T==="failed"){x.value="Your site's page came back without a place for the block - the preview cannot show it.";return}x.peek()==="Loading your site\u2026"&&(x.value=Qe),Ee()}};window.addEventListener("message",Me),c(()=>window.removeEventListener("message",Me));async function Ce(){const e=++W;T="loading",x.value="Loading your site\u2026";let t;try{const n=m.peek()?{...Ke,theme:m.peek()}:Ke;({html:t}=await $.pages.previewFull(yt,n,`<div id="${me}"></div>`))}catch(n){t=`<!doctype html><html><head><meta charset="utf-8"></head><body style="font:14px system-ui;margin:0"><div id="${me}" style="padding:1rem"></div></body></html>`,x.value=`Without your site's styles - ${n?.message||"the page did not render"}`}!C()||e!==W||(ae.srcdoc=pt(t,e,me))}let ne=0;c(M.effect(()=>{if(a.content.value,a.css.value,a.name.value,Y.value,U.value,k.value!=="write"){if(T==="none"){Ce();return}cancelAnimationFrame(ne),ne=requestAnimationFrame(Ee)}}));let xe=m.peek();c(M.effect(()=>{const e=m.value;e!==xe&&(xe=e,T!=="none"&&Ce())})),c(()=>{cancelAnimationFrame(ne),W++}),$.theme.catalog().then(({themes:e=[],custom:t=[]}={})=>{if(!C())return;const n=s.querySelector("#block-preview-theme"),o=i=>String(i).replace(/-/g," ").replace(/\b\w/g,le=>le.toUpperCase()),w=[...e.filter(i=>!String(i).startsWith("admin-")).map(i=>({id:i,label:o(i)})),...(Array.isArray(t)?t:[]).filter(i=>i?.id).map(i=>({id:i.id,label:i.label||o(i.id)}))],H=w.map(i=>i.id);for(const{id:i,label:le}of w){const ie=document.createElement("option");ie.value=i,ie.textContent=le,n.appendChild(ie)}m.peek()&&!H.includes(m.peek())?m.value="":n.value=m.peek()}).catch(()=>{s.querySelector("#block-preview-theme")?.remove(),C()&&m.peek()&&(m.value="")});const Ae=e=>{const t=e.target?.dataset?.sample;t&&(h[t]=e.target.value,delete P[t],Y.value++)};b.addEventListener("input",Ae),c(()=>b.removeEventListener("input",Ae));function se(e){r.focus(),r.setRangeText(e,r.selectionStart,r.selectionEnd,"end"),r.dispatchEvent(new Event("input",{bubbles:!0}))}async function Le(e){switch(e){case"undo":r.focus(),document.execCommand("undo");break;case"redo":r.focus(),document.execCommand("redo");break;case"cut":r.selectionStart!==r.selectionEnd&&(await G(r.value.slice(r.selectionStart,r.selectionEnd)),se(""));break;case"copy":r.selectionStart!==r.selectionEnd&&await G(r.value.slice(r.selectionStart,r.selectionEnd));break;case"paste":try{se(await navigator.clipboard.readText())}catch{E.toast("Use Ctrl+V to paste.",{type:"info"})}break;case"select-all":r.focus(),r.select();break;case"format":{const t=it(r.value);A.setValue(t),a.content.value=t;break}default:break}}async function z(){if(q.peek())return;V.value=!0;const{errors:e,name:t}=K.peek();if(Object.keys(e).length){E.toast(e.name||e.css,{type:"warning"});return}const n=d?t:l;q.value=!0;try{const o=ut(Z(),be);if(await $.blocks.put(n,o),!C())return;be=o.meta,O.value=de(Z());try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}nt("#/blocks"),E.toast(d?`Created "${n}".`:"Block saved.",{type:"success"}),d&&(location.hash=`#/blocks/edit/${encodeURIComponent(n)}`)}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{q.value=!1}}async function Xe(){D.peek()&&await E.confirm("Discard the changes you have not saved?")&&(V.value=!1,Se(Ze(O.peek())))}function Ze(e){try{const[t,n,o,w,H]=JSON.parse(e);return{name:t,project:n,bundled:o,content:w,css:H}}catch{return v}}const et=M.applyBindings(Ge,s,{methods:{save:()=>z(),discard:()=>Xe(),setWrite:()=>{k.value="write"},setSplit:()=>{k.value="split"},setPreview:()=>{k.value="preview"},setWidth:e=>{Ve.includes(String(e))&&(ee.value=String(e))},act:e=>Le(e)}});c(()=>et.dispose()),c(at(s,{position:"bottom"})),A=Ie(s.querySelector(".bex-write"),{initialValue:v.content,language:"html",autoIndent:!0,onChange:e=>{a.content.value=e},onCursor:({line:e,col:t})=>{we.value=`Ln ${e}, Col ${t}`}}),N=Ie(s.querySelector(".bex-css"),{initialValue:v.css,language:"css",autoIndent:!0,onChange:e=>{a.css.value=e}}),A.textarea.setAttribute("aria-label","Template HTML"),A.textarea.placeholder=`<div class="card mb-3">
6
+ </div>`;let h=null,Ue=!1;function St(){Ue||!window.R?.use||(Ue=!0,R.use((u,p,s)=>{if(!(ft.test(p?.path||"")&&G(u?.path)!==G(p?.path))||!h?.dirty())return s();const c=h;return E.confirm(`You have unsaved changes to ${Ne(c.label())}. Leave anyway? They are kept as a draft for your next visit.`).then(H=>{H?s():history.replaceState(null,"",c.hash)})}),M.subscribe("router:afterChange",({to:u}={})=>{if(h&&G(u?.path)!==h.path){const p=h;h=null,p.dispose()}}))}function Et(u,p){const s=u.querySelector(".dm-plugin-banner-name"),g=s?.lastChild;g&&g.nodeType===3&&s.querySelector(".dm-plugin-banner-sep")&&(g.textContent=p)}function Q(u){return navigator.clipboard?.writeText(u).then(()=>!0,()=>!1)??Promise.resolve(!1)}export const blockEditorView={templateUrl:"/admin/js/templates/block-editor.html",async onMount(u){if(St(),h){const e=h;h=null,e.dispose()}const p=u.get(0),s=p.querySelector(".bex")||p,g=[],c=e=>(g.push(e),e);let H=!1;const C=()=>!H&&s.isConnected,ve=location.hash.match(/^#\/blocks\/edit\/([^/?#]+)/),l=ve?decodeURIComponent(ve[1]):null,d=!l,Ye=location.hash,ze=G(location.hash.slice(1)),Je=d&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",he=E.loader(p,{type:"dots"});let y=null,X=[],Z=[];try{[y,X,Z]=await Promise.all([l?$.blocks.get(l):Promise.resolve(null),$.blocks.list().catch(()=>[]),$.projects.list().catch(()=>[])])}catch(e){he.destroy(),E.toast(e.message||"Block not found.",{type:"error"}),R.navigate("/blocks");return}if(he.destroy(),!s.isConnected)return;const fe=(Array.isArray(X)?X:[]).map(e=>e.name).sort((e,t)=>e.localeCompare(t));let be=y?.meta||{};const v=lt(y?{...y,name:l}:null,{project:Je}),a={};for(const[e,t]of Object.entries(v))a[e]=M.observable(t);const ee=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),ye=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),O=M.observable(de(v)),D=M.computed(()=>de(ye())!==O.value),q=M.observable(!1),V=M.observable(!1),K=M.computed(()=>st(ye(),{isNew:d,taken:fe}));let ke="split";try{ke=bt.includes(S.get(pe))?S.get(pe):"split"}catch{}const k=M.observable(ke);c(M.effect(()=>{const e=k.value;try{S.set(pe,e)}catch{}}));const we=M.observable("Ln 1, Col 1");let L={};try{L=S.get(He)||{}}catch{}const te=M.observable(Ve.includes(L.width)?L.width:"full"),U=M.observable(String(L.repeat)==="3"?"3":"1"),m=M.observable(typeof L.theme=="string"?L.theme:""),x=M.observable("");c(M.effect(()=>{const e={width:te.value,repeat:U.value,theme:m.value};try{S.set(He,e)}catch{}}));const f={},P={},Y=M.observable(0),oe=M.observable([]),ge=()=>We(a.content.peek());c(M.effect(()=>{const e=ct(a.content.value),t=We(a.content.value);let n=!1;for(const o of e){const w=t.has(o);if(!(o in f)||o in P&&P[o]!==w){const B=o in f;if(f[o]=_e(o,new Date,{raw:w}),P[o]=w,B){n=!0;const i=s.querySelector(`.bex-samples [data-sample="${CSS.escape(o)}"]`);i&&(i.value=f[o])}}}oe.value=e.map(o=>({key:o,label:t.has(o)?`{{{${o}}}}`:`{{${o}}}`,value:f[o]})),n&&queueMicrotask(()=>Y.value++)}));const Ge={...a,dirty:D,saving:q,mode:k,cursor:we,sampleRows:oe,isNew:d,notNew:!d,pvWidth:te,pvRepeat:U,pvTheme:m,pvStatus:x,titleText:d?"New block":l,metaText:M.computed(()=>{if(d)return"Not saved yet";const e=[];return(y?.updatedAt||y?.meta?.updatedAt)&&e.push(`changed ${Be(y.updatedAt||y.meta.updatedAt)}`),e.push(`${a.content.value.split(`
7
+ `).length} lines`),a.css.value.trim()&&e.push(`${Math.ceil(a.css.value.length/1024)} KB CSS`),e.join(" \xB7 ")}),nameErr:M.computed(()=>(V.value||a.name.value)&&K.value.errors.name||""),cssErr:M.computed(()=>K.value.errors.css||""),scopeHint:M.computed(()=>`[data-block="${vt(a.name.value)}"]`),noSamples:M.computed(()=>oe.value.length===0),noSamplesText:"No {{placeholders}} in the template yet.",modeCls:M.computed(()=>`bex-body is-${k.value}`),cannotSave:M.computed(()=>q.value||V.value&&Object.keys(K.value.errors).length>0)},ae=s.querySelector("#block-project");for(const e of Array.isArray(Z)?Z:[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,ae.appendChild(t)}if(v.project&&![...ae.options].some(e=>e.value===v.project)){const e=document.createElement("option");e.value=v.project,e.textContent=`${v.project} (no longer a project)`,ae.appendChild(e)}const F=s.querySelector("#block-quick-switch");for(const e of fe){const t=document.createElement("option");t.value=e,t.textContent=e,e===l&&(t.selected=!0),F.appendChild(t)}const b=s.querySelector(".bex-samples");b.innerHTML=gt;let A=null,N=null,r=null;function Se(e){for(const[t,n]of Object.entries(e))a[t]&&(a[t].value=n);A.setValue(String(e.content??"")),N.setValue(String(e.css??""))}const ne=s.querySelector("#block-preview-frame");let T="none",W=0;const Qe="As your site shows it \xB7 not saved",Ee=()=>{if(!C()||k.peek()==="write"||T!=="ready")return;const e={content:a.content.peek(),css:a.css.peek(),name:a.name.peek()},t=wt();if(!t){x.value="The preview needs the HTML cleaner, which did not load - reload the admin.";return}const n=mt(e,f,{repeat:Number(U.peek()),sanitize:t});try{ne.contentWindow?.postMessage({source:"dm-block-editor",type:"paint",tick:W,html:n},"*")}catch{}},Me=e=>{const t=dt(e,ne.contentWindow,W);if(!(!t||!C())){if(t.type==="save"){z();return}if(T=t.host?"ready":"failed",T==="failed"){x.value="Your site's page came back without a place for the block - the preview cannot show it.";return}x.peek()==="Loading your site\u2026"&&(x.value=Qe),Ee()}};window.addEventListener("message",Me),c(()=>window.removeEventListener("message",Me));async function Ce(){const e=++W;T="loading",x.value="Loading your site\u2026";let t;try{const n=m.peek()?{...Ke,theme:m.peek()}:Ke;({html:t}=await $.pages.previewFull(yt,n,`<div id="${me}"></div>`))}catch(n){t=`<!doctype html><html><head><meta charset="utf-8"></head><body style="font:14px system-ui;margin:0"><div id="${me}" style="padding:1rem"></div></body></html>`,x.value=`Without your site's styles - ${n?.message||"the page did not render"}`}!C()||e!==W||(ne.srcdoc=pt(t,e,me))}let se=0;c(M.effect(()=>{if(a.content.value,a.css.value,a.name.value,Y.value,U.value,k.value!=="write"){if(T==="none"){Ce();return}cancelAnimationFrame(se),se=requestAnimationFrame(Ee)}}));let xe=m.peek();c(M.effect(()=>{const e=m.value;e!==xe&&(xe=e,T!=="none"&&Ce())})),c(()=>{cancelAnimationFrame(se),W++}),$.theme.catalog().then(({themes:e=[],custom:t=[]}={})=>{if(!C())return;const n=s.querySelector("#block-preview-theme"),o=i=>String(i).replace(/-/g," ").replace(/\b\w/g,ie=>ie.toUpperCase()),w=[...e.filter(i=>!String(i).startsWith("admin-")).map(i=>({id:i,label:o(i)})),...(Array.isArray(t)?t:[]).filter(i=>i?.id).map(i=>({id:i.id,label:i.label||o(i.id)}))],B=w.map(i=>i.id);for(const{id:i,label:ie}of w){const ue=document.createElement("option");ue.value=i,ue.textContent=ie,n.appendChild(ue)}m.peek()&&!B.includes(m.peek())?m.value="":n.value=m.peek()}).catch(()=>{s.querySelector("#block-preview-theme")?.remove(),C()&&m.peek()&&(m.value="")});const Ae=e=>{const t=e.target?.dataset?.sample;t&&(f[t]=e.target.value,delete P[t],Y.value++)};b.addEventListener("input",Ae),c(()=>b.removeEventListener("input",Ae));function re(e){r.focus(),r.setRangeText(e,r.selectionStart,r.selectionEnd,"end"),r.dispatchEvent(new Event("input",{bubbles:!0}))}async function Le(e){switch(e){case"undo":r.focus(),document.execCommand("undo");break;case"redo":r.focus(),document.execCommand("redo");break;case"cut":r.selectionStart!==r.selectionEnd&&(await Q(r.value.slice(r.selectionStart,r.selectionEnd)),re(""));break;case"copy":r.selectionStart!==r.selectionEnd&&await Q(r.value.slice(r.selectionStart,r.selectionEnd));break;case"paste":try{re(await navigator.clipboard.readText())}catch{E.toast("Use Ctrl+V to paste.",{type:"info"})}break;case"select-all":r.focus(),r.select();break;case"format":{const t=it(r.value);A.setValue(t),a.content.value=t;break}default:break}}async function z(){if(q.peek())return;V.value=!0;const{errors:e,name:t}=K.peek();if(Object.keys(e).length){E.toast(e.name||e.css,{type:"warning"});return}const n=d?t:l;q.value=!0;try{const o=ut(ee(),be);if(await $.blocks.put(n,o),!C())return;be=o.meta,O.value=de(ee());try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}nt("#/blocks"),E.toast(d?`Created "${n}".`:"Block saved.",{type:"success"}),d&&(location.hash=`#/blocks/edit/${encodeURIComponent(n)}`)}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{q.value=!1}}async function Xe(){D.peek()&&await E.confirm("Discard the changes you have not saved?")&&(V.value=!1,Se(Ze(O.peek())))}function Ze(e){try{const[t,n,o,w,B]=JSON.parse(e);return{name:t,project:n,bundled:o,content:w,css:B}}catch{return v}}const et=M.applyBindings(Ge,s,{methods:{save:()=>z(),discard:()=>Xe(),setWrite:()=>{k.value="write"},setSplit:()=>{k.value="split"},setPreview:()=>{k.value="preview"},setWidth:e=>{Ve.includes(String(e))&&(te.value=String(e))},act:e=>Le(e)}});c(()=>et.dispose()),c(at(s,{position:"bottom"})),A=Ie(s.querySelector(".bex-write"),{initialValue:v.content,language:"html",autoIndent:!0,onChange:e=>{a.content.value=e},onCursor:({line:e,col:t})=>{we.value=`Ln ${e}, Col ${t}`}}),N=Ie(s.querySelector(".bex-css"),{initialValue:v.css,language:"css",autoIndent:!0,onChange:e=>{a.css.value=e}}),A.textarea.setAttribute("aria-label","Template HTML"),A.textarea.placeholder=`<div class="card mb-3">
8
8
  <div class="card-body">
9
9
  <h3>{{title}}</h3>
10
10
  <p>{{message}}</p>
11
11
  </div>
12
12
  </div>`,N.textarea.setAttribute("aria-label","Custom CSS"),N.textarea.placeholder=`/* Scoped to this block - e.g.
13
13
  color: var(--dm-primary);
14
- h3 { font-size: 1.25rem; } */`,c(()=>{A.destroy(),N.destroy()}),r=A.textarea;const Te=s.querySelector(".bex-toolbar"),Re=e=>{const t=e.target.closest("[data-action]");t&&Le(t.dataset.action)};Te.addEventListener("click",Re),c(()=>Te.removeEventListener("click",Re));const $e=()=>{const e=F.value;e&&e!==l?location.hash=`#/blocks/edit/${encodeURIComponent(e)}`:F.value=l||""};F.addEventListener("change",$e),c(()=>F.removeEventListener("change",$e));const re=e=>ge().has(e)?`{{{${e}}}}`:`{{${e}}}`,tt=e=>[{header:re(e)},{label:"Insert into the template",icon:"corner-down-right",action:()=>se(re(e))},{label:"Copy placeholder",icon:"copy",action:async()=>{const t=re(e);E.toast(await G(t)?"Copied.":t,{type:"info"})}},{label:"Reset sample value",icon:"rotate-ccw",action:()=>{const t=ge().has(e);h[e]=_e(e,new Date,{raw:t}),P[e]=t;const n=b.querySelector(`[data-sample="${CSS.escape(e)}"]`);n&&(n.value=h[e]),Y.value++}}],ot=E.contextMenu(b,{cursor:"glyph",match:".bex-sample",inherit:!1,density:"compact",items:e=>tt(e.dataset.key)});c(()=>ot?.destroy?.());const je=e=>{const t=e.target.closest(".bex-kebab");t&&Fe(t)},Oe=e=>{const t=e.target.closest(".bex-sample");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Fe(t))};b.addEventListener("click",je),b.addEventListener("keydown",Oe),c(()=>{b.removeEventListener("click",je),b.removeEventListener("keydown",Oe)}),Et(p,d?"New block":l),ue(u,{id:"block-editor-list",icon:"arrow-left",title:"All blocks",onClick:()=>{location.hash="#/blocks"}}),d?p.querySelector('[data-banner-action="block-editor-shortcode"]')?.remove():ue(u,{id:"block-editor-shortcode",icon:"copy",title:"Copy shortcode",onClick:async()=>{const e=ft(l);E.toast(await G(e)?"Shortcode copied - set the collection slug.":e,{type:"info",duration:5e3})}}),ue(u,{id:"block-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const De=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",De,!0),c(()=>document.removeEventListener("keydown",De,!0));const qe=()=>{if(D.peek())try{S.set(j(l),{form:Z(),base:O.peek(),at:new Date().toISOString()})}catch{}},Pe=e=>{D.peek()&&(qe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Pe),c(()=>window.removeEventListener("beforeunload",Pe)),f={hash:Ye,path:ze,dirty:()=>D.peek(),label:()=>d?"the new block":`"${l}"`,dispose(){B=!0,qe(),g.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s);let ce=null;try{ce=S.get(j(l))}catch{}const _=rt(ce,O.peek());if(ce)try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}if(_){const e=_.at?` (${He(_.at)})`:"",t=d?"a new block":`"${Ne(l)}"`,n=_.stale?`You left unsaved changes to ${t}${e}, but the block has been saved since. Restore your version anyway?`:`You left unsaved changes to ${t}${e}. Restore them?`;if(await E.confirm(n)&&C()){const o={...v,..._.form};d||(o.name=l),Se(o)}}}};
14
+ h3 { font-size: 1.25rem; } */`,c(()=>{A.destroy(),N.destroy()}),r=A.textarea;const Te=s.querySelector(".bex-toolbar"),Re=e=>{const t=e.target.closest("[data-action]");t&&Le(t.dataset.action)};Te.addEventListener("click",Re),c(()=>Te.removeEventListener("click",Re));const $e=()=>{const e=F.value;e&&e!==l?location.hash=`#/blocks/edit/${encodeURIComponent(e)}`:F.value=l||""};F.addEventListener("change",$e),c(()=>F.removeEventListener("change",$e));const ce=e=>ge().has(e)?`{{{${e}}}}`:`{{${e}}}`,tt=e=>[{header:ce(e)},{label:"Insert into the template",icon:"corner-down-right",action:()=>re(ce(e))},{label:"Copy placeholder",icon:"copy",action:async()=>{const t=ce(e);E.toast(await Q(t)?"Copied.":t,{type:"info"})}},{label:"Reset sample value",icon:"rotate-ccw",action:()=>{const t=ge().has(e);f[e]=_e(e,new Date,{raw:t}),P[e]=t;const n=b.querySelector(`[data-sample="${CSS.escape(e)}"]`);n&&(n.value=f[e]),Y.value++}}],ot=E.contextMenu(b,{cursor:"glyph",match:".bex-sample",inherit:!1,density:"compact",items:e=>tt(e.dataset.key)});c(()=>ot?.destroy?.());const je=e=>{const t=e.target.closest(".bex-kebab");t&&Fe(t)},Oe=e=>{const t=e.target.closest(".bex-sample");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Fe(t))};b.addEventListener("click",je),b.addEventListener("keydown",Oe),c(()=>{b.removeEventListener("click",je),b.removeEventListener("keydown",Oe)}),Et(p,d?"New block":l),J(u,{id:"block-editor-list",icon:"arrow-left",title:"All blocks",onClick:()=>{location.hash="#/blocks"}}),d?p.querySelector('[data-banner-action="block-editor-shortcode"]')?.remove():J(u,{id:"block-editor-shortcode",icon:"copy",title:"Copy shortcode",onClick:async()=>{const e=ht(l);E.toast(await Q(e)?"Shortcode copied - set the collection slug.":e,{type:"info",duration:5e3})}}),J(u,{id:"block-editor-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),J(u,{id:"block-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>z()});const De=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),z())};document.addEventListener("keydown",De,!0),c(()=>document.removeEventListener("keydown",De,!0));const qe=()=>{if(D.peek())try{S.set(j(l),{form:ee(),base:O.peek(),at:new Date().toISOString()})}catch{}},Pe=e=>{D.peek()&&(qe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Pe),c(()=>window.removeEventListener("beforeunload",Pe)),h={hash:Ye,path:ze,dirty:()=>D.peek(),label:()=>d?"the new block":`"${l}"`,dispose(){H=!0,qe(),g.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(s);let le=null;try{le=S.get(j(l))}catch{}const _=rt(le,O.peek());if(le)try{S.remove?S.remove(j(l)):S.set(j(l),null)}catch{}if(_){const e=_.at?` (${Be(_.at)})`:"",t=d?"a new block":`"${Ne(l)}"`,n=_.stale?`You left unsaved changes to ${t}${e}, but the block has been saved since. Restore your version anyway?`:`You left unsaved changes to ${t}${e}. Restore them?`;if(await E.confirm(n)&&C()){const o={...v,..._.form};d||(o.name=l),Se(o)}}}};
@@ -1,4 +1,4 @@
1
- import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ne,openMenuAt as ae,sidebarCount as Se,viewLifetime as Me}from"../lib/tool-kit.js";import{filterByProject as Le,getProjectFromHash as Ie,renderProjectFilterNotice as Te}from"../lib/project-context.js";import{FILTERS as je,arrangeBlocks as $e,blockRow as Ne,checkNewName as De,copyName as se,shortcodeFor as oe,usageText as le}from"../lib/blocks-arrange.js";import{fmtDate as Be}from"../lib/dates.js";const ce=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",A="blocks.filter",Re=`
1
+ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ne,openMenuAt as ae,sidebarCount as Se,viewLifetime as Me}from"../lib/tool-kit.js";import{filterByProject as Le,getProjectFromHash as Ie,renderProjectFilterNotice as Te}from"../lib/project-context.js";import{FILTERS as je,arrangeBlocks as $e,blockRow as Ne,checkNewName as Be,copyName as oe,shortcodeFor as se,usageText as le}from"../lib/blocks-arrange.js";import{fmtDate as De}from"../lib/dates.js";const ce=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",U="blocks.filter",Re=`
2
2
  <div class="blx-row" data-name="{{name}}" tabindex="0" data-bind-class="unused && 'is-unused'">
3
3
  <span class="blx-icon" aria-hidden="true"><span data-icon="box" data-icon-size="16"></span></span>
4
4
  <span class="blx-main"><strong>{{name}}</strong><span data-bind-hidden="!description">{{description}}</span><small>{{meta}}</small></span>
@@ -10,13 +10,13 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}fr
10
10
  </span>
11
11
  <span class="blx-uses" title="Pages and views that use it">{{uses}}</span>
12
12
  <button type="button" class="blx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
- </div>`,g=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(c){const a=document.createElement("template");return a.innerHTML=c.trim(),a.content.firstElementChild}const i=Me(ce);async function qe(c){try{return await navigator.clipboard.writeText(c),!0}catch{const a=document.createElement("textarea");a.value=c,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let h=!1;try{h=document.execCommand("copy")}catch{}return a.remove(),h}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(c){i.reset();const a=c.get(0).querySelector(".blx")||c.get(0),h=()=>ce.test(location.hash)&&a.isConnected,w=Ie(),p=M.observable([]),U=M.observable(!0),D=M.observable("");let H="all";try{H=je.includes(S.get(A))?S.get(A):"all"}catch{}const y=M.observable(H),O=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(A,e)}catch{}}));const re=Be,B=M.observable([]);i.add(M.effect(()=>{B.value=$e(p.value,{filter:y.value,query:O.value}).map(e=>Ne(e,re))}));const de=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),ue=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),F=e=>p.peek().find(t=>t.name===e),pe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",P=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function K(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!h())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Le(e,w),Te(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,D.value=""}catch(e){D.value=`Could not load the blocks: ${e.message||e}`}finally{U.value=!1}}const T=async()=>{Se(Fe),h()&&await K()};async function me(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(le(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function he(e){const t=oe(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function W(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const r=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${r.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",W),i.add(()=>k.removeEventListener("change",W));const x=new Set;let f=null,z=!1;function fe(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
13
+ </div>`,g=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(s){const a=document.createElement("template");return a.innerHTML=s.trim(),a.content.firstElementChild}const i=Me(ce);async function qe(s){try{return await navigator.clipboard.writeText(s),!0}catch{const a=document.createElement("textarea");a.value=s,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let h=!1;try{h=document.execCommand("copy")}catch{}return a.remove(),h}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(s){i.reset();const a=s.get(0).querySelector(".blx")||s.get(0),h=()=>ce.test(location.hash)&&a.isConnected,w=Ie(),p=M.observable([]),H=M.observable(!0),B=M.observable("");let O="all";try{O=je.includes(S.get(U))?S.get(U):"all"}catch{}const y=M.observable(O),P=M.observable("");i.add(M.effect(()=>{const e=y.value;try{S.set(U,e)}catch{}}));const re=De,D=M.observable([]);i.add(M.effect(()=>{D.value=$e(p.value,{filter:y.value,query:P.value}).map(e=>Ne(e,re))}));const de=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),ue=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),F=e=>p.peek().find(t=>t.name===e),pe=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",K=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function W(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!h())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Le(e,w),Te(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,B.value=""}catch(e){B.value=`Could not load the blocks: ${e.message||e}`}finally{H.value=!1}}const T=async()=>{Se(Fe),h()&&await W()};async function me(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(le(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function he(e){const t=se(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function z(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const r=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${r.name}".`,{type:"success"}),await T()}catch(r){E.toast(r.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",z),i.add(()=>k.removeEventListener("change",z));const x=new Set;let f=null,_=!1;function fe(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
14
14
  <div class="blx-form">
15
15
  <p class="blx-note blx-where-sum"></p>
16
16
  <ul class="blx-where"></ul>
17
17
  <div><div class="form-label">Shortcode</div><div class="blx-code"></div>
18
18
  <small class="blx-hint">Right-click the block in the list and Copy shortcode to paste it into a page.</small></div>
19
- </div>`);n.querySelector(".blx-where-sum").textContent=le(e)+".",n.querySelector(".blx-code").textContent=oe(e);const r=n.querySelector(".blx-where"),b=(o,j,$)=>{const L=document.createElement("li"),s=document.createElement("a");s.href=o,s.textContent=j,s.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(s,d),r.appendChild(L)};for(const o of e.usage?.pages||[])b(`#/pages/edit${o}`,o,"page");for(const o of e.usage?.views||[])b(`#/views/edit/${encodeURIComponent(o)}`,o,"view");t.element.appendChild(n),x.add(t),ne(t,()=>x.delete(t)),t.open()}function _(e,t=null){const n=()=>p.peek().map(l=>l.name),r=t?.name??se(e.name,n()),b=M.observable(r),o=M.observable(!1),j=M.computed(()=>De(b.value,p.value.map(l=>l.name))),$={name:b,saving:o,err:j,cannot:M.computed(()=>o.value||!!j.value)},L=()=>b.peek()!==se(e.name,n())||!!t,s=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
19
+ </div>`);n.querySelector(".blx-where-sum").textContent=le(e)+".",n.querySelector(".blx-code").textContent=se(e);const r=n.querySelector(".blx-where"),b=(l,j,$)=>{const L=document.createElement("li"),o=document.createElement("a");o.href=l,o.textContent=j,o.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(o,d),r.appendChild(L)};for(const l of e.usage?.pages||[])b(`#/pages/edit${l}`,l,"page");for(const l of e.usage?.views||[])b(`#/views/edit/${encodeURIComponent(l)}`,l,"view");t.element.appendChild(n),x.add(t),ne(t,()=>x.delete(t)),t.open()}function Y(e,t=null){const n=()=>p.peek().map(c=>c.name),r=t?.name??oe(e.name,n()),b=M.observable(r),l=M.observable(!1),j=M.computed(()=>Be(b.value,p.value.map(c=>c.name))),$={name:b,saving:l,err:j,cannot:M.computed(()=>l.value||!!j.value)},L=()=>b.peek()!==oe(e.name,n())||!!t,o=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
20
20
  <div class="blx-form">
21
21
  <div>
22
22
  <label class="form-label" for="blx-dup-name">Name of the copy
@@ -31,4 +31,4 @@ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as ee}fr
31
31
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
32
32
  <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
33
33
  </div>
34
- </div>`);let R=!1;async function Q(){if($.cannot.peek())return;o.value=!0;const l=b.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(l,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${l}".`,{type:"success"}),R=!0,s.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{o.value=!1}}s.element.appendChild(d);const we=te(d),ke=M.applyBindings($,d,{methods:{save:Q,cancel:()=>s.close()}}),xe=l=>{l.key==="Enter"&&l.target.matches("input")&&(l.preventDefault(),Q())};d.addEventListener("keydown",xe);const X={dirty:L,values:()=>({name:b.peek()}),source:e.name};f=X,x.add(s),ne(s,()=>{ke.dispose(),we(),x.delete(s),f===X&&(f=null)});const Ee=s.close.bind(s);let q=!1;s.close=async l=>{if(!R&&!z&&l!==!0&&L()){if(q)return;q=!0;const u=await E.confirm("Discard the name you typed?");if(q=!1,!u)return}return R=!0,Ee()},I.scan(d),s.open();const Z=d.querySelector("#blx-dup-name");Z?.focus(),Z?.select()}function ye(e){const t=F(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=P(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>he(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>fe(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>_(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ve=M.applyBindings({blocks:p,loading:U,loadError:D,filter:y,query:O,rows:B,counts:de,emptyText:ue},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>ve.dispose()),i.add(te(a,{position:"bottom"})),i.add(M.effect(()=>{B.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ge=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ye(e.dataset.name)});i.add(()=>ge?.destroy?.());const Y=e=>{F(e)&&(location.hash=P(e))},J=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){ae(n);return}Y(t.dataset.name)},V=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&Y(t.dataset.name))};m.addEventListener("click",J),m.addEventListener("keydown",V),i.add(()=>{m.removeEventListener("click",J),m.removeEventListener("keydown",V)}),ee(c,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),ee(c,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=pe}});const G=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",G),i.add(()=>window.removeEventListener("beforeunload",G)),i.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}z=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await K(),!h())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=F(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&h()&&_(e,C.values)}}};
34
+ </div>`);let R=!1;async function X(){if($.cannot.peek())return;l.value=!0;const c=b.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(c,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${c}".`,{type:"success"}),R=!0,o.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{l.value=!1}}o.element.appendChild(d);const we=te(d),ke=M.applyBindings($,d,{methods:{save:X,cancel:()=>o.close()}}),xe=c=>{c.key==="Enter"&&c.target.matches("input")&&(c.preventDefault(),X())};d.addEventListener("keydown",xe);const Z={dirty:L,values:()=>({name:b.peek()}),source:e.name};f=Z,x.add(o),ne(o,()=>{ke.dispose(),we(),x.delete(o),f===Z&&(f=null)});const Ee=o.close.bind(o);let q=!1;o.close=async c=>{if(!R&&!_&&c!==!0&&L()){if(q)return;q=!0;const u=await E.confirm("Discard the name you typed?");if(q=!1,!u)return}return R=!0,Ee()},I.scan(d),o.open();const ee=d.querySelector("#blx-dup-name");ee?.focus(),ee?.select()}function ye(e){const t=F(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=K(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>he(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>fe(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>Y(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ve=M.applyBindings({blocks:p,loading:H,loadError:B,filter:y,query:P,rows:D,counts:de,emptyText:ue},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});i.add(()=>ve.dispose()),i.add(te(a,{position:"bottom"})),i.add(M.effect(()=>{D.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ge=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ye(e.dataset.name)});i.add(()=>ge?.destroy?.());const J=e=>{F(e)&&(location.hash=K(e))},V=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){ae(n);return}J(t.dataset.name)},G=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.name))};m.addEventListener("click",V),m.addEventListener("keydown",G),i.add(()=>{m.removeEventListener("click",V),m.removeEventListener("keydown",G)}),A(s,{id:"blocks-guide",icon:"book",title:"Blocks guide",onClick:()=>{location.hash="#/docs/usage/blocks"}}),A(s,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),A(s,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=pe}});const Q=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Q),i.add(()=>window.removeEventListener("beforeunload",Q)),i.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}_=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await W(),!h())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=F(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&h()&&Y(e,C.values)}}};
@@ -1 +1 @@
1
- import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,n){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof n=="function"&&n(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const n=o.find("#tutorial-scaffolder-mount").get(0);n&&i(n,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","tools","dashboard"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],s=o=>o.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join("");for(const o of a)docViews["usage"+s(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+s(o)]=t(`api-${o}`);
1
+ import{mountScaffolder as i}from"../lib/crud-tutorial.js";function t(o,s){return{templateUrl:`/admin/js/templates/docs/${o}.html`,async onMount(e){typeof s=="function"&&s(e),Domma.icons.scan(),Domma.syntax.scan()}}}export const docViews={tutorialCrud:t("tutorial-crud",o=>{const s=o.find("#tutorial-scaffolder-mount").get(0);s&&i(s,null,null)}),tutorialPlugin:t("tutorial-plugin"),tutorialForms:t("tutorial-forms"),componentsReference:t("components-reference"),componentsHowto:t("components-howto"),componentsWalkthrough:t("components-walkthrough"),componentsRules:t("components-rules")};const a=["pages","media","navigation","dconfig","shortcodes","site-settings","plugins","users-roles","views","actions","cta-shortcode","editions","collections","blocks","tools","dashboard"],c=["authentication","pages","settings","layouts","navigation","media","users","plugins","collections","views","actions","forms","scaffold","external","content","builder"],n=o=>o.split("-").map(s=>s.charAt(0).toUpperCase()+s.slice(1)).join("");for(const o of a)docViews["usage"+n(o)]=t(`usage-${o}`);for(const o of c)docViews["apiRef"+n(o)]=t(`api-${o}`);
@@ -1,4 +1,4 @@
1
- import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"../lib/plugin-chrome.js";import{SHAPES as tt,whileLoading as at}from"../lib/loading.js";import{helpPopovers as Le}from"../lib/help-popover.js";import{onceClosed as nt,openMenuAt as Ue,sidebarCount as st,viewLifetime as lt}from"../lib/tool-kit.js";import{pluginAccent as Re}from"../lib/plugin-accent.js";import{reportRestart as it}from"../lib/server-restart.js";import{FILTERS as ot,arrangeCatalogue as rt,arrangePlugins as dt,catalogueAction as te,catalogueRow as ct,checkScaffold as ut,dependencyRows as pt,formatPrice as je,fmtReleased as mt,needsAttention as bt,planLines as ht,pluginRow as ft,pluginStatus as Be,readSettings as vt,settingsFields as De,slugifyPlugin as gt,updateFor as Fe}from"../lib/plugins-arrange.js";const qe=/^#\/plugins\/?(\?.*)?$/,yt="#/plugins",ue="plugins.filter",ae="plugins.draft",wt=`
1
+ import{api as x,getUser as et}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{SHAPES as tt,whileLoading as at}from"../lib/loading.js";import{helpPopovers as Le}from"../lib/help-popover.js";import{onceClosed as nt,openMenuAt as Ue,sidebarCount as st,viewLifetime as lt}from"../lib/tool-kit.js";import{pluginAccent as Re}from"../lib/plugin-accent.js";import{reportRestart as it}from"../lib/server-restart.js";import{FILTERS as ot,arrangeCatalogue as rt,arrangePlugins as dt,catalogueAction as te,catalogueRow as ct,checkScaffold as ut,dependencyRows as pt,formatPrice as je,fmtReleased as bt,needsAttention as mt,planLines as ht,pluginRow as ft,pluginStatus as Be,readSettings as vt,settingsFields as De,slugifyPlugin as gt,updateFor as Fe}from"../lib/plugins-arrange.js";const qe=/^#\/plugins\/?(\?.*)?$/,yt="#/plugins",pe="plugins.filter",ae="plugins.draft",wt=`
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>
@@ -34,7 +34,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
34
34
  <strong data-bind-text="displayName"></strong>
35
35
  <span class="plx-chip" data-bind-class="stateCls" data-bind-text="stateText"></span>
36
36
  <small class="plx-hint" data-bind-hidden="!reason" data-bind-text="reason"></small>
37
- </li>`,d=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){v.reset();const b=A.get(0).querySelector(".plx")||A.get(0),k=()=>qe.test(location.hash)&&b.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.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"}})),h=M.observable({available:null,site:null,items:[]}),be=M.observable(!0),ne=M.observable(""),m=M.observable("");let he="all";try{he=ot.includes(S.get(ue))?S.get(ue):"all"}catch{}const T=M.observable(he),V=M.observable(""),j=M.observable("all"),fe=M.observable("");v.add(M.effect(()=>{const e=T.value;try{S.set(ue,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:T.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>bt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(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."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:fe.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(b.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{be.value=!1}}async function ve(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ve()]),st(yt)};async function B(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 ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.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=ht(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(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="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(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${d(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 x.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"})}k()&&await P()}}function ge(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 ye(e,t){const n=W(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 ${d(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 x.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"})}k()&&await P()}}async function we(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const xe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=b.querySelector(".plx-file");async function _e(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 x.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>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(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>${d(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>${d(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="",k()&&await P()}}const ke=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",ke),v.add(()=>z.removeEventListener("change",ke));const G=new Set;let U=null,$e=!1;function Ee({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,N=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(N)return;N=!0;const q=await E.confirm("Discard the changes you have not saved?");if(N=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",mt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const O=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${O}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<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">${H}<input id="${O}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${O}" class="form-input" ${K}>${s.options.map(Oe=>`<option value="${d(Oe.value)}">${d(Oe.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${O}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${O}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,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}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=pe(`
37
+ </li>`,d=T=>String(T??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function be(T){const m=document.createElement("template");return m.innerHTML=T.trim(),m.content.firstElementChild}const v=lt(qe);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(T){v.reset();const m=T.get(0).querySelector(".plx")||T.get(0),k=()=>qe.test(location.hash)&&m.isConnected,L=M.observable([]),R=M.observable([]),me=M.computed(()=>R.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"}})),h=M.observable({available:null,site:null,items:[]}),he=M.observable(!0),ne=M.observable(""),b=M.observable("");let fe="all";try{fe=ot.includes(S.get(pe))?S.get(pe):"all"}catch{}const N=M.observable(fe),V=M.observable(""),j=M.observable("all"),ve=M.observable("");v.add(M.effect(()=>{const e=N.value;try{S.set(pe,e)}catch{}}));const Y=M.computed(()=>new Map((h.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(h.peek().items||[]).find(t=>t.slug===e),se=M.observable([]);v.add(M.effect(()=>{const e=Y.value;se.value=dt(L.value,{filter:N.value,query:V.value,latest:e}).map(t=>({...ft(t,e),accent:Re(t.name)}))}));const We=M.computed(()=>{const e=L.value,t=Y.value,a=e.filter(n=>Be(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>mt(n,t)).length}}),J=M.computed(()=>(h.value.items||[]).filter(e=>te(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."),le=M.observable([]);v.add(M.effect(()=>{le.value=rt(h.value.items,{filter:j.value,query:ve.value}).map(e=>{const t=ct(e),a=t.action==="update"||t.action==="install"||t.action==="buy";return{...t,accent:Re(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const ze=M.computed(()=>{const e=h.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>te(t)==="install").length,updates:J.value}}),Q=e=>L.peek().find(t=>t.name===e);async function ie(){try{const[e,t]=await at(m.querySelector(".dm-loading-slot"),Promise.all([x.plugins.list(),x.tools.list().catch(()=>[])]),tt.iconRows);if(!k())return;L.value=Array.isArray(e)?e:[],R.value=Array.isArray(t)?t:[],ne.value=""}catch(e){ne.value=`Could not load the plugins: ${e.message||e}`}finally{he.value=!1}}async function ge(){try{const e=await x.marketplace.catalogue();if(!k())return;h.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{h.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([ie(),ge()]),st(yt)};async function B(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 ${d(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||b.peek())return;const l=i=>a==="tool"?x.tools.update(e.name,{enabled:t,cascade:i}):x.plugins.update(e.name,{enabled:t,cascade:i});b.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=ht(c.body.plan);if(b.value="",!await E.confirm(`<p>Switching ${d(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(o=>`<li>${d(o)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;b.value=`Switching ${e.displayName||e.name} ${n}\u2026`,i=await l(!0)}await it(i,{onStatus:c=>{b.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{b.value="",k()&&await P()}}async function D(e,{update:t=!1}={}){const a=d(e.displayName||e.slug),n=t?`<p>Update ${a} from ${d(e.installedVersion)} to ${d(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${d(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 x.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"})}k()&&await P()}}function ye(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 we(e,t){const n=W(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 ${d(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 x.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"})}k()&&await P()}}async function xe(e){try{await x.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ke=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=m.querySelector(".plx-file");async function _e(e){if(b.peek())return;b.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await x.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(b.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${d(n.name)} ${d(n.installedVersion||"the installed version")} is already installed. Update it to ${d(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>${d(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>${d(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}b.value=`Installing ${e.name}\u2026`}}finally{b.value="",k()&&await P()}}const $e=async()=>{const e=z.files?.[0];z.value="",e&&await _e(e)};z.addEventListener("change",$e),v.add(()=>z.removeEventListener("change",$e));const G=new Set;let U=null,Ee=!1;function Se({title:e,size:t="md",panel:a,model:n,methods:l,dirty:i=()=>!1,form:c=null,cleanup:u=()=>{}}){const o=E.slideover({title:d(e),size:t,position:"right"});o.element.appendChild(a);const $=Le(a),y=M.applyBindings(n,a,{methods:l});c&&(U=c),G.add(o),nt(o,()=>{u(),y.dispose(),$(),G.delete(o),U===c&&(U=null)});const w=o.close.bind(o);let f=!1,O=!1;return o.close=async F=>{if(!f&&F!==!0&&i()){if(O)return;O=!0;const q=await E.confirm("Discard the changes you have not saved?");if(O=!1,!q)return}return f=!0,w()},I.scan(a),o.open(),o}function oe(e,t=null){const a=Q(e);if(!a)return;const n=Be(a),l=De(a),i=W(a.name),c=Fe(a,Y.peek()),u=!!(c&&i?.entitled&&!i?.needsCms),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},y=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>vt(l,Object.fromEntries(l.map(s=>[s.key,y[s.key].value])))),f=M.observable(!1),O=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",bt(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),q=(s,p)=>{const A=`plx-set-${p}`,Z=s.help?` <span data-help="${d(s.help)}"></span>`:"",H=`<label class="form-label" for="${A}">${d(s.label)}${Z}</label>`,K=`data-model="v${p}.value"`,ee=`<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">${H}<input id="${A}" type="checkbox" class="form-switch" ${K}></div>`;if(s.type==="select")return`<div>${H}<select id="${A}" class="form-input" ${K}>${s.options.map(Ae=>`<option value="${d(Ae.value)}">${d(Ae.label)}</option>`).join("")}</select>${ee}</div>`;if(s.type==="json")return`<div>${H}<textarea id="${A}" class="form-input plx-json" rows="5" spellcheck="false" ${K}></textarea>${ee}</div>`;if(s.type==="textarea")return`<div>${H}<textarea id="${A}" class="form-input" rows="3" ${K}></textarea>${ee}</div>`;const Xe=s.type==="number"?"number":s.secret?"password":"text",Ze=`${s.min!=null?` min="${d(s.min)}"`:""}${s.max!=null?` max="${d(s.max)}"`:""}`;return`<div>${H}<input id="${A}" type="${Xe}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Ze} ${K}>${ee}</div>`},r=pt(a,{plugins:L.peek(),tools:R.peek(),catalogue:h.peek().items||[]}),C={saving:f,needs:M.observable(r.needs),worksWith:M.observable(r.worksWith),hasNeeds:r.needs.length>0,hasWorksWith:r.worksWith.length>0,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}`,y[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},_=be(`
38
38
  <div class="plx-form">
39
39
  <p class="plx-desc"></p>
40
40
  <dl class="plx-facts"></dl>
@@ -65,7 +65,7 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
65
65
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
66
66
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
67
67
  </div>
68
- </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=$t});const Ge=_.querySelector(".plx-facts");for(const[s,p]of F){const O=document.createElement("dt");O.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Ge.append(O,Z)}async function Ie(){if(C.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await x.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await X.close(),G.has(X)||s()},X=Ee({title:a.displayName||a.name,size:"md",panel:_,model:C,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]=y[p.key].peek());return s}}:null,methods:{save:Ie,cancel:()=>X.close(),toggle:ce(()=>B(a,!a.enabled)),update:ce(()=>D(i,{update:!0})),source:ce(()=>xe(a)),exportIt:()=>we(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Ie())})}function Se(e=null){const t=et(),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(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=gt(r))}),o=M.observable(!1),$=M.computed(()=>ut(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),y=r=>M.computed(()=>$.value.errors[r]||""),w={...a,saving:o,nameErr:y("displayName"),slugErr:y("slug"),descErr:y("description"),authorErr:y("author"),iconErr:y("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=pe(`
68
+ </div>`);_.querySelector(".plx-desc").textContent=a.description||"",_.querySelectorAll(".plx-deps").forEach(s=>{s.innerHTML=$t});const Ge=_.querySelector(".plx-facts");for(const[s,p]of F){const A=document.createElement("dt");A.textContent=s;const Z=document.createElement("dd");Z.textContent=p,Ge.append(A,Z)}async function Oe(){if(C.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await x.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),X.close(!0),k()&&await ie()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await X.close(),G.has(X)||s()},X=Se({title:a.displayName||a.name,size:"md",panel:_,model:C,dirty:O,form:l.length?{kind:"settings",name:a.name,dirty:O,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=y[p.key].peek());return s}}:null,methods:{save:Oe,cancel:()=>X.close(),toggle:ce(()=>B(a,!a.enabled)),update:ce(()=>D(i,{update:!0})),source:ce(()=>ke(a)),exportIt:()=>xe(a)}});_.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Oe())})}function Me(e=null){const t=et(),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(([r,C])=>[r,C.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.peek()])),i=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const r=a.displayName.value;c||(a.slug.value=gt(r))}),o=M.observable(!1),$=M.computed(()=>ut(Object.fromEntries(Object.entries(a).map(([r,C])=>[r,C.value])),L.value.map(r=>r.name))),y=r=>M.computed(()=>$.value.errors[r]||""),w={...a,saving:o,nameErr:y("displayName"),slugErr:y("slug"),descErr:y("description"),authorErr:y("author"),iconErr:y("icon"),cannot:M.computed(()=>o.value||Object.keys($.value.errors).length>0)},f=be(`
69
69
  <div class="plx-form">
70
70
  <div>
71
71
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -94,14 +94,14 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
94
94
  <input id="plx-np-icon" type="text" class="form-input font-mono" maxlength="40" data-model="icon.value">
95
95
  <small class="plx-err" data-bind-hidden="!iconErr.value" data-bind-text="iconErr.value"></small>
96
96
  </div>
97
- <p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. Ctrl+Enter creates.</p>
97
+ <p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. The book button in the banner opens the Writing a Tool guide. Ctrl+Enter creates.</p>
98
98
  <div class="plx-actions">
99
99
  <span class="plx-spacer"></span>
100
100
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
101
101
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
102
102
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
103
103
  </div>
104
- </div>`);async function N(){if(w.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await x.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Ee({title:"New plugin",size:"sm",panel:f,model:w,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function He(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=Fe(t,Y.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:De(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(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:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>xe(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>we(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>ye(t.name,t.displayName)}]]}function Ke(e){const t=R.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:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ve=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Ye(e){const t=W(e);if(!t)return[];const a=te(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,T.value="all",Te.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>ye(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${je(t)!=="Paid"?` - ${je(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>ge(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 Je(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?ge(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,T.value="all",Te.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});v.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Ue($);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),v.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=b.querySelector(".plx-browse");de.appendChild(pe(`
104
+ </div>`);async function O(){if(w.cannot.peek())return;o.value=!0;const{payload:r}=$.peek();try{await x.plugins.scaffold(r),E.toast(`"${r.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await P()}catch(C){E.toast(C.message||"Could not create the plugin.",{type:"error"})}finally{o.value=!1}}const F=Se({title:"New plugin",size:"sm",panel:f,model:w,dirty:i,form:{kind:"new",name:null,dirty:i,values:l},cleanup:()=>u(),methods:{create:O,cancel:()=>F.close()}}),q=f.querySelector("#plx-np-slug");q.addEventListener("input",()=>{c=!!q.value}),f.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="Enter"&&(r.preventDefault(),O())}),f.querySelector("#plx-np-name")?.focus()}function He(e){const t=Q(e);if(!t)return[];const a=W(t.name),n=Fe(t,Y.peek()),l=!!b.peek();return[{header:t.displayName||t.name},{label:De(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>oe(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>B(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>B(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:()=>D(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ke(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>xe(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>we(t.name,t.displayName)}]]}function Ke(e){const t=R.peek().find(n=>n.name===e);if(!t)return[];const a=!!b.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>B(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>B(t,!0,"tool")}]}const Ve=e=>{const t=R.peek().find(a=>a.name===e);t&&B(t,!t.enabled,"tool")};function Ye(e){const t=W(e);if(!t)return[];const a=te(t),n=!!b.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>D(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,N.value="all",Ne.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>we(t.slug,t.displayName)}]:[],...a==="buy"?[{label:`${t.licenceStatus==="expired"?"Renew":"Buy"}${je(t)!=="Paid"?` - ${je(t)}`:""}\u2026`,icon:"shopping-cart",action:()=>ye(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 Je(e){const t=W(e);if(!t)return;const a=te(t);a==="install"?D(t):a==="update"?D(t,{update:!0}):a==="buy"?ye(t):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,N.value="all",Ne.activate?.(0))}function re(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});v.add(()=>l?.destroy?.());const i=u=>{const o=u.target.closest(".plx-row");if(!o)return;const $=u.target.closest(".plx-kebab");if($){Ue($);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),v.add(()=>{e.removeEventListener("click",i),e.removeEventListener("keydown",c)})}const de=m.querySelector(".plx-browse");de.appendChild(be(`
105
105
  <div class="plx-card">
106
106
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
107
107
  <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>
@@ -124,4 +124,4 @@ import{api as x,getUser as et}from"../api.js";import{addBannerAction as Ae}from"
124
124
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
125
125
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
126
126
  </div>
127
- </div>`)),de.querySelector(".plx-catalogue").innerHTML=kt;const Me=b.querySelector(".plx-installed");Me.innerHTML=wt;const Ce=b.querySelector(".plx-tools");Ce.innerHTML=xt;const Te=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings({plugins:L,tools:R,toolRows:me,catalogue:h,loading:be,loadError:ne,restarting:m,filter:T,query:V,rows:se,counts:We,emptyText:Pe,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:fe,browseRows:le,browseCounts:ze,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});v.add(()=>Qe.dispose()),v.add(Le(b,{position:"bottom"})),v.add(M.effect(()=>{se.value,me.value,le.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),re(Me,"name",He,e=>oe(e)),re(Ce,"tool",Ke,Ve),re(de.querySelector(".plx-catalogue"),"slug",Ye,Je),Ae(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),Ae(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Se()});const Ne=e=>{(m.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ne),v.add(()=>window.removeEventListener("beforeunload",Ne)),v.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}$e=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([ie(),ve()]),!k())return;let g=null;try{g=S.get(ae)}catch{}if(g?.values&&!$e){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=g.kind==="new"?"the new plugin you started":`your settings for ${d(Q(g.name)?.displayName||g.name)}`;(g.kind==="new"||Q(g.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(g.kind==="new"?Se(g.values):oe(g.name,g.values))}}};
127
+ </div>`)),de.querySelector(".plx-catalogue").innerHTML=kt;const Ce=m.querySelector(".plx-installed");Ce.innerHTML=wt;const Te=m.querySelector(".plx-tools");Te.innerHTML=xt;const Ne=E.tabs(m.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings({plugins:L,tools:R,toolRows:me,catalogue:h,loading:he,loadError:ne,restarting:b,filter:N,query:V,rows:se,counts:We,emptyText:Pe,updateCount:J,updateCountText:M.computed(()=>`${J.value} update${J.value===1?"":"s"}`),browseFilter:j,browseQuery:ve,browseRows:le,browseCounts:ze,siteText:M.computed(()=>h.value.site?`Licensed to ${h.value.site}.`:""),browseState:M.computed(()=>h.value.available===null?"loading":h.value.failed?"error":h.value.available?"list":"file")},m,{methods:{showAll:()=>{N.value="all"},showOn:()=>{N.value="on"},showOff:()=>{N.value="off"},showAttention:()=>{N.value="attention"},bAll:()=>{j.value="all"},bAvailable:()=>{j.value="available"},bInstalled:()=>{j.value="installed"},bUpdates:()=>{j.value="updates"}}});v.add(()=>Qe.dispose()),v.add(Le(m,{position:"bottom"})),v.add(M.effect(()=>{se.value,me.value,le.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),re(Ce,"name",He,e=>oe(e)),re(Te,"tool",Ke,Ve),re(de.querySelector(".plx-catalogue"),"slug",Ye,Je),ue(T,{id:"plugins-guide",icon:"book",title:"Writing a Tool (guide)",onClick:()=>{location.hash="#/tutorials/plugin"}}),ue(T,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),ue(T,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>Me()});const Ie=e=>{(b.peek()||U?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ie),v.add(()=>window.removeEventListener("beforeunload",Ie)),v.add(()=>{if(U?.dirty())try{S.set(ae,{kind:U.kind,name:U.name,values:U.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(m),await Promise.all([ie(),ge()]),!k())return;let g=null;try{g=S.get(ae)}catch{}if(g?.values&&!Ee){try{S.remove?S.remove(ae):S.set(ae,null)}catch{}const e=g.kind==="new"?"the new plugin you started":`your settings for ${d(Q(g.name)?.displayName||g.name)}`;(g.kind==="new"||Q(g.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(g.kind==="new"?Me(g.values):oe(g.name,g.values))}}};
@@ -265,7 +265,7 @@
265
265
  "icon": "database"
266
266
  },
267
267
  {
268
- "text": "Writing a Plugin",
268
+ "text": "Writing a Tool",
269
269
  "url": "#/tutorials/plugin",
270
270
  "icon": "package"
271
271
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.46",
3
+ "version": "1.6.48",
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",
@@ -84,7 +84,7 @@
84
84
  "@fastify/rate-limit": "^10.3.0",
85
85
  "@fastify/static": "9.1.1",
86
86
  "bcryptjs": "^3.0.3",
87
- "domma-js": "0.52.0",
87
+ "domma-js": "0.52.1",
88
88
  "dotenv": "^17.2.3",
89
89
  "fastify": "5.8.5",
90
90
  "gray-matter": "^4.0.3",
package/server/server.js CHANGED
@@ -684,6 +684,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content
684
684
  // seeded once, so new pages reach existing sites only by registering here. The two
685
685
  // sub-folders have no key in older menus; findFolder() falls back to their name.
686
686
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collections & Forms', url: '#/docs/usage/collections', icon: 'database'}});
687
+ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Blocks', url: '#/docs/usage/blocks', icon: 'box'}});
687
688
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
688
689
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Dashboard', url: '#/docs/usage/dashboard', icon: 'home'}});
689
690
  registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
@@ -57,10 +57,11 @@ const MAP = [
57
57
  ['usage-cta-shortcode', 'usage/cta-shortcode', 'CTA Shortcode'],
58
58
  ['usage-editions', 'usage/editions', 'Editions & Licences'],
59
59
  ['usage-collections', 'usage/collections', 'Collections & Forms'],
60
+ ['usage-blocks', 'usage/blocks', 'Blocks'],
60
61
  ['usage-tools', 'usage/tools', 'Built-in Tools'],
61
62
  ['usage-dashboard', 'usage/dashboard', 'Dashboard'],
62
63
  ['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
63
- ['tutorial-plugin', 'tutorials/plugin', 'Writing a Plugin'],
64
+ ['tutorial-plugin', 'tutorials/plugin', 'Writing a Tool'],
64
65
  ['tutorial-forms', 'tutorials/forms', 'Form Follow-Up'],
65
66
  ['components-reference', 'components', 'Components Reference'],
66
67
  ['components-howto', 'components/howto', 'Components How-To'],