domma-cms 0.100.2 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Oe,openMenuAt as Me,settingsStore as He,sidebarCount as ee,viewLifetime as Ke}from"../lib/tool-kit.js";import{openImageEditor as _e}from"../lib/image-editor.js";import{KIND_LABEL as De,arrangeMedia as je,clashes as Ve,formatBytes as Le,isEditable as te,isPreviewable as Ye,kindCounts as Ge,kindOf as Je,markdownFor as ae,mediaTile as Qe,normalisePrefs as We,safeName as B}from"../lib/media-arrange.js";import{fmtDateTime as Xe}from"../lib/dates.js";const Ae=/^#\/media\/?(\?.*)?$/,ne="#/media",O="media.draft",Se=He("media.prefs",We),Ze=`
1
+ import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-chrome.js";import{SHAPES as He,whileLoading as Oe}from"../lib/loading.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Ke,openMenuAt as Me,settingsStore as _e,sidebarCount as ee,viewLifetime as je}from"../lib/tool-kit.js";import{openImageEditor as Ve}from"../lib/image-editor.js";import{KIND_LABEL as De,arrangeMedia as Ye,clashes as Ge,formatBytes as Le,isEditable as te,isPreviewable as Je,kindCounts as Qe,kindOf as We,markdownFor as ae,mediaTile as Xe,normalisePrefs as Ze,safeName as B}from"../lib/media-arrange.js";import{fmtDateTime as et}from"../lib/dates.js";const Se=/^#\/media\/?(\?.*)?$/,ne="#/media",H="media.draft",Ae=_e("media.prefs",Ze),tt=`
2
2
  <div class="mdx-tile" data-name="{{name}}" tabindex="0" title="{{name}}">
3
3
  <div class="mdx-thumb">
4
4
  <img data-bind-hidden="!preview" data-bind-src="thumb" alt="" loading="lazy" decoding="async">
@@ -7,7 +7,7 @@ import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-
7
7
  <span class="mdx-chip">{{kindLabel}}</span>
8
8
  <button type="button" class="mdx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
9
9
  <div class="mdx-info"><span class="mdx-name">{{name}}</span><span class="mdx-meta">{{meta}}</span></div>
