domma-cms 0.75.0 → 0.77.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.
Files changed (150) hide show
  1. package/CLAUDE.md +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/action-editor-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
  6. package/admin/js/lib/api-tokens-arrange.js +3 -0
  7. package/admin/js/lib/block-editor-arrange.js +2 -0
  8. package/admin/js/lib/collection-editor-arrange.js +3 -0
  9. package/admin/js/lib/collection-entries-arrange.js +1 -0
  10. package/admin/js/lib/component-editor-arrange.js +27 -0
  11. package/admin/js/lib/context-menu-editor-arrange.js +1 -0
  12. package/admin/js/lib/dashboard-arrange.js +1 -0
  13. package/admin/js/lib/effects-arrange.js +3 -0
  14. package/admin/js/lib/form-editor-arrange.js +1 -0
  15. package/admin/js/lib/form-submissions-arrange.js +2 -0
  16. package/admin/js/lib/layouts-arrange.js +1 -0
  17. package/admin/js/lib/menu-editor-arrange.js +2 -0
  18. package/admin/js/lib/my-profile-arrange.js +1 -0
  19. package/admin/js/lib/notifications-arrange.js +1 -0
  20. package/admin/js/lib/page-editor-arrange.js +1 -0
  21. package/admin/js/lib/plugin-code-arrange.js +1 -0
  22. package/admin/js/lib/plugins-arrange.js +2 -0
  23. package/admin/js/lib/project-page-arrange.js +1 -0
  24. package/admin/js/lib/role-editor-arrange.js +3 -0
  25. package/admin/js/lib/roles-arrange.js +1 -0
  26. package/admin/js/lib/search-settings-arrange.js +1 -0
  27. package/admin/js/lib/sidebar-renderer.js +2 -2
  28. package/admin/js/lib/site-settings-arrange.js +1 -0
  29. package/admin/js/lib/user-editor-arrange.js +1 -0
  30. package/admin/js/lib/users-arrange.js +1 -0
  31. package/admin/js/lib/view-editor-arrange.js +14 -0
  32. package/admin/js/templates/action-editor.html +223 -147
  33. package/admin/js/templates/api-endpoint-editor.html +172 -117
  34. package/admin/js/templates/api-tokens.html +83 -10
  35. package/admin/js/templates/block-editor.html +194 -203
  36. package/admin/js/templates/collection-editor.html +191 -113
  37. package/admin/js/templates/collection-entries.html +99 -36
  38. package/admin/js/templates/component-editor.html +128 -126
  39. package/admin/js/templates/context-menu-editor.html +208 -148
  40. package/admin/js/templates/dashboard.html +184 -22
  41. package/admin/js/templates/effects.html +155 -72
  42. package/admin/js/templates/form-editor.html +41 -26
  43. package/admin/js/templates/form-submissions.html +78 -26
  44. package/admin/js/templates/layouts.html +126 -43
  45. package/admin/js/templates/menu-editor.html +57 -17
  46. package/admin/js/templates/menu-locations.html +54 -11
  47. package/admin/js/templates/my-profile.html +100 -12
  48. package/admin/js/templates/notifications.html +70 -10
  49. package/admin/js/templates/page-editor.html +47 -26
  50. package/admin/js/templates/plugin-code.html +91 -73
  51. package/admin/js/templates/plugins.html +100 -28
  52. package/admin/js/templates/project-detail.html +83 -145
  53. package/admin/js/templates/project-settings.html +88 -53
  54. package/admin/js/templates/role-editor.html +158 -83
  55. package/admin/js/templates/roles.html +78 -7
  56. package/admin/js/templates/search.html +193 -216
  57. package/admin/js/templates/settings.html +404 -472
  58. package/admin/js/templates/user-editor.html +150 -14
  59. package/admin/js/templates/users.html +69 -9
  60. package/admin/js/templates/view-editor.html +203 -180
  61. package/admin/js/templates/view-preview.html +45 -45
  62. package/admin/js/views/action-editor.js +7 -1
  63. package/admin/js/views/api-endpoint-editor.js +2 -3
  64. package/admin/js/views/api-tokens.js +62 -8
  65. package/admin/js/views/block-editor.js +14 -8
  66. package/admin/js/views/collection-editor.js +64 -5
  67. package/admin/js/views/collection-entries.js +50 -1
  68. package/admin/js/views/component-editor.js +2 -28
  69. package/admin/js/views/context-menu-editor.js +54 -55
  70. package/admin/js/views/dashboard.js +7 -1
  71. package/admin/js/views/effects.js +31 -1
  72. package/admin/js/views/form-editor.js +7 -7
  73. package/admin/js/views/form-submissions.js +11 -1
  74. package/admin/js/views/index.js +1 -1
  75. package/admin/js/views/layouts.js +81 -1
  76. package/admin/js/views/login.js +2 -2
  77. package/admin/js/views/menu-editor.js +21 -43
  78. package/admin/js/views/menu-locations.js +16 -1
  79. package/admin/js/views/my-profile.js +1 -1
  80. package/admin/js/views/notifications.js +18 -1
  81. package/admin/js/views/page-editor.js +26 -26
  82. package/admin/js/views/plugin-code.js +23 -4
  83. package/admin/js/views/plugins.js +97 -39
  84. package/admin/js/views/project-detail.js +9 -1
  85. package/admin/js/views/project-settings.js +1 -1
  86. package/admin/js/views/role-editor.js +11 -3
  87. package/admin/js/views/roles.js +49 -4
  88. package/admin/js/views/search.js +7 -1
  89. package/admin/js/views/settings.js +13 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +13 -4
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/admin/js/views/view-preview.js +1 -1
  94. package/package.json +1 -1
  95. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  96. package/plugins/_lib/admin/mail/contacts.js +4 -1
  97. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  98. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  99. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  100. package/plugins/_lib/admin/mail/mail.css +1 -1
  101. package/plugins/_lib/admin/mail/mail.html +1 -1
  102. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  103. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  104. package/plugins/_lib/mail/readRoutes.js +43 -1
  105. package/plugins/_lib/mail/unreadBadge.js +42 -0
  106. package/plugins/free-tier.lock.json +5 -5
  107. package/plugins/mail-reader/CLAUDE.md +1 -0
  108. package/plugins/mail-reader/plugin.js +3 -1
  109. package/plugins/mail-reader/plugin.json +5 -4
  110. package/server/routes/api/api-tokens.js +56 -0
  111. package/server/routes/api/auth.js +7 -1
  112. package/server/routes/api/collections.js +13 -1
  113. package/server/routes/api/dashboard.js +32 -4
  114. package/server/routes/api/effects.js +68 -13
  115. package/server/routes/api/forms.js +104 -13
  116. package/server/routes/api/layouts.js +93 -2
  117. package/server/routes/api/notifications.js +140 -14
  118. package/server/routes/api/plugins.js +135 -4
  119. package/server/routes/api/roles.js +111 -0
  120. package/server/routes/api/search.js +38 -4
  121. package/server/routes/api/settings.js +52 -0
  122. package/server/routes/api/users.js +65 -3
  123. package/server/routes/api/views.js +55 -1
  124. package/server/server.js +12 -0
  125. package/server/services/components.js +9 -0
  126. package/server/services/plugins.js +7 -0
  127. package/server/services/roles.js +3 -0
  128. package/server/services/search.js +58 -0
  129. package/server/services/users.js +16 -2
  130. package/server/services/views.js +14 -0
  131. package/admin/js/templates/dashboard/activity-feed.html +0 -3
  132. package/admin/js/templates/dashboard/cache.html +0 -32
  133. package/admin/js/templates/dashboard/health-detail.html +0 -2
  134. package/admin/js/templates/dashboard/journeys.html +0 -17
  135. package/admin/js/templates/dashboard/kpi-strip.html +0 -34
  136. package/admin/js/templates/dashboard/spike-feed.html +0 -3
  137. package/admin/js/templates/dashboard/top-pages.html +0 -3
  138. package/admin/js/templates/dashboard/traffic-chart.html +0 -3
  139. package/admin/js/templates/plugin-marketplace.html +0 -29
  140. package/admin/js/views/block-editor-enhance.js +0 -1
  141. package/admin/js/views/dashboard/lib/escape.js +0 -1
  142. package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
  143. package/admin/js/views/dashboard/widgets/cache.js +0 -1
  144. package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
  145. package/admin/js/views/dashboard/widgets/journeys.js +0 -1
  146. package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
  147. package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
  148. package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
  149. package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
  150. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -1,4 +1,23 @@