10
- </div>`,R=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function et(u){const c=document.createElement("template");return c.innerHTML=u.trim(),c.content.firstElementChild}const l=Ke(Ae);async function H(u,c){try{await navigator.clipboard.writeText(u),E.toast(`${c} copied.`,{type:"success"})}catch{E.toast("Copy failed - the browser refused the clipboard.",{type:"error"})}}export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(u){l.reset();const c=u.get(0).querySelector(".mdx")||u.get(0),b=()=>Ae.test(location.hash)&&c.isConnected,se=Se.read(),p=M.observable([]),oe=M.observable(!0),K=M.observable(""),y=M.observable(se.kind),_=M.observable(se.sort),ie=M.observable(""),D=M.observable(""),q=M.observable(!1);let j=String(Date.now());l.add(M.effect(()=>Se.write({kind:y.value,sort:_.value})));const U=M.observable([]);l.add(M.effect(()=>{const e=Date.now();U.value=je(p.value,{kind:y.value,query:ie.value,sort:_.value}).map(t=>Qe(t,{now:e,stamp:j}))}));const re=M.computed(()=>Ge(p.value));l.add(M.effect(()=>{y.value!=="all"&&!re.value[y.value]&&p.value.length&&(y.value="all")}));const Ce=M.computed(()=>{const e=p.value,t=U.value.length,s=Le(e.reduce((n,i)=>n+(Number(i.size)||0),0));return t===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${s}`:`${t} of ${e.length} \xB7 ${s} in all`}),Te=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),L=e=>p.peek().find(t=>t.name===e);async function A(){try{const e=await z.media.list();if(!b())return;j=String(Date.now()),p.value=Array.isArray(e)?e:[],K.value=""}catch(e){K.value=`Could not load the media: ${e.message||e}`}finally{oe.value=!1}}async function le(e){const t=[...e||[]].filter(o=>o&&o.size!==void 0);if(!t.length)return;if(D.peek()){E.toast("An upload is still running.",{type:"warning"});return}const s=Ve(t.map(o=>o.name),p.peek().map(o=>o.name));if(s.length){const o=s.slice(0,5).map(w=>`"${R(w)}"`).join(", ")+(s.length>5?` and ${s.length-5} more`:"");if(!await E.confirm(`${s.length===1?"A file":`${s.length} files`} with the same name ${s.length===1?"is":"are"} already here: ${o}. Replace ${s.length===1?"it":"them"}?`))return}const n=[];let i=0;try{for(const o of t){D.value=`Uploading ${i+1} of ${t.length} - ${o.name}`;const w=new FormData;w.append("file",o);try{await z.media.upload(w),i++}catch(J){n.push(`${o.name}: ${J.message||"failed"}`)}}}finally{D.value=""}i&&E.toast(`Uploaded ${i} file${i===1?"":"s"}.`,{type:"success"}),n.length&&E.toast(`Not uploaded - ${n.join("; ")}`,{type:"error",duration:9e3}),ee(ne),b()&&await A()}async function V(e){if(!await E.confirm(`Delete "${R(e.name)}"? Pages that show it will show a broken image or link. This cannot be undone.`))return!1;try{return await z.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),ee(ne),b()&&await A(),!0}catch(t){return E.toast(`Not deleted: ${t.message||t}`,{type:"error"}),!1}}async function de(e){const t=await _e(e);return t&&b()&&(ee(ne),await A()),t}const Y=e=>window.location.origin+e.url;let h=null,ce=!1;const G=new Set;function k(e,{focusRename:t=!1,draftName:s=null}={}){const n=L(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const i=M.observable(s??n.name),o=M.observable(!1),w=M.computed(()=>{const a=i.value.trim();if(!a)return"A file needs a name.";const r=B(a);return r!==n.name&&L(r)?`"${r}" is already here.`:""}),J=M.computed(()=>{const a=i.value.trim(),r=B(a);return a&&r!==a?`Saved as "${r}" - only letters, numbers, dots, hyphens and underscores are kept.`:""}),ge=()=>i.peek().trim()!==n.name,we={newName:i,saving:o,renameErr:w,renameHint:J,cannotRename:M.computed(()=>o.value||!!w.value||B(i.value.trim())===n.name),editable:te(n.name)},m=E.slideover({title:R(n.name),size:"md",position:"right"}),d=et(`
10
+ </div>`,q=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function at(u){const d=document.createElement("template");return d.innerHTML=u.trim(),d.content.firstElementChild}const l=je(Se);async function O(u,d){try{await navigator.clipboard.writeText(u),E.toast(`${d} copied.`,{type:"success"})}catch{E.toast("Copy failed - the browser refused the clipboard.",{type:"error"})}}export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(u){l.reset();const d=u.get(0).querySelector(".mdx")||u.get(0),b=()=>Se.test(location.hash)&&d.isConnected,oe=Ae.read(),p=M.observable([]),se=M.observable(!0),K=M.observable(""),y=M.observable(oe.kind),_=M.observable(oe.sort),ie=M.observable(""),D=M.observable(""),R=M.observable(!1);let j=String(Date.now());l.add(M.effect(()=>Ae.write({kind:y.value,sort:_.value})));const U=M.observable([]);l.add(M.effect(()=>{const e=Date.now();U.value=Ye(p.value,{kind:y.value,query:ie.value,sort:_.value}).map(t=>Xe(t,{now:e,stamp:j}))}));const re=M.computed(()=>Qe(p.value));l.add(M.effect(()=>{y.value!=="all"&&!re.value[y.value]&&p.value.length&&(y.value="all")}));const Ce=M.computed(()=>{const e=p.value,t=U.value.length,o=Le(e.reduce((n,i)=>n+(Number(i.size)||0),0));return t===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${o}`:`${t} of ${e.length} \xB7 ${o} in all`}),Te=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),L=e=>p.peek().find(t=>t.name===e);async function A(){try{const e=await Oe(d.querySelector(".dm-loading-slot"),z.media.list(),He.tiles);if(!b())return;j=String(Date.now()),p.value=Array.isArray(e)?e:[],K.value=""}catch(e){K.value=`Could not load the media: ${e.message||e}`}finally{se.value=!1}}async function le(e){const t=[...e||[]].filter(s=>s&&s.size!==void 0);if(!t.length)return;if(D.peek()){E.toast("An upload is still running.",{type:"warning"});return}const o=Ge(t.map(s=>s.name),p.peek().map(s=>s.name));if(o.length){const s=o.slice(0,5).map(w=>`"${q(w)}"`).join(", ")+(o.length>5?` and ${o.length-5} more`:"");if(!await E.confirm(`${o.length===1?"A file":`${o.length} files`} with the same name ${o.length===1?"is":"are"} already here: ${s}. Replace ${o.length===1?"it":"them"}?`))return}const n=[];let i=0;try{for(const s of t){D.value=`Uploading ${i+1} of ${t.length} - ${s.name}`;const w=new FormData;w.append("file",s);try{await z.media.upload(w),i++}catch(J){n.push(`${s.name}: ${J.message||"failed"}`)}}}finally{D.value=""}i&&E.toast(`Uploaded ${i} file${i===1?"":"s"}.`,{type:"success"}),n.length&&E.toast(`Not uploaded - ${n.join("; ")}`,{type:"error",duration:9e3}),ee(ne),b()&&await A()}async function V(e){if(!await E.confirm(`Delete "${q(e.name)}"? Pages that show it will show a broken image or link. This cannot be undone.`))return!1;try{return await z.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),ee(ne),b()&&await A(),!0}catch(t){return E.toast(`Not deleted: ${t.message||t}`,{type:"error"}),!1}}async function de(e){const t=await Ve(e);return t&&b()&&(ee(ne),await A()),t}const Y=e=>window.location.origin+e.url;let h=null,ce=!1;const G=new Set;function k(e,{focusRename:t=!1,draftName:o=null}={}){const n=L(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const i=M.observable(o??n.name),s=M.observable(!1),w=M.computed(()=>{const a=i.value.trim();if(!a)return"A file needs a name.";const r=B(a);return r!==n.name&&L(r)?`"${r}" is already here.`:""}),J=M.computed(()=>{const a=i.value.trim(),r=B(a);return a&&r!==a?`Saved as "${r}" - only letters, numbers, dots, hyphens and underscores are kept.`:""}),ge=()=>i.peek().trim()!==n.name,we={newName:i,saving:s,renameErr:w,renameHint:J,cannotRename:M.computed(()=>s.value||!!w.value||B(i.value.trim())===n.name),editable:te(n.name)},m=E.slideover({title:q(n.name),size:"md",position:"right"}),c=at(`
11
11
  <div class="mdx-panel">
12
12
  <div class="mdx-preview"></div>
13
13
  <dl class="mdx-facts"></dl>
@@ -37,4 +37,4 @@ import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-
37
37
  <a class="btn btn-sm btn-ghost mdx-open" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="13"></span> Open</a>
38
38
  <button type="button" class="btn btn-sm btn-outline" data-on-click="edit" data-bind-hidden="!editable"><span data-icon="edit" data-icon-size="13"></span> Edit image</button>
39
39
  </div>
40
- </div>`),Q=d.querySelector(".mdx-preview"),$=Je(n.name);if(Ye(n.name)){const a=document.createElement("img");a.src=`${n.url}?t=${encodeURIComponent(j)}`,a.alt=n.name,Q.appendChild(a)}else if($==="video"||$==="audio"){const a=document.createElement($);a.src=n.url,a.controls=!0,a.preload="metadata",Q.appendChild(a)}else{const a=document.createElement("span");a.setAttribute("data-icon",$==="document"?"file-text":"paperclip"),a.setAttribute("data-icon-size","40"),Q.appendChild(a)}const qe=d.querySelector(".mdx-facts"),P=(a,r)=>{const xe=document.createElement("dt");xe.textContent=a;const Z=document.createElement("dd");return Z.textContent=r,qe.append(xe,Z),Z};if(P("Type",`${De[$]==="Images"?"Image":De[$].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),P("Size",Le(n.size)),P("Uploaded",n.createdAt?Xe(n.createdAt):"-"),te(n.name)){const a=P("Dimensions","\u2026");z.media.info(n.name).then(r=>{a.textContent=r?.width?`${r.width} \xD7 ${r.height} px`:"-"}).catch(()=>{a.textContent="-"})}d.querySelector(".mdx-url").value=Y(n),d.querySelector(".mdx-md").value=ae(n),d.querySelector(".mdx-open").href=n.url;let N=!1;async function ke(){if(!we.cannotRename.peek()){o.value=!0;try{const a=await z.media.rename(n.name,i.peek().trim());E.toast(`Renamed to "${a?.name||B(i.peek().trim())}".`,{type:"success"}),N=!0,m.close(),b()&&await A()}catch(a){E.toast(`Not renamed: ${a.message||a}`,{type:"error"})}finally{o.value=!1}}}m.element.appendChild(d);const Ie=$e(d),Ue=M.applyBindings(we,d,{methods:{rename:ke,copyUrl:()=>H(Y(n),"Address"),copyMd:()=>H(ae(n),"Markdown"),async del(){await V(n)&&(N=!0,m.close())},async edit(){N=!0,m.close(),await de(n)}}}),W=d.querySelector("#mdx-rename"),Fe=a=>{a.key==="Enter"&&a.target===W&&(a.preventDefault(),ke())};d.addEventListener("keydown",Fe);const Ee={name:n.name,dirty:ge,newName:()=>i.peek()};h=Ee,G.add(m),Oe(m,()=>{Ue.dispose(),Ie(),d.querySelectorAll("video, audio").forEach(a=>{try{a.pause()}catch{}}),G.delete(m),h===Ee&&(h=null)});const Pe=m.close.bind(m);let X=!1;m.close=async a=>{if(!N&&!ce&&a!==!0&&ge()){if(X)return;X=!0;const r=await E.confirm("Discard the new name you typed?");if(X=!1,!r)return}return N=!0,Pe()},I.scan(d),m.open(),(t||s)&&(W.focus(),W.select())}function Ne(e){const t=L(e);return t?[{header:t.name},{label:"Details\u2026",icon:"info",action:()=>k(t.name)},{label:"Copy address",icon:"link",action:()=>H(Y(t),"Address")},{label:"Copy for a page",icon:"copy",action:()=>H(ae(t),"Markdown")},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")},{type:"divider"},{label:"Rename\u2026",icon:"type",action:()=>k(t.name,{focusRename:!0})},...te(t.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>de(t)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>V(t)}]:[]}const f=c.querySelector(".mdx-grid");f.innerHTML=Ze;const x=e=>()=>{y.value=e},ze=M.applyBindings({files:p,loading:oe,loadError:K,kind:y,sort:_,query:ie,tiles:U,counts:re,totalText:Ce,emptyText:Te,busyText:D,dragging:q},c,{methods:{kindAll:x("all"),kindImage:x("image"),kindVideo:x("video"),kindAudio:x("audio"),kindDocument:x("document"),kindOther:x("other")}});l.add(()=>ze.dispose()),l.add($e(c,{position:"bottom"})),l.add(M.effect(()=>{U.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const Re=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>Ne(e.dataset.name)});l.add(()=>Re?.destroy?.());const me=e=>{const t=e.target.closest(".mdx-tile");if(!t)return;const s=e.target.closest(".mdx-kebab");if(s){Me(s);return}k(t.dataset.name)},ue=e=>{const t=e.target.closest(".mdx-tile");if(!t||e.target!==t)return;const s=L(t.dataset.name);s&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Me(t)):e.key==="Enter"?k(s.name):e.key==="F2"?(e.preventDefault(),k(s.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),V(s)))};f.addEventListener("click",me),f.addEventListener("keydown",ue),l.add(()=>{f.removeEventListener("click",me),f.removeEventListener("keydown",ue)});const C=c.querySelector(".mdx-file-input"),pe=()=>{const e=[...C.files];C.value="",le(e)};C.addEventListener("change",pe),l.add(()=>C.removeEventListener("change",pe)),Be(u,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>C.click()});const F=e=>[...e.dataTransfer?.types||[]].includes("Files");let T=0;const fe=e=>{F(e)&&(e.preventDefault(),T++,q.value=!0)},ve=e=>{F(e)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")},be=e=>{F(e)&&(T=Math.max(0,T-1),T||(q.value=!1))},ye=e=>{F(e)&&(e.preventDefault(),T=0,q.value=!1,le(e.dataTransfer.files))},v=u.get(0);v.addEventListener("dragenter",fe),v.addEventListener("dragover",ve),v.addEventListener("dragleave",be),v.addEventListener("drop",ye),l.add(()=>{v.removeEventListener("dragenter",fe),v.removeEventListener("dragover",ve),v.removeEventListener("dragleave",be),v.removeEventListener("drop",ye)});const he=e=>{(D.peek()||h?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",he),l.add(()=>window.removeEventListener("beforeunload",he)),l.add(()=>{if(h?.dirty())try{S.set(O,{name:h.name,newName:h.newName(),at:new Date().toISOString()})}catch{}ce=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(c),await A(),!b())return;let g=null;try{g=S.get(O)}catch{}if(g?.name){try{S.remove?S.remove(O):S.set(O,null)}catch{}L(g.name)&&await E.confirm(`You were renaming "${R(g.name)}" to "${R(g.newName)}" and left before saving. Pick up where you left off?`)&&b()&&k(g.name,{draftName:g.newName})}}};
40
+ </div>`),Q=c.querySelector(".mdx-preview"),$=We(n.name);if(Je(n.name)){const a=document.createElement("img");a.src=`${n.url}?t=${encodeURIComponent(j)}`,a.alt=n.name,Q.appendChild(a)}else if($==="video"||$==="audio"){const a=document.createElement($);a.src=n.url,a.controls=!0,a.preload="metadata",Q.appendChild(a)}else{const a=document.createElement("span");a.setAttribute("data-icon",$==="document"?"file-text":"paperclip"),a.setAttribute("data-icon-size","40"),Q.appendChild(a)}const Re=c.querySelector(".mdx-facts"),P=(a,r)=>{const xe=document.createElement("dt");xe.textContent=a;const Z=document.createElement("dd");return Z.textContent=r,Re.append(xe,Z),Z};if(P("Type",`${De[$]==="Images"?"Image":De[$].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),P("Size",Le(n.size)),P("Uploaded",n.createdAt?et(n.createdAt):"-"),te(n.name)){const a=P("Dimensions","\u2026");z.media.info(n.name).then(r=>{a.textContent=r?.width?`${r.width} \xD7 ${r.height} px`:"-"}).catch(()=>{a.textContent="-"})}c.querySelector(".mdx-url").value=Y(n),c.querySelector(".mdx-md").value=ae(n),c.querySelector(".mdx-open").href=n.url;let N=!1;async function ke(){if(!we.cannotRename.peek()){s.value=!0;try{const a=await z.media.rename(n.name,i.peek().trim());E.toast(`Renamed to "${a?.name||B(i.peek().trim())}".`,{type:"success"}),N=!0,m.close(),b()&&await A()}catch(a){E.toast(`Not renamed: ${a.message||a}`,{type:"error"})}finally{s.value=!1}}}m.element.appendChild(c);const Ie=$e(c),Ue=M.applyBindings(we,c,{methods:{rename:ke,copyUrl:()=>O(Y(n),"Address"),copyMd:()=>O(ae(n),"Markdown"),async del(){await V(n)&&(N=!0,m.close())},async edit(){N=!0,m.close(),await de(n)}}}),W=c.querySelector("#mdx-rename"),Fe=a=>{a.key==="Enter"&&a.target===W&&(a.preventDefault(),ke())};c.addEventListener("keydown",Fe);const Ee={name:n.name,dirty:ge,newName:()=>i.peek()};h=Ee,G.add(m),Ke(m,()=>{Ue.dispose(),Ie(),c.querySelectorAll("video, audio").forEach(a=>{try{a.pause()}catch{}}),G.delete(m),h===Ee&&(h=null)});const Pe=m.close.bind(m);let X=!1;m.close=async a=>{if(!N&&!ce&&a!==!0&&ge()){if(X)return;X=!0;const r=await E.confirm("Discard the new name you typed?");if(X=!1,!r)return}return N=!0,Pe()},I.scan(c),m.open(),(t||o)&&(W.focus(),W.select())}function Ne(e){const t=L(e);return t?[{header:t.name},{label:"Details\u2026",icon:"info",action:()=>k(t.name)},{label:"Copy address",icon:"link",action:()=>O(Y(t),"Address")},{label:"Copy for a page",icon:"copy",action:()=>O(ae(t),"Markdown")},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")},{type:"divider"},{label:"Rename\u2026",icon:"type",action:()=>k(t.name,{focusRename:!0})},...te(t.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>de(t)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>V(t)}]:[]}const f=d.querySelector(".mdx-grid");f.innerHTML=tt;const x=e=>()=>{y.value=e},ze=M.applyBindings({files:p,loading:se,loadError:K,kind:y,sort:_,query:ie,tiles:U,counts:re,totalText:Ce,emptyText:Te,busyText:D,dragging:R},d,{methods:{kindAll:x("all"),kindImage:x("image"),kindVideo:x("video"),kindAudio:x("audio"),kindDocument:x("document"),kindOther:x("other")}});l.add(()=>ze.dispose()),l.add($e(d,{position:"bottom"})),l.add(M.effect(()=>{U.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const qe=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>Ne(e.dataset.name)});l.add(()=>qe?.destroy?.());const me=e=>{const t=e.target.closest(".mdx-tile");if(!t)return;const o=e.target.closest(".mdx-kebab");if(o){Me(o);return}k(t.dataset.name)},ue=e=>{const t=e.target.closest(".mdx-tile");if(!t||e.target!==t)return;const o=L(t.dataset.name);o&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Me(t)):e.key==="Enter"?k(o.name):e.key==="F2"?(e.preventDefault(),k(o.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),V(o)))};f.addEventListener("click",me),f.addEventListener("keydown",ue),l.add(()=>{f.removeEventListener("click",me),f.removeEventListener("keydown",ue)});const C=d.querySelector(".mdx-file-input"),pe=()=>{const e=[...C.files];C.value="",le(e)};C.addEventListener("change",pe),l.add(()=>C.removeEventListener("change",pe)),Be(u,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>C.click()});const F=e=>[...e.dataTransfer?.types||[]].includes("Files");let T=0;const fe=e=>{F(e)&&(e.preventDefault(),T++,R.value=!0)},ve=e=>{F(e)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")},be=e=>{F(e)&&(T=Math.max(0,T-1),T||(R.value=!1))},ye=e=>{F(e)&&(e.preventDefault(),T=0,R.value=!1,le(e.dataTransfer.files))},v=u.get(0);v.addEventListener("dragenter",fe),v.addEventListener("dragover",ve),v.addEventListener("dragleave",be),v.addEventListener("drop",ye),l.add(()=>{v.removeEventListener("dragenter",fe),v.removeEventListener("dragover",ve),v.removeEventListener("dragleave",be),v.removeEventListener("drop",ye)});const he=e=>{(D.peek()||h?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",he),l.add(()=>window.removeEventListener("beforeunload",he)),l.add(()=>{if(h?.dirty())try{S.set(H,{name:h.name,newName:h.newName(),at:new Date().toISOString()})}catch{}ce=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(d),await A(),!b())return;let g=null;try{g=S.get(H)}catch{}if(g?.name){try{S.remove?S.remove(H):S.set(H,null)}catch{}L(g.name)&&await E.confirm(`You were renaming "${q(g.name)}" to "${q(g.newName)}" and left before saving. Pick up where you left off?`)&&b()&&k(g.name,{draftName:g.newName})}}};
@@ -1,4 +1,4 @@
1
- import{apiRequest as u}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as z}from"../lib/help-popover.js";import{openMenuAt as R,sidebarCount as j,viewLifetime as V}from"../lib/tool-kit.js";import{filterByProject as Y}from"../lib/project-context.js";import{FILTERS as J,arrangeMenus as Q,isPlaced as X,menuRow as D,menuShortcode as Z,slotsByMenu as ee}from"../lib/menus-arrange.js";const $=/^#\/(menus|projects\/[^/]+\/menus)\/?(\?.*)?$/,U="#/menus",f="menus.filter",te=`
1
+ import{apiRequest as u}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{SHAPES as z,whileLoading as V}from"../lib/loading.js";import{helpPopovers as Y}from"../lib/help-popover.js";import{openMenuAt as R,sidebarCount as j,viewLifetime as J}from"../lib/tool-kit.js";import{filterByProject as Q}from"../lib/project-context.js";import{FILTERS as X,arrangeMenus as Z,isPlaced as ee,menuRow as D,menuShortcode as te,slotsByMenu as ne}from"../lib/menus-arrange.js";const $=/^#\/(menus|projects\/[^/]+\/menus)\/?(\?.*)?$/,q="#/menus",f="menus.filter",oe=`
2
2
  <div class="mnx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="placed ? '' : 'is-unplaced'">
3
3
  <span class="mnx-icon" aria-hidden="true"><span data-icon="menu" data-icon-size="16"></span></span>
4
4
  <span class="mnx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
@@ -8,4 +8,4 @@ import{apiRequest as u}from"../api.js";import{addBannerAction as A}from"../lib/p
8
8
  <span class="mnx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS or a plugin">Bundled</span>
9
9
  </span>
10
10
  <button type="button" class="mnx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
- </div>`,ne=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),s=V($);function ae(){const n=location.hash.match(/^#\/projects\/([^/?#]+)\/menus/);if(!n)return null;try{return decodeURIComponent(n[1])}catch{return n[1]}}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(n){s.reset();const r=n.get(0).querySelector(".mnx")||n.get(0),d=()=>$.test(location.hash)&&r.isConnected,i=ae(),c=M.observable([]),v=M.observable({}),m=M.observable([]),w=M.observable(!0),p=M.observable("");let x="all";try{x=J.includes(S.get(f))?S.get(f):"all"}catch{}const l=M.observable(x),T=M.observable("");s.add(M.effect(()=>{const e=l.value;try{S.set(f,e)}catch{}}));const h=M.computed(()=>ee(v.value)),y=M.observable([]);s.add(M.effect(()=>{const e={bySlug:h.value,registry:m.value};y.value=Q(c.value,{...e,filter:l.value,query:T.value}).map(t=>D(t,e))}));const q=M.computed(()=>{const e=c.value,t=e.filter(a=>X(a,h.value)).length;return{all:e.length,placed:t,unplaced:e.length-t}}),N=M.computed(()=>c.value.length?"No menus match.":i?"No menus in this project yet - New menu in the banner makes one.":"No menus yet - New menu in the banner makes one."),P=M.computed(()=>i?`Showing the menus in the project "${i}".`:""),B=e=>c.peek().find(t=>t.slug===e);async function g(){try{const[e,t,a]=await Promise.all([u("/menus",{method:"GET"}),u("/menu-locations",{method:"GET"}).catch(()=>({})),u("/menu-locations/registry",{method:"GET"}).catch(()=>[])]);if(!d())return;const L=Array.isArray(e)?e:[];c.value=i?Y(L,i):L,v.value=t&&typeof t=="object"?t:{},m.value=Array.isArray(a)?a:[],p.value=""}catch(e){p.value=`Could not load the menus: ${e.message||e}`}finally{w.value=!1}}const b=e=>{location.hash=`#/menus/edit/${encodeURIComponent(e)}`};function _(){if(i)try{sessionStorage.setItem("__projectContext",i)}catch{}location.hash="#/menus/new"}async function F(e){try{const t=await u(`/menus/${encodeURIComponent(e.slug)}/duplicate`,{method:"POST"});E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}async function G(e){const t=Z(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function K(e){if(await E.confirm(`Delete the menu "${ne(e.name||e.slug)}"? This cannot be undone.`))try{await u(`/menus/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Menu deleted.",{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error",duration:7e3})}}function O(e){const t=B(e);if(!t)return[];const a=D(t,{bySlug:h.peek(),registry:m.peek()});return[{header:t.name||t.slug},{label:"Edit",icon:"edit",action:()=>b(t.slug)},{label:"Duplicate",icon:"copy",action:()=>F(t)},{label:"Copy shortcode",icon:"code",action:()=>G(t)},{type:"divider"},{label:"Locations\u2026",icon:"map",action:()=>{location.hash="#/menu-locations"}},{type:"divider"},a.canDelete?{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>K(t)}:{label:"Delete - in a location",icon:"trash",disabled:!0,title:a.deleteWhy}]}const o=r.querySelector(".mnx-list");o.innerHTML=te;const H=M.applyBindings({menus:c,loading:w,loadError:p,filter:l,query:T,rows:y,counts:q,emptyText:N,projectText:P},r,{methods:{showAll:()=>{l.value="all"},showPlaced:()=>{l.value="placed"},showUnplaced:()=>{l.value="unplaced"}}});s.add(()=>H.dispose()),s.add(z(r,{position:"bottom"})),s.add(M.effect(()=>{y.value,requestAnimationFrame(()=>{o.isConnected&&I.scan(o)})}));const W=E.contextMenu(o,{cursor:"glyph",match:".mnx-row",inherit:!1,density:"compact",items:e=>O(e.dataset.slug)});s.add(()=>W?.destroy?.());const k=e=>{const t=e.target.closest(".mnx-row");if(!t)return;const a=e.target.closest(".mnx-kebab");if(a){R(a);return}b(t.dataset.slug)},C=e=>{const t=e.target.closest(".mnx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),R(t)):e.key==="Enter"&&e.target===t&&b(t.dataset.slug))};o.addEventListener("click",k),o.addEventListener("keydown",C),s.add(()=>{o.removeEventListener("click",k),o.removeEventListener("keydown",C)}),A(n,{id:"menus-new",icon:"plus",title:"New menu",onClick:_}),A(n,{id:"menus-locations",icon:"map",title:"Locations",onClick:()=>{location.hash="#/menu-locations"}}),I.scan(r),await g()}};
11
+ </div>`,ae=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),s=J($);function se(){const n=location.hash.match(/^#\/projects\/([^/?#]+)\/menus/);if(!n)return null;try{return decodeURIComponent(n[1])}catch{return n[1]}}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(n){s.reset();const c=n.get(0).querySelector(".mnx")||n.get(0),d=()=>$.test(location.hash)&&c.isConnected,i=se(),l=M.observable([]),v=M.observable({}),m=M.observable([]),w=M.observable(!0),p=M.observable("");let x="all";try{x=X.includes(S.get(f))?S.get(f):"all"}catch{}const r=M.observable(x),T=M.observable("");s.add(M.effect(()=>{const e=r.value;try{S.set(f,e)}catch{}}));const h=M.computed(()=>ne(v.value)),g=M.observable([]);s.add(M.effect(()=>{const e={bySlug:h.value,registry:m.value};g.value=Z(l.value,{...e,filter:r.value,query:T.value}).map(t=>D(t,e))}));const P=M.computed(()=>{const e=l.value,t=e.filter(o=>ee(o,h.value)).length;return{all:e.length,placed:t,unplaced:e.length-t}}),U=M.computed(()=>l.value.length?"No menus match.":i?"No menus in this project yet - New menu in the banner makes one.":"No menus yet - New menu in the banner makes one."),N=M.computed(()=>i?`Showing the menus in the project "${i}".`:""),B=e=>l.peek().find(t=>t.slug===e);async function y(){try{const[e,t,o]=await V(c.querySelector(".dm-loading-slot"),Promise.all([u("/menus",{method:"GET"}),u("/menu-locations",{method:"GET"}).catch(()=>({})),u("/menu-locations/registry",{method:"GET"}).catch(()=>[])]),z.rows);if(!d())return;const L=Array.isArray(e)?e:[];l.value=i?Q(L,i):L,v.value=t&&typeof t=="object"?t:{},m.value=Array.isArray(o)?o:[],p.value=""}catch(e){p.value=`Could not load the menus: ${e.message||e}`}finally{w.value=!1}}const b=e=>{location.hash=`#/menus/edit/${encodeURIComponent(e)}`};function _(){if(i)try{sessionStorage.setItem("__projectContext",i)}catch{}location.hash="#/menus/new"}async function F(e){try{const t=await u(`/menus/${encodeURIComponent(e.slug)}/duplicate`,{method:"POST"});E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),j(q),d()&&await y()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}async function G(e){const t=te(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function H(e){if(await E.confirm(`Delete the menu "${ae(e.name||e.slug)}"? This cannot be undone.`))try{await u(`/menus/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Menu deleted.",{type:"success"}),j(q),d()&&await y()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error",duration:7e3})}}function K(e){const t=B(e);if(!t)return[];const o=D(t,{bySlug:h.peek(),registry:m.peek()});return[{header:t.name||t.slug},{label:"Edit",icon:"edit",action:()=>b(t.slug)},{label:"Duplicate",icon:"copy",action:()=>F(t)},{label:"Copy shortcode",icon:"code",action:()=>G(t)},{type:"divider"},{label:"Locations\u2026",icon:"map",action:()=>{location.hash="#/menu-locations"}},{type:"divider"},o.canDelete?{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>H(t)}:{label:"Delete - in a location",icon:"trash",disabled:!0,title:o.deleteWhy}]}const a=c.querySelector(".mnx-list");a.innerHTML=oe;const O=M.applyBindings({menus:l,loading:w,loadError:p,filter:r,query:T,rows:g,counts:P,emptyText:U,projectText:N},c,{methods:{showAll:()=>{r.value="all"},showPlaced:()=>{r.value="placed"},showUnplaced:()=>{r.value="unplaced"}}});s.add(()=>O.dispose()),s.add(Y(c,{position:"bottom"})),s.add(M.effect(()=>{g.value,requestAnimationFrame(()=>{a.isConnected&&I.scan(a)})}));const W=E.contextMenu(a,{cursor:"glyph",match:".mnx-row",inherit:!1,density:"compact",items:e=>K(e.dataset.slug)});s.add(()=>W?.destroy?.());const k=e=>{const t=e.target.closest(".mnx-row");if(!t)return;const o=e.target.closest(".mnx-kebab");if(o){R(o);return}b(t.dataset.slug)},C=e=>{const t=e.target.closest(".mnx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),R(t)):e.key==="Enter"&&e.target===t&&b(t.dataset.slug))};a.addEventListener("click",k),a.addEventListener("keydown",C),s.add(()=>{a.removeEventListener("click",k),a.removeEventListener("keydown",C)}),A(n,{id:"menus-new",icon:"plus",title:"New menu",onClick:_}),A(n,{id:"menus-locations",icon:"map",title:"Locations",onClick:()=>{location.hash="#/menu-locations"}}),I.scan(c),await y()}};
@@ -1,4 +1,4 @@
1
- import{api as g,apiRequest as m}from"../api.js";import{addBannerAction as U}from"../lib/plugin-chrome.js";import{helpPopovers as ee}from"../lib/help-popover.js";import{onceClosed as Ae,openMenuAt as te,sidebarCount as Ne,viewLifetime as Re}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as se,FILTERS as Le,arrangeNotifications as De,filterCounts as $e,normaliseSettings as Ie,notificationRow as ne,sourceRow as Oe}from"../lib/notifications-arrange.js";import{desktopPermission as ae,notificationSettings as oe,primeSeen as Pe,requestDesktopPermission as qe,showTestNotification as Ue}from"../lib/desktop-notify.js";const ie=/^#\/system\/notifications\/?(\?.*)?$/,_e="#/system/notifications",_="notifications.filter",Be=`
1
+ import{api as w,apiRequest as f}from"../api.js";import{addBannerAction as U}from"../lib/plugin-chrome.js";import{SHAPES as Ae,whileLoading as Re}from"../lib/loading.js";import{helpPopovers as ee}from"../lib/help-popover.js";import{onceClosed as Ne,openMenuAt as te,sidebarCount as Le,viewLifetime as De}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as se,FILTERS as $e,arrangeNotifications as Ie,filterCounts as Oe,normaliseSettings as Pe,notificationRow as ne,sourceRow as qe}from"../lib/notifications-arrange.js";import{desktopPermission as ae,notificationSettings as oe,primeSeen as Ue,requestDesktopPermission as _e,showTestNotification as Be}from"../lib/desktop-notify.js";const ie=/^#\/system\/notifications\/?(\?.*)?$/,We="#/system/notifications",_="notifications.filter",Fe=`
2
2
  <div class="ntx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls" data-bind-aria-expanded="open ? 'true' : 'false'">
3
3
  <span class="ntx-dot" aria-hidden="true"></span>
4
4
  <span class="ntx-main">
@@ -13,7 +13,7 @@ import{api as g,apiRequest as m}from"../api.js";import{addBannerAction as U}from
13
13
  </span>
14
14
  </span>
15
15
  <button type="button" class="ntx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
16
- </div>`,We=r=>String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Fe=`
16
+ </div>`,He=r=>String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),je=`
17
17
  <div class="ntx-src" data-id="{{id}}">
18
18
  <div class="ntx-src-head">
19
19
  <span class="ntx-src-name"><strong>{{label}}</strong><small>{{meta}}</small></span>
@@ -29,9 +29,9 @@ import{api as g,apiRequest as m}from"../api.js";import{addBannerAction as U}from
29
29
  <button type="button" class="btn btn-sm btn-ghost ntx-src-run" data-bind-hidden="!every">Run now</button>
30
30
  </div>
31
31
  <small class="ntx-src-err" data-bind-hidden="!lastError">{{lastError}}</small>
32
- </div>`,C={granted:"Allowed in this browser.",default:"Your browser will ask for permission when you turn this on.",denied:"Blocked in this browser. Allow notifications for this site in the browser's site settings (the icon by the address), then turn this on.",unsupported:"This browser cannot show desktop notifications here - they need a current browser and an https address."};function je(r){const c=document.createElement("template");return c.innerHTML=r.trim(),c.content.firstElementChild}const p=Re(ie);async function f(){Ne(_e);try{const{count:r}=await g.system.notifications.unreadCount(),c=$("#topbar-bell-badge");r>0?c.text(r>99?"99+":String(r)).removeAttr("hidden"):c.attr("hidden","").text("")}catch{}}export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(r){p.reset();const c=r.get(0).querySelector(".ntx")||r.get(0),A=()=>ie.test(location.hash)&&c.isConnected,i=M.observable([]),k=M.observable(!0),N=M.observable("");let B="all";try{B=Le.includes(S.get(_))?S.get(_):"all"}catch{}const h=M.observable(B),W=M.observable(""),x=M.observable("");p.add(M.effect(()=>{const e=h.value;try{S.set(_,e)}catch{}}));const F=M.computed(()=>$e(i.value));p.add(M.effect(()=>{const e=F.value;!k.value&&h.peek()!=="all"&&!e[h.peek()]&&(h.value="all")}));const j=M.observable([]);p.add(M.effect(()=>{const e=Date.now(),t=x.value;j.value=De(i.value,{filter:h.value,query:W.value}).map(n=>ne(n,{now:e,expanded:t}))}));const re=M.computed(()=>i.value.length?"No notifications match.":"No notifications - you're all caught up."),d=M.observable(oe.read());p.add(M.effect(()=>oe.write(d.value)));const R=(e,t)=>{d.value=Ie({...d.peek(),[e]:t})},H=e=>i.peek().find(t=>String(t.id)===String(e)),le=(e,t)=>{i.value=i.peek().map(n=>String(n.id)===String(e)?t(n):n)},G=e=>{i.value=i.peek().filter(t=>String(t.id)!==String(e))};async function T({markRead:e=!0}={}){try{const t=await g.system.notifications.list();if(!A())return;const n=Array.isArray(t)?t:[];if(i.value=n.map(l=>({...l,isNew:!!l.unread})),N.value="",e&&n.some(l=>l.unread)){if(await m("/system/notifications/read-all",{method:"POST"}).catch(()=>{}),!A())return;i.value=i.peek().map(l=>({...l,unread:!1})),f()}}catch(t){N.value=`Could not load notifications: ${t.message||t}`}finally{k.value=!1}}async function K(e,t){try{t?await g.system.notifications.markRead(e.id):await m(`/system/notifications/${encodeURIComponent(e.id)}/unread`,{method:"POST"}),le(e.id,n=>({...n,unread:!t})),f()}catch(n){E.toast(`Not changed: ${n.message||n}`,{type:"error"})}}async function Y(e){try{await g.system.notifications.dismiss(e.id),G(e.id),f()}catch(t){E.toast(`Could not dismiss it: ${t.message||t}`,{type:"error"})}}async function ce(e){if(await E.confirm(`Delete "${We(e.data?.title||"this notification")}" for everyone? Dismissing hides it for you only.`))try{await g.system.notifications.remove(e.id),G(e.id),E.toast("Notification deleted.",{type:"success"}),f()}catch(t){E.toast(/403|forbidden|permission/i.test(String(t.message||t))?"Deleting needs the Notifications delete permission - Dismiss hides it for you.":`Could not delete it: ${t.message||t}`,{type:"error"})}}async function de(){const e=i.peek().length;if(!e){E.toast("Nothing to dismiss.",{type:"info"});return}if(await E.confirm(`Dismiss all ${e} notification${e===1?"":"s"}? They are hidden for you only.`))try{await m("/system/notifications/dismiss-all",{method:"POST"}),i.value=[],f()}catch(t){E.toast(`Could not dismiss them: ${t.message||t}`,{type:"error"})}}async function pe(e){const t=e.data||{},n=[t.title,t.body,t.link].filter(Boolean).join(`
32
+ </div>`,C={granted:"Allowed in this browser.",default:"Your browser will ask for permission when you turn this on.",denied:"Blocked in this browser. Allow notifications for this site in the browser's site settings (the icon by the address), then turn this on.",unsupported:"This browser cannot show desktop notifications here - they need a current browser and an https address."};function Ge(r){const l=document.createElement("template");return l.innerHTML=r.trim(),l.content.firstElementChild}const u=De(ie);async function b(){Le(We);try{const{count:r}=await w.system.notifications.unreadCount(),l=$("#topbar-bell-badge");r>0?l.text(r>99?"99+":String(r)).removeAttr("hidden"):l.attr("hidden","").text("")}catch{}}export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(r){u.reset();const l=r.get(0).querySelector(".ntx")||r.get(0),A=()=>ie.test(location.hash)&&l.isConnected,i=M.observable([]),k=M.observable(!0),R=M.observable("");let B="all";try{B=$e.includes(S.get(_))?S.get(_):"all"}catch{}const h=M.observable(B),W=M.observable(""),x=M.observable("");u.add(M.effect(()=>{const e=h.value;try{S.set(_,e)}catch{}}));const F=M.computed(()=>Oe(i.value));u.add(M.effect(()=>{const e=F.value;!k.value&&h.peek()!=="all"&&!e[h.peek()]&&(h.value="all")}));const H=M.observable([]);u.add(M.effect(()=>{const e=Date.now(),t=x.value;H.value=Ie(i.value,{filter:h.value,query:W.value}).map(n=>ne(n,{now:e,expanded:t}))}));const re=M.computed(()=>i.value.length?"No notifications match.":"No notifications - you're all caught up."),d=M.observable(oe.read());u.add(M.effect(()=>oe.write(d.value)));const N=(e,t)=>{d.value=Pe({...d.peek(),[e]:t})},j=e=>i.peek().find(t=>String(t.id)===String(e)),le=(e,t)=>{i.value=i.peek().map(n=>String(n.id)===String(e)?t(n):n)},G=e=>{i.value=i.peek().filter(t=>String(t.id)!==String(e))};async function T({markRead:e=!0}={}){try{const t=i.peek().length?null:l.querySelector(".dm-loading-slot"),n=await Re(t,w.system.notifications.list(),Ae.iconRows);if(!A())return;const p=Array.isArray(n)?n:[];if(i.value=p.map(c=>({...c,isNew:!!c.unread})),R.value="",e&&p.some(c=>c.unread)){if(await f("/system/notifications/read-all",{method:"POST"}).catch(()=>{}),!A())return;i.value=i.peek().map(c=>({...c,unread:!1})),b()}}catch(t){R.value=`Could not load notifications: ${t.message||t}`}finally{k.value=!1}}async function K(e,t){try{t?await w.system.notifications.markRead(e.id):await f(`/system/notifications/${encodeURIComponent(e.id)}/unread`,{method:"POST"}),le(e.id,n=>({...n,unread:!t})),b()}catch(n){E.toast(`Not changed: ${n.message||n}`,{type:"error"})}}async function Y(e){try{await w.system.notifications.dismiss(e.id),G(e.id),b()}catch(t){E.toast(`Could not dismiss it: ${t.message||t}`,{type:"error"})}}async function ce(e){if(await E.confirm(`Delete "${He(e.data?.title||"this notification")}" for everyone? Dismissing hides it for you only.`))try{await w.system.notifications.remove(e.id),G(e.id),E.toast("Notification deleted.",{type:"success"}),b()}catch(t){E.toast(/403|forbidden|permission/i.test(String(t.message||t))?"Deleting needs the Notifications delete permission - Dismiss hides it for you.":`Could not delete it: ${t.message||t}`,{type:"error"})}}async function de(){const e=i.peek().length;if(!e){E.toast("Nothing to dismiss.",{type:"info"});return}if(await E.confirm(`Dismiss all ${e} notification${e===1?"":"s"}? They are hidden for you only.`))try{await f("/system/notifications/dismiss-all",{method:"POST"}),i.value=[],b()}catch(t){E.toast(`Could not dismiss them: ${t.message||t}`,{type:"error"})}}async function pe(e){const t=e.data||{},n=[t.title,t.body,t.link].filter(Boolean).join(`
33
33
 
34
- `);try{await navigator.clipboard.writeText(n),E.toast("Copied.",{type:"success"})}catch{E.toast("Copying is blocked here - select the text instead.",{type:"warning"})}}const L=e=>{x.value=x.peek()===String(e)?"":String(e)};function ue(e){const t=H(e);if(!t)return[];const n=ne(t);return[{header:n.title},{label:n.open||x.peek()===n.id?"Close":"Read it",icon:"eye",action:()=>L(e)},...n.link?[n.linkTarget==="_self"?{label:"Go there",icon:"arrow-right",action:()=>{location.hash=n.link}}:{label:"Open link",icon:"external-link",action:()=>window.open(n.link,"_blank","noopener")}]:[],{label:"Copy text",icon:"copy",action:()=>pe(t)},t.unread?{label:"Mark as read",icon:"check",action:()=>K(t,!0)}:{label:"Mark as unread",icon:"mail",action:()=>K(t,!1)},{type:"divider"},{label:"Dismiss",icon:"x",action:()=>Y(t)},{label:"Delete for everyone\u2026",icon:"trash",danger:!0,action:()=>ce(t)}]}const b=c.querySelector(".ntx-list");b.innerHTML=Be;const y=e=>()=>{h.value=e},he=M.applyBindings({items:i,loading:k,loadError:N,filter:h,query:W,rows:j,counts:F,emptyText:re},c,{methods:{showAll:y("all"),showNew:y("new"),showCritical:y("critical"),showWarning:y("warning"),showInfo:y("info"),showSuccess:y("success")}});p.add(()=>he.dispose()),p.add(ee(c,{position:"bottom"}));const me=E.contextMenu(b,{cursor:"glyph",match:".ntx-row",inherit:!1,density:"compact",items:e=>ue(e.dataset.id)});p.add(()=>me?.destroy?.());const z=e=>{const t=e.target.closest(".ntx-row");if(!t)return;const n=e.target.closest(".ntx-kebab");if(n){te(n);return}e.target.closest("a")||L(t.dataset.id)},J=e=>{const t=e.target.closest(".ntx-row");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),te(t);else if(e.key==="Enter"&&e.target===t)L(t.dataset.id);else if(e.key==="Delete"&&e.target===t){const n=H(t.dataset.id);n&&Y(n)}}};b.addEventListener("click",z),b.addEventListener("keydown",J),p.add(()=>{b.removeEventListener("click",z),b.removeEventListener("keydown",J)});async function fe(){try{const{added:e}=await m("/system/notifications/samples",{method:"POST"});await T({markRead:!1}),f(),E.toast(`${e} sample notifications added.${d.peek().desktop?" They pop up on the desktop within a minute.":""}`,{type:"success"})}catch(e){E.toast(/403|forbidden|permission/i.test(String(e.message||e))?"Adding samples needs the Notifications delete permission.":`Could not add samples: ${e.message||e}`,{type:"error"})}}function be(){const e=E.slideover({title:"Notifications settings",size:"md",position:"right"}),t=je(`
34
+ `);try{await navigator.clipboard.writeText(n),E.toast("Copied.",{type:"success"})}catch{E.toast("Copying is blocked here - select the text instead.",{type:"warning"})}}const L=e=>{x.value=x.peek()===String(e)?"":String(e)};function ue(e){const t=j(e);if(!t)return[];const n=ne(t);return[{header:n.title},{label:n.open||x.peek()===n.id?"Close":"Read it",icon:"eye",action:()=>L(e)},...n.link?[n.linkTarget==="_self"?{label:"Go there",icon:"arrow-right",action:()=>{location.hash=n.link}}:{label:"Open link",icon:"external-link",action:()=>window.open(n.link,"_blank","noopener")}]:[],{label:"Copy text",icon:"copy",action:()=>pe(t)},t.unread?{label:"Mark as read",icon:"check",action:()=>K(t,!0)}:{label:"Mark as unread",icon:"mail",action:()=>K(t,!1)},{type:"divider"},{label:"Dismiss",icon:"x",action:()=>Y(t)},{label:"Delete for everyone\u2026",icon:"trash",danger:!0,action:()=>ce(t)}]}const y=l.querySelector(".ntx-list");y.innerHTML=Fe;const v=e=>()=>{h.value=e},me=M.applyBindings({items:i,loading:k,loadError:R,filter:h,query:W,rows:H,counts:F,emptyText:re},l,{methods:{showAll:v("all"),showNew:v("new"),showCritical:v("critical"),showWarning:v("warning"),showInfo:v("info"),showSuccess:v("success")}});u.add(()=>me.dispose()),u.add(ee(l,{position:"bottom"}));const he=E.contextMenu(y,{cursor:"glyph",match:".ntx-row",inherit:!1,density:"compact",items:e=>ue(e.dataset.id)});u.add(()=>he?.destroy?.());const z=e=>{const t=e.target.closest(".ntx-row");if(!t)return;const n=e.target.closest(".ntx-kebab");if(n){te(n);return}e.target.closest("a")||L(t.dataset.id)},J=e=>{const t=e.target.closest(".ntx-row");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),te(t);else if(e.key==="Enter"&&e.target===t)L(t.dataset.id);else if(e.key==="Delete"&&e.target===t){const n=j(t.dataset.id);n&&Y(n)}}};y.addEventListener("click",z),y.addEventListener("keydown",J),u.add(()=>{y.removeEventListener("click",z),y.removeEventListener("keydown",J)});async function fe(){try{const{added:e}=await f("/system/notifications/samples",{method:"POST"});await T({markRead:!1}),b(),E.toast(`${e} sample notifications added.${d.peek().desktop?" They pop up on the desktop within a minute.":""}`,{type:"success"})}catch(e){E.toast(/403|forbidden|permission/i.test(String(e.message||e))?"Adding samples needs the Notifications delete permission.":`Could not add samples: ${e.message||e}`,{type:"error"})}}function be(){const e=E.slideover({title:"Notifications settings",size:"md",position:"right"}),t=Ge(`
35
35
  <div class="ntx-panel">
36
36
  <div class="ntx-setting-row">
37
37
  <label class="form-label" for="ntx-set-desktop" style="margin:0">Desktop notifications
@@ -78,4 +78,4 @@ import{api as g,apiRequest as m}from"../api.js";import{addBannerAction as U}from
78
78
  <span class="spacer"></span>
79
79
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
80
80
  </div>
81
- </div>`),n=t.querySelector("#ntx-set-desktop"),l=t.querySelector(".ntx-src-list");l.innerHTML=Fe;const w=M.observable([]),D=M.observable([]),O=M.observable(!1),ye=M.effect(()=>{D.value=w.value.map(s=>Oe(s))}),ve=M.effect(()=>{const s=D.value;setTimeout(()=>s.forEach(a=>{const o=l.querySelector(`.ntx-src[data-id="${CSS.escape(a.id)}"] .ntx-src-aud`);o&&(o.value=a.audience)}),0)}),P=s=>{w.value=w.peek().map(a=>a.id===s.id?s:a)},V=s=>`/system/notifications/sources/${encodeURIComponent(s)}`;m("/system/notifications/sources",{method:"GET"}).then(s=>{w.value=s.sources||[],O.value=!0}).catch(()=>{O.value=!1});async function X(s,a){const o=w.peek().find(v=>v.id===s);try{const{setting:v}=await m(V(s),{method:"PUT",body:JSON.stringify(a)});P({...o,...v})}catch(v){P({...o}),E.toast(`Not saved: ${v.message||v}`,{type:"error"})}}async function ge(s,a){a.disabled=!0;try{const o=await m(`${V(s)}/run`,{method:"POST"});o.source&&P(o.source),E.toast(o.ok?"Done - anything it found is in the list.":`It failed: ${o.error}`,{type:o.ok?"success":"error"}),await T({markRead:!1}),f()}catch(o){E.toast(`Could not run it: ${o.message||o}`,{type:"error"})}finally{a.disabled=!1}}const Q=s=>{const a=s.target.closest(".ntx-src");a&&(s.target.matches(".ntx-src-on")?X(a.dataset.id,{enabled:s.target.checked}):s.target.matches(".ntx-src-aud")&&X(a.dataset.id,{audience:s.target.value}))},Z=s=>{const a=s.target.closest(".ntx-src-run");a&&ge(a.closest(".ntx-src").dataset.id,a)};l.addEventListener("change",Q),l.addEventListener("click",Z);const u=M.observable(ae()),we=M.computed(()=>d.value.desktop&&u.value!=="granted"?`On, but not showing: ${C[u.value]}`:C[u.value]),ke=M.computed(()=>u.value==="denied"||u.value==="unsupported"?"is-bad":""),xe=M.computed(()=>d.value.desktop&&u.value==="granted"),q=()=>{n.checked=d.peek().desktop};function Se(){const s=ae();if(s==="unsupported"||s==="denied"){u.value=s,E.toast(C[s],{type:"warning"}),q();return}(s==="granted"?Promise.resolve("granted"):qe()).then(async o=>{u.value=o,o==="granted"?(Pe(await g.system.notifications.list().catch(()=>[])),R("desktop",!0),E.toast("Desktop notifications are on for this browser.",{type:"success"})):E.toast(o==="denied"?C.denied:"Not turned on - the browser was not given permission.",{type:"warning"}),q()})}const Ee=s=>M.computed({read:()=>d.value[s],write:a=>R(s,a)}),Te={...Object.fromEntries(Object.keys(se).map(s=>[s,Ee(s)])),desktop:M.computed({read:()=>d.value.desktop,write:s=>s?Se():R("desktop",!1)}),permText:we,permTone:ke,canTest:xe,sourceRows:D,sourcesShown:O};e.element.appendChild(t);const Me=ee(t),Ce=M.applyBindings(Te,t,{methods:{done:()=>e.close(),reset:()=>{d.value={...se},q()},samples:()=>{fe()},test:()=>{Ue()||E.toast("The browser would not show it - check its notification settings.",{type:"warning"})}}});Ae(e,()=>{Ce.dispose(),Me(),ye(),ve(),l.removeEventListener("change",Q),l.removeEventListener("click",Z)}),e.open()}U(r,{id:"notifications-settings",icon:"settings",title:"Notifications settings",onClick:be}),U(r,{id:"notifications-dismiss-all",icon:"check-circle",title:"Dismiss all",onClick:()=>de()}),U(r,{id:"notifications-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>{k.value=!0,T()}}),I.scan(c),await T(),A()&&I.scan(c)}};
81
+ </div>`),n=t.querySelector("#ntx-set-desktop"),p=t.querySelector(".ntx-src-list");p.innerHTML=je;const c=M.observable([]),D=M.observable([]),O=M.observable(!1),ye=M.effect(()=>{D.value=c.value.map(s=>qe(s))}),ve=M.effect(()=>{const s=D.value;setTimeout(()=>s.forEach(a=>{const o=p.querySelector(`.ntx-src[data-id="${CSS.escape(a.id)}"] .ntx-src-aud`);o&&(o.value=a.audience)}),0)}),P=s=>{c.value=c.peek().map(a=>a.id===s.id?s:a)},V=s=>`/system/notifications/sources/${encodeURIComponent(s)}`;f("/system/notifications/sources",{method:"GET"}).then(s=>{c.value=s.sources||[],O.value=!0}).catch(()=>{O.value=!1});async function X(s,a){const o=c.peek().find(g=>g.id===s);try{const{setting:g}=await f(V(s),{method:"PUT",body:JSON.stringify(a)});P({...o,...g})}catch(g){P({...o}),E.toast(`Not saved: ${g.message||g}`,{type:"error"})}}async function ge(s,a){a.disabled=!0;try{const o=await f(`${V(s)}/run`,{method:"POST"});o.source&&P(o.source),E.toast(o.ok?"Done - anything it found is in the list.":`It failed: ${o.error}`,{type:o.ok?"success":"error"}),await T({markRead:!1}),b()}catch(o){E.toast(`Could not run it: ${o.message||o}`,{type:"error"})}finally{a.disabled=!1}}const Q=s=>{const a=s.target.closest(".ntx-src");a&&(s.target.matches(".ntx-src-on")?X(a.dataset.id,{enabled:s.target.checked}):s.target.matches(".ntx-src-aud")&&X(a.dataset.id,{audience:s.target.value}))},Z=s=>{const a=s.target.closest(".ntx-src-run");a&&ge(a.closest(".ntx-src").dataset.id,a)};p.addEventListener("change",Q),p.addEventListener("click",Z);const m=M.observable(ae()),we=M.computed(()=>d.value.desktop&&m.value!=="granted"?`On, but not showing: ${C[m.value]}`:C[m.value]),ke=M.computed(()=>m.value==="denied"||m.value==="unsupported"?"is-bad":""),xe=M.computed(()=>d.value.desktop&&m.value==="granted"),q=()=>{n.checked=d.peek().desktop};function Se(){const s=ae();if(s==="unsupported"||s==="denied"){m.value=s,E.toast(C[s],{type:"warning"}),q();return}(s==="granted"?Promise.resolve("granted"):_e()).then(async o=>{m.value=o,o==="granted"?(Ue(await w.system.notifications.list().catch(()=>[])),N("desktop",!0),E.toast("Desktop notifications are on for this browser.",{type:"success"})):E.toast(o==="denied"?C.denied:"Not turned on - the browser was not given permission.",{type:"warning"}),q()})}const Ee=s=>M.computed({read:()=>d.value[s],write:a=>N(s,a)}),Te={...Object.fromEntries(Object.keys(se).map(s=>[s,Ee(s)])),desktop:M.computed({read:()=>d.value.desktop,write:s=>s?Se():N("desktop",!1)}),permText:we,permTone:ke,canTest:xe,sourceRows:D,sourcesShown:O};e.element.appendChild(t);const Me=ee(t),Ce=M.applyBindings(Te,t,{methods:{done:()=>e.close(),reset:()=>{d.value={...se},q()},samples:()=>{fe()},test:()=>{Be()||E.toast("The browser would not show it - check its notification settings.",{type:"warning"})}}});Ne(e,()=>{Ce.dispose(),Me(),ye(),ve(),p.removeEventListener("change",Q),p.removeEventListener("click",Z)}),e.open()}U(r,{id:"notifications-settings",icon:"settings",title:"Notifications settings",onClick:be}),U(r,{id:"notifications-dismiss-all",icon:"check-circle",title:"Dismiss all",onClick:()=>de()}),U(r,{id:"notifications-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>{k.value=!0,T()}}),I.scan(l),await T(),A()&&I.scan(l)}};
@@ -1,4 +1,4 @@
1
- import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Z,settingsStore as we,sidebarCount as ke,viewLifetime as xe}from"../lib/tool-kit.js";import{filterByProject as Me,getProjectFromHash as Pe}from"../lib/project-context.js";import{arrangePages as Ee,checkPrune as Se,normaliseSettings as ee,pageRow as Ce,statusCounts as $e,treeRows as Te}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,Le="#/pages",P="pages.prune.draft",De=`
1
+ import{api as K}from"../api.js";import{SHAPES as be,whileLoading as ye}from"../lib/loading.js";import{addBannerAction as we}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ke,openMenuAt as Z,settingsStore as xe,sidebarCount as Me,viewLifetime as Pe}from"../lib/tool-kit.js";import{filterByProject as Ee,getProjectFromHash as Se}from"../lib/project-context.js";import{arrangePages as Ce,checkPrune as $e,normaliseSettings as ee,pageRow as Te,statusCounts as Le,treeRows as Ae}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,De="#/pages",P="pages.prune.draft",je=`
2
2
  <div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
4
4
  <span class="pgx-chips">
@@ -9,7 +9,7 @@ import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-
9
9
  <span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
10
10
  </span>
11
11
  <button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
12
- </div>`,y=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ae(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}const ae=we("pages.settings",ee),s=xe(te),je=i=>`#/pages/edit${i}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(i){s.reset();const l=i.get(0).querySelector(".pgx")||i.get(0),C=()=>te.test(location.hash)&&l.isConnected,d=Pe(),$=ae.read(),o=M.observable([]),N=M.observable(0),q=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),H=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const D=M.observable([]);s.add(M.effect(()=>{D.value=Ee(o.value,{status:p.value,query:H.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=D.value.map(e=>Ce(e))}));const ne=M.computed(()=>$e(o.value)),se=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=N.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>o.peek().find(t=>t.urlPath===e);async function re(){try{const e=await K.pages.list();if(!C())return;const t=Array.isArray(e)?e:[],a=d?Me(t,d):t;N.value=t.length-a.length,o.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{q.value=!1}}const w=e=>{location.hash=je(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),ke(Le)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const A=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,m=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Se(m.value,a)),Y={keep:m,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(m.peek()),G=()=>String(m.peek())!==he||!!t,r=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Ae(`
12
+ </div>`,y=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Re(l){const o=document.createElement("template");return o.innerHTML=l.trim(),o.content.firstElementChild}const ae=xe("pages.settings",ee),s=Pe(te),Be=l=>`#/pages/edit${l}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(l){s.reset();const o=l.get(0).querySelector(".pgx")||l.get(0),C=()=>te.test(location.hash)&&o.isConnected,d=Se(),$=ae.read(),r=M.observable([]),q=M.observable(0),H=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),N=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const A=M.observable([]);s.add(M.effect(()=>{A.value=Ce(r.value,{status:p.value,query:N.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=A.value.map(e=>Te(e))}));const ne=M.computed(()=>Le(r.value)),se=M.computed(()=>r.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=q.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>r.peek().find(t=>t.urlPath===e);async function re(){try{const e=await ye(o.querySelector(".dm-loading-slot"),K.pages.list(),be.rows);if(!C())return;const t=Array.isArray(e)?e:[],a=d?Ee(t,d):t;q.value=t.length-a.length,r.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{H.value=!1}}const w=e=>{location.hash=Be(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),r.value=r.peek().filter(a=>a.urlPath!==e.urlPath),Me(De)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const D=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,v=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>$e(v.value,a)),Y={keep:v,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(v.peek()),G=()=>String(v.peek())!==he||!!t,i=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Re(`
13
13
  <div class="pgx-form">
14
14
  <p class="pgx-note" data-bind-text="intro"></p>
15
15
  <div>
@@ -25,4 +25,4 @@ import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-
25
25
  <button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
26
26
  <span data-icon="archive" data-icon-size="13"></span> Trim</button>
27
27
  </div>
28
- </div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,r.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}r.element.appendChild(h);const ge=X(h),ve=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>r.close()}}),me=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",me);const Q={path:e.urlPath,dirty:G,values:()=>({keep:m.peek()})};c=Q,A.add(r),ye(r,()=>{ve.dispose(),ge(),A.delete(r),c===Q&&(c=null)});const fe=r.close.bind(r);let R=!1,B=!1;r.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),r.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=l.querySelector(".pgx-list");u.innerHTML=De;const pe=M.applyBindings({pages:o,loading:q,loadError:T,status:p,sort:L,mode:g,query:H,rows:U,counts:ne,emptyText:se,projectNote:oe},l,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(l,{position:"bottom"}));const j=l.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Te(D.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),be(i,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...A].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await re(),!C())return;let v=null;try{v=S.get(P)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,v.values)}}};
28
+ </div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),r.value=r.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,i.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}i.element.appendChild(h);const ge=X(h),me=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>i.close()}}),ve=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",ve);const Q={path:e.urlPath,dirty:G,values:()=>({keep:v.peek()})};c=Q,D.add(i),ke(i,()=>{me.dispose(),ge(),D.delete(i),c===Q&&(c=null)});const fe=i.close.bind(i);let R=!1,B=!1;i.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),i.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=o.querySelector(".pgx-list");u.innerHTML=je;const pe=M.applyBindings({pages:r,loading:H,loadError:T,status:p,sort:L,mode:g,query:N,rows:U,counts:ne,emptyText:se,projectNote:oe},o,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(o,{position:"bottom"}));const j=o.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Ae(A.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),we(l,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...D].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(o),await re(),!C())return;let m=null;try{m=S.get(P)}catch{}if(m?.path&&m.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(m.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,m.values)}}};
@@ -1,4 +1,4 @@
1
- import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as Xe,openMenuAt as Ae,sidebarCount as Ze,viewLifetime as et}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as tt}from"../lib/server-restart.js";import{FILTERS as at,arrangeCatalogue as nt,arrangePlugins as st,catalogueAction as ee,catalogueRow as lt,checkScaffold as it,fmtReleased as ot,needsAttention as rt,planLines as ct,pluginRow as dt,pluginStatus as Ue,readSettings as ut,settingsFields as Re,slugifyPlugin as pt,updateFor as je}from"../lib/plugins-arrange.js";const Be=/^#\/plugins\/?(\?.*)?$/,mt="#/plugins",de="plugins.filter",te="plugins.draft",bt=`
1
+ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{SHAPES as Xe,whileLoading as Ze}from"../lib/loading.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as et,openMenuAt as Ae,sidebarCount as tt,viewLifetime as at}from"../lib/tool-kit.js";import{pluginAccent as Le}from"../lib/plugin-accent.js";import{reportRestart as nt}from"../lib/server-restart.js";import{FILTERS as st,arrangeCatalogue as lt,arrangePlugins as it,catalogueAction as ee,catalogueRow as ot,checkScaffold as rt,fmtReleased as ct,needsAttention as dt,planLines as ut,pluginRow as pt,pluginStatus as Ue,readSettings as mt,settingsFields as Re,slugifyPlugin as bt,updateFor as je}from"../lib/plugins-arrange.js";const Be=/^#\/plugins\/?(\?.*)?$/,vt="#/plugins",de="plugins.filter",te="plugins.draft",ft=`
2
2
  <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
3
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
4
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -10,7 +10,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
10
10
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
11
11
  </span>
12
12
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
13
- </div>`,vt=`
13
+ </div>`,ht=`
14
14
  <div class="plx-row" data-tool="{{name}}" tabindex="0" data-bind-class="rowCls">
15
15
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
16
16
  <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
@@ -19,7 +19,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
19
19
  <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
20
20
  </span>
21
21
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
- </div>`,ft=`
22
+ </div>`,gt=`
23
23
  <div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
24
24
  <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
25
25
  <span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
@@ -29,7 +29,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
29
29
  </span>
30
30
  <button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
31
31
  <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
32
- </div>`,r=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ue(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const g=et(Be);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){g.reset();const b=A.get(0).querySelector(".plx")||A.get(0),$=()=>Be.test(location.hash)&&b.isConnected,U=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),v=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),ae=M.observable(""),m=M.observable("");let be="all";try{be=at.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),W=M.observable(""),R=M.observable("all"),ve=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const _=M.computed(()=>new Map((v.value.items||[]).map(e=>[e.slug,e.version]))),H=e=>(v.peek().items||[]).find(t=>t.slug===e),ne=M.observable([]);g.add(M.effect(()=>{const e=_.value;ne.value=st(U.value,{filter:T.value,query:W.value,latest:e}).map(t=>({...dt(t,e),accent:Le(t.name)}))}));const De=M.computed(()=>{const e=U.value,t=_.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>rt(n,t)).length}}),Y=M.computed(()=>(v.value.items||[]).filter(e=>ee(e)==="update").length),Fe=M.computed(()=>U.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),se=M.observable([]);g.add(M.effect(()=>{se.value=nt(v.value.items,{filter:R.value,query:ve.value}).map(e=>{const t=lt(e),a=t.action==="update"||t.action==="install";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const qe=M.computed(()=>{const e=v.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>ee(t)==="install").length,updates:Y.value}}),J=e=>U.peek().find(t=>t.name===e);async function le(){try{const[e,t]=await Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]);if(!$())return;U.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],ae.value=""}catch(e){ae.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function fe(){try{const e=await k.marketplace.catalogue();if(!$())return;v.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{v.value={available:!1,site:null,items:[],failed:!0}}}const z=async()=>{await Promise.all([le(),fe()]),Ze(mt)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${r(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=o=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:o}):k.plugins.update(e.name,{enabled:t,cascade:o});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let o;try{o=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ct(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${r(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(i=>`<li>${r(i)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,o=await l(!0)}await tt(o,{onStatus:c=>{m.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(o){E.toast(`Not switched ${n}: ${o.message||o}`,{type:"error"})}finally{m.value="",$()&&await z()}}async function B(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),o=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${o} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await z()}}async function he(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await k.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}$()&&await z()}}async function ge(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ye=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},P=b.querySelector(".plx-file");async function ze(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await z()}}const xe=async()=>{const e=P.files?.[0];P.value="",e&&await ze(e)};P.addEventListener("change",xe),g.add(()=>P.removeEventListener("change",xe));const Q=new Set;let L=null,we=!1;function ke({title:e,size:t="md",panel:a,model:n,methods:l,dirty:o=()=>!1,form:c=null,cleanup:u=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const C=Oe(a),x=M.applyBindings(n,a,{methods:l});c&&(L=c),Q.add(i),Xe(i,()=>{u(),x.dispose(),C(),Q.delete(i),L===c&&(L=null)});const w=i.close.bind(i);let f=!1,N=!1;return i.close=async D=>{if(!f&&D!==!0&&o()){if(N)return;N=!0;const F=await E.confirm("Discard the changes you have not saved?");if(N=!1,!F)return}return f=!0,w()},I.scan(a),i.open(),i}function ie(e,t=null){const a=J(e);if(!a)return;const n=Ue(a),l=Re(a),o=H(a.name),c=je(a,_.peek()),u=!!(c&&o?.entitled&&!o?.needsCms),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),w=M.computed(()=>ut(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,D=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ot(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),F=(s,p)=>{const O=`plx-set-${p}`,X=s.help?` <span data-help="${r(s.help)}"></span>`:"",K=`<label class="form-label" for="${O}">${r(s.label)}${X}</label>`,V=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${K}<input id="${O}" type="checkbox" class="form-switch" ${V}></div>`;if(s.type==="select")return`<div>${K}<select id="${O}" class="form-input" ${V}>${s.options.map(Ne=>`<option value="${r(Ne.value)}">${r(Ne.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${K}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${V}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${K}<textarea id="${O}" class="form-input" rows="3" ${V}></textarea>${Z}</div>`;const Je=s.type==="number"?"number":s.secret?"password":"text",Qe=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${K}<input id="${O}" type="${Je}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Qe} ${V}>${Z}</div>`},d={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,x[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},h=ue(`
32
+ </div>`,r=A=>String(A??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ue(A){const b=document.createElement("template");return b.innerHTML=A.trim(),b.content.firstElementChild}const g=at(Be);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(A){g.reset();const b=A.get(0).querySelector(".plx")||A.get(0),$=()=>Be.test(location.hash)&&b.isConnected,U=M.observable([]),q=M.observable([]),pe=M.computed(()=>q.value.map(e=>{const t=[...(e.requiredBy||[]).map(a=>a.displayName),...(e.usedBy||[]).map(a=>a.displayName)];return{name:e.name,displayName:e.displayName,icon:e.icon,meta:[e.description,t.length?`used by ${t.join(", ")}`:"",e.locked?"locked by your manager":""].filter(Boolean).join(" \xB7 "),locked:e.locked===!0,statusText:e.enabled?"On":"Off",statusCls:e.enabled?"is-ok":"is-muted",rowCls:e.enabled?"":"is-off"}})),v=M.observable({available:null,site:null,items:[]}),me=M.observable(!0),ae=M.observable(""),m=M.observable("");let be="all";try{be=st.includes(S.get(de))?S.get(de):"all"}catch{}const T=M.observable(be),V=M.observable(""),R=M.observable("all"),ve=M.observable("");g.add(M.effect(()=>{const e=T.value;try{S.set(de,e)}catch{}}));const W=M.computed(()=>new Map((v.value.items||[]).map(e=>[e.slug,e.version]))),_=e=>(v.peek().items||[]).find(t=>t.slug===e),ne=M.observable([]);g.add(M.effect(()=>{const e=W.value;ne.value=it(U.value,{filter:T.value,query:V.value,latest:e}).map(t=>({...pt(t,e),accent:Le(t.name)}))}));const De=M.computed(()=>{const e=U.value,t=W.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>dt(n,t)).length}}),Y=M.computed(()=>(v.value.items||[]).filter(e=>ee(e)==="update").length),Fe=M.computed(()=>U.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),se=M.observable([]);g.add(M.effect(()=>{se.value=lt(v.value.items,{filter:R.value,query:ve.value}).map(e=>{const t=ot(e),a=t.action==="update"||t.action==="install";return{...t,accent:Le(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const qe=M.computed(()=>{const e=v.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>ee(t)==="install").length,updates:Y.value}}),J=e=>U.peek().find(t=>t.name===e);async function le(){try{const[e,t]=await Ze(b.querySelector(".dm-loading-slot"),Promise.all([k.plugins.list(),k.tools.list().catch(()=>[])]),Xe.iconRows);if(!$())return;U.value=Array.isArray(e)?e:[],q.value=Array.isArray(t)?t:[],ae.value=""}catch(e){ae.value=`Could not load the plugins: ${e.message||e}`}finally{me.value=!1}}async function fe(){try{const e=await k.marketplace.catalogue();if(!$())return;v.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{v.value={available:!1,site:null,items:[],failed:!0}}}const P=async()=>{await Promise.all([le(),fe()]),tt(vt)};async function j(e,t,a="plugin"){if(e.core)return;const n=t?"on":"off";if(e.locked){E.toast(`${e.displayName||e.name} is locked by your site's manager - ask them to switch it ${n}.`,{type:"info",duration:6e3});return}if(!await E.confirm(`Switch ${r(e.displayName||e.name)} ${n}? The server restarts to apply it, which takes a few seconds.`)||m.peek())return;const l=o=>a==="tool"?k.tools.update(e.name,{enabled:t,cascade:o}):k.plugins.update(e.name,{enabled:t,cascade:o});m.value=`Switching ${e.displayName||e.name} ${n}\u2026`;try{let o;try{o=await l(!1)}catch(c){if(c.status!==409||!c.body?.needsConfirm)throw c;const u=ut(c.body.plan);if(m.value="",!await E.confirm(`<p>Switching ${r(e.displayName||e.name)} ${n} also means:</p><ul>${u.map(i=>`<li>${r(i)}</li>`).join("")}</ul><p>Go ahead?</p>`))return;m.value=`Switching ${e.displayName||e.name} ${n}\u2026`,o=await l(!0)}await nt(o,{onStatus:c=>{m.value=c}}),E.toast(`${e.displayName||e.name} is ${n}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(o){E.toast(`Not switched ${n}: ${o.message||o}`,{type:"error"})}finally{m.value="",$()&&await P()}}async function B(e,{update:t=!1}={}){const a=r(e.displayName||e.slug),n=t?`<p>Update ${a} from ${r(e.installedVersion)} to ${r(e.version)}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`:`<p>Install ${a} v${r(e.version)}?</p> <p class="text-muted">It arrives licensed to this site and takes effect after a restart.</p>`;if(await E.confirm(n,{title:t?"Update plugin":"Install plugin",confirmText:t?"Update":"Install",cancelText:"Cancel",html:!0})){try{const l=await k.marketplace.install(e.slug,e.version,{update:t}),o=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${o} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}$()&&await P()}}async function he(e,t){if(await E.confirm(`<p>Uninstall ${r(t||e)}?</p> <p class="text-muted">Its files are removed, and its <code>data/</code> folder with them. Roles it added are removed and their users moved to <code>user</code>. Restart the server to finish.</p>`,{title:"Uninstall plugin",confirmText:"Uninstall",cancelText:"Cancel",html:!0})){try{await k.marketplace.uninstall(e),E.toast(`${t||e} uninstalled. Restart the server to apply.`,{type:"success",duration:6e3})}catch(a){E.toast(`Uninstall failed: ${a.message||a}`,{type:"error"})}$()&&await P()}}async function ge(e){try{await k.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const ye=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},z=b.querySelector(".plx-file");async function Pe(e){if(m.peek())return;m.value=`Installing ${e.name}\u2026`;const t={};try{for(let a=0;a<4;a++)try{const n=await k.plugins.installFile(e,t),l=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${l} Restart the server to activate it.`,{type:"success",duration:6e3});return}catch(n){if(m.value="",n.code==="CONFLICT"&&!t.confirmUpgrade){if(!await E.confirm(`<p>${r(n.name)} ${r(n.installedVersion||"the installed version")} is already installed. Update it to ${r(n.incomingVersion||"this file")}?</p> <p class="text-muted">The plugin keeps its data, and the current version is put back if the update fails.</p>`,{title:"Update plugin",confirmText:"Update",cancelText:"Cancel",html:!0}))return;t.confirmUpgrade=!0}else if(/refusing to downgrade/i.test(n.message)&&!t.confirmDowngrade){if(!await E.confirm(`<p>${r(n.message)} Install it anyway?</p> <p class="text-muted">An older version may not understand data written by the newer one.</p>`,{title:"Downgrade plugin",confirmText:"Downgrade",cancelText:"Cancel",html:!0}))return;t.confirmDowngrade=!0}else if(/unsigned|signature/i.test(n.message)&&!t.confirmUnsigned){if(!await E.confirm(`<p>${r(n.message)}</p> <p class="text-muted">An unsigned plugin has not been verified as coming from the marketplace. Its code runs on this server with full access. Install it anyway?</p>`,{title:"Unsigned plugin",confirmText:"Install anyway",cancelText:"Cancel",html:!0}))return;t.confirmUnsigned=!0}else{E.toast(n.message||"Install failed.",{type:"error"});return}m.value=`Installing ${e.name}\u2026`}}finally{m.value="",$()&&await P()}}const xe=async()=>{const e=z.files?.[0];z.value="",e&&await Pe(e)};z.addEventListener("change",xe),g.add(()=>z.removeEventListener("change",xe));const Q=new Set;let L=null,we=!1;function ke({title:e,size:t="md",panel:a,model:n,methods:l,dirty:o=()=>!1,form:c=null,cleanup:u=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const C=Oe(a),x=M.applyBindings(n,a,{methods:l});c&&(L=c),Q.add(i),et(i,()=>{u(),x.dispose(),C(),Q.delete(i),L===c&&(L=null)});const w=i.close.bind(i);let f=!1,N=!1;return i.close=async D=>{if(!f&&D!==!0&&o()){if(N)return;N=!0;const F=await E.confirm("Discard the changes you have not saved?");if(N=!1,!F)return}return f=!0,w()},I.scan(a),i.open(),i}function ie(e,t=null){const a=J(e);if(!a)return;const n=Ue(a),l=Re(a),o=_(a.name),c=je(a,W.peek()),u=!!(c&&o?.entitled&&!o?.needsCms),C={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(p=>p.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable(C[s.key])])),w=M.computed(()=>mt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),N=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,D=[["Version",`v${a.version}${c?` (v${c} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",ct(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),F=(s,p)=>{const O=`plx-set-${p}`,X=s.help?` <span data-help="${r(s.help)}"></span>`:"",K=`<label class="form-label" for="${O}">${r(s.label)}${X}</label>`,H=`data-model="v${p}.value"`,Z=`<small class="plx-err" data-bind-hidden="!err${p}.value" data-bind-text="err${p}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${K}<input id="${O}" type="checkbox" class="form-switch" ${H}></div>`;if(s.type==="select")return`<div>${K}<select id="${O}" class="form-input" ${H}>${s.options.map(Ne=>`<option value="${r(Ne.value)}">${r(Ne.label)}</option>`).join("")}</select>${Z}</div>`;if(s.type==="json")return`<div>${K}<textarea id="${O}" class="form-input plx-json" rows="5" spellcheck="false" ${H}></textarea>${Z}</div>`;if(s.type==="textarea")return`<div>${K}<textarea id="${O}" class="form-input" rows="3" ${H}></textarea>${Z}</div>`;const Je=s.type==="number"?"number":s.secret?"password":"text",Qe=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${K}<input id="${O}" type="${Je}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${Qe} ${H}>${Z}</div>`},d={saving:f,hasFields:l.length>0,cannot:M.computed(()=>f.value||!Object.keys(w.value.changed).length||Object.keys(w.value.errors).length>0),...Object.fromEntries(l.flatMap((s,p)=>[[`v${p}`,x[s.key]],[`err${p}`,M.computed(()=>w.value.errors[s.key]||"")]]))},h=ue(`
33
33
  <div class="plx-form">
34
34
  <p class="plx-desc"></p>
35
35
  <dl class="plx-facts"></dl>
@@ -50,7 +50,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
50
50
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
51
51
  <span data-icon="check" data-icon-size="13"></span> Save settings</button>
52
52
  </div>
53
- </div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Ye=h.querySelector(".plx-facts");for(const[s,p]of D){const O=document.createElement("dt");O.textContent=s;const X=document.createElement("dd");X.textContent=p,Ye.append(O,X)}async function Te(){if(d.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await le()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await G.close(),Q.has(G)||s()},G=ke({title:a.displayName||a.name,size:"md",panel:h,model:d,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=x[p.key].peek());return s}}:null,methods:{save:Te,cancel:()=>G.close(),toggle:ce(()=>j(a,!a.enabled)),update:ce(()=>B(o,{update:!0})),source:ce(()=>ye(a)),exportIt:()=>ge(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Te())})}function $e(e=null){const t=Ge(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()])),o=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const d=a.displayName.value;c||(a.slug.value=pt(d))}),i=M.observable(!1),C=M.computed(()=>it(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.value])),U.value.map(d=>d.name))),x=d=>M.computed(()=>C.value.errors[d]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys(C.value.errors).length>0)},f=ue(`
53
+ </div>`);h.querySelector(".plx-desc").textContent=a.description||"";const Ye=h.querySelector(".plx-facts");for(const[s,p]of D){const O=document.createElement("dt");O.textContent=s;const X=document.createElement("dd");X.textContent=p,Ye.append(O,X)}async function Te(){if(d.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await k.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),G.close(!0),$()&&await le()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{f.value=!1}}const ce=s=>async()=>{await G.close(),Q.has(G)||s()},G=ke({title:a.displayName||a.name,size:"md",panel:h,model:d,dirty:N,form:l.length?{kind:"settings",name:a.name,dirty:N,values:()=>{const s={};for(const p of l)p.secret||(s[p.key]=x[p.key].peek());return s}}:null,methods:{save:Te,cancel:()=>G.close(),toggle:ce(()=>j(a,!a.enabled)),update:ce(()=>B(o,{update:!0})),source:ce(()=>ye(a)),exportIt:()=>ge(a)}});h.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Te())})}function $e(e=null){const t=Ge(),a={displayName:M.observable(e?.displayName??""),slug:M.observable(e?.slug??""),description:M.observable(e?.description??""),author:M.observable(e?.author??(t?.name||t?.username||"")),icon:M.observable(e?.icon??"box")},n=JSON.stringify(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.peek()])),o=()=>JSON.stringify(l())!==n||!!e;let c=!!e?.slug;const u=M.effect(()=>{const d=a.displayName.value;c||(a.slug.value=bt(d))}),i=M.observable(!1),C=M.computed(()=>rt(Object.fromEntries(Object.entries(a).map(([d,h])=>[d,h.value])),U.value.map(d=>d.name))),x=d=>M.computed(()=>C.value.errors[d]||""),w={...a,saving:i,nameErr:x("displayName"),slugErr:x("slug"),descErr:x("description"),authorErr:x("author"),iconErr:x("icon"),cannot:M.computed(()=>i.value||Object.keys(C.value.errors).length>0)},f=ue(`
54
54
  <div class="plx-form">
55
55
  <div>
56
56
  <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
@@ -86,7 +86,7 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
86
86
  <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
87
87
  <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
88
88
  </div>
89
- </div>`);async function N(){if(w.cannot.peek())return;i.value=!0;const{payload:d}=C.peek();try{await k.plugins.scaffold(d),E.toast(`"${d.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),D.close(!0),$()&&await z()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const D=ke({title:"New plugin",size:"sm",panel:f,model:w,dirty:o,form:{kind:"new",name:null,dirty:o,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>D.close()}}),F=f.querySelector("#plx-np-slug");F.addEventListener("input",()=>{c=!!F.value}),f.addEventListener("keydown",d=>{(d.ctrlKey||d.metaKey)&&d.key==="Enter"&&(d.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function Pe(e){const t=J(e);if(!t)return[];const a=H(t.name),n=je(t,_.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Re(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>ie(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ye(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ge(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>he(t.name,t.displayName)}]]}function Ke(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const Ve=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function We(e){const t=H(e);if(!t)return[];const a=ee(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{W.value=t.slug,T.value="all",Ce.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>he(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function _e(e){const t=H(e);if(!t)return;const a=ee(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(W.value=t.slug,T.value="all",Ce.activate?.(0))}function oe(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const o=u=>{const i=u.target.closest(".plx-row");if(!i)return;const C=u.target.closest(".plx-kebab");if(C){Ae(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},c=u=>{const i=u.target.closest(".plx-row");i&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ae(i)):u.key==="Enter"&&u.target===i&&n(i.dataset[t]))};e.addEventListener("click",o),e.addEventListener("keydown",c),g.add(()=>{e.removeEventListener("click",o),e.removeEventListener("keydown",c)})}const re=b.querySelector(".plx-browse");re.appendChild(ue(`
89
+ </div>`);async function N(){if(w.cannot.peek())return;i.value=!0;const{payload:d}=C.peek();try{await k.plugins.scaffold(d),E.toast(`"${d.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),D.close(!0),$()&&await P()}catch(h){E.toast(h.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const D=ke({title:"New plugin",size:"sm",panel:f,model:w,dirty:o,form:{kind:"new",name:null,dirty:o,values:l},cleanup:()=>u(),methods:{create:N,cancel:()=>D.close()}}),F=f.querySelector("#plx-np-slug");F.addEventListener("input",()=>{c=!!F.value}),f.addEventListener("keydown",d=>{(d.ctrlKey||d.metaKey)&&d.key==="Enter"&&(d.preventDefault(),N())}),f.querySelector("#plx-np-name")?.focus()}function ze(e){const t=J(e);if(!t)return[];const a=_(t.name),n=je(t,W.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Re(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>ie(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>j(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>j(t,!0)}],...n?[{label:a?.needsCms?`Update to v${n} - needs a newer Domma CMS`:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled||!!a?.needsCms,action:()=>B(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>ye(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ge(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>he(t.name,t.displayName)}]]}function Ke(e){const t=q.peek().find(n=>n.name===e);if(!t)return[];const a=!!m.peek();return[{header:t.displayName},t.enabled?{label:t.locked?"Switch off (locked)":"Switch off\u2026",icon:"pause",disabled:a||t.locked,action:()=>j(t,!1,"tool")}:{label:t.locked?"Switch on (locked)":"Switch on\u2026",icon:"play",disabled:a||t.locked,action:()=>j(t,!0,"tool")}]}const He=e=>{const t=q.peek().find(a=>a.name===e);t&&j(t,!t.enabled,"tool")};function Ve(e){const t=_(e);if(!t)return[];const a=ee(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>B(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{V.value=t.slug,T.value="all",Ce.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>he(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[],...a==="needs-cms"?[{label:t.needsCms,icon:"alert-triangle",disabled:!0,action:()=>{}}]:[]]}function We(e){const t=_(e);if(!t)return;const a=ee(t);a==="install"?B(t):a==="update"?B(t,{update:!0}):a==="needs-cms"?E.toast(t.needsCms,{type:"warning",duration:6e3}):t.installed&&(V.value=t.slug,T.value="all",Ce.activate?.(0))}function oe(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:u=>a(u.dataset[t])});g.add(()=>l?.destroy?.());const o=u=>{const i=u.target.closest(".plx-row");if(!i)return;const C=u.target.closest(".plx-kebab");if(C){Ae(C);return}if(u.target.closest(".plx-act")){u.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},c=u=>{const i=u.target.closest(".plx-row");i&&(u.key==="F10"&&u.shiftKey?(u.preventDefault(),Ae(i)):u.key==="Enter"&&u.target===i&&n(i.dataset[t]))};e.addEventListener("click",o),e.addEventListener("keydown",c),g.add(()=>{e.removeEventListener("click",o),e.removeEventListener("keydown",c)})}const re=b.querySelector(".plx-browse");re.appendChild(ue(`
90
90
  <div class="plx-card">
91
91
  <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
92
92
  <p class="plx-note" data-bind-hidden="browseState.value !== 'error'">The catalogue could not be loaded - it needs an administrator, and a manager that is reachable.</p>
@@ -109,4 +109,4 @@ import{api as k,getUser as Ge}from"../api.js";import{addBannerAction as Ie}from"
109
109
  <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
110
110
  <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
111
111
  </div>
112
- </div>`)),re.querySelector(".plx-catalogue").innerHTML=ft;const Ee=b.querySelector(".plx-installed");Ee.innerHTML=bt;const Se=b.querySelector(".plx-tools");Se.innerHTML=vt;const Ce=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),He=M.applyBindings({plugins:U,tools:q,toolRows:pe,catalogue:v,loading:me,loadError:ae,restarting:m,filter:T,query:W,rows:ne,counts:De,emptyText:Fe,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:ve,browseRows:se,browseCounts:qe,siteText:M.computed(()=>v.value.site?`Licensed to ${v.value.site}.`:""),browseState:M.computed(()=>v.value.available===null?"loading":v.value.failed?"error":v.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>He.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ne.value,pe.value,se.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),oe(Ee,"name",Pe,e=>ie(e)),oe(Se,"tool",Ke,Ve),oe(re.querySelector(".plx-catalogue"),"slug",We,_e),Ie(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>P.click()}),Ie(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>$e()});const Me=e=>{(m.peek()||L?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Me),g.add(()=>window.removeEventListener("beforeunload",Me)),g.add(()=>{if(L?.dirty())try{S.set(te,{kind:L.kind,name:L.name,values:L.values(),at:new Date().toISOString()})}catch{}we=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([le(),fe()]),!$())return;let y=null;try{y=S.get(te)}catch{}if(y?.values&&!we){try{S.remove?S.remove(te):S.set(te,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?$e(y.values):ie(y.name,y.values))}}};
112
+ </div>`)),re.querySelector(".plx-catalogue").innerHTML=gt;const Ee=b.querySelector(".plx-installed");Ee.innerHTML=ft;const Se=b.querySelector(".plx-tools");Se.innerHTML=ht;const Ce=E.tabs(b.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),_e=M.applyBindings({plugins:U,tools:q,toolRows:pe,catalogue:v,loading:me,loadError:ae,restarting:m,filter:T,query:V,rows:ne,counts:De,emptyText:Fe,updateCount:Y,updateCountText:M.computed(()=>`${Y.value} update${Y.value===1?"":"s"}`),browseFilter:R,browseQuery:ve,browseRows:se,browseCounts:qe,siteText:M.computed(()=>v.value.site?`Licensed to ${v.value.site}.`:""),browseState:M.computed(()=>v.value.available===null?"loading":v.value.failed?"error":v.value.available?"list":"file")},b,{methods:{showAll:()=>{T.value="all"},showOn:()=>{T.value="on"},showOff:()=>{T.value="off"},showAttention:()=>{T.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});g.add(()=>_e.dispose()),g.add(Oe(b,{position:"bottom"})),g.add(M.effect(()=>{ne.value,pe.value,se.value,requestAnimationFrame(()=>{b.isConnected&&I.scan(b)})})),oe(Ee,"name",ze,e=>ie(e)),oe(Se,"tool",Ke,He),oe(re.querySelector(".plx-catalogue"),"slug",Ve,We),Ie(A,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>z.click()}),Ie(A,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>$e()});const Me=e=>{(m.peek()||L?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Me),g.add(()=>window.removeEventListener("beforeunload",Me)),g.add(()=>{if(L?.dirty())try{S.set(te,{kind:L.kind,name:L.name,values:L.values(),at:new Date().toISOString()})}catch{}we=!0,[...Q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(b),await Promise.all([le(),fe()]),!$())return;let y=null;try{y=S.get(te)}catch{}if(y?.values&&!we){try{S.remove?S.remove(te):S.set(te,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(J(y.name)?.displayName||y.name)}`;(y.kind==="new"||J(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&(y.kind==="new"?$e(y.values):ie(y.name,y.values))}}};
@@ -1,4 +1,4 @@
1
- import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAction as oe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{openMenuAt as F,settingsStore as ie,sidebarCount as v,viewLifetime as re}from"../lib/tool-kit.js";import{resetPassword as le,withdrawReset as ce}from"../lib/password-reset-actions.js";import{filterByProject as de}from"../lib/project-context.js";import{FILTERS as ue,SORTS as pe,arrangeUsers as me,canManage as W,resetAllowed as he,roleIndex as fe,userRow as ve}from"../lib/users-arrange.js";import{fmtDateTime as be}from"../lib/dates.js";const K=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,b="#/users",ye=`
1
+ import{api as B,apiRequest as O,getUser as oe}from"../api.js";import{addBannerAction as ae}from"../lib/plugin-chrome.js";import{SHAPES as ne,whileLoading as ie}from"../lib/loading.js";import{helpPopovers as re}from"../lib/help-popover.js";import{openMenuAt as F,settingsStore as le,sidebarCount as v,viewLifetime as ce}from"../lib/tool-kit.js";import{resetPassword as de,withdrawReset as ue}from"../lib/password-reset-actions.js";import{filterByProject as pe}from"../lib/project-context.js";import{FILTERS as me,SORTS as he,arrangeUsers as fe,canManage as W,resetAllowed as ve,roleIndex as be,userRow as ye}from"../lib/users-arrange.js";import{fmtDateTime as we}from"../lib/dates.js";const H=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,b="#/users",ge=`
2
2
  <div class="usx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="usx-avatar" aria-hidden="true">{{initials}}</span>
4
4
  <span class="usx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
@@ -11,4 +11,4 @@ import{api as B,apiRequest as O,getUser as ae}from"../api.js";import{addBannerAc
11
11
  <span class="usx-chip is-outline" data-bind-hidden="!resetOpen" title="A password reset link was sent and has not been used yet">Reset link open</span>
12
12
  </span>
13
13
  <button type="button" class="usx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
14
- </div>`,Y=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),_=ie("users.list",a=>({filter:ue.includes(a?.filter)?a.filter:"all",sort:pe.includes(a?.sort)?a.sort:"name"})),i=re(K);function we(){const a=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!a)return null;try{return decodeURIComponent(a[1])}catch{return a[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(a){i.reset();const r=a.get(0).querySelector(".usx")||a.get(0),m=()=>K.test(location.hash)&&r.isConnected,f=ae(),l=we(),x=_.read(),c=M.observable([]),d=M.observable({}),R=M.observable(!0),y=M.observable(""),u=M.observable(x.filter),w=M.observable(x.sort),g=M.observable(""),$=M.observable(""),k=M.observable(0);i.add(M.effect(()=>{_.write({filter:u.value,sort:w.value})}));const A=M.observable([]);i.add(M.effect(()=>{const e=d.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():be(s)}catch{return s}};A.value=me(c.value,{filter:u.value,role:g.value,query:$.value,sort:w.value}).map(s=>ve(s,{roles:e,me:f,when:t}))}));const G=M.computed(()=>{const e=c.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),H=M.computed(()=>c.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),J=M.computed(()=>l?`Showing only users in the project "${l}"${k.value?` - ${k.value} more elsewhere`:""}.`:""),C=e=>c.peek().find(t=>t.id===e);async function h(){try{const[e,t]=await Promise.all([B.users.list(),O("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!m())return;let s=Array.isArray(e)?e:[];if(l){const o=de(s,l);k.value=s.length-o.length,s=o}d.value=fe(t?.entries||[]),V(),c.value=s,y.value=""}catch(e){y.value=`Could not load the users: ${e.message||e}`}finally{R.value=!1}}function V(){const e=r.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=g.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(d.peek()).sort((s,o)=>s[1].level-o[1].level).forEach(([s,o])=>{const n=document.createElement("option");n.value=s,n.textContent=o.label,e.appendChild(n)}),e.value=t}const S=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function T(e,t){if(!(!t&&!await E.confirm(`Make ${Y(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await O(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),v(b),m()&&await h()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function z(e){if(await E.confirm(`Delete ${Y(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await B.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),v(b),m()&&await h()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function j(e,t){await le(e,t)&&(v(b),m()&&await h())}async function Q(e){await ce(e)&&(v(b),m()&&await h())}async function X(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function Z(e){const t=C(e);if(!t)return[];const s=W(f,t,d.peek()),o=s.self?"not on your own account":"your role is not senior to theirs",n=he(f,t,d.peek()),se=!!t.resetLinkExpires&&Date.parse(t.resetLinkExpires)>Date.now(),P=q=>n.ok?q:`${q.replace(/…$/,"")} - ${n.why}`;return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${o}`,icon:"edit",disabled:!s.edit,action:()=>S(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>X(t)},{type:"divider"},{label:P("Send password reset email\u2026"),icon:"mail",disabled:!n.ok,action:()=>j(t,"email")},{label:P("Copy a reset link"),icon:"link",disabled:!n.ok,action:()=>j(t,"link")},...se?[{label:s.other?"Withdraw the reset link":`Withdraw the reset link - ${o}`,icon:"x-circle",disabled:!s.other,action:()=>Q(t)}]:[],{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${o}`,icon:"play",disabled:!s.other,action:()=>T(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${o}`,icon:"pause",disabled:!s.other,action:()=>T(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${o}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>z(t)}]}const p=r.querySelector(".usx-list");p.innerHTML=ye;const ee=M.applyBindings({users:c,loading:R,loadError:y,filter:u,sort:w,role:g,query:$,rows:A,counts:G,emptyText:H,projectNote:J},r,{methods:{showAll:()=>{u.value="all"},showActive:()=>{u.value="active"},showInactive:()=>{u.value="inactive"}}});i.add(()=>ee.dispose()),i.add(ne(r,{position:"bottom"}));const te=E.contextMenu(p,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>Z(e.dataset.id)});i.add(()=>te?.destroy?.());const L=e=>{const t=C(e);t&&(W(f,t,d.peek()).edit?S(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},N=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){F(s);return}L(t.dataset.id)},U=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),F(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};p.addEventListener("click",N),p.addEventListener("keydown",U),i.add(()=>{p.removeEventListener("click",N),p.removeEventListener("keydown",U)}),oe(a,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=l?`#/users/new?project=${encodeURIComponent(l)}`:"#/users/new"}}),I.scan(r),await h()}};
14
+ </div>`,K=o=>String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Y=le("users.list",o=>({filter:me.includes(o?.filter)?o.filter:"all",sort:he.includes(o?.sort)?o.sort:"name"})),r=ce(H);function ke(){const o=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!o)return null;try{return decodeURIComponent(o[1])}catch{return o[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(o){r.reset();const i=o.get(0).querySelector(".usx")||o.get(0),m=()=>H.test(location.hash)&&i.isConnected,f=oe(),l=ke(),x=Y.read(),c=M.observable([]),d=M.observable({}),R=M.observable(!0),y=M.observable(""),u=M.observable(x.filter),w=M.observable(x.sort),g=M.observable(""),$=M.observable(""),k=M.observable(0);r.add(M.effect(()=>{Y.write({filter:u.value,sort:w.value})}));const A=M.observable([]);r.add(M.effect(()=>{const e=d.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():we(s)}catch{return s}};A.value=fe(c.value,{filter:u.value,role:g.value,query:$.value,sort:w.value}).map(s=>ye(s,{roles:e,me:f,when:t}))}));const _=M.computed(()=>{const e=c.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),G=M.computed(()=>c.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),J=M.computed(()=>l?`Showing only users in the project "${l}"${k.value?` - ${k.value} more elsewhere`:""}.`:""),S=e=>c.peek().find(t=>t.id===e);async function h(){try{const[e,t]=await ie(i.querySelector(".dm-loading-slot"),Promise.all([B.users.list(),O("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]),ne.iconRows);if(!m())return;let s=Array.isArray(e)?e:[];if(l){const a=pe(s,l);k.value=s.length-a.length,s=a}d.value=be(t?.entries||[]),V(),c.value=s,y.value=""}catch(e){y.value=`Could not load the users: ${e.message||e}`}finally{R.value=!1}}function V(){const e=i.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=g.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(d.peek()).sort((s,a)=>s[1].level-a[1].level).forEach(([s,a])=>{const n=document.createElement("option");n.value=s,n.textContent=a.label,e.appendChild(n)}),e.value=t}const C=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function T(e,t){if(!(!t&&!await E.confirm(`Make ${K(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await O(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),v(b),m()&&await h()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function z(e){if(await E.confirm(`Delete ${K(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await B.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),v(b),m()&&await h()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function L(e,t){await de(e,t)&&(v(b),m()&&await h())}async function Q(e){await ue(e)&&(v(b),m()&&await h())}async function X(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function Z(e){const t=S(e);if(!t)return[];const s=W(f,t,d.peek()),a=s.self?"not on your own account":"your role is not senior to theirs",n=ve(f,t,d.peek()),se=!!t.resetLinkExpires&&Date.parse(t.resetLinkExpires)>Date.now(),P=q=>n.ok?q:`${q.replace(/…$/,"")} - ${n.why}`;return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${a}`,icon:"edit",disabled:!s.edit,action:()=>C(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>X(t)},{type:"divider"},{label:P("Send password reset email\u2026"),icon:"mail",disabled:!n.ok,action:()=>L(t,"email")},{label:P("Copy a reset link"),icon:"link",disabled:!n.ok,action:()=>L(t,"link")},...se?[{label:s.other?"Withdraw the reset link":`Withdraw the reset link - ${a}`,icon:"x-circle",disabled:!s.other,action:()=>Q(t)}]:[],{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${a}`,icon:"play",disabled:!s.other,action:()=>T(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${a}`,icon:"pause",disabled:!s.other,action:()=>T(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${a}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>z(t)}]}const p=i.querySelector(".usx-list");p.innerHTML=ge;const ee=M.applyBindings({users:c,loading:R,loadError:y,filter:u,sort:w,role:g,query:$,rows:A,counts:_,emptyText:G,projectNote:J},i,{methods:{showAll:()=>{u.value="all"},showActive:()=>{u.value="active"},showInactive:()=>{u.value="inactive"}}});r.add(()=>ee.dispose()),r.add(re(i,{position:"bottom"}));const te=E.contextMenu(p,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>Z(e.dataset.id)});r.add(()=>te?.destroy?.());const j=e=>{const t=S(e);t&&(W(f,t,d.peek()).edit?C(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},N=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){F(s);return}j(t.dataset.id)},U=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),F(t)):e.key==="Enter"&&e.target===t&&j(t.dataset.id))};p.addEventListener("click",N),p.addEventListener("keydown",U),r.add(()=>{p.removeEventListener("click",N),p.removeEventListener("keydown",U)}),ae(o,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=l?`#/users/new?project=${encodeURIComponent(l)}`:"#/users/new"}}),I.scan(i),await h()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.100.2",
3
+ "version": "0.101.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",