1
- import{api as d}from"../api.js";import{reportRestart as V}from"../lib/server-restart.js";import{createSimpleEditor as A}from"../lib/simple-editor.js";import{languageForFile as q}from"../lib/syntax-highlight.js";export const pluginCodeView={templateUrl:"/admin/js/templates/plugin-code.html",async onMount(i){const b=window.location.hash.match(/\/plugins\/code\/([a-z0-9-]+)/),l=b?b[1]:null;if(!l){E.toast("No plugin specified.",{type:"error"}),R.navigate("/plugins");return}i.find("#pcode-title").text(`${l} - Code`),document.title=`${l} Code - Domma CMS`;const C=i.find("#pcode-save").get(0),k=i.find("#pcode-saveall").get(0),S=i.find("#pcode-delete").get(0),x=i.find("#pcode-rename").get(0),K=i.find("#pcode-status").get(0),F=i.find("#pcode-cursor").get(0),L=i.find("#pcode-current").get(0),$=i.find("#pcode-filelist"),P=i.find("#pcode-editor").get(0);let m=[],n=null,c=null;const r=new Map,h=e=>r.has(e)&&r.get(e).content!==r.get(e).saved,p=()=>[...r.keys()].filter(h);function v(e){K.textContent=e}function f(){C.disabled=!n||!h(n),k.disabled=p().length===0,S.disabled=!n,x.disabled=!n}function g(){$.empty();const e=$.get(0),a=new Map;m.forEach(t=>{const s=t.path.includes("/")?t.path.slice(0,t.path.lastIndexOf("/")):"";a.has(s)||a.set(s,[]),a.get(s).push(t)}),[...a.keys()].sort((t,s)=>t.localeCompare(s)).forEach(t=>{if(t!==""){const s=document.createElement("div");s.className="pcode-dir",s.textContent=t+"/",e.appendChild(s)}a.get(t).forEach(s=>{const o=document.createElement("button");if(o.className="pcode-file"+(s.path===n?" active":""),o.style.paddingLeft=t===""?".5rem":"1rem",o.textContent=s.path.slice(t===""?0:t.length+1),o.title=s.path,h(s.path)){const w=document.createElement("span");w.className="pcode-dirty",w.textContent="\u25CF",o.appendChild(w)}o.addEventListener("click",()=>y(s.path)),e.appendChild(o)})})}async function u(){try{m=(await d.plugins.files(l)).files||[],g()}catch(e){E.toast(`Failed to list files: ${e.message}`,{type:"error"})}}function O(e,a){c&&c.destroy(),c=A(P,{initialValue:a,language:q(e),autoIndent:!0,onChange:t=>{r.get(e).content=t,f(),g()},onCursor:({line:t,col:s})=>{F.textContent=`Ln ${t}, Col ${s}`}}),c.textarea.addEventListener("keydown",t=>{(t.ctrlKey||t.metaKey)&&t.key==="s"&&(t.preventDefault(),t.shiftKey?N():j())}),c.focus()}async function y(e){if(e!==n){if(!r.has(e))try{const a=await d.plugins.readFile(l,e);r.set(e,{content:a.content,saved:a.content})}catch(a){E.toast(`Failed to open ${e}: ${a.message}`,{type:"error"});return}n=e,L.textContent=e+(q(e)?"":" (plain text)"),O(e,r.get(e).content),F.textContent="Ln 1, Col 1",f(),g()}}async function D(e){const a=r.get(e),t=await d.plugins.writeFile(l,e,a.content);return a.saved=a.content,t.restartRequired}async function j(){if(!(!n||!h(n))){try{const e=await D(n);v(e?`Saved ${n} - server file: restart to apply.`:`Saved ${n} - hard-refresh the admin tab to see it.`),E.toast(e?"Saved. Restart the server to apply.":"Saved.",{type:e?"warning":"success"})}catch(e){E.toast(`Save failed: ${e.message}`,{type:"error"})}f(),g(),u()}}async function N(){const e=p();if(!e.length)return;let a=!1;const t=[];for(const s of e)try{a=await D(s)||a}catch(o){t.push(`${s}: ${o.message}`)}t.length?E.toast(`Some saves failed - ${t.join("; ")}`,{type:"error"}):(v(a?`Saved ${e.length} file(s) - server files included: restart to apply.`:`Saved ${e.length} file(s) - hard-refresh the admin tab to see them.`),E.toast(`Saved ${e.length} file(s).`,{type:a?"warning":"success"})),f(),g(),u()}C.addEventListener("click",j),k.addEventListener("click",N);async function B(e,a){const t=o=>String(o||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;");return await E.confirm(`<form id="pcode-path-form"><div class="form-group">
2
- <label class="form-label">File path (inside the plugin)</label>
3
- <input class="form-control" type="text" name="path" value="${t(a)}" placeholder="admin/views/cart.js">
4
- </div></form>`,{title:e,confirmText:"OK",html:!0})&&document.querySelector('#pcode-path-form input[name="path"]')?.value.trim()||null}i.find("#pcode-newfile").on("click",async()=>{const e=await B("New File");if(e)try{await d.plugins.writeFile(l,e,""),r.set(e,{content:"",saved:""}),await u(),await y(e)}catch(a){E.toast(`Could not create file: ${a.message}`,{type:"error"})}}),x.addEventListener("click",async()=>{if(!n)return;if(["plugin.json","plugin.js","config.js"].includes(n)){E.toast(`${n} is required and cannot be renamed.`,{type:"error"});return}const e=await B("Rename File",n);if(!(!e||e===n))try{await d.plugins.writeFile(l,e,r.get(n)?.content??""),await d.plugins.deleteFile(l,n),r.set(e,{content:r.get(n).content,saved:r.get(n).content}),r.delete(n),n=null,await u(),await y(e),E.toast("Renamed.",{type:"success"})}catch(a){E.toast(`Rename failed: ${a.message}`,{type:"error"}),u()}}),S.addEventListener("click",async()=>{if(n&&await E.confirm(`Delete ${n}? This cannot be undone.`))try{await d.plugins.deleteFile(l,n),r.delete(n),E.toast(`Deleted ${n}.`,{type:"success"}),n=null,L.textContent="Select a file",c&&(c.destroy(),c=null),f(),await u()}catch(e){E.toast(`Delete failed: ${e.message}`,{type:"error"})}}),i.find("#pcode-back").on("click",async()=>{p().length&&!await E.confirm(`Discard unsaved changes in ${p().length} file(s)?`)||R.navigate("/plugins")});const M=e=>{p().length&&(e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",M),window.addEventListener("hashchange",function e(){window.removeEventListener("beforeunload",M),window.removeEventListener("hashchange",e)}),i.find("#pcode-restart").on("click",async()=>{const e=p().length;if(await E.confirm((e?`${e} file(s) have unsaved changes - they will NOT be included. `:"")+"Restart the server now? Server-side plugin changes apply on boot. The admin will be unreachable for a few seconds.",{title:"Restart Server",confirmText:"Restart"}))try{const t=await d.plugins.restart();t.supervised||E.toast("No supervisor detected - if the server does not come back, start it manually.",{type:"warning"}),await V(t,{onStatus:v})}catch(t){E.toast(`Restart failed: ${t.message}`,{type:"error"})}}),await u();const T=m.find(e=>e.path==="plugin.js")||m[0];T&&await y(T.path),I.scan()}};
1
+ import{api as u}from"../api.js";import{reportRestart as ze}from"../lib/server-restart.js";import{createSimpleEditor as He}from"../lib/simple-editor.js";import{languageForFile as Ee}from"../lib/syntax-highlight.js";import{addBannerAction as K}from"../lib/plugin-chrome.js";import{helpPopovers as Se}from"../lib/help-popover.js";import{onceClosed as Ue,openMenuAt as Z}from"../lib/tool-kit.js";import{REQUIRED_FILES as ee,checkPath as Ve,draftOf as Ye,fileRows as We,fileScope as te,restorePlan as _e}from"../lib/plugin-code-arrange.js";const Qe=/^\/plugins\/code\/[a-z0-9-]+\/?(\?.*)?$/,L=r=>`plugin-code.draft.${r}`,Ge=`
2
+ <div class="pcx-item" data-key="{{key}}">
3
+ <div class="pcx-dir" data-bind-hidden="!isDir">{{label}}</div>
4
+ <div class="pcx-file" data-bind-hidden="isDir" data-bind-class="cls" data-path="{{path}}" tabindex="0" title="{{path}} - {{meta}}">
5
+ <span class="pcx-name">{{label}}</span>
6
+ <span class="pcx-dirty" data-bind-hidden="!dirty" title="Unsaved changes">\u25CF</span>
7
+ <button type="button" class="pcx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
8
+ </div>
9
+ </div>`,T=r=>String(r??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Je(r){const d=document.createElement("template");return d.innerHTML=r.trim(),d.content.firstElementChild}function $e(r,d){const s=r.querySelector(".dm-plugin-banner-name");if(!s)return;const p=document.createElement("span");p.className="dm-plugin-banner-parent",p.textContent="Marketplace";const v=document.createElement("span");v.className="dm-plugin-banner-sep",v.setAttribute("aria-hidden","true"),v.textContent="\u203A",s.replaceChildren(p,v,document.createTextNode(d))}let f=null,Me=!1;function Xe(){Me||!window.R?.use||(Me=!0,R.use((r,d,s)=>{const v=Qe.test(d?.path||"")&&(r?.path||"")!==(d?.path||"")&&f?f.dirtyCount():0;return v?E.confirm(`${v===1?"A file has":`${v} files have`} unsaved changes. Leave anyway? They are kept as a draft for your next visit to this plugin's code.`).then(c=>{c?s():f&&history.replaceState(null,"",f.hash)}):s()}),M.subscribe("router:afterChange",({to:r}={})=>{if(f&&(r?.path||"")!==f.path){const d=f;f=null,d.dispose()}}))}export const pluginCodeView={templateUrl:"/admin/js/templates/plugin-code.html",async onMount(r){if(Xe(),f){const e=f;f=null,e.dispose()}const d=window.location.hash.match(/\/plugins\/code\/([a-z0-9-]+)/),s=d?d[1]:null;if(!s){E.toast("No plugin specified.",{type:"error"}),R.navigate("/plugins");return}const p=r.get(0).querySelector(".pcx")||r.get(0),v=[],c=e=>(v.push(e),e);let z=!1;const m=()=>!z&&p.isConnected,b=M.observable([]),ne=M.observable(!0),H=M.observable(""),ae=M.observable(""),o=M.observable(null),U=M.observable(0),w=M.observable(!1),A=M.observable("Ctrl+S saves the open file, Ctrl+Shift+S every unsaved file."),j=M.observable(""),V=M.observable(!1),i=new Map,k=()=>{U.value=U.peek()+1},F=e=>i.has(e)&&i.get(e).content!==i.get(e).saved,N=()=>[...i.keys()].filter(F),q=M.computed(()=>(U.value,new Set(N()))),se=M.observable([]);c(M.effect(()=>{se.value=We(b.value,{openPath:o.value,dirty:q.value,query:ae.value})}));const Ce={files:b,loading:ne,loadError:H,query:ae,openPath:o,saving:w,status:A,cursor:j,restartPending:V,rows:se,currentDirty:M.computed(()=>!!o.value&&q.value.has(o.value)),dirtyCount:M.computed(()=>q.value.size),dirtyText:M.computed(()=>q.value.size===1?"1 unsaved file":`${q.value.size} unsaved files`),currentText:M.computed(()=>{const e=o.value;return e?e+(Ee(e)?"":" (plain text)"):"Select a file"}),scopeLabel:M.computed(()=>o.value?te(o.value).label:""),scopeHint:M.computed(()=>o.value?te(o.value).hint:""),scopeCls:M.computed(()=>o.value&&te(o.value).kind==="server"?"is-warn":"is-info"),emptyText:M.computed(()=>b.value.length?"No files match.":"This plugin has no editable files.")};async function x(){try{const e=await u.plugins.files(s);if(!m())return;b.value=e.files||[],H.value=""}catch(e){H.value=`Could not list the files: ${e.message||e}`}finally{ne.value=!1}}const Y=p.querySelector("#pcode-editor");let h=null;c(()=>{try{h?.destroy()}catch{}h=null});function Re(e,t){try{h?.destroy()}catch{}Y.replaceChildren(),h=He(Y,{initialValue:t,language:Ee(e),autoIndent:!0,onChange:n=>{const a=i.get(e);if(!a)return;const l=a.content!==a.saved;a.content=n,a.content!==a.saved!==l&&k()},onCursor:({line:n,col:a})=>{j.value=`Ln ${n}, Col ${a}`}}),h.focus()}async function g(e){if(!(!e||e===o.peek())){if(!i.has(e))try{const t=await u.plugins.readFile(s,e);i.set(e,{content:t.content,saved:t.content})}catch(t){E.toast(`Could not open ${e}: ${t.message||t}`,{type:"error"});return}m()&&(o.value=e,j.value="Ln 1, Col 1",Re(e,i.get(e).content),k())}}async function oe(e){const t=i.get(e),n=t.content,a=await u.plugins.writeFile(s,e,n);return t.saved=n,!!a?.restartRequired}function re(e,t){e&&(V.value=!0),A.value=e?`Saved ${t} - server code: restart the server to apply.`:`Saved ${t} - refresh the admin tab to see it.`,k(),x()}async function W(e=o.peek()){if(!(!e||!F(e)||w.peek())){w.value=!0;try{const t=await oe(e);E.toast(t?"Saved. Restart the server to apply.":"Saved.",{type:t?"warning":"success"}),re(t,e)}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{w.value=!1}}}async function ie(){const e=N();if(!e.length||w.peek())return;w.value=!0;let t=!1;const n=[];for(const a of e)try{t=await oe(a)||t}catch(l){n.push(`${a}: ${l.message||l}`)}w.value=!1,n.length?E.toast(`Some files were not saved - ${n.join("; ")}`,{type:"error",duration:8e3}):E.toast(`Saved ${e.length===1?"1 file":`${e.length} files`}.`,{type:t?"warning":"success"}),re(t,e.length===1?e[0]:`${e.length-n.length} files`)}async function le(e=o.peek()){if(!e||!F(e)||!await E.confirm(`Throw away your changes to ${T(e)}? It goes back to what is saved.`))return;const t=i.get(e);t.content=t.saved,e===o.peek()&&h?.setValue(t.saved),k()}const _=new Set;c(()=>[..._].forEach(e=>{try{e.close(!0)}catch{}}));function Q({title:e,initial:t="",from:n="",submitText:a,help:l}){return new Promise(Pe=>{const P=M.observable(t),ge=M.computed(()=>Ve(P.value,{existing:b.value.map(D=>D.path),from:n})),Ae=M.computed(()=>n&&P.value.trim()===n),be={value:P,err:ge,cannot:M.computed(()=>!!ge.value||Ae.value),submitText:a,help:l},y=E.slideover({title:T(e),size:"sm",position:"right"}),$=Je(`
10
+ <div class="pcx-form">
11
+ <div>
12
+ <label class="form-label" for="pcx-f-path">File path
13
+ <span data-help="A path inside the plugin, e.g. admin/views/cart.js. Folders are made as needed. Only .js, .json, .md, .html, .css, .txt and .svg files can be edited here."></span></label>
14
+ <input id="pcx-f-path" type="text" class="form-input font-mono" spellcheck="false" autocomplete="off" data-model="value.value">
15
+ <small class="pcx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
16
+ <small class="pcx-hint" data-bind-text="help"></small>
17
+ </div>
18
+ <div class="pcx-actions">
19
+ <span class="pcx-spacer"></span>
20
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
21
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="ok" data-bind-disabled="cannot.value" data-bind-text="submitText"></button>
22
+ </div>
23
+ </div>`);let we=null;const Ie=M.applyBindings(be,$,{methods:{ok:()=>{be.cannot.peek()||(we=P.peek().trim(),J=!0,y.close())},cancel:()=>y.close()}});y.element.appendChild($);const je=Se($);_.add(y),Ue(y,()=>{Ie.dispose(),je(),_.delete(y),Pe(we)});const Be=y.close.bind(y);let J=!1,X=!1;y.close=async D=>{if(!J&&D!==!0&&P.peek().trim()!==t.trim()){if(X)return;X=!0;const Ke=await E.confirm("Discard the path you typed?");if(X=!1,!Ke)return}return J=!0,Be()},$.querySelector("#pcx-f-path").addEventListener("keydown",D=>{D.key==="Enter"&&(D.preventDefault(),$.querySelector('[data-on-click="ok"]').click())}),I.scan($),y.open();const ke=$.querySelector("#pcx-f-path");ke.focus();const xe=t.lastIndexOf(".");xe>t.lastIndexOf("/")&&ke.setSelectionRange(t.lastIndexOf("/")+1,xe)})}async function G(e=""){const t=await Q({title:"New file",initial:e?`${e}/`:"admin/",submitText:"Create",help:"Created empty and opened. An admin/ file applies on a refresh; anything else on a restart."});if(!(!t||!m()))try{await u.plugins.writeFile(s,t,""),i.set(t,{content:"",saved:""}),await x(),await g(t)}catch(n){E.toast(`Could not create ${t}: ${n.message||n}`,{type:"error"})}}async function De(e){if(ee.includes(e))return;const t=await Q({title:`Rename ${e}`,initial:e,from:e,submitText:"Rename",help:"Your unsaved changes to it come along."});if(!t||t===e||!m())return;const n=i.get(e)||{content:(await u.plugins.readFile(s,e)).content,saved:null};try{await u.plugins.writeFile(s,t,n.content),await u.plugins.deleteFile(s,e),i.delete(e),i.set(t,{content:n.content,saved:n.content});const a=o.peek()===e;a&&(o.value=null),await x(),a&&await g(t),k(),E.toast(`Renamed to ${t}.`,{type:"success"})}catch(a){E.toast(`Rename failed: ${a.message||a}`,{type:"error"}),x()}}async function Le(e){const t=e.lastIndexOf("."),n=t>e.lastIndexOf("/")?`${e.slice(0,t)}-copy${e.slice(t)}`:`${e}-copy`,a=await Q({title:`Duplicate ${e}`,initial:n,submitText:"Duplicate",help:"The copy is saved with the text shown in the editor now."});if(!(!a||!m()))try{const l=i.get(e)?.content??(await u.plugins.readFile(s,e)).content;await u.plugins.writeFile(s,a,l),i.set(a,{content:l,saved:l}),await x(),await g(a)}catch(l){E.toast(`Could not duplicate: ${l.message||l}`,{type:"error"})}}async function Te(e){if(ee.includes(e))return;const t=F(e)?" Your unsaved changes to it go too.":"";if(await E.confirm(`Delete ${T(e)}? This cannot be undone.${t}`))try{if(await u.plugins.deleteFile(s,e),i.delete(e),o.peek()===e){o.value=null;try{h?.destroy()}catch{}h=null,Y.replaceChildren(),j.value=""}E.toast(`Deleted ${e}.`,{type:"success"}),k(),await x()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}}async function Fe(e){try{await navigator.clipboard.writeText(e),E.toast("Path copied.",{type:"success"})}catch{E.toast(e,{type:"info"})}}async function ce(){const e=N().length;if(await E.confirm((e?`${e===1?"A file has":`${e} files have`} unsaved changes - they will NOT be included. `:"")+"Restart the server now? Server-side plugin code applies when it starts. The admin is unreachable for a few seconds.",{title:"Restart server",confirmText:"Restart"}))try{const n=await u.plugins.restart();n.supervised||E.toast("No supervisor detected - if the server does not come back, start it by hand.",{type:"warning"}),await ze(n,{onStatus:a=>{A.value=a}}),V.value=!1}catch(n){E.toast(`Restart failed: ${n.message||n}`,{type:"error"})}}function ue(e){if(!e)return[];const t=ee.includes(e),n=F(e),a=e.includes("/")?e.slice(0,e.lastIndexOf("/")):"";return[{header:e},{label:"Open",icon:"file-text",disabled:o.peek()===e,action:()=>g(e)},{label:"Save",icon:"save",disabled:!n,action:()=>W(e)},{label:"Revert changes\u2026",icon:"rotate-ccw",disabled:!n,action:()=>le(e)},{type:"divider"},{label:"Copy path",icon:"copy",action:()=>Fe(e)},{label:`New file in ${a?`${a}/`:"the plugin"}\u2026`,icon:"plus",action:()=>G(a)},{label:"Duplicate\u2026",icon:"copy",action:()=>Le(e)},{label:t?"Rename - required file":"Rename\u2026",icon:"edit-3",disabled:t,action:()=>De(e)},{type:"divider"},{label:t?"Delete - required file":"Delete\u2026",icon:"trash",danger:!0,disabled:t,action:()=>Te(e)}]}const C=p.querySelector(".pcx-list");C.innerHTML=Ge;const Ne=M.applyBindings(Ce,p,{methods:{save:()=>W(),revert:()=>le(),newFile:()=>G(),restart:()=>ce()}});c(()=>Ne.dispose()),c(Se(p,{position:"bottom"}));const qe=E.contextMenu(C,{cursor:"glyph",match:".pcx-file",inherit:!1,density:"compact",items:e=>ue(e.dataset.path)});c(()=>qe?.destroy?.());const B=p.querySelector(".pcx-more"),Oe=E.contextMenu(B.parentElement,{cursor:"glyph",match:".pcx-more",inherit:!1,density:"compact",items:()=>ue(o.peek())});c(()=>Oe?.destroy?.());const de=()=>Z(B);B.addEventListener("click",de),c(()=>B.removeEventListener("click",de));const pe=e=>{const t=e.target.closest(".pcx-file");if(!t)return;const n=e.target.closest(".pcx-kebab");if(n){Z(n);return}g(t.dataset.path)},fe=e=>{const t=e.target.closest(".pcx-file");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.path))};C.addEventListener("click",pe),C.addEventListener("keydown",fe),c(()=>{C.removeEventListener("click",pe),C.removeEventListener("keydown",fe)});const ve=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!m()||e.target?.closest?.(".pcx-form")||(e.preventDefault(),e.stopPropagation(),e.shiftKey?ie():W())};document.addEventListener("keydown",ve,!0),c(()=>document.removeEventListener("keydown",ve,!0)),K(r,{id:"pcode-back",icon:"arrow-left",title:"Back to the Marketplace",onClick:()=>{location.hash="#/plugins"}}),K(r,{id:"pcode-new",icon:"plus",title:"New file",onClick:()=>G()}),K(r,{id:"pcode-saveall",icon:"save",title:"Save all (Ctrl+Shift+S)",onClick:()=>ie()}),K(r,{id:"pcode-restart",icon:"refresh-cw",title:"Restart server",onClick:()=>ce()});const me=()=>{const e=Ye(i);try{e?S.set(L(s),{...e,at:new Date().toISOString()}):S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}},ye=e=>{N().length&&(me(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ye),c(()=>window.removeEventListener("beforeunload",ye)),f={path:location.hash.replace(/^#/,"").split("?")[0]||`/plugins/code/${s}`,hash:location.hash,dirtyCount:()=>N().length,dispose(){z||(me(),z=!0,v.splice(0).reverse().forEach(e=>{try{e()}catch{}}))}},document.title=`${s} code - Domma CMS`,$e(r.get(0),s),u.plugins.list().then(e=>{const t=(Array.isArray(e)?e:[]).find(n=>n.name===s);t?.displayName&&m()&&($e(r.get(0),t.displayName),document.title=`${t.displayName} code - Domma CMS`)}).catch(()=>{}),I.scan(p),await x(),!m())return;let O=null;try{O=S.get(L(s))}catch{}if(O?.files&&Object.keys(O.files).length){try{S.remove?S.remove(L(s)):S.set(L(s),null)}catch{}const e={};if(await Promise.all(Object.keys(O.files).map(async n=>{e[n]=await u.plugins.readFile(s,n).then(a=>a.content).catch(()=>null)})),!m())return;const t=_e(O,e);if(t.restore.length){const n=t.restore.length,a=[t.changed.length?` Changed on the server since: ${t.changed.map(T).join(", ")} - restoring puts your version back over it (unsaved until you save).`:"",t.gone.length?` No longer there: ${t.gone.map(T).join(", ")} (left out).`:""].join("");if(await E.confirm(`You left ${n===1?"a file":`${n} files`} unsaved here (${t.restore.map(l=>T(l.path)).join(", ")}). Restore ${n===1?"it":"them"}?${a}`)&&m()){for(const l of t.restore)i.set(l.path,{content:l.content,saved:l.saved});k(),await g(t.restore[0].path),A.value=`Restored ${n===1?"1 unsaved file":`${n} unsaved files`} - save to keep them.`;return}}}const he=b.peek().find(e=>e.path==="plugin.js")||b.peek()[0];he&&await g(he.path)}};
@@ -1,43 +1,101 @@
1
- import{api as f,getUser as C}from"../api.js";import{pluginMarketplaceView as x}from"./plugin-marketplace.js";import{pluginAccent as S,pluginWatermark as T}from"../lib/plugin-accent.js";import{reportRestart as N}from"../lib/server-restart.js";export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(s){s.find("#new-plugin-btn").on("click",()=>I(s));const c=s.find("#install-file-input").get(0);s.find("#install-file-btn").on("click",()=>c?.click()),c?.addEventListener("change",async()=>{const i=c.files?.[0];c.value="",i&&await L(s,i)});let o=!1;s.find('[data-tab="marketplace"]').on("click",async function(){if(o)return;const i=s.find("#marketplace-mount");try{const n=await fetch(x.templateUrl);if(!n.ok)throw new Error(`Template ${n.status}`);const d=await n.text(),p=new DOMParser().parseFromString(d,"text/html");for(;p.body.firstChild;)i.get(0).appendChild(p.body.firstChild);await x.onMount(i),o=!0}catch{E.toast("Failed to load marketplace.",{type:"error"})}}),E.tabs(s.find("#plugin-tabs").get(0));const e=E.loader(s.get(0),{type:"dots"});let t=[];try{t=await f.plugins.list()}catch(i){E.toast(`Failed to load plugins: ${i.message}`,{type:"error"})}if(e.destroy(),!t.length){s.find("#plugins-empty").show();return}y(s,t),Domma.icons.scan()}};async function L(s,c){const o=s.find("#install-file-btn"),e=()=>o.prop("disabled",!1).html('<span data-icon="upload"></span> Install from File');o.prop("disabled",!0).text("Installing\u2026");const t={};for(let i=0;i<4;i++)try{const n=await f.plugins.installFile(c,t),d=n.action==="upgrade"?`${n.name} updated${n.from?` from ${n.from}`:""} to ${n.version}.`:`${n.name} ${n.version} installed.`;E.toast(`${d} Restart the server to activate it.`,{type:"success"}),e();const l=await f.plugins.list();s.find("#plugins-empty").hide(),y(s,l);return}catch(n){if(n.code==="CONFLICT"&&!t.confirmUpgrade){const d=n.installedVersion||"the installed version",l=n.incomingVersion||"this file";if(!await E.confirm(`<p>${r(n.name)} ${r(d)} is already installed. Update it to ${r(l)}?</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}))break;t.confirmUpgrade=!0;continue}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}))break;t.confirmDowngrade=!0;continue}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}))break;t.confirmUnsigned=!0;continue}E.toast(n.message||"Install failed.",{type:"error"});break}e(),Domma.icons.scan()}function I(s){const c=E.slideover({title:"New Plugin",size:"sm",position:"right"}),o=document.createElement("div");o.style.cssText="padding:.25rem 0 .5rem;";const e=C(),t=document.createElement("div");F.create({displayName:{type:"string",label:"Display Name",placeholder:"e.g. Event Manager",required:!0},slug:{type:"string",label:"Slug",placeholder:"event-manager",required:!0,help:"Folder name under plugins/ - lowercase letters, digits, hyphens."},description:{type:"string",label:"Description",placeholder:"What does this plugin do?"},author:{type:"string",label:"Author"},icon:{type:"string",label:"Icon",placeholder:"box",help:"A Domma icon name (as used in data-icon)."}},{author:e?.name||e?.username||"",icon:"box"},{showSubmitButton:!1}).renderTo(t),o.appendChild(t);const i=document.createElement("p");i.className="text-muted",i.style.cssText="font-size:.8rem;margin:.5rem 0 0;",i.textContent="The plugin is scaffolded from the built-in template with server routes, an admin view and a CLAUDE.md, and enabled automatically. Restart the server to activate it.",o.appendChild(i);const n=document.createElement("div");n.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const d=document.createElement("button");d.className="btn btn-ghost",d.textContent="Cancel";const l=document.createElement("button");l.className="btn btn-primary",l.textContent="Create Plugin",n.appendChild(d),n.appendChild(l),o.appendChild(n),c.element.appendChild(o),c.open();const p=t.querySelector('input[name="displayName"]'),a=t.querySelector('input[name="slug"]');setTimeout(()=>p?.focus(),50);let u=!1;a?.addEventListener("input",()=>{u=!0}),p?.addEventListener("input",()=>{u||!a||(a.value=p.value.toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,40))});async function b(){const m={};for(const g of["displayName","slug","description","author","icon"])m[g]=t.querySelector(`input[name="${g}"]`)?.value.trim()||"";if(!m.displayName||!m.slug){E.toast("Display name and slug are required.",{type:"error"});return}l.disabled=!0,l.textContent="Creating\u2026";try{await f.plugins.scaffold(m),c.close(),E.toast(`Plugin "${m.displayName}" created. Restart the server to activate it.`,{type:"success"});const g=await f.plugins.list();g.length&&(s.find("#plugins-empty").hide(),y(s,g))}catch(g){E.toast(g.message||"Failed to create plugin.",{type:"error"}),l.disabled=!1,l.textContent="Create Plugin"}}d.addEventListener("click",()=>c.close()),l.addEventListener("click",b),a?.addEventListener("keydown",m=>{m.key==="Enter"&&b()})}function U(s){if(!s)return"";const c=new Date(s);if(Number.isNaN(c.getTime()))return"";try{return D(s).format("DD.MM.YYYY")}catch{return""}}function B(s){const o={grace:["Renew","var(--dm-warning,#d97706)","The rental term has ended - still running during the grace period"],"support-ended":["Support ended","var(--dm-info,#0284c7)","Owned outright and still running, but no longer entitled to updates"],expired:["Expired","var(--dm-danger,#dc2626)","The rental term and grace period have ended - this plugin is not running"],unlicensed:["Unlicensed","var(--dm-danger,#dc2626)","No valid entitlement for this site - this plugin is not running"]}[s];if(!o)return"";const[e,t,i]=o;return`<span class="badge badge-outline" title="${r(i)}" style="font-size:0.65rem;padding:1px 6px;background:none;border:1px solid;color:${t};border-color:${t};">${e}</span>`}function y(s,c){const o=s.find("#plugins-grid").empty();c.forEach(e=>{const t=!!e.core,i=e.sourceEditable!==!1,n="var(--dm-warning,#d97706)",d=e.closedSource?`<span class="badge badge-outline" title="${r(e.licence||"Commercial licence")} - source is not editable here" style="font-size:0.65rem;padding:1px 6px;background:none;border:1px solid;color:${n};border-color:${n};">Licensed</span>`:"",l=B(e.entitlement),p=e.enabled&&["expired","unlicensed"].includes(e.entitlement),a=e.enabled&&e.supersededBy,u=t?"badge-info":p?"badge-danger":a?"badge-secondary":e.enabled?"badge-success":"badge-secondary",b=t?"Built in":p?"Not running":a?"Replaced":e.enabled?"Enabled":"Disabled",m=e.enabled?"Disable":"Enable",g=e.enabled?"btn-ghost":"btn-primary",h=e.author?`by ${r(e.author)}`:"",v=U(e.date),w=e.icon||"package",k=`
2
- <div class="card plugin-card" data-plugin="${e.name}" style="--plugin-accent:${S(e.name)}">
3
- ${T(w).outerHTML}
4
- <div class="card-body">
5
- <div class="plugin-header">
6
- <div class="plugin-icon"><span data-icon="${w}"></span></div>
7
- <div class="plugin-meta">
8
- <div class="plugin-name">${r(e.displayName)}</div>
9
- <div class="plugin-version">v${r(e.version)}${h?` &middot; ${h}`:""}</div>
10
- </div>
11
- <div style="display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end;flex-shrink:0;margin-left:auto;">
12
- ${d}
13
- ${l}
14
- <span class="badge ${u}">${b}</span>
15
- </div>
1
+ import{api as U,getUser as _e}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as Te}from"../lib/help-popover.js";import{onceClosed as He,openMenuAt as Ie,sidebarCount as Ye,viewLifetime as Je}from"../lib/tool-kit.js";import{pluginAccent as Ne}from"../lib/plugin-accent.js";import{reportRestart as Qe}from"../lib/server-restart.js";import{FILTERS as Ge,arrangeCatalogue as Xe,arrangePlugins as Ze,catalogueAction as oe,catalogueRow as et,checkScaffold as tt,fmtReleased as at,needsAttention as nt,pluginRow as st,pluginStatus as Ue,readSettings as lt,settingsFields as Oe,slugifyPlugin as it,updateFor as Ae}from"../lib/plugins-arrange.js";const Le=/^#\/plugins\/?(\?.*)?$/,ot="#/plugins",re="plugins.filter",X="plugins.draft",rt=`
2
+ <div class="plx-row" data-name="{{name}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
3
+ <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
4
+ <span class="plx-main"><strong>{{displayName}}</strong><small data-bind-text="meta"></small></span>
5
+ <span class="plx-chips">
6
+ <span class="plx-chip is-warn" data-bind-hidden="!update" data-bind-text="updateText"></span>
7
+ <span class="plx-chip" data-bind-hidden="!entText" data-bind-class="entCls" data-bind-text="entText" title="{{entTitle}}"></span>
8
+ <span class="plx-chip is-info" data-bind-hidden="!licensed" title="Licensed - its source is not viewable here">Licensed</span>
9
+ <span class="plx-chip" data-bind-class="statusCls" data-bind-text="statusText"></span>
10
+ </span>
11
+ <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
12
+ </div>`,ct=`
13
+ <div class="plx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls" style="--plugin-accent: {{accent}}">
14
+ <span class="plx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="17"></span></span>
15
+ <span class="plx-main"><strong>{{displayName}}</strong><small>{{meta}}</small></span>
16
+ <span class="plx-chips">
17
+ <span class="plx-chip" data-bind-hidden="!licText" data-bind-class="licCls" data-bind-text="licText"></span>
18
+ <span class="plx-chip">{{price}}</span>
19
+ </span>
20
+ <button type="button" class="btn btn-sm plx-act" data-bind-class="actCls" data-bind-disabled="actDisabled" data-bind-text="actionText"></button>
21
+ <button type="button" class="plx-kebab" aria-label="Actions for {{displayName}}" title="Actions">\u22EE</button>
22
+ </div>`,r=O=>String(O??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ce(O){const p=document.createElement("template");return p.innerHTML=O.trim(),p.content.firstElementChild}const h=Je(Le);export const pluginsView={templateUrl:"/admin/js/templates/plugins.html",async onMount(O){h.reset();const p=O.get(0).querySelector(".plx")||O.get(0),k=()=>Le.test(location.hash)&&p.isConnected,L=M.observable([]),b=M.observable({available:null,site:null,items:[]}),de=M.observable(!0),Z=M.observable(""),m=M.observable("");let ue="all";try{ue=Ge.includes(S.get(re))?S.get(re):"all"}catch{}const C=M.observable(ue),K=M.observable(""),R=M.observable("all"),pe=M.observable("");h.add(M.effect(()=>{const e=C.value;try{S.set(re,e)}catch{}}));const V=M.computed(()=>new Map((b.value.items||[]).map(e=>[e.slug,e.version]))),W=e=>(b.peek().items||[]).find(t=>t.slug===e),ee=M.observable([]);h.add(M.effect(()=>{const e=V.value;ee.value=Ze(L.value,{filter:C.value,query:K.value,latest:e}).map(t=>({...st(t,e),accent:Ne(t.name)}))}));const Re=M.computed(()=>{const e=L.value,t=V.value,a=e.filter(n=>Ue(n).running).length;return{all:e.length,on:a,off:e.length-a,attention:e.filter(n=>nt(n,t)).length}}),_=M.computed(()=>(b.value.items||[]).filter(e=>oe(e)==="update").length),je=M.computed(()=>L.value.length?"No plugins match.":"No plugins installed. Browse the catalogue, or Install from file in the banner."),te=M.observable([]);h.add(M.effect(()=>{te.value=Xe(b.value.items,{filter:R.value,query:pe.value}).map(e=>{const t=et(e),a=t.action==="update"||t.action==="install";return{...t,accent:Ne(e.slug),actCls:a?"btn-primary":"btn-ghost",actDisabled:!a}})}));const Fe=M.computed(()=>{const e=b.value.items||[];return{all:e.length,installed:e.filter(t=>t.installed).length,available:e.filter(t=>!t.installed&&t.entitled).length,updates:_.value}}),H=e=>L.peek().find(t=>t.name===e);async function ae(){try{const e=await U.plugins.list();if(!k())return;L.value=Array.isArray(e)?e:[],Z.value=""}catch(e){Z.value=`Could not load the plugins: ${e.message||e}`}finally{de.value=!1}}async function be(){try{const e=await U.marketplace.catalogue();if(!k())return;b.value={available:!!e?.available,site:e?.site||null,items:e?.catalogue||[]}}catch{b.value={available:!1,site:null,items:[],failed:!0}}}const B=async()=>{await Promise.all([ae(),be()]),Ye(ot)};async function ne(e,t){if(e.core)return;const a=t?"on":"off";if(await E.confirm(`Switch ${r(e.displayName||e.name)} ${a}? The server restarts to apply it, which takes a few seconds.`)&&!m.peek()){m.value=`Switching ${e.displayName||e.name} ${a}\u2026`;try{const n=await U.plugins.update(e.name,{enabled:t});await Qe(n,{onStatus:l=>{m.value=l}}),E.toast(`${e.displayName||e.name} is ${a}.${t?" Reload the admin to open its screens.":""}`,{type:"success",duration:6e3});try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}catch(n){E.toast(`Not switched ${a}: ${n.message||n}`,{type:"error"})}finally{m.value="",k()&&await B()}}}async function j(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 U.marketplace.install(e.slug,e.version,{update:t}),v=l?.action==="upgrade"?`${e.slug} updated${l.from?` from ${l.from}`:""} to ${l.version}.`:`${e.slug} ${l?.version??e.version} installed.`;E.toast(`${v} Restart the server to activate it.`,{type:"success",duration:6e3})}catch(l){E.toast(l.message||"Install failed.",{type:"error"})}k()&&await B()}}async function me(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 U.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"})}k()&&await B()}}async function ve(e){try{await U.plugins.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}const fe=e=>{location.hash=`#/plugins/code/${encodeURIComponent(e.name)}`},q=p.querySelector(".plx-file");async function De(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 U.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="",k()&&await B()}}const ge=async()=>{const e=q.files?.[0];q.value="",e&&await De(e)};q.addEventListener("change",ge),h.add(()=>q.removeEventListener("change",ge));const Y=new Set;let A=null,he=!1;function ye({title:e,size:t="md",panel:a,model:n,methods:l,dirty:v=()=>!1,form:u=null,cleanup:c=()=>{}}){const i=E.slideover({title:r(e),size:t,position:"right"});i.element.appendChild(a);const $=Te(a),x=M.applyBindings(n,a,{methods:l});u&&(A=u),Y.add(i),He(i,()=>{c(),x.dispose(),$(),Y.delete(i),A===u&&(A=null)});const w=i.close.bind(i);let f=!1,T=!1;return i.close=async F=>{if(!f&&F!==!0&&v()){if(T)return;T=!0;const D=await E.confirm("Discard the changes you have not saved?");if(T=!1,!D)return}return f=!0,w()},I.scan(a),i.open(),i}function se(e,t=null){const a=H(e);if(!a)return;const n=Ue(a),l=Oe(a),v=W(a.name),u=Ae(a,V.peek()),c=!!(u&&v?.entitled),$={...Object.fromEntries(l.map(s=>[s.key,s.value])),...Object.fromEntries(Object.entries(t||{}).filter(([s])=>!l.find(d=>d.key===s)?.secret))},x=Object.fromEntries(l.map(s=>[s.key,M.observable($[s.key])])),w=M.computed(()=>lt(l,Object.fromEntries(l.map(s=>[s.key,x[s.key].value])))),f=M.observable(!1),T=()=>Object.keys(w.peek().changed).length>0||Object.keys(w.peek().errors).length>0,F=[["Version",`v${a.version}${u?` (v${u} available)`:""}`],["Status",n.text+(a.supersededBy?` - replaced by ${a.supersededBy}`:"")],["Author",a.author],["Released",at(a.date)],["Licence",a.licence],["Slug",a.name]].filter(([,s])=>s),D=(s,d)=>{const N=`plx-set-${d}`,Q=s.help?` <span data-help="${r(s.help)}"></span>`:"",z=`<label class="form-label" for="${N}">${r(s.label)}${Q}</label>`,P=`data-model="v${d}.value"`,G=`<small class="plx-err" data-bind-hidden="!err${d}.value" data-bind-text="err${d}.value"></small>`;if(s.type==="checkbox")return`<div class="plx-setting">${z}<input id="${N}" type="checkbox" class="form-switch" ${P}></div>`;if(s.type==="select")return`<div>${z}<select id="${N}" class="form-input" ${P}>${s.options.map(Me=>`<option value="${r(Me.value)}">${r(Me.label)}</option>`).join("")}</select>${G}</div>`;if(s.type==="json")return`<div>${z}<textarea id="${N}" class="form-input plx-json" rows="5" spellcheck="false" ${P}></textarea>${G}</div>`;if(s.type==="textarea")return`<div>${z}<textarea id="${N}" class="form-input" rows="3" ${P}></textarea>${G}</div>`;const Ve=s.type==="number"?"number":s.secret?"password":"text",We=`${s.min!=null?` min="${r(s.min)}"`:""}${s.max!=null?` max="${r(s.max)}"`:""}`;return`<div>${z}<input id="${N}" type="${Ve}" class="form-input" autocomplete="${s.secret?"new-password":"off"}"${We} ${P}>${G}</div>`},o={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,d)=>[[`v${d}`,x[s.key]],[`err${d}`,M.computed(()=>w.value.errors[s.key]||"")]]))},g=ce(`
23
+ <div class="plx-form">
24
+ <p class="plx-desc"></p>
25
+ <dl class="plx-facts"></dl>
26
+ <div data-if="hasFields">
27
+ <p class="plx-sub">Settings
28
+ <span data-help-title="Settings" data-help="Saved to config/plugins.json for this site. Only what you change is stored - anything you leave keeps the plugin's own default, so a later version can improve it. Most plugins read their settings as they run; a few only at start-up."></span></p>
29
+ <div class="plx-form">${l.map(D).join("")}</div>
30
+ </div>
31
+ <p class="plx-hint" data-if="!hasFields">This plugin has no settings to change here.</p>
32
+ <div class="plx-actions">
33
+ ${a.core?"":`<button type="button" class="btn btn-sm btn-ghost" data-on-click="toggle"><span data-icon="${a.enabled?"pause":"play"}" data-icon-size="13"></span> ${a.enabled?"Switch off":"Switch on"}</button>`}
34
+ ${c?`<button type="button" class="btn btn-sm btn-ghost" data-on-click="update"><span data-icon="download" data-icon-size="13"></span> Update to v${r(u)}</button>`:""}
35
+ ${a.sourceEditable!==!1?'<button type="button" class="btn btn-sm btn-ghost" data-on-click="source"><span data-icon="code" data-icon-size="13"></span> Source</button>':""}
36
+ ${a.closedSource?"":'<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportIt" title="Download as a .dcmsplugin file"><span data-icon="download" data-icon-size="13"></span> Export</button>'}
37
+ <span class="plx-spacer"></span>
38
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Close</button>
39
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-if="hasFields" data-bind-disabled="cannot.value">
40
+ <span data-icon="check" data-icon-size="13"></span> Save settings</button>
41
+ </div>
42
+ </div>`);g.querySelector(".plx-desc").textContent=a.description||"";const Ke=g.querySelector(".plx-facts");for(const[s,d]of F){const N=document.createElement("dt");N.textContent=s;const Q=document.createElement("dd");Q.textContent=d,Ke.append(N,Q)}async function Se(){if(o.cannot.peek())return;const{changed:s}=w.peek();f.value=!0;try{await U.plugins.update(a.name,{settings:{...a.settings||{},...s}}),E.toast(`${a.displayName||a.name}: settings saved.`,{type:"success"}),J.close(!0),k()&&await ae()}catch(d){E.toast(`Not saved: ${d.message||d}`,{type:"error"})}finally{f.value=!1}}const ie=s=>async()=>{await J.close(),Y.has(J)||s()},J=ye({title:a.displayName||a.name,size:"md",panel:g,model:o,dirty:T,form:l.length?{kind:"settings",name:a.name,dirty:T,values:()=>{const s={};for(const d of l)d.secret||(s[d.key]=x[d.key].peek());return s}}:null,methods:{save:Se,cancel:()=>J.close(),toggle:ie(()=>ne(a,!a.enabled)),update:ie(()=>j(v,{update:!0})),source:ie(()=>fe(a)),exportIt:()=>ve(a)}});g.addEventListener("keydown",s=>{(s.ctrlKey||s.metaKey)&&s.key==="Enter"&&(s.preventDefault(),Se())})}function xe(e=null){const t=_e(),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(([o,g])=>[o,g.peek()]))),l=()=>Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.peek()])),v=()=>JSON.stringify(l())!==n||!!e;let u=!!e?.slug;const c=M.effect(()=>{const o=a.displayName.value;u||(a.slug.value=it(o))}),i=M.observable(!1),$=M.computed(()=>tt(Object.fromEntries(Object.entries(a).map(([o,g])=>[o,g.value])),L.value.map(o=>o.name))),x=o=>M.computed(()=>$.value.errors[o]||""),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($.value.errors).length>0)},f=ce(`
43
+ <div class="plx-form">
44
+ <div>
45
+ <label class="form-label" for="plx-np-name">Display name <span class="text-danger">*</span></label>
46
+ <input id="plx-np-name" type="text" class="form-input" maxlength="60" placeholder="Event Manager" data-model="displayName.value">
47
+ <small class="plx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
48
+ </div>
49
+ <div>
50
+ <label class="form-label" for="plx-np-slug">Slug
51
+ <span data-help="The plugin's folder under plugins/ and its id everywhere - lowercase letters, digits and hyphens, starting with a letter. Made from the name as you type."></span></label>
52
+ <input id="plx-np-slug" type="text" class="form-input font-mono" maxlength="40" data-model="slug.value">
53
+ <small class="plx-err" data-bind-hidden="!slugErr.value" data-bind-text="slugErr.value"></small>
16
54
  </div>
17
- <p class="plugin-desc">${r(e.description)}</p>
18
- ${v?`<p class="plugin-date text-muted" style="font-size:.8rem;margin:0">Released ${v}</p>`:""}
19
- ${a?`<p class="text-muted" style="font-size:.8rem;margin:.25rem 0 0">Replaced by ${r(e.supersededBy)}.</p>`:""}
55
+ <div>
56
+ <label class="form-label" for="plx-np-desc">Description</label>
57
+ <input id="plx-np-desc" type="text" class="form-input" maxlength="300" placeholder="What does this plugin do?" data-model="description.value">
58
+ <small class="plx-err" data-bind-hidden="!descErr.value" data-bind-text="descErr.value"></small>
59
+ </div>
60
+ <div>
61
+ <label class="form-label" for="plx-np-author">Author</label>
62
+ <input id="plx-np-author" type="text" class="form-input" maxlength="80" data-model="author.value">
63
+ <small class="plx-err" data-bind-hidden="!authorErr.value" data-bind-text="authorErr.value"></small>
64
+ </div>
65
+ <div>
66
+ <label class="form-label" for="plx-np-icon">Icon
67
+ <span data-help="A Domma icon name, as used in data-icon - box, calendar, users\u2026"></span></label>
68
+ <input id="plx-np-icon" type="text" class="form-input font-mono" maxlength="40" data-model="icon.value">
69
+ <small class="plx-err" data-bind-hidden="!iconErr.value" data-bind-text="iconErr.value"></small>
70
+ </div>
71
+ <p class="plx-hint">Made from the built-in template - server routes, an admin view and a CLAUDE.md - and switched on. Restart the server to activate it. Ctrl+Enter creates.</p>
72
+ <div class="plx-actions">
73
+ <span class="plx-spacer"></span>
74
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
75
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
76
+ <span data-icon="plus" data-icon-size="13"></span> Create plugin</button>
77
+ </div>
78
+ </div>`);async function T(){if(w.cannot.peek())return;i.value=!0;const{payload:o}=$.peek();try{await U.plugins.scaffold(o),E.toast(`"${o.displayName}" created. Restart the server to activate it.`,{type:"success",duration:6e3}),F.close(!0),k()&&await B()}catch(g){E.toast(g.message||"Could not create the plugin.",{type:"error"})}finally{i.value=!1}}const F=ye({title:"New plugin",size:"sm",panel:f,model:w,dirty:v,form:{kind:"new",name:null,dirty:v,values:l},cleanup:()=>c(),methods:{create:T,cancel:()=>F.close()}}),D=f.querySelector("#plx-np-slug");D.addEventListener("input",()=>{u=!!D.value}),f.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),T())}),f.querySelector("#plx-np-name")?.focus()}function Be(e){const t=H(e);if(!t)return[];const a=W(t.name),n=Ae(t,V.peek()),l=!!m.peek();return[{header:t.displayName||t.name},{label:Oe(t).length?"Details and settings\u2026":"Details\u2026",icon:"settings",action:()=>se(t.name)},...t.core?[]:[t.enabled?{label:"Switch off\u2026",icon:"pause",disabled:l,action:()=>ne(t,!1)}:{label:"Switch on\u2026",icon:"play",disabled:l,action:()=>ne(t,!0)}],...n?[{label:`Update to v${n}\u2026`,icon:"download",disabled:l||!a?.entitled,action:()=>j(a,{update:!0})}]:[],{type:"divider"},{label:"View source",icon:"code",disabled:t.sourceEditable===!1,action:()=>fe(t)},{label:"Export .dcmsplugin",icon:"download",disabled:t.closedSource===!0,action:()=>ve(t)},...t.core?[]:[{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:l,action:()=>me(t.name,t.displayName)}]]}function qe(e){const t=W(e);if(!t)return[];const a=oe(t),n=!!m.peek();return[{header:t.displayName||t.slug},...a==="install"?[{label:`Install v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t)}]:[],...a==="update"?[{label:`Update to v${t.version}\u2026`,icon:"download",disabled:n,action:()=>j(t,{update:!0})}]:[],...t.installed?[{label:"Open in Installed",icon:"package",action:()=>{K.value=t.slug,C.value="all",$e.activate?.(0)}},{type:"divider"},{label:"Uninstall\u2026",icon:"trash",danger:!0,disabled:n,action:()=>me(t.slug,t.displayName)}]:[],...a==="locked"?[{label:"Not licensed for this site",icon:"lock",disabled:!0,action:()=>{}}]:[]]}function ze(e){const t=W(e);if(!t)return;const a=oe(t);a==="install"?j(t):a==="update"?j(t,{update:!0}):t.installed&&(K.value=t.slug,C.value="all",$e.activate?.(0))}function we(e,t,a,n){const l=E.contextMenu(e,{cursor:"glyph",match:".plx-row",inherit:!1,density:"compact",items:c=>a(c.dataset[t])});h.add(()=>l?.destroy?.());const v=c=>{const i=c.target.closest(".plx-row");if(!i)return;const $=c.target.closest(".plx-kebab");if($){Ie($);return}if(c.target.closest(".plx-act")){c.target.closest("button").disabled||n(i.dataset[t]);return}n(i.dataset[t])},u=c=>{const i=c.target.closest(".plx-row");i&&(c.key==="F10"&&c.shiftKey?(c.preventDefault(),Ie(i)):c.key==="Enter"&&c.target===i&&n(i.dataset[t]))};e.addEventListener("click",v),e.addEventListener("keydown",u),h.add(()=>{e.removeEventListener("click",v),e.removeEventListener("keydown",u)})}const le=p.querySelector(".plx-browse");le.appendChild(ce(`
79
+ <div class="plx-card">
80
+ <p class="plx-note" data-bind-hidden="browseState.value !== 'loading'">Loading the catalogue\u2026</p>
81
+ <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>
82
+ <div data-bind-hidden="browseState.value !== 'file'">
83
+ <p style="margin:0 0 .4rem"><strong>This site installs plugins from a file.</strong></p>
84
+ <p class="plx-note">Buy a plugin and you are sent a single <code>.dcmsplugin</code> file - Install from file in the banner installs it, and the same button takes an update later. Sites managed for you browse and install here instead, over their manager connection.</p>
20
85
  </div>
21
- <div class="plugin-footer">
22
- <span class="text-muted" style="font-size:.8rem">${t?"Part of the CMS - always on":"Restart server after changes"}</span>
23
- <div class="plugin-footer-actions">
24
- ${i?`<button class="btn btn-sm btn-ghost btn-plugin-code" data-name="${e.name}"><span data-icon="code"></span> Code</button>`:""}
25
- ${e.closedSource?"":`<button class="btn btn-sm btn-ghost btn-plugin-export" data-name="${e.name}" data-tooltip="Export as .dcmsplugin"><span data-icon="download" data-icon-size="14"></span></button>`}
26
- ${e.settingsSchema?`<button class="btn btn-sm btn-ghost btn-plugin-settings" data-name="${e.name}"><span data-icon="settings"></span> Settings</button>`:""}
27
- ${t?"":`<button class="btn btn-sm ${g} btn-toggle-plugin"
28
- data-name="${e.name}"
29
- data-enabled="${e.enabled?"1":"0"}">
30
- <span data-icon="${e.enabled?"pause":"play"}"></span> ${m}
31
- </button>`}
86
+ <div data-bind-hidden="browseState.value !== 'list'">
87
+ <div class="plx-bar">
88
+ <div class="plx-seg" role="group" aria-label="Show">
89
+ <button type="button" data-on-click="bAll" data-bind-class="browseFilter.value === 'all' && 'is-on'">All <b data-bind-text="browseCounts.value.all"></b></button>
90
+ <button type="button" data-on-click="bAvailable" data-bind-class="browseFilter.value === 'available' && 'is-on'">Available <b data-bind-text="browseCounts.value.available"></b></button>
91
+ <button type="button" data-on-click="bInstalled" data-bind-class="browseFilter.value === 'installed' && 'is-on'">Installed <b data-bind-text="browseCounts.value.installed"></b></button>
92
+ <button type="button" data-on-click="bUpdates" data-bind-class="browseFilter.value === 'updates' && 'is-on'" data-bind-hidden="!browseCounts.value.updates">Updates <b data-bind-text="browseCounts.value.updates"></b></button>
93
+ </div>
94
+ <input type="search" class="form-input plx-search" placeholder="Search the catalogue\u2026" aria-label="Search the catalogue" data-model="browseQuery.value">
95
+ <span data-help-title="Browse" data-help="What this site's manager offers it. Install brings a plugin in licensed to this site; Update replaces the installed version and keeps its data. One not licensed yet is listed so you can see it exists - ask for it to be added. Either takes effect after a server restart."></span>
32
96
  </div>
97
+ <p class="plx-note" data-bind-text="siteText.value" data-bind-hidden="!siteText.value"></p>
98
+ <p class="plx-note" data-bind-hidden="browseRows.value.length">Nothing in the catalogue matches.</p>
99
+ <div class="plx-list plx-catalogue" data-each="browseRows.value key=slug"></div>
33
100
  </div>
34
- </div>`;o.append(k)}),Domma.icons.scan(),Domma.effects.reveal(".plugin-card",{animation:"fade",stagger:60,duration:400}),Domma.effects.ripple(".btn-toggle-plugin"),o.on("click",".btn-plugin-code",function(){R.navigate(`/plugins/code/${$(this).data("name")}`)}),o.on("click",".btn-plugin-export",async function(){const e=$(this).data("name");try{await f.plugins.exportBundle(e)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}),o.on("click",".btn-plugin-settings",async function(){const e=$(this).data("name"),t=c.find(a=>a.name===e);if(!t?.settingsSchema)return;const n=`<form id="plugin-settings-form">${t.settingsSchema.map(a=>{const u=t.settings?.[a.key]??a.default??"";if(a.type==="select"){const b=a.options.map(m=>`<option value="${r(m.value)}"${String(u)===String(m.value)?" selected":""}>${r(m.label)}</option>`).join("");return`<div class="form-group">
35
- <label class="form-label">${r(a.label)}</label>
36
- <select class="form-control" name="${r(a.key)}">${b}</select>
37
- </div>`}return a.type==="number"?`<div class="form-group">
38
- <label class="form-label">${r(a.label)}</label>
39
- <input class="form-control" type="number" name="${r(a.key)}" value="${r(String(u))}"${a.min!=null?` min="${a.min}"`:""}${a.max!=null?` max="${a.max}"`:""}>
40
- </div>`:`<div class="form-group">
41
- <label class="form-label">${r(a.label)}</label>
42
- <input class="form-control" type="text" name="${r(a.key)}" value="${r(String(u))}">
43
- </div>`}).join("")}</form>`;if(!await E.confirm(n,{title:`${r(t.displayName)} Settings`,confirmText:"Save",cancelText:"Cancel",html:!0}))return;const l=document.getElementById("plugin-settings-form");if(!l)return;const p={};t.settingsSchema.forEach(a=>{const u=l.elements[a.key];u&&(p[a.key]=a.type==="number"?Number(u.value):u.value)});try{await f.plugins.update(e,{settings:p}),t.settings={...t.settings,...p},E.toast("Settings saved.",{type:"success"})}catch(a){E.toast(`Failed to save: ${a.message}`,{type:"error"})}}),o.on("click",".btn-toggle-plugin",async function(){const e=$(this),t=e.data("name"),i=e.data("enabled")!==1&&e.data("enabled")!=="1";e.prop("disabled",!0).text(i?"Enabling\u2026":"Disabling\u2026");try{const n=await f.plugins.update(t,{enabled:i});E.toast(`Plugin ${i?"enabled":"disabled"}.`,{type:"success"}),await N(n,{onStatus:l=>e.text(l.slice(0,24))});const d=await f.plugins.list();y(s,d)}catch(n){E.toast(`Failed: ${n.message}`,{type:"error"}),e.prop("disabled",!1)}})}function r(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}
101
+ </div>`)),le.querySelector(".plx-catalogue").innerHTML=ct;const ke=p.querySelector(".plx-installed");ke.innerHTML=rt;const $e=E.tabs(p.querySelector(".plx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Pe=M.applyBindings({plugins:L,catalogue:b,loading:de,loadError:Z,restarting:m,filter:C,query:K,rows:ee,counts:Re,emptyText:je,updateCount:_,updateCountText:M.computed(()=>`${_.value} update${_.value===1?"":"s"}`),browseFilter:R,browseQuery:pe,browseRows:te,browseCounts:Fe,siteText:M.computed(()=>b.value.site?`Licensed to ${b.value.site}.`:""),browseState:M.computed(()=>b.value.available===null?"loading":b.value.failed?"error":b.value.available?"list":"file")},p,{methods:{showAll:()=>{C.value="all"},showOn:()=>{C.value="on"},showOff:()=>{C.value="off"},showAttention:()=>{C.value="attention"},bAll:()=>{R.value="all"},bAvailable:()=>{R.value="available"},bInstalled:()=>{R.value="installed"},bUpdates:()=>{R.value="updates"}}});h.add(()=>Pe.dispose()),h.add(Te(p,{position:"bottom"})),h.add(M.effect(()=>{ee.value,te.value,requestAnimationFrame(()=>{p.isConnected&&I.scan(p)})})),we(ke,"name",Be,e=>se(e)),we(le.querySelector(".plx-catalogue"),"slug",qe,ze),Ce(O,{id:"plugins-install-file",icon:"upload",title:"Install from file (.dcmsplugin)",onClick:()=>q.click()}),Ce(O,{id:"plugins-new",icon:"plus",title:"New plugin",onClick:()=>xe()});const Ee=e=>{(m.peek()||A?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ee),h.add(()=>window.removeEventListener("beforeunload",Ee)),h.add(()=>{if(A?.dirty())try{S.set(X,{kind:A.kind,name:A.name,values:A.values(),at:new Date().toISOString()})}catch{}he=!0,[...Y].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(p),await Promise.all([ae(),be()]),!k())return;let y=null;try{y=S.get(X)}catch{}if(y?.values&&!he){try{S.remove?S.remove(X):S.set(X,null)}catch{}const e=y.kind==="new"?"the new plugin you started":`your settings for ${r(H(y.name)?.displayName||y.name)}`;(y.kind==="new"||H(y.name))&&await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&k()&&(y.kind==="new"?xe(y.values):se(y.name,y.values))}}};
@@ -1 +1,9 @@
1
- import{api as b}from"../api.js";import{openQuickCreate as U,INLINE_TYPES as Y}from"../lib/project-quick-create.js";function o(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}const A=[{key:"pages",label:"Pages",singular:"Page",icon:"file-text",color:"var(--dm-primary)",path:"/pages/new"},{key:"collections",label:"Collections",singular:"Collection",icon:"database",color:"var(--dm-secondary)",path:"/collections/new"},{key:"forms",label:"Forms",singular:"Form",icon:"layout",color:"var(--dm-success)",path:"/forms/new"},{key:"actions",label:"Actions",singular:"Action",icon:"zap",color:"var(--dm-warning)",path:"/actions/new"},{key:"menus",label:"Menus",singular:"Menu",icon:"menu",color:"var(--dm-info)",path:"/menus/new"},{key:"blocks",label:"Blocks",singular:"Block",icon:"box",color:"var(--dm-primary-hover)",path:"/blocks/new"},{key:"components",label:"Components",singular:"Component",icon:"component",color:"var(--dm-warning-dark)",path:"/components/new"},{key:"views",label:"Views",singular:"View",icon:"eye",color:"var(--dm-info)",path:"/views/new"},{key:"roles",label:"Roles",singular:"Role",icon:"shield",color:"var(--dm-danger)",path:"/roles"},{key:"users",label:"Users",singular:"User",icon:"users",color:"var(--dm-primary-dark)",path:"/users/new"},{key:"apis",label:"APIs",singular:"API Endpoint",icon:"code",color:"var(--dm-success)",path:"/api-endpoints/new"}];function I(e){if(!e)return null;const r=new Date(e).getTime();if(Number.isNaN(r))return null;const s=Math.max(0,Math.round((Date.now()-r)/1e3)),n=Math.round(s/60),l=Math.round(n/60),t=Math.round(l/24);if(s<45)return"just now";if(n<60)return`${n} min${n===1?"":"s"} ago`;if(l<24)return`${l} hour${l===1?"":"s"} ago`;if(t<30)return`${t} day${t===1?"":"s"} ago`;const c=Math.round(t/30);if(c<12)return`${c} month${c===1?"":"s"} ago`;const p=Math.round(t/365);return`${p} year${p===1?"":"s"} ago`}function w(e,r){if(!e)return null;const s=new Date(e);if(Number.isNaN(s.getTime()))return null;try{return D(e).format(r)}catch{return s.toLocaleDateString()}}function g({icon:e,color:r,key:s,value:n,title:l}){if(n==null||n==="")return"";const t=`color:${r};background:color-mix(in srgb, ${r} 12%, transparent);border-color:color-mix(in srgb, ${r} 25%, transparent)`,c=l?` title="${o(l)}"`:"";return`<span class="pd-meta-badge" style="${t}"${c}><span data-icon="${o(e)}"></span><span class="pd-meta-k">${o(s)}</span><span class="pd-meta-v">${o(n)}</span></span>`}export const projectDetailView={templateUrl:"/admin/js/templates/project-detail.html",async onMount(e){const s=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)$/),n=s?decodeURIComponent(s[1]):null;if(!n){location.hash="#/projects";return}const l=E.loader(e.get(0),{type:"dots"});let t;try{t=await b.projects.get(n).catch(()=>null)}finally{l.destroy()}if(!t){E.toast("Project not found.",{type:"error"}),location.hash="#/projects";return}const c=encodeURIComponent(n);e.find("#pd-name").text(t.name||n),e.find("#pd-icon").attr("data-icon",t.icon||"folder"),e.find("#pd-desc").text(t.description||""),e.find("#pd-settings").attr("href","#/projects/"+c+"/settings");const p=[g({icon:"calendar",color:"var(--dm-primary)",key:"Created",value:w(t.createdAt,"D MMM YYYY")}),g({icon:"user",color:"var(--dm-secondary)",key:"Creator",value:t.createdBy||"Unknown"}),g({icon:"clock",color:"var(--dm-success)",key:"Updated",value:I(t.updatedAt),title:w(t.updatedAt,"D MMM YYYY, HH:mm")})].join("");e.find("#pd-meta").html(p,{safe:!1}),Domma.icons.scan(e.find(".pd-hero").get(0));const u=e.find("#pd-counts"),v=e.find("#pd-total"),j={slug:n,name:t.name||n,rootUrl:t.rootUrl||""};async function h(){const y=E.loader(u.get(0),{type:"dots"});let i;try{i=await b.projects.artefacts(n).catch(()=>({}))}finally{y.destroy()}let d=0;const M=A.map(a=>{const m=Array.isArray(i[a.key])?i[a.key].length:0;d+=m;const C=Y.includes(a.key),f=`<span class="pd-tile-chip" style="color:${a.color};background:color-mix(in srgb, ${a.color} 12%, transparent)"><span data-icon="${o(a.icon)}"></span></span>`,k=`<span class="pd-tile-body"><span class="pd-tile-count">${m}</span><span class="pd-tile-label">${o(a.label)}</span></span>`,x=m>0?`<a class="pd-tile-link" href="#/projects/${c}/${o(a.key)}">${f}${k}</a>`:`<div class="pd-tile-link pd-tile-link--empty">${f}${k}</div>`,N=`<button class="pd-tile-add" data-type="${o(a.key)}" data-inline="${C?"1":""}" data-path="${o(a.path)}" title="New ${o(a.singular)}" aria-label="New ${o(a.singular)}"><span data-icon="plus"></span></button>`;return`<div class="pd-tile${m>0?" pd-tile--link":""}" style="--c:${a.color}">${x}${N}</div>`});u.html(`<div class="pd-tiles">${M.join("")}</div>`,{safe:!1}),v.text(d===1?"1 artefact":`${d} artefacts`),Domma.icons.scan(u.get(0))}await h(),u.off("click",".pd-tile-add").on("click",".pd-tile-add",function(){const y=$(this).data("type");if($(this).data("inline")){U({type:y,project:j,onCreated:h});return}const i=$(this).data("path"),d=String(i).includes("?")?"&":"?";location.hash="#"+i+d+"project="+encodeURIComponent(n)}),Domma.icons.scan(e.get(0))}};
1
+ import{api as b}from"../api.js";import{openQuickCreate as z,INLINE_TYPES as K}from"../lib/project-quick-create.js";import{addBannerAction as j}from"../lib/plugin-chrome.js";import{helpPopovers as V}from"../lib/help-popover.js";import{openMenuAt as U,viewLifetime as Q}from"../lib/tool-kit.js";import{countsOf as N,projectFromPath as Y,relTime as _,tilesFor as G}from"../lib/project-page-arrange.js";const P=/^#\/projects\/(?!new(?:[/?#]|$))[^/?#]+\/?(\?.*)?$/,c=Q(P),J=`
2
+ <div class="pdx-tile" data-key="{{key}}" tabindex="0" data-bind-class="cls" style="--c: {{tone}}">
3
+ <span class="pdx-tile-chip" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="20"></span></span>
4
+ <span class="pdx-tile-body"><strong data-bind-text="countText"></strong><small>{{label}}</small></span>
5
+ <span class="pdx-tile-actions">
6
+ <button type="button" class="pdx-add" title="New {{singular}}" aria-label="New {{singular}}">+</button>
7
+ <button type="button" class="pdx-kebab" title="Actions" aria-label="Actions for {{label}}">\u22EE</button>
8
+ </span>
9
+ </div>`;export function setBannerSection(s,o){const a=s.querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(!a)return;const l=a.nextSibling;l&&l.nodeType===3?l.nodeValue=o:a.after(document.createTextNode(o))}export const projectDetailView={templateUrl:"/admin/js/templates/project-detail.html",async onMount(s){c.reset();const o=Y(location.hash);if(!o){location.hash="#/projects";return}const r=s.get(0).querySelector(".pdx")||s.get(0),a=()=>P.test(location.hash)&&r.isConnected&&Y(location.hash)===o,l=encodeURIComponent(o),h=M.observable(null),y=M.observable({}),v=M.observable(!0),g=M.observable(""),d=M.observable(!1),p=M.observable([]),w=M.observable("");c.add(M.effect(()=>{const e=G(y.value,{slug:o,inline:K});p.value=e.tiles,w.value=v.value?"":e.totalText}));const n=()=>h.value||{},q={loading:v,loadError:g,busy:d,tiles:p,totalText:w,name:M.computed(()=>n().name||o),description:M.computed(()=>n().description||""),off:M.computed(()=>h.value&&n().enabled===!1),builtIn:M.computed(()=>n().protected===!0),rootUrl:M.computed(()=>n().rootUrl||""),rootText:M.computed(()=>n().rootUrl?`Pages under ${n().rootUrl}`:"No root URL - pages are filed by hand"),created:M.computed(()=>n().createdAt?D(n().createdAt).format("D MMM YYYY"):""),creator:M.computed(()=>n().createdBy||""),updated:M.computed(()=>_(n().updatedAt)),updatedTitle:M.computed(()=>n().updatedAt?D(n().updatedAt).format("D MMM YYYY, HH:mm"):"")},C=()=>{try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}};async function u(){try{const[e,t]=await Promise.all([b.projects.get(o),b.projects.artefacts(o).catch(()=>({}))]);if(!a())return;h.value=e,y.value=N(t),g.value="",setBannerSection(s.get(0),e?.name||o);const m=r.querySelector(".pdx-hero-icon");if(m){m.replaceChildren();const k=document.createElement("span");k.setAttribute("data-icon",e?.icon||"folder"),k.setAttribute("data-icon-size","32"),m.appendChild(k),I.scan(m)}}catch(e){if(!a())return;if(/not found|404/i.test(String(e?.message||e))){E.toast("Project not found.",{type:"error"}),location.hash="#/projects";return}g.value=`Could not load the project: ${e.message||e}`}finally{v.value=!1}}async function $(){const e=await b.projects.artefacts(o).catch(()=>null);!a()||!e||(y.value=N(e),C())}async function H(){if(!d.peek()){d.value=!0;try{await b.projects.update(o,{enabled:!0}),E.toast(`"${n().name||o}" enabled - its public pages and API are back.`,{type:"success"}),C(),a()&&await u()}catch(e){E.toast(`Not enabled: ${e.message||e}`,{type:"error"})}finally{d.value=!1}}}const x=e=>p.peek().find(t=>t.key===e),B=()=>({slug:o,name:n().name||o,rootUrl:n().rootUrl||""});function f(e,{inEditor:t=!1}={}){if(e){if(e.inline&&!t){z({type:e.key,project:B(),onCreated:$});return}location.hash=e.newHash}}const A=e=>{e?.has&&(location.hash=e.listHash)},T=e=>{const t=x(e);t&&(t.has?A(t):f(t))};function F(e){const t=x(e);return t?[{header:`${t.label} \xB7 ${t.count}`},{label:"Open the list",icon:"list",disabled:!t.has,action:()=>A(t)},{label:`New ${t.singular}\u2026`,icon:"plus",action:()=>f(t)},...t.inline?[{label:`New ${t.singular} in its editor`,icon:"edit",action:()=>f(t,{inEditor:!0})}]:[]]:[]}const i=r.querySelector(".pdx-tiles");i.innerHTML=J;const O=M.applyBindings(q,r,{methods:{enable:H,retry:()=>u()}});c.add(()=>O.dispose()),c.add(V(r,{position:"bottom"})),c.add(M.effect(()=>{p.value,requestAnimationFrame(()=>{i.isConnected&&I.scan(i)})}));const R=E.contextMenu(i,{cursor:"glyph",match:".pdx-tile",inherit:!1,density:"compact",items:e=>F(e.dataset.key)});c.add(()=>R?.destroy?.());const L=e=>{const t=e.target.closest(".pdx-tile");if(t){if(e.target.closest(".pdx-kebab")){U(e.target.closest(".pdx-kebab"));return}if(e.target.closest(".pdx-add")){f(x(t.dataset.key));return}T(t.dataset.key)}},S=e=>{const t=e.target.closest(".pdx-tile");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),U(t)):e.key==="Enter"&&e.target===t&&T(t.dataset.key))};i.addEventListener("click",L),i.addEventListener("keydown",S),c.add(()=>{i.removeEventListener("click",L),i.removeEventListener("keydown",S)}),j(s,{id:"project-overview-settings",icon:"settings",title:"Project settings",onClick:()=>{location.hash=`#/projects/${l}/settings`}}),j(s,{id:"project-overview-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>u()}),j(s,{id:"project-overview-all",icon:"folder",title:"All projects",onClick:()=>{location.hash="#/projects"}}),I.scan(r),await u(),a()&&I.scan(r)}};
@@ -1 +1 @@
1
- import{api as c}from"../api.js";import{makeIconInput as y}from"../lib/shortcode-modal.js";export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(e){const i=location.hash.split("?")[0].match(/^#\/projects\/([^/]+)\/settings$/),o=i?decodeURIComponent(i[1]):null;if(!o){location.hash="#/projects";return}const m=E.loader(e.get(0),{type:"dots"});let s;try{s=await c.projects.get(o)}catch(t){E.toast(`Failed to load project: ${t.message||t}`,{type:"error"}),location.hash="#/projects";return}finally{m.destroy()}const g=encodeURIComponent(o);e.find("#ps-title").text(`Settings - ${s.name||o}`),e.find("#ps-back").attr("href","#/projects/"+g);const u=e.find("#ps-slug"),l=e.find("#ps-name"),d=e.find("#ps-description"),a=e.find("#ps-rootUrl"),p=e.find("#ps-sortOrder"),f=e.find("#ps-enabled");if(u.val(s.slug||""),l.val(s.name||""),d.val(s.description||""),a.val(s.rootUrl||""),p.val(s.sortOrder??0),f.prop("checked",s.enabled!==!1),s.protected){a.attr("disabled","disabled");const t=a.get(0)?.nextElementSibling;t?.classList.contains("form-hint")&&(t.textContent="Locked - built-in project"),e.find("#ps-danger-zone").css("display","none"),e.find("#ps-enabled-group").css("display","none")}const h=e.find("#ps-icon-mount").get(0),n=y("e.g. folder, users, box",s.icon||"folder");n.input.id="ps-icon",n.input.classList.add("form-input"),h.appendChild(n.el),Domma.icons.scan(e.get(0)),e.find("#ps-save").on("click",async()=>{const t={name:l.val().trim(),description:d.val().trim(),icon:n.input.value.trim()||"folder",sortOrder:Number.parseInt(p.val(),10)||0};if(!s.protected){const r=a.val().trim();r&&(t.rootUrl=r),t.enabled=f.prop("checked")}try{await c.projects.update(o,t),E.toast("Saved.",{type:"success"})}catch(r){E.toast(`Save failed: ${r.message||r}`,{type:"error"})}}),e.find("#ps-untag-all").on("click",async()=>{if(await E.confirm(`Untag all artefacts from project "${o}"? They will become site-wide.`))try{const t=await c.projects.untagAll(o);E.toast(`Untagged: ${JSON.stringify(t.untagged)}`,{type:"success"}),setTimeout(()=>location.reload(),600)}catch(t){E.toast(`Untag failed: ${t.message||t}`,{type:"error"})}}),e.find("#ps-delete").on("click",async()=>{if(await E.confirm(`Delete project "${o}"? The server refuses if any artefacts are still tagged.`))try{await c.projects.remove(o),E.toast("Project deleted.",{type:"success"}),location.hash="#/projects"}catch(t){E.toast(`Delete refused: ${t.message||t}`,{type:"error"})}})}};
1
+ import{api as m}from"../api.js";import{makeIconInput as me}from"../lib/shortcode-modal.js";import{addBannerAction as B}from"../lib/plugin-chrome.js";import{helpPopovers as he}from"../lib/help-popover.js";import{sidebarCount as ve}from"../lib/tool-kit.js";import{countsOf as ne,draftKey as U,projectFromPath as ge,settingsForm as K,settingsPayload as ye,settingsRules as be,settingsSnapshot as re,untagSummary as ke}from"../lib/project-page-arrange.js";import{setBannerSection as ae}from"./project-detail.js";const N=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),C=n=>String(n||"").replace(/^#/,"").split("?")[0].replace(/\/+$/,"");let c=null,se=!1;function je(){se||!window.R?.use||(se=!0,R.use((n,t,h)=>!c||C(t?.path)!==c.path||C(n?.path)===c.path||!c.dirty()?h():E.confirm("You have unsaved changes to this project. Leave anyway? They are kept as a draft for your next visit.").then($=>{$?h():history.replaceState(null,"",`#${t.path}`)})),M.subscribe("router:afterChange",({to:n}={})=>{if(c&&C(n?.path)!==c.path){const t=c;c=null,t.dispose()}}))}export const projectSettingsView={templateUrl:"/admin/js/templates/project-settings.html",async onMount(n){if(je(),c){const e=c;c=null,e.dispose()}const t=ge(location.hash,{settings:!0});if(!t){location.hash="#/projects";return}const h=encodeURIComponent(t),$=`/projects/${h}/settings`,H=[],v=e=>(H.push(e),e);let Y=!1;const p=n.get(0).querySelector(".psx")||n.get(0),O=()=>!Y&&p.isConnected,V=E.loader(n.get(0),{type:"dots"});let g,W={};try{[g,W]=await Promise.all([m.projects.get(t),m.projects.artefacts(t).then(ne).catch(()=>({}))])}catch(e){V.destroy(),E.toast(`Could not load the project: ${e.message||e}`,{type:"error"}),location.hash="#/projects";return}if(V.destroy(),!p.isConnected)return;const r=M.observable(g),d=be(g),z=K(g),a=Object.fromEntries(Object.entries(z).map(([e,o])=>[e,M.observable(o)])),G=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),J=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),y=M.observable(z),x=M.computed(()=>re(y.value)),j=M.computed(()=>re(G())!==x.value),P=M.computed(()=>ye(G(),r.value)),Q=e=>M.computed(()=>P.value.errors[e]||""),b=M.observable(!1),l=M.observable(!1),w=M.observable(""),L=M.observable(W),k=M.computed(()=>ke(L.value)),ce={...a,saving:b,busy:l,dirty:j,errorText:w,slug:t,rootLocked:d.rootLocked,canToggle:d.canToggle,canDelete:d.canDelete,canUntag:d.canUntag,dangerShown:d.canDelete||d.canUntag,nameErr:Q("name"),rootErr:Q("rootUrl"),cannot:M.computed(()=>b.value||Object.keys(P.value.errors).length>0),filedText:M.computed(()=>{const e=k.value;if(!e.total)return"Nothing is filed under this project.";const o=r.value.rootUrl&&(L.value.pages||0)>0?` Pages under ${r.value.rootUrl} belong here by their address - untagging does not move them; clear the root URL (and save) for that.`:"";return`${e.total} filed here: ${e.text}.${o}`}),noneFiled:M.computed(()=>k.value.total===0),deleteBlocked:M.computed(()=>k.value.total>0||l.value),deleteHint:M.computed(()=>k.value.total?"Untag what is filed here first - a project can only be deleted once nothing is filed under it.":"Deleting removes the project record. It cannot be undone.")},T=()=>{try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}ve("#/projects")},F=()=>{try{S.remove?S.remove(U(t)):S.set(U(t),null)}catch{}};async function q(){if(b.peek())return;const{errors:e,payload:o}=P.peek();if(Object.keys(e).length){E.toast("Fix the marked fields first.",{type:"warning"});return}b.value=!0,w.value="";try{const s=await m.projects.update(t,o);if(!O())return;r.value=s||{...r.peek(),...o},y.value=K(r.peek());for(const[f,A]of Object.entries(y.peek()))a[f].value=A;F(),ae(n.get(0),r.peek().name||t),T(),E.toast("Project saved.",{type:"success"})}catch(s){w.value=s.message||"Could not save the project.",E.toast("Not saved - see the message at the top.",{type:"error"})}finally{b.value=!1}}async function ie(){await E.confirm("Throw away your unsaved changes to this project?")&&(X(y.peek()),w.value="")}function X(e){for(const[o,s]of Object.entries(e))a[o]&&(a[o].value=s);i&&i.input.value!==String(a.icon.peek())&&(i.input.value=String(a.icon.peek()))}async function Z(){const e=await m.projects.artefacts(t).then(ne).catch(()=>null);O()&&e&&(L.value=e)}async function le(){if(l.peek())return;const e=k.peek();if(!e.total)return;const o=N(r.peek().name||t);if(await E.confirm(`Untag everything filed under "${o}" (${N(e.text)})? It all stays where it is and belongs to Core from then on. The project itself is kept.`)){l.value=!0;try{const s=await m.projects.untagAll(t),f=Object.values(s?.untagged||{}).reduce((A,fe)=>A+(Number(fe)||0),0);E.toast(`Untagged ${f} artefact${f===1?"":"s"}.${r.peek().rootUrl?` Pages under ${r.peek().rootUrl} stay - they belong here by their address.`:""}`,{type:"success",duration:6e3}),T(),await Z()}catch(s){E.toast(`Untag failed: ${s.message||s}`,{type:"error"})}finally{l.value=!1}}}async function ue(){if(l.peek())return;const e=N(r.peek().name||t);if(await E.confirm(`Delete the project "${e}"? This cannot be undone.`)){l.value=!0;try{await m.projects.remove(t),F(),y.value=J(),E.toast(`Deleted "${r.peek().name||t}".`,{type:"success"}),T(),location.hash="#/projects"}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error",duration:7e3}),await Z()}finally{l.value=!1}}}const pe=p.querySelector(".psx-icon-mount"),i=me("folder, users, box\u2026",a.icon.peek());i.input.id="psx-icon",i.input.classList.add("form-input");const _=()=>{a.icon.value=i.input.value};i.input.addEventListener("input",_),v(()=>i.input.removeEventListener("input",_)),pe.appendChild(i.el),p.querySelector("#psx-slug").value=t;const de=M.applyBindings(ce,p,{methods:{save:q,discard:ie,untagAll:le,remove:ue}});v(()=>de.dispose()),v(he(p,{position:"bottom"})),ae(n.get(0),g.name||t),B(n,{id:"project-settings-overview",icon:"eye",title:"Project overview",onClick:()=>{location.hash=`#/projects/${h}`}}),B(n,{id:"project-settings-all",icon:"folder",title:"All projects",onClick:()=>{location.hash="#/projects"}}),B(n,{id:"project-settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>q()});const ee=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),q())};document.addEventListener("keydown",ee,!0),v(()=>document.removeEventListener("keydown",ee,!0));const te=()=>{if(j.peek())try{S.set(U(t),{form:J(),base:x.peek(),at:new Date().toISOString()})}catch{}},oe=e=>{j.peek()&&(te(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",oe),v(()=>window.removeEventListener("beforeunload",oe)),c={path:C($),dirty:()=>j.peek(),dispose(){Y=!0,te(),H.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p);let u=null;try{u=S.get(U(t))}catch{}if(u?.form){F();const e=u.base&&u.base!==x.peek(),o=u.at?` (${D(u.at).format("D MMM HH:mm")})`:"",s=e?`You left unsaved changes to this project${o}, but it has been changed since. Put your changes back over the current settings?`:`You left unsaved changes to this project${o}. Pick up where you left off?`;if(await E.confirm(s)&&O()){const f=K({...r.peek(),...u.form,enabled:u.form.enabled!==!1});X(f)}}}};
@@ -1,3 +1,11 @@
1
- import{api as I,getUser as Y}from"../api.js";const D=[{value:"badge-danger",label:"Red (Admin)"},{value:"badge-warning",label:"Orange (Manager)"},{value:"badge-info",label:"Blue (Editor)"},{value:"badge-secondary",label:"Grey (Subscriber)"},{value:"badge-success",label:"Green"},{value:"badge-primary",label:"Primary"}];export const roleEditorView={templateUrl:"/admin/js/templates/role-editor.html",async onMount(o){if(!Y()){R.navigate("/");return}const N=window.location.hash.match(/\/roles\/edit\/([^/?#]+)/)?.[1];if(!N){R.navigate("/roles");return}E.tabs(o.find("#role-tabs").get(0));const U=E.loader(o.get(0),{type:"dots"});let B,k,H;try{[B,k,H]=await Promise.all([I.get(`/collections/roles/entries/${N}`),I.get("/auth/permissions-registry"),I.projects.list().catch(()=>[])])}catch(e){U.destroy(),E.toast(`Failed to load role: ${e.message}`,{type:"error"}),R.navigate("/roles");return}U.destroy();const n=B.data||{},C=n.level===0,L=Object.fromEntries(k.resources.map(e=>[e.key,e])),A={};k.groups.forEach(e=>{A[e]=[]}),k.resources.forEach(e=>{A[e.group]&&A[e.group].push(e.key)}),o.find("#role-title").text(`Edit Role: ${n.label||n.name}`);const z=o.find("#role-name"),M=o.find("#role-label"),G=o.find("#role-level"),_=o.find("#role-badge");D.forEach(e=>{const t=document.createElement("option");t.value=e.value,t.textContent=e.label,n.badgeClass===e.value&&(t.selected=!0),_.get(0).appendChild(t)});const F=o.find("#role-project");(H||[]).forEach(e=>{const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,n.meta?.project===e.slug&&(t.selected=!0),F.get(0).appendChild(t)}),z.val(n.name||""),M.val(n.label||""),G.val(n.level??""),C&&(z.prop("disabled",!0),G.prop("disabled",!0));const V=o.find("#role-admin-allow"),q=o.find("#role-admin-home");C?o.find("#role-admin-scope-card").hide():(V.val((n.admin?.allow||[]).join(`
2
- `)),q.val(n.admin?.home||""));const Q=o.find("#permissions-container").get(0),W=n.permissions||[],S={},Z={},J={},K=(e,t)=>W.includes(e)||W.includes(`${e}.${t}`);k.groups.forEach(e=>{const t=A[e];if(!t.length)return;const r=document.createElement("div");r.className="card mb-3";const d=document.createElement("div");d.className="card-header",d.style.cssText="display:flex;align-items:center;justify-content:space-between;cursor:default;";const v=document.createElement("strong");v.textContent=e;const i=document.createElement("label");i.style.cssText="display:flex;align-items:center;gap:.4rem;font-size:.85rem;font-weight:normal;cursor:pointer;";const c=document.createElement("input");c.type="checkbox",c.style.cursor="pointer",i.appendChild(c),i.appendChild(document.createTextNode("Select All")),J[e]=c,d.appendChild(v),d.appendChild(i),r.appendChild(d);const u=document.createElement("div");u.className="card-body",u.style.cssText="padding:.75rem 1rem;",t.forEach((l,T)=>{const g=L[l],m=g.actions.map(s=>s.key),f=T===t.length-1,a=document.createElement("div");a.style.cssText=`margin-bottom:${f?"0":".75rem"};padding-bottom:${f?"0":".75rem"};${f?"":"border-bottom:1px solid var(--dm-border);"}`;const h=document.createElement("div");h.style.cssText="display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem;";const j=document.createElement("span");j.dataset.icon=g.icon;const x=document.createElement("strong");x.textContent=g.label,x.style.fontSize=".9rem";const p=document.createElement("label");p.style.cssText="margin-left:auto;font-size:.8rem;display:flex;align-items:center;gap:.35rem;cursor:pointer;";const y=document.createElement("input");y.type="checkbox",y.style.cursor="pointer";const X=m.every(s=>K(l,s));y.checked=X,Z[l]=y,p.appendChild(y),p.appendChild(document.createTextNode("Full Access")),h.appendChild(j),h.appendChild(x),h.appendChild(p),a.appendChild(h);const O=document.createElement("div");O.style.cssText="display:flex;gap:1.5rem;padding-left:1.5rem;flex-wrap:wrap;";const $={};g.actions.forEach(s=>{const w=document.createElement("label");w.style.cssText="display:flex;align-items:center;gap:.3rem;font-size:.875rem;cursor:pointer;";const b=document.createElement("input");b.type="checkbox",b.style.cursor="pointer",b.checked=K(l,s.key),b.dataset.res=l,b.dataset.action=s.key,S[`${l}.${s.key}`]=b,$[s.key]=b,w.appendChild(b),w.appendChild(document.createTextNode(s.label)),O.appendChild(w)}),y.addEventListener("change",()=>{m.forEach(s=>{$[s].checked=y.checked}),P(e,t)}),m.forEach(s=>{$[s].addEventListener("change",()=>{y.checked=m.every(w=>$[w].checked),P(e,t)})}),a.appendChild(O),u.appendChild(a)}),c.addEventListener("change",()=>{t.forEach(l=>{L[l].actions.map(m=>m.key).forEach(m=>{const f=S[`${l}.${m}`];f&&(f.checked=c.checked)});const g=Z[l];g&&(g.checked=c.checked)})}),P(e,t),r.appendChild(u),Q.appendChild(r)}),Domma.icons.scan("#permissions-container"),o.find("#btn-save-role").on("click",async()=>{const e=z.val().trim().toLowerCase().replace(/[^a-z0-9-]/g,"-"),t=M.val().trim(),r=parseInt(G.val(),10),d=_.val();if(!t){E.toast("Display label is required.",{type:"error"});return}const v=[];k.resources.forEach(a=>{const h=a.key,j=a.actions.map(p=>p.key),x=j.filter(p=>S[`${h}.${p}`]?.checked);x.length===j.length?v.push(h):x.forEach(p=>v.push(`${h}.${p}`))});const i=F.val()||"",c=String(V.val()||"").split(`
3
- `).map(a=>a.trim()).filter(Boolean),u=String(q.val()||"").trim(),l=[...c,u].find(a=>a&&!/^\/[A-Za-z0-9\-_/]*$/.test(a));if(!C&&l){E.toast(`"${l}" is not an admin route - use a path like /portal.`,{type:"error"});return}const T=!C&&(c.length||u)?{allow:c,...u&&{home:u}}:null,{admin:g,...m}=n,f={data:{...m,...T&&{admin:T},name:C?n.name:e||n.name,label:t,level:C||isNaN(r)?n.level:r,permissions:v,badgeClass:d,meta:{...n.meta||{},project:i||null}}};try{await I.put(`/collections/roles/entries/${N}`,f),E.toast("Role saved.",{type:"success"}),R.navigate("/roles")}catch(a){E.toast(`Save failed: ${a.message}`,{type:"error"})}});function P(e,t){const r=J[e];r&&(r.checked=t.every(d=>L[d].actions.map(i=>i.key).every(i=>S[`${d}.${i}`]?.checked)))}}};
1
+ import{api as O,apiRequest as Ue,getUser as Be}from"../api.js";import{addBannerAction as ke}from"../lib/plugin-chrome.js";import{helpPopovers as He}from"../lib/help-popover.js";import{openMenuAt as Se,sidebarCount as Pe}from"../lib/tool-kit.js";import{BADGE_OPTIONS as xe}from"../lib/roles-arrange.js";import{checkRole as Ke,expandPerms as Ge,formFromRole as z,matrixRows as _e,payloadFromForm as Ve,roleFacts as Ye,selfRisks as Je,setActions as Ee,snapshot as F,summaryLines as We,toggleAction as ze}from"../lib/role-editor-arrange.js";const Qe=/^\/roles\/edit\/[^/?#]+(\?.*)?$/,P=o=>String(o||"").split("?")[0],T=o=>`role-editor.draft.${o}`,w=["name","label","level","badgeClass","project","adminAllow","adminHome"],Xe=`
2
+ <div class="rex-row" data-bind-class="kind === 'group' ? 'is-group' : ''" data-id="{{id}}" data-kind="{{kind}}" data-key="{{key}}" data-group="{{group}}" tabindex="0">
3
+ <span class="rex-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="15"></span></span>
4
+ <span class="rex-main"><strong>{{label}}</strong><small>{{description}}</small></span>
5
+ <span class="rex-acts">{{#each actions key=key}}<button type="button" class="rex-act" data-act="{{key}}" aria-pressed="{{on}}" title="{{title}}">{{label}}</button>{{/each}}</span>
6
+ <button type="button" class="rex-full" data-state="{{state}}" data-bind-hidden="kind === 'group'" data-bind-disabled="locked" title="Every action in this area">Full</button>
7
+ <span class="rex-state">{{meta}}</span>
8
+ <button type="button" class="rex-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
9
+ </div>`,Ze="<li>{{text}}</li>",et='<div class="rex-sum"><strong>{{title}}</strong><span>{{text}}</span></div>',we=o=>String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Q=(o,c,p=`${c}s`)=>`${o} ${o===1?c:p}`;function Me(o,c){const p=o.querySelector(".dm-plugin-banner-name");if(!p)return;const d=p.querySelector(".dm-plugin-banner-parent"),C=p.querySelector(".dm-plugin-banner-sep");d&&C&&p.replaceChildren(d,C,document.createTextNode(c))}let v=null,Re=!1;function tt(){Re||!window.R?.use||(Re=!0,R.use((o,c,p)=>!(Qe.test(c?.path||"")&&P(o?.path)!==P(c?.path))||!v?.dirty()?p():E.confirm("This role has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(C=>{C?p():v&&history.replaceState(null,"",v.hash)})),M.subscribe("router:afterChange",({to:o}={})=>{if(v&&P(o?.path)!==v.path){const c=v;v=null,c.dispose()}}))}export const roleEditorView={templateUrl:"/admin/js/templates/role-editor.html",async onMount(o){if(tt(),v){const e=v;v=null,e.dispose()}const c=Be();if(!c){R.navigate("/");return}const p=location.hash.match(/^#\/roles\/edit\/([^/?#]+)/),d=p?decodeURIComponent(p[1]):"";if(!d){R.navigate("/roles");return}const C=[],f=e=>(C.push(e),e);let X=!1;const m=o.get(0).querySelector(".rex")||o.get(0),N=()=>!X&&m.isConnected,Z=E.loader(o.get(0),{type:"dots"});let ee,K,te,j,G;try{[ee,K,te,j,G]=await Promise.all([O.get(`/collections/roles/entries/${encodeURIComponent(d)}`),O.get("/auth/permissions-registry"),O.projects.list().catch(()=>[]),O.get("/collections/roles/entries?limit=1000").catch(()=>({entries:[]})),Ue("/roles/_holders",{method:"GET"}).catch(()=>null)])}catch(e){Z.destroy(),E.toast(`Could not load the role: ${e.message||e}`,{type:"error"}),R.navigate("/roles");return}if(Z.destroy(),!m.isConnected)return;const h=K.resources||[],oe=K.groups||[];let a=ee.data||{};const r=Ye(a),ae=j?.entries||(Array.isArray(j)?j:[]),Ce=ae.map(e=>e.data?.name).filter(Boolean),_=e=>Number(G?.counts?.[e]||0),ne=[c.role,...Array.isArray(c.additionalRoles)?c.additionalRoles:[]].filter(Boolean),re=ae.map(e=>e.data||{}).filter(e=>ne.includes(e.name)&&e.name!==a.name),se={holds:ne.includes(a.name),otherRoot:re.some(e=>e.level===0),otherGranted:[...new Set(re.flatMap(e=>Ge(e.permissions,h)))]},$=z(a,h),l={};for(const e of w)l[e]=M.observable($[e]);const n=M.observable($.granted),U=()=>({...Object.fromEntries(w.map(e=>[e,l[e].value])),granted:n.value}),le=()=>({...Object.fromEntries(w.map(e=>[e,l[e].peek()])),granted:n.peek()}),L=M.observable(F($)),b=M.observable(a.name||""),ie=M.observable(a.level),B=M.computed(()=>F(U())!==L.value),H=(e,t)=>JSON.stringify(t.map(s=>JSON.parse(e)[s])),$e=M.computed(()=>H(F(U()),w)!==H(L.value,w)),Le=M.computed(()=>H(F(U()),["granted"])!==H(L.value,["granted"])),A=M.observable(!1),V=M.computed(()=>Ke(U(),{facts:r,taken:Ce,originalName:b.value})),Te=M.computed(()=>A.value||Object.keys(V.value).length>0),ce=M.observable(""),q=M.observable("all"),Y=M.observable([]);f(M.effect(()=>{Y.value=_e(h,oe,n.value,{query:ce.value,show:q.value,locked:r.isRoot})}));const k=m.querySelector(".rex-matrix");f(M.effect(()=>{Y.value,requestAnimationFrame(()=>{k.isConnected&&I.scan(k)})}));const Ae=h.reduce((e,t)=>e+(t.actions||[]).length,0),J=M.computed(()=>Je({...se,oldLevel:ie.value,newLevel:Number.parseInt(l.level.value,10),granted:n.value,isRoot:r.isRoot})),de=M.observable([]);f(M.effect(()=>{de.value=J.value.map((e,t)=>({id:String(t),text:e}))}));const ue=M.observable([]);f(M.effect(()=>{const e=String(l.adminAllow.value||"").split(`
10
+ `).map(t=>t.trim()).filter(Boolean);ue.value=We(n.value,h,oe,{isRoot:r.isRoot,admin:{allow:e,home:String(l.adminHome.value||"").trim()}})}));const qe={...l,granted:n,dirty:B,generalDirty:$e,permsDirty:Le,errors:V,cannot:Te,saving:A,query:ce,show:q,rows:Y,risks:J,riskRows:de,summary:ue,isRoot:r.isRoot,nameLockedText:r.nameLocked,pluginText:r.plugin?`From ${r.plugin}`:"",previewLabel:M.computed(()=>String(l.label.value||"").trim()||l.name.value||"This role"),holdersText:M.computed(()=>G?`Held by ${Q(_(b.value),"user")}`:""),renameNote:M.computed(()=>{const e=_(b.value),t=String(l.name.value||"").trim();return t&&t!==b.value&&e?`${Q(e,"user")} ${e===1?"holds":"hold"} "${b.value}": renamed, they are on a role that no longer exists until you move them under Users.`:""}),scopeText:M.computed(()=>{const e=String(l.adminAllow.value||"").split(`
11
+ `).map(t=>t.trim()).filter(Boolean);return e.length?e.length===1?`Confined to ${e[0]}`:`Confined to ${e.length} screens`:""}),grantedCount:M.computed(()=>r.isRoot?"all":String(n.value.length)),grantedText:M.computed(()=>r.isRoot?"Every permission (root)":`${n.value.length} of ${Ae} actions granted`)},pe=m.querySelector("#rex-badge");for(const e of xe){const t=document.createElement("option");t.value=e.value,t.textContent=e.label,pe.appendChild(t)}if($.badgeClass&&!xe.some(e=>e.value===$.badgeClass)){const e=document.createElement("option");e.value=$.badgeClass,e.textContent=$.badgeClass,pe.appendChild(e)}const Oe=m.querySelector("#rex-project");for(const e of te||[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,Oe.appendChild(t)}async function W(){if(A.peek())return;const e=Object.values(V.peek());if(e.length){E.toast(e[0],{type:"error"});return}const t=[...J.peek()],s=String(l.name.peek()||"").trim(),u=_(b.peek());if(!r.nameLocked&&s!==b.peek()&&u&&t.push(`${Q(u,"user")} ${u===1?"holds":"hold"} "${b.peek()}" - after renaming they are on a role that no longer exists until you move them.`),t.length&&!await E.confirm(`${t.map(y=>we(y)).join("<br><br>")}<br><br>Save anyway?`)||!N())return;A.value=!0;const i=Ve(le(),a,{facts:r,resources:h});try{if(await O.put(`/collections/roles/entries/${encodeURIComponent(d)}`,{data:i}),!N())return;a=i;const y=z(a,h);for(const be of w)l[be].value=y[be];n.value=y.granted,L.value=F(y),b.value=a.name,ie.value=a.level;try{S.remove?S.remove(T(d)):S.set(T(d),null)}catch{}if(Me(o.get(0),a.label||a.name),Pe("#/roles"),se.holds){try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}try{document.dispatchEvent(new CustomEvent("dm:admin-home-changed"))}catch{}}E.toast(`Saved "${a.label||a.name}".`,{type:"success"})}catch(y){E.toast(`Not saved: ${y.message||y}`,{type:"error"})}finally{A.value=!1}}async function De(){if(!await E.confirm("Throw away the changes you have not saved?")||!N())return;const e=z(a,h);for(const t of w)l[t].value=e[t];n.value=e.granted}const me=e=>e?.closest?.(".rex-row");function x(e,t){if(r.isRoot||!e)return;const{kind:s,key:u,group:i}=e.dataset;s==="group"?n.value=Ee(n.peek(),h,{group:i,...t}):n.value=Ee(n.peek(),h,{resource:u,...t})}const Fe=e=>{const{kind:t,key:s,group:u}=e.dataset,i=r.isRoot,y=e.querySelector(".rex-main strong")?.textContent||s||u;return t==="group"?[{header:`${u} - every area`},{label:"Grant everything",icon:"check-circle",disabled:i,action:()=>x(e,{to:!0})},{label:"View only",icon:"eye",disabled:i,action:()=>x(e,{only:"read"})},{label:"Grant nothing",icon:"x-circle",disabled:i,action:()=>x(e,{to:!1})}]:[{header:y},{label:"Full access",icon:"check-circle",disabled:i,action:()=>x(e,{to:!0})},{label:"View only",icon:"eye",disabled:i,action:()=>x(e,{only:"read"})},{label:"No access",icon:"x-circle",disabled:i,action:()=>x(e,{to:!1})},{type:"divider"},{label:"Copy permission key",icon:"copy",action:()=>{navigator.clipboard?.writeText(s).then(()=>E.toast(`Copied "${s}".`,{type:"success"}),()=>E.toast(s,{type:"info",duration:6e3}))}}]};k.innerHTML=Xe,m.querySelector(".rex-risks").innerHTML=Ze,m.querySelector(".rex-summary").innerHTML=et;const Ie=E.tabs(m.querySelector(".rex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Ne=M.applyBindings(qe,m,{methods:{save:()=>W(),discard:()=>De(),showAll:()=>{q.value="all"},showOn:()=>{q.value="on"},showOff:()=>{q.value="off"}}});f(()=>Ne.dispose()),f(He(m,{position:"bottom"}));const je=E.contextMenu(k,{cursor:"glyph",match:".rex-row",inherit:!1,density:"compact",items:Fe});f(()=>je?.destroy?.());const ve=e=>{const t=me(e.target);if(!t)return;const s=e.target.closest(".rex-kebab");if(s){Se(s);return}if(r.isRoot)return;const u=e.target.closest(".rex-act");if(u){n.value=ze(n.peek(),`${t.dataset.key}.${u.dataset.act}`);return}e.target.closest(".rex-full")&&x(t,{to:e.target.closest(".rex-full").dataset.state!=="all"})},fe=e=>{const t=me(e.target);if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),Se(t);return}e.key==="Enter"&&e.target===t&&t.dataset.kind==="res"&&!r.isRoot&&(e.preventDefault(),x(t,{to:t.querySelector(".rex-full")?.dataset.state!=="all"}))}};k.addEventListener("click",ve),k.addEventListener("keydown",fe),f(()=>{k.removeEventListener("click",ve),k.removeEventListener("keydown",fe)}),Me(o.get(0),a.label||a.name||"Edit"),ke(o,{id:"role-editor-back",icon:"arrow-left",title:"Back to roles",onClick:()=>{location.hash="#/roles"}}),ke(o,{id:"role-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>W()});const he=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),W())};document.addEventListener("keydown",he,!0),f(()=>document.removeEventListener("keydown",he,!0));const ge=()=>{if(B.peek())try{S.set(T(d),{form:le(),base:L.peek(),at:new Date().toISOString()})}catch{}},ye=e=>{B.peek()&&(ge(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ye),f(()=>window.removeEventListener("beforeunload",ye)),v={path:P(location.hash.replace(/^#/,"")),hash:location.hash,dirty:()=>B.peek(),dispose(){X=!0,ge(),C.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(m);let g=null;try{g=S.get(T(d))}catch{}if(g?.form){try{S.remove?S.remove(T(d)):S.set(T(d),null)}catch{}const e=g.base&&g.base!==L.peek(),t=g.at?` (${D(g.at).format("D MMM HH:mm")})`:"",s=we(a.label||a.name),u=e?`You left unsaved changes to "${s}"${t}, but the role has been saved since. Restore your version anyway?`:`You left unsaved changes to "${s}"${t}. Restore them?`;if(await E.confirm(u)&&N()){for(const i of w)i in g.form&&(l[i].value=String(g.form[i]??""));Array.isArray(g.form.granted)&&(n.value=[...g.form.granted].sort())}}}};
@@ -1,4 +1,49 @@
1
- import{api as y,getUser as C}from"../api.js";import{getProjectFromHash as N}from"../lib/project-context.js";import{keepTableIconsAlive as k}from"../lib/table-icons.js";const $=[{value:"badge-danger",label:"Red (Admin)"},{value:"badge-warning",label:"Orange (Manager)"},{value:"badge-info",label:"Blue (Editor)"},{value:"badge-secondary",label:"Grey (Subscriber)"},{value:"badge-success",label:"Green"},{value:"badge-primary",label:"Primary"}];function w(t){if(!t||!t.length)return"<em>None</em>";const f=new Set(t.map(i=>i.split(".")[0])),b=t.every(i=>!i.includes(".")),o=f.size;return`${o} resource${o!==1?"s":""} ${b?"(full)":"(partial)"}`}function x(t){return String(t).replace(/"/g,"&quot;")}export const rolesView={templateUrl:"/admin/js/templates/roles.html",async onMount(t){if(!C()){R.navigate("/");return}const b=E.loader(t.get(0),{type:"dots"});let o=[];const i=N(),v=async()=>{o=(await y.get("/collections/roles/entries?limit=100")).entries||[],i&&(o=o.filter(a=>(a?.data?.meta?.project||"core")===i))},h=()=>{T.create("#roles-table",{pageSize:25,data:o,columns:[{key:"data",title:"Label",render:e=>`<span class="badge ${e.badgeClass||"badge-secondary"}">${e.label||""}</span>`},{key:"data",title:"Name",render:e=>`<code>${e.name||""}</code>`},{key:"data",title:"Level",render:e=>e.level??""},{key:"data",title:"Permissions",render:e=>w(e.permissions)},{key:"id",title:"Actions",render:(e,a)=>{const n=a.data?.level===0;return`
2
- <a href="#/roles/edit/${e}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- ${n?"":`<button class="btn btn-sm btn-ghost btn-delete-role" data-id="${e}" data-name="${x(a.data?.label)}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>`}
4
- `}}],emptyMessage:"No roles found."}),Domma.icons.scan(),document.querySelectorAll("#roles-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})};try{await v()}finally{b.destroy()}h(),k(t.get(0)),document.addEventListener("click",async function e(a){if(!document.contains(t.get(0))){document.removeEventListener("click",e);return}const n=a.target.closest(".btn-delete-role");if(!n)return;const m=n.dataset.id,p=n.dataset.name;if(await E.confirm(`Delete role <strong>${p}</strong>? This cannot be undone.`))try{await y.delete(`/collections/roles/entries/${m}`),E.toast("Role deleted.",{type:"success"}),await v(),h()}catch(u){E.toast(`Failed: ${u.message}`,{type:"error"})}}),t.find("#btn-add-role").on("click",()=>{const e=document.createElement("div");e.style.cssText="display:flex;flex-direction:column;gap:1rem;padding:1rem;";const a=(l,c)=>{const s=document.createElement("div"),r=document.createElement("label");return r.textContent=l,r.style.cssText="display:block;font-size:.85rem;margin-bottom:.25rem;font-weight:600;",s.appendChild(r),s.appendChild(c),s},n=Object.assign(document.createElement("input"),{type:"text",className:"form-input",placeholder:"e.g. moderator"}),m=Object.assign(document.createElement("input"),{type:"text",className:"form-input",placeholder:"e.g. Moderator"}),p=Object.assign(document.createElement("input"),{type:"number",className:"form-input",value:"5",min:"1"}),d=document.createElement("select");d.className="form-input",$.forEach(l=>{const c=Object.assign(document.createElement("option"),{value:l.value,textContent:l.label});d.appendChild(c)});const u=Object.assign(document.createElement("button"),{className:"btn btn-primary",textContent:"Create Role"});e.appendChild(a("Name (slug)",n)),e.appendChild(a("Display Label",m)),e.appendChild(a("Level",p)),e.appendChild(a("Badge Colour",d)),e.appendChild(u);const g=E.slideover({title:"Add Role",size:"sm",position:"right"});g.element.appendChild(e),g.open(),u.addEventListener("click",async()=>{const l=n.value.trim().toLowerCase().replace(/[^a-z0-9-]/g,"-"),c=m.value.trim(),s=parseInt(p.value,10);if(!l||!c||isNaN(s)||s<1){E.toast("Name, label and level (\u2265 1) are required.",{type:"error"});return}try{const r=await y.post("/collections/roles/entries",{data:{name:l,label:c,level:s,permissions:[],badgeClass:d.value}});g.close(),E.toast("Role created. Set permissions in the editor.",{type:"success"}),R.navigate(`/roles/edit/${r.id}`)}catch(r){E.toast(`Error: ${r.message}`,{type:"error"})}})})}};
1
+ import{apiRequest as f,getUser as xe}from"../api.js";import{addBannerAction as Ee}from"../lib/plugin-chrome.js";import{helpPopovers as te}from"../lib/help-popover.js";import{onceClosed as ke,openMenuAt as ae,sidebarCount as Me,viewLifetime as Se}from"../lib/tool-kit.js";import{BADGE_OPTIONS as Te,FILTERS as $e,arrangeRoles as Ce,checkNewRole as Le,deleteBlock as le,duplicateOf as Oe,filterCounts as Ne,slugify as Ie}from"../lib/roles-arrange.js";const ne=/^#\/(projects\/[^/?#]+\/)?roles\/?(\?.*)?$/,je="#/roles",x="roles.draft",A="roles.filter",Re=`
2
+ <div class="rlx-row" data-id="{{id}}" tabindex="0">
3
+ <span class="rlx-badge"><span class="badge" data-bind-class="badgeClass">{{label}}</span></span>
4
+ <span class="rlx-main"><strong class="font-mono">{{name}}</strong><small>{{meta}}</small></span>
5
+ <span class="rlx-chips">
6
+ <span class="rlx-chip">{{levelText}}</span>
7
+ <span class="rlx-chip" data-bind-hidden="!holdersText" data-bind-class="unheld ? 'is-warn' : 'is-ok'" title="Users holding this role, as their main role or an extra one">{{holdersText}}</span>
8
+ <span class="rlx-chip is-info" data-bind-hidden="!plugin" title="Brought by a plugin">{{plugin}}</span>
9
+ <span class="rlx-chip is-warn" data-bind-hidden="!scope" title="Its holders see only these admin screens">{{scope}}</span>
10
+ <span class="rlx-chip" data-bind-hidden="!builtIn" title="Seeded by the CMS - cannot be deleted">Built in</span>
11
+ </span>
12
+ <button type="button" class="rlx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
13
+ </div>`,De=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Be(s){const n=document.createElement("template");return n.innerHTML=s.trim(),n.content.firstElementChild}const se=(s,n,p=`${n}s`)=>`${s} ${s===1?n:p}`,i=Se(ne);export const rolesView={templateUrl:"/admin/js/templates/roles.html",async onMount(s){if(!xe()){R.navigate("/");return}i.reset();const n=s.get(0).querySelector(".rlx")||s.get(0),p=()=>ne.test(location.hash)&&n.isConnected,y=(location.hash.match(/^#\/projects\/([^/?#]+)\/roles/)||[])[1]?decodeURIComponent(location.hash.match(/^#\/projects\/([^/?#]+)\/roles/)[1]):"",c=M.observable([]),k=M.observable({}),q=M.observable([]),P=M.observable(!0),T=M.observable("");let K="all";try{K=$e.includes(S.get(A))?S.get(A):"all"}catch{}const d=M.observable(K),_=M.observable("");i.add(M.effect(()=>{const e=d.value;try{S.set(A,e)}catch{}}));const $=M.computed(()=>y?c.value.filter(e=>(e?.data?.meta?.project||"core")===y):c.value),F=M.observable([]);i.add(M.effect(()=>{F.value=Ce($.value,{filter:d.value,query:_.value,holders:k.value})}));const oe=M.computed(()=>Ne($.value)),re=M.computed(()=>$.value.length?"No roles match.":"No roles here."),ie=M.computed(()=>{const e=q.value;if(!e.length)return"";const t=[...new Set(e.map(a=>a.role))].map(a=>`"${a}"`).join(", ");return`${se(e.length,"user")} ${e.length===1?"holds":"hold"} a role that no longer exists (${t}) - edit them under Users.`}),ce=e=>{const t=c.peek().find(a=>a.id===e);return t?{id:t.id,...t.data||{}}:null};async function J(){try{const[e,t]=await Promise.all([f("/collections/roles/entries?limit=1000",{method:"GET"}),f("/roles/_holders",{method:"GET"}).catch(()=>null)]);if(!p())return;c.value=e?.entries||[],k.value=t?.counts||{},q.value=t?.orphans||[],T.value=""}catch(e){T.value=`Could not load the roles: ${e.message||e}`}finally{P.value=!1}}const h=e=>{location.hash=`#/roles/edit/${encodeURIComponent(e)}`},C=()=>Me(je);async function de(e){try{await navigator.clipboard.writeText(e.name),E.toast(`Copied "${e.name}".`,{type:"success"})}catch{E.toast(`Role name: ${e.name}`,{type:"info",duration:6e3})}}async function ue(e){const t=Oe(e,c.peek().map(a=>a.data?.name));try{const a=await f("/collections/roles/entries",{method:"POST",body:JSON.stringify({data:t})});E.toast(`Made "${t.label}" with the same permissions.`,{type:"success"}),C(),a?.id&&p()&&h(a.id)}catch(a){E.toast(`Not duplicated: ${a.message||a}`,{type:"error"})}}async function pe(e){const t=le(e);if(t){E.toast(t,{type:"warning"});return}const a=k.peek()[e.name]||0,g=a?` ${se(a,"user")} ${a===1?"holds":"hold"} it and will need another role.`:"";if(await E.confirm(`Delete the role "${De(e.label||e.name)}"?${g} This cannot be undone.`))try{await f(`/collections/roles/entries/${encodeURIComponent(e.id)}`,{method:"DELETE"}),E.toast(`Deleted "${e.label||e.name}".`,{type:"success"}),C(),await J()}catch(v){E.toast(`Not deleted: ${v.message||v}`,{type:"error"})}}let m=null;const L=new Set;let G=!1;function H(e=null){const t={label:"",name:"",level:"5",badgeClass:"badge-secondary",...e||{}},a=Object.fromEntries(Object.entries(t).map(([l,r])=>[l,M.observable(String(r))]));let g=!!e?.name;const v=()=>Object.fromEntries(Object.keys(a).map(l=>[l,a[l].peek()])),ve=JSON.stringify(t),W=()=>JSON.stringify(v())!==ve||!!e,w=M.observable(!1),N=M.computed(()=>Le(Object.fromEntries(Object.keys(a).map(l=>[l,a[l].value])),{taken:c.value.map(l=>l.data?.name),project:y})),j=l=>M.computed(()=>N.value.errors[l]||""),Q={...a,saving:w,nameErr:j("name"),labelErr:j("label"),levelErr:j("level"),previewText:M.computed(()=>a.label.value.trim()||"New role"),cannot:M.computed(()=>w.value||Object.keys(N.value.errors).length>0)},fe=M.effect(()=>{const l=a.label.value;g||(a.name.value=Ie(l))}),o=E.slideover({title:"New role",size:"sm",position:"right"}),u=Be(`
14
+ <div class="rlx-form">
15
+ <div>
16
+ <label class="form-label" for="rlx-f-label">Label <span class="text-danger">*</span></label>
17
+ <input id="rlx-f-label" type="text" class="form-input" maxlength="60" placeholder="Moderator" data-model="label.value">
18
+ <small class="rlx-err" data-bind-hidden="!labelErr.value" data-bind-text="labelErr.value"></small>
19
+ </div>
20
+ <div>
21
+ <label class="form-label" for="rlx-f-name">Name
22
+ <span data-help="The role's id - what a user record and a page's visibility hold. Made from the label as you type."></span></label>
23
+ <input id="rlx-f-name" type="text" class="form-input font-mono" maxlength="60" data-model="name.value">
24
+ <small class="rlx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
25
+ </div>
26
+ <div>
27
+ <label class="form-label" for="rlx-f-level">Level
28
+ <span data-help-title="Level" data-help="Lower outranks higher: level 1 is an admin, 2 the ordinary user. Someone can only manage users on a higher number than their own, and 'visibility: this-role' on a page also lets in every role on the same or a lower number."></span></label>
29
+ <input id="rlx-f-level" type="number" min="1" class="form-input" data-model="level.value">
30
+ <small class="rlx-err" data-bind-hidden="!levelErr.value" data-bind-text="levelErr.value"></small>
31
+ </div>
32
+ <div>
33
+ <label class="form-label" for="rlx-f-badge">Badge colour</label>
34
+ <div class="rlx-preview">
35
+ <select id="rlx-f-badge" class="form-input" data-model="badgeClass.value">
36
+ ${Te.map(l=>`<option value="${l.value}">${l.label}</option>`).join("")}
37
+ </select>
38
+ <span class="badge" data-bind-class="badgeClass.value" data-bind-text="previewText.value"></span>
39
+ </div>
40
+ </div>
41
+ <small class="rlx-hint">It starts with no permissions - the role editor opens next to set them.</small>
42
+ <div class="rlx-actions">
43
+ <small class="rlx-hint">Ctrl+Enter creates.</small>
44
+ <span class="rlx-spacer"></span>
45
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
46
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
47
+ <span data-icon="plus" data-icon-size="13"></span> Create role</button>
48
+ </div>
49
+ </div>`);let D=!1;async function X(){if(Q.cannot.peek())return;w.value=!0;const{payload:l}=N.peek();try{const r=await f("/collections/roles/entries",{method:"POST",body:JSON.stringify({data:l})});E.toast(`Created "${l.label}" - now set its permissions.`,{type:"success"}),D=!0,o.close(),C(),r?.id&&h(r.id)}catch(r){E.toast(`Not created: ${r.message||r}`,{type:"error"})}finally{w.value=!1}}o.element.appendChild(u);const ye=te(u),ge=M.applyBindings(Q,u,{methods:{save:X,cancel:()=>o.close()}}),B=u.querySelector("#rlx-f-name"),Z=()=>{g=!!B.value};B.addEventListener("input",Z),u.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),X())});const ee={dirty:W,values:v};m=ee,L.add(o),ke(o,()=>{fe(),ge.dispose(),ye(),B.removeEventListener("input",Z),L.delete(o),m===ee&&(m=null)});const we=o.close.bind(o);let U=!1;o.close=async l=>{if(!D&&!G&&l!==!0&&W()){if(U)return;U=!0;const r=await E.confirm("Discard the new role you have not created?");if(U=!1,!r)return}return D=!0,we()},I.scan(u),o.open(),u.querySelector("#rlx-f-label")?.focus()}function me(e){const t=ce(e);if(!t)return[];const a=le(t);return[{header:t.label||t.name},{label:"Edit permissions",icon:"edit",action:()=>h(t.id)},{label:"Duplicate",icon:"copy",action:()=>ue(t)},{label:"Copy name",icon:"clipboard",action:()=>de(t)},{type:"divider"},a?{label:t.plugin?`Delete - from ${t.plugin}`:"Delete - built in",icon:"trash",disabled:!0,title:a}:{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>pe(t)}]}const b=n.querySelector(".rlx-list");b.innerHTML=Re;const be=M.applyBindings({projectSlug:y,entries:c,loading:P,loadError:T,filter:d,query:_,rows:F,counts:oe,emptyText:re,orphanText:ie},n,{methods:{showAll:()=>{d.value="all"},showBuiltIn:()=>{d.value="built-in"},showCustom:()=>{d.value="custom"},showPlugins:()=>{d.value="plugins"}}});i.add(()=>be.dispose()),i.add(te(n,{position:"bottom"}));const he=E.contextMenu(b,{cursor:"glyph",match:".rlx-row",inherit:!1,density:"compact",items:e=>me(e.dataset.id)});i.add(()=>he?.destroy?.());const Y=e=>{const t=e.target.closest(".rlx-row");if(!t)return;const a=e.target.closest(".rlx-kebab");if(a){ae(a);return}h(t.dataset.id)},z=e=>{const t=e.target.closest(".rlx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ae(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.id))};b.addEventListener("click",Y),b.addEventListener("keydown",z),i.add(()=>{b.removeEventListener("click",Y),b.removeEventListener("keydown",z)}),Ee(s,{id:"roles-new",icon:"plus",title:"New role",onClick:()=>H()});const V=e=>{m?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",V),i.add(()=>window.removeEventListener("beforeunload",V)),i.add(()=>{if(m?.dirty())try{S.set(x,{values:m.values(),at:new Date().toISOString()})}catch{}G=!0,[...L].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(n),await J(),!p())return;I.scan(n);let O=null;try{O=S.get(x)}catch{}if(O?.values){try{S.remove?S.remove(x):S.set(x,null)}catch{}await E.confirm("You left a new role unfinished. Pick up where you left off?")&&p()&&H(O.values)}}};