domma-cms 0.76.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 (88) hide show
  1. package/CLAUDE.md +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +2 -2
  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/block-editor-arrange.js +2 -0
  7. package/admin/js/lib/collection-editor-arrange.js +3 -0
  8. package/admin/js/lib/collection-entries-arrange.js +1 -0
  9. package/admin/js/lib/component-editor-arrange.js +27 -0
  10. package/admin/js/lib/context-menu-editor-arrange.js +1 -0
  11. package/admin/js/lib/dashboard-arrange.js +1 -0
  12. package/admin/js/lib/form-editor-arrange.js +1 -0
  13. package/admin/js/lib/form-submissions-arrange.js +2 -0
  14. package/admin/js/lib/menu-editor-arrange.js +2 -0
  15. package/admin/js/lib/page-editor-arrange.js +1 -0
  16. package/admin/js/lib/plugin-code-arrange.js +1 -0
  17. package/admin/js/lib/project-page-arrange.js +1 -0
  18. package/admin/js/lib/role-editor-arrange.js +3 -0
  19. package/admin/js/lib/user-editor-arrange.js +1 -0
  20. package/admin/js/lib/view-editor-arrange.js +14 -0
  21. package/admin/js/templates/action-editor.html +223 -147
  22. package/admin/js/templates/api-endpoint-editor.html +172 -117
  23. package/admin/js/templates/block-editor.html +194 -203
  24. package/admin/js/templates/collection-editor.html +191 -113
  25. package/admin/js/templates/collection-entries.html +99 -36
  26. package/admin/js/templates/component-editor.html +128 -126
  27. package/admin/js/templates/context-menu-editor.html +208 -148
  28. package/admin/js/templates/dashboard.html +184 -22
  29. package/admin/js/templates/form-editor.html +41 -26
  30. package/admin/js/templates/form-submissions.html +78 -26
  31. package/admin/js/templates/menu-editor.html +57 -17
  32. package/admin/js/templates/menu-locations.html +54 -11
  33. package/admin/js/templates/page-editor.html +47 -26
  34. package/admin/js/templates/plugin-code.html +91 -73
  35. package/admin/js/templates/project-detail.html +83 -145
  36. package/admin/js/templates/project-settings.html +88 -53
  37. package/admin/js/templates/role-editor.html +158 -83
  38. package/admin/js/templates/user-editor.html +150 -14
  39. package/admin/js/templates/view-editor.html +203 -180
  40. package/admin/js/templates/view-preview.html +45 -45
  41. package/admin/js/views/action-editor.js +7 -1
  42. package/admin/js/views/api-endpoint-editor.js +2 -3
  43. package/admin/js/views/block-editor.js +14 -8
  44. package/admin/js/views/collection-editor.js +64 -5
  45. package/admin/js/views/collection-entries.js +50 -1
  46. package/admin/js/views/component-editor.js +2 -28
  47. package/admin/js/views/context-menu-editor.js +54 -55
  48. package/admin/js/views/dashboard.js +7 -1
  49. package/admin/js/views/form-editor.js +7 -7
  50. package/admin/js/views/form-submissions.js +11 -1
  51. package/admin/js/views/index.js +1 -1
  52. package/admin/js/views/login.js +2 -2
  53. package/admin/js/views/menu-editor.js +21 -43
  54. package/admin/js/views/menu-locations.js +16 -1
  55. package/admin/js/views/page-editor.js +26 -26
  56. package/admin/js/views/plugin-code.js +23 -4
  57. package/admin/js/views/project-detail.js +9 -1
  58. package/admin/js/views/project-settings.js +1 -1
  59. package/admin/js/views/role-editor.js +11 -3
  60. package/admin/js/views/user-editor.js +1 -1
  61. package/admin/js/views/view-editor.js +1 -1
  62. package/admin/js/views/view-preview.js +1 -1
  63. package/package.json +1 -1
  64. package/server/routes/api/dashboard.js +32 -4
  65. package/server/routes/api/forms.js +104 -13
  66. package/server/routes/api/users.js +12 -2
  67. package/server/routes/api/views.js +55 -1
  68. package/server/services/components.js +9 -0
  69. package/server/services/users.js +2 -2
  70. package/server/services/views.js +14 -0
  71. package/admin/js/templates/dashboard/activity-feed.html +0 -3
  72. package/admin/js/templates/dashboard/cache.html +0 -32
  73. package/admin/js/templates/dashboard/health-detail.html +0 -2
  74. package/admin/js/templates/dashboard/journeys.html +0 -17
  75. package/admin/js/templates/dashboard/kpi-strip.html +0 -34
  76. package/admin/js/templates/dashboard/spike-feed.html +0 -3
  77. package/admin/js/templates/dashboard/top-pages.html +0 -3
  78. package/admin/js/templates/dashboard/traffic-chart.html +0 -3
  79. package/admin/js/views/block-editor-enhance.js +0 -1
  80. package/admin/js/views/dashboard/lib/escape.js +0 -1
  81. package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
  82. package/admin/js/views/dashboard/widgets/cache.js +0 -1
  83. package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
  84. package/admin/js/views/dashboard/widgets/journeys.js +0 -1
  85. package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
  86. package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
  87. package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
  88. package/admin/js/views/dashboard/widgets/traffic-chart.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 +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 +1 @@
1
- import{api as a,getUser as N}from"../api.js";function I(c){return{string:"string",email:"email",tel:"string",number:"number",textarea:"textarea",select:"select",radio:"select",checkbox:"boolean","checkbox-group":"select",date:"string",time:"string",url:"string",hidden:"string"}[c]||"string"}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(c){const b=window.location.hash.match(/#\/users\/edit\/([^/]+)/),u=b?b[1]:null,r=!!u,C=E.loader(c.get(0),{type:"dots"});let o,m,y,v;try{[o,m,y,v]=await Promise.all([r?a.users.get(u).catch(()=>null):Promise.resolve(null),a.collections.listEntries("roles",{limit:100}).catch(()=>null),a.collections.get("user-profiles").catch(()=>null),a.projects.list().catch(()=>[])])}finally{C.destroy()}if(r&&!o){E.toast("User not found.",{type:"error"}),R.navigate("/users");return}c.find("#editor-title").text(r?"Edit User":"New User"),c.find("#cancel-btn").on("click",()=>R.navigate("/users"));const O=N(),h=e=>(m?.entries||[]).find(i=>i.data.name===e)?.data?.level??99,B=h(O?.role),j=(m?.entries||[]).filter(e=>(e.data.level??99)>B||r&&e.data.name===o?.role).map(e=>({value:e.data.name,label:e.data.label})).sort((e,i)=>h(e.value)-h(i.value)),S=[{value:"admin",label:"Admin"},{value:"user",label:"User"}],A=j.length>0?j:S,P=(v||[]).map(e=>({value:e.slug,label:e.name||e.slug})),q=[{value:"",label:"(none)"},...P],L={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Jane Smith"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"jane@example.com"}},role:{type:"select",required:!0,label:"Primary role",options:A,formConfig:{tooltip:"Shown as the user's badge. Used for hierarchy comparisons."}},additionalRoles:{type:"multiselect",label:"Additional roles",options:A,formConfig:{placeholder:"",tooltip:"Optional - extra roles whose permissions also apply. Useful for someone who is both, say, a recruiter AND a content editor."}},ownedByProject:{type:"select",label:"Owned by project",options:q,formConfig:{tooltip:"Administrative tag - controls where this user appears in the sidebar Projects section. Mapped to meta.project."}},projects:{type:"multiselect",label:"Project access",options:P,formConfig:{placeholder:"",tooltip:"Access scope - limits which artefacts the user can see and edit. Leave empty for no restriction."}},isActive:{type:"boolean",label:"Active account"},password:{type:"password",required:!r,minLength:r?0:8,label:r?"New Password":"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:r?"Leave blank to keep current password":"Minimum 8 characters"}}},T=o?{name:o.name||"",email:o.email||"",role:o.role||"user",additionalRoles:Array.isArray(o.additionalRoles)?o.additionalRoles:[],ownedByProject:o.meta?.project||"",projects:Array.isArray(o.projects)?o.projects:[],isActive:o.isActive!==!1}:{role:"user",additionalRoles:[],ownedByProject:"",projects:[],isActive:!0};F.render("#user-form-container",L,T,{layout:"stacked",submitText:r?"Update User":"Create User",onSubmit:async e=>{if(r&&e.password&&e.password.length<8)return E.toast("Password must be at least 8 characters.",{type:"warning"}),!1;const t=(Array.isArray(e.additionalRoles)?e.additionalRoles:[]).filter(s=>s&&s!==e.role).filter((s,d,n)=>n.indexOf(s)===d),l=(Array.isArray(e.projects)?e.projects:[]).filter(Boolean).filter((s,d,n)=>n.indexOf(s)===d),p=e.ownedByProject||null,g={name:e.name,email:e.email,role:e.role,additionalRoles:t,projects:l,meta:{...o?.meta||{},project:p},isActive:!!e.isActive};e.password&&(g.password=e.password);try{let s;r?s=await a.users.update(u,g):s=await a.users.create(g);const d=y?.fields||[];if(d.length>0){const n={};for(const w of d)w.name in e&&(n[w.name]=e[w.name]);const x=r?u:s?.id;x&&Object.keys(n).length>0&&await a.users.update(x,{profile:n})}E.toast("User saved successfully.",{type:"success"}),R.navigate("/users")}catch(s){return E.toast(`Save failed: ${s.message||"Unknown error"}`,{type:"error"}),!1}}});const U=y?.fields||[];if(U.length>0){c.find("#profile-card").show();const e={},i={};for(const t of U){const l={type:I(t.type),label:t.label||t.name};t.required&&(l.required=!0),t.placeholder&&(l.formConfig={placeholder:t.placeholder}),t.options&&Array.isArray(t.options)&&(l.options=t.options.map(p=>typeof p=="string"?{value:p,label:p}:p)),e[t.name]=l,i[t.name]=o?.profile?.[t.name]??""}F.render("#profile-form-container",e,i,{layout:"stacked",submitText:r?"Update Profile":"Save Profile",onSubmit:async t=>{const f=u;if(!f)return E.toast("Save the user first, then update the profile.",{type:"info"}),!1;try{await a.users.update(f,{profile:t}),E.toast("Profile saved.",{type:"success"})}catch(l){return E.toast(`Profile save failed: ${l.message||"Unknown error"}`,{type:"error"}),!1}}})}Domma.icons.scan()}};
1
+ import{api as Y,getUser as Ve}from"../api.js";import{addBannerAction as Ae}from"../lib/plugin-chrome.js";import{helpPopovers as Ge}from"../lib/help-popover.js";import{sidebarCount as Te}from"../lib/tool-kit.js";import{roleIndex as Je,initials as We}from"../lib/users-arrange.js";import{fieldKind as ue,fieldOptions as de}from"../lib/my-profile-arrange.js";import{buildUserPayload as Xe,checkUserForm as Re,draftOf as ze,editRights as Qe,emailConflict as Ze,formFromUser as pe,roleChoices as et,snapshot as oe}from"../lib/user-editor-arrange.js";const tt=/^\/users\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=a=>String(a||"").split("?")[0].replace(/\/$/,""),p=a=>String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),re=a=>{if(!a)return"";try{return D(a).format("D MMM YYYY")}catch{return String(a).slice(0,10)}},at=`<button type="button" class="uex-pick-chip" data-role="{{name}}" data-bind-class="on && 'is-on'" data-bind-disabled="disabled" title="{{title}}" data-bind-aria-pressed="on ? 'true' : 'false'">{{label}}</button>`,st=`<button type="button" class="uex-pick-chip" data-project="{{slug}}" data-bind-class="on && 'is-on'" data-bind-aria-pressed="on ? 'true' : 'false'" title="{{title}}">{{name}}</button>`;let k=null,Ne=!1;function ot(){Ne||!window.R?.use||(Ne=!0,R.use((a,r,v)=>{const c=ne(r?.path);return!(tt.test(r?.path||"")&&ne(a?.path)!==c)||!k?.dirty()||k.path!==c?v():E.confirm("You have unsaved changes to this user. Leave anyway? They are kept for your next visit - except a password, which is typed again.").then(L=>{L?v():history.replaceState(null,"",`#${c}`)})}),M.subscribe("router:afterChange",({to:a}={})=>{if(k&&ne(a?.path)!==k.path){const r=k;k=null,r.dispose()}}))}function nt(a,r){const v=ue(a.type),c=`uex-pf-${r}`,s=a.required?' <span class="text-danger">*</span>':"",L=a.help||a.description?` <span data-help="${p(a.help||a.description)}"></span>`:"",P=`<label class="form-label" for="${c}">${p(a.label||a.name)}${s}${L}</label>`,i=`<small class="uex-err" data-bind-hidden="!pe_${r}.value" data-bind-text="pe_${r}.value"></small>`,F=a.placeholder?` placeholder="${p(a.placeholder)}"`:"";switch(v){case"textarea":return`<div class="uex-wide">${P}<textarea id="${c}" class="form-input" rows="3"${F} data-model="pf_${r}.value"></textarea>${i}</div>`;case"select":return`<div>${P}<select id="${c}" class="form-input" data-model="pf_${r}.value"><option value="">-</option>${de(a).map(m=>`<option value="${p(m.value)}">${p(m.label)}</option>`).join("")}</select>${i}</div>`;case"checkbox":return`<div><label class="uex-check"><input id="${c}" type="checkbox" data-model="pf_${r}.value"> ${p(a.label||a.name)}${s}</label>${L}${i}</div>`;case"multi":return`<div class="uex-wide">${P}<div data-multi="${r}">${de(a).map((m,u)=>`<label class="uex-check"><input type="checkbox" data-opt="${p(m.value)}" data-bind-checked="po_${r}_${u}.value"> ${p(m.label)}</label>`).join("")}</div>${i}</div>`;default:return`<div>${P}<input id="${c}" type="${v}" class="form-input"${F} data-model="pf_${r}.value">${i}</div>`}}function ve(a,r){const v=a.querySelector(".dm-plugin-banner-name");if(!v)return;const c=v.querySelector(".dm-plugin-banner-parent")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-parent",textContent:"Users"}),s=v.querySelector(".dm-plugin-banner-sep")||Object.assign(document.createElement("span"),{className:"dm-plugin-banner-sep",textContent:"\u203A"});s.setAttribute("aria-hidden","true"),v.replaceChildren(c,s,document.createTextNode(r))}export const userEditorView={templateUrl:"/admin/js/templates/user-editor.html",async onMount(a){if(ot(),k){const e=k;k=null,e.dispose()}const r=ne(location.hash.slice(1)),v=r.match(/^\/users\/edit\/([^/?#]+)/),c=v?decodeURIComponent(v[1]):null,s=!c,L=s&&new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",P=[],i=e=>(P.push(e),e);let F=!1;const m=a.get(0),u=m.querySelector(".uex")||m,A=()=>!F&&u.isConnected,K=M.observable(!0),me=M.observable(""),V=M.observable(!1),_=M.observable(null),O=M.observable(""),y=M.observable(""),W=M.observable(""),_e=M.observable(!1),$=M.observable("user"),h=M.observable([]),X=M.observable(""),x=M.observable([]),z=M.observable(!0),q=M.observable(""),G=M.observable(""),fe=M.observable(!1),he=M.observable(!1),be=M.observable([]),ge=M.observable([]),T=M.observable({self:!1,canEdit:!0,reason:""}),B=M.observable([]);let w={};const ke=M.observable([]);let b=[];const N=new Map,ye=M.observable(0),g=M.observable(pe(null,[])),Oe=()=>(ye.value,Object.fromEntries([...N].map(([e,t])=>[e,t.value]))),qe=()=>Object.fromEntries([...N].map(([e,t])=>[e,t.peek()])),we=()=>({name:O.value,email:y.value,password:W.value,role:$.value,additionalRoles:h.value,ownedByProject:X.value,projects:x.value,isActive:z.value,profile:Oe()}),le=()=>({name:O.peek(),email:y.peek(),password:W.peek(),role:$.peek(),additionalRoles:h.peek(),ownedByProject:X.peek(),projects:x.peek(),isActive:z.peek(),profile:qe()}),Q=M.computed(()=>Re(we(),{isNew:s,fields:b,saved:s?void 0:g.value,choices:B.value})),U=M.computed(()=>!K.value&&T.value.canEdit&&oe(we())!==oe(g.value)),H=M.computed(()=>!T.value.canEdit);i(M.effect(()=>{const e=new Set(h.value);be.value=B.value.filter(t=>t.name!==$.value).map(t=>({name:t.name,label:t.label,on:e.has(t.name),disabled:!t.assignable&&!e.has(t.name),title:t.assignable||e.has(t.name)?`Level ${t.level}`:t.reason}))})),i(M.effect(()=>{const e=new Set(x.value);ge.value=ke.value.map(t=>({slug:t.slug,name:t.name||t.slug,on:e.has(t.slug),title:t.slug}))}));const Be={loading:K,loadError:me,saving:V,name:O,email:y,password:W,showPw:_e,role:$,ownedByProject:X,isActive:z,hasProfile:fe,hasProjects:he,extraChips:be,projectChips:ge,err:Q,dirty:U,locked:H,isSelf:M.computed(()=>T.value.self),lockedReason:M.computed(()=>T.value.reason),emailErr:M.computed(()=>Q.value.email||(q.value&&y.value.trim().toLowerCase()===G.value?q.value:"")),cannotSave:M.computed(()=>V.value||H.value||!U.value||Object.keys(Q.value).length>0||!!(q.value&&y.value.trim().toLowerCase()===G.value)),saveText:s?"Create user":"Save",dirtyText:s?"This user has not been created yet.":"You have unsaved changes.",pwLabel:M.computed(()=>s?"Password":"New password"),pwPlaceholder:M.computed(()=>s?"At least 8 characters":"Leave empty to keep it"),avatar:M.computed(()=>We({name:O.value,email:y.value})),shownName:M.computed(()=>O.value.trim()||(s?"New user":_.value?.name||"")),shownEmail:M.computed(()=>y.value.trim()||""),roleText:M.computed(()=>[$.value,...h.value.filter(t=>t!==$.value)].filter(Boolean).map(t=>w[t]?.label||t).join(" + ")||"-"),accessHint:M.computed(()=>x.value.length?`Sees only ${x.value.length===1?"that project":"those projects"}, plus Core.`:"Nothing ticked: no restriction - sees every project."),factsText:M.computed(()=>{const e=_.value;return s||!e?"Not created yet":[e.createdAt&&`Added ${re(e.createdAt)}`,e.lastLogin?`last signed in ${re(e.lastLogin)}`:"never signed in",e.updatedAt&&`changed ${re(e.updatedAt)}`].filter(Boolean).join(" \xB7 ")})},Z=`user-editor.draft.${c||"new"}`;function ee(e){O.value=e.name,y.value=e.email,W.value="",$.value=e.role,h.value=[...e.additionalRoles||[]],X.value=e.ownedByProject||"",x.value=[...e.projects||[]],z.value=e.isActive!==!1;for(const[t,o]of N)t in(e.profile||{})&&(o.value=e.profile[t])}function Ie(e,t){const o=Ze(e);return o?(q.value=o,G.value=String(t||"").trim().toLowerCase(),u.querySelector("#uex-email")?.focus(),!0):!1}async function ie(){if(V.peek()||K.peek()||H.peek())return;const e=le(),t=Re(e,{isNew:s,fields:b,saved:s?void 0:g.peek(),choices:B.peek()});if(Object.keys(t).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const o=Xe(g.peek(),e,{isNew:s,fields:b,meta:_.peek()?.meta||{}});if(!o){E.toast("Nothing has changed.",{type:"info"});return}if(!(!s&&T.peek().self&&o.update.role&&(w[o.update.role]?.level??99)>(w[g.peek().role]?.level??99)&&!await E.confirm(`Give yourself the less senior role "${p(w[o.update.role]?.label||o.update.role)}"? You will not be able to raise it again yourself.`))){V.value=!0;try{if(s){const d=await Y.users.create(o.create);if(!A())return;te(),g.value=le(),E.toast(`Created ${d?.name||e.name.trim()}.`,{type:"success"}),Te("#/users"),location.hash=d?.id?`#/users/edit/${encodeURIComponent(d.id)}`:"#/users";return}const n=await Y.users.update(c,o.update);if(!A())return;_.value=n;const l=pe(n,b);if(g.value=l,ee(l),te(),ve(m,n.name||n.email||"Edit"),T.peek().self){try{S.set("auth_user",{...S.get("auth_user")||{},name:n.name,email:n.email})}catch{}const d=document.getElementById("topbar-user-name");d&&n.name&&(d.textContent=n.name)}Te("#/users"),E.toast("Saved.",{type:"success"})}catch(n){Ie(n,e.email)||E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{V.value=!1}}}async function Ue(){U.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!A()||(ee(g.peek()),te()))}function $e(){if(!U.peek())return;const e=ze(le());if(oe({...e,password:""})!==oe(g.peek()))try{S.set(Z,{form:e,at:new Date().toISOString(),basedOn:_.peek()?.updatedAt||null})}catch{}}function te(){try{S.remove?S.remove(Z):S.set(Z,null)}catch{}}u.querySelector(".uex-roles").innerHTML=at,u.querySelector(".uex-projects").innerHTML=st;const Me=e=>{if(H.peek())return;const t=e.target.closest(".uex-pick-chip");if(!(!t||t.disabled)){if(t.dataset.role){const o=t.dataset.role,n=h.peek();h.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}else if(t.dataset.project){const o=t.dataset.project,n=x.peek();x.value=n.includes(o)?n.filter(l=>l!==o):[...n,o]}}};u.addEventListener("click",Me),i(()=>u.removeEventListener("click",Me)),i(M.effect(()=>{q.peek()&&y.value.trim().toLowerCase()!==G.peek()&&(q.value="",G.value="")})),i(M.effect(()=>{const e=$.value;h.peek().includes(e)&&(h.value=h.peek().filter(t=>t!==e))})),Ae(a,{id:"user-editor-back",icon:"arrow-left",title:"Back to users",onClick:()=>{location.hash="#/users"}}),Ae(a,{id:"user-editor-save",icon:"save",title:s?"Create user (Ctrl+S)":"Save (Ctrl+S)",onClick:()=>ie()}),ve(m,s?"New user":"Edit");const Ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!A()||(e.preventDefault(),e.stopImmediatePropagation(),ie())};document.addEventListener("keydown",Ee,!0),i(()=>document.removeEventListener("keydown",Ee,!0));const Se=e=>{U.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Se),i(()=>window.removeEventListener("beforeunload",Se)),k={path:r,dirty:()=>U.peek(),dispose(){F=!0,$e(),P.splice(0).reverse().forEach(e=>{try{e()}catch{}})}};let f=null,xe=null,je=null,j=[];try{[f,je,xe,j]=await Promise.all([s?Promise.resolve(null):Y.users.get(c),Y.collections.listEntries("roles",{limit:1e3}).catch(()=>null),Y.collections.get("user-profiles").catch(()=>null),Y.projects.list().catch(()=>[])])}catch(e){if(!A())return;me.value=e?.status===404?"There is no such user - it may have been deleted.":`Could not load this user: ${e.message||e}`,K.value=!1,I.scan(u);return}if(!A())return;j=Array.isArray(j)?j:[],ke.value=j,w=Je(je?.entries||[]),Object.keys(w).length||(w={admin:{label:"Admin",level:1},user:{label:"User",level:2}});const Ce=Ve()||{};T.value=Qe(Ce,f,w),B.value=et(w,Ce,f),_.value=f;const He=u.querySelector(".uex-role-select");He.innerHTML=B.peek().map(e=>`<option value="${p(e.name)}"${e.assignable?"":" disabled"}>${p(e.label)}${e.assignable?"":" - only a more senior role can give it"}</option>`).join("");const De=u.querySelector(".uex-owner-select");De.innerHTML=`<option value="">(none)</option>${j.map(e=>`<option value="${p(e.slug)}">${p(e.name||e.slug)}</option>`).join("")}`,he.value=j.length>0;const Le=B.peek().filter(e=>e.assignable).map(e=>e.name),Ye=Le.includes("user")?"user":Le.at(-1)||"user";b=(xe?.fields||[]).filter(e=>e&&e.name);const J=pe(f,b,{project:j.some(e=>e.slug===L)?L:"",defaultRole:Ye});b.forEach(e=>N.set(e.name,M.observable(J.profile[e.name])));const Fe=M.applyBindings(Be,u,{methods:{save:ie,discard:Ue}});i(()=>Fe.dispose());const ce=b.map((e,t)=>({f:e,i:t})).filter(({f:e})=>ue(e.type)!=="hidden");if(ce.length){const e=u.querySelector(".uex-profile");e.innerHTML=ce.map(({f:l,i:d})=>nt(l,d)).join("");const t={};b.forEach((l,d)=>{t[`pf_${d}`]=N.get(l.name),t[`pe_${d}`]=M.computed(()=>Q.value[`profile.${l.name}`]||""),ue(l.type)==="multi"&&de(l).forEach((ae,se)=>{t[`po_${d}_${se}`]=M.computed(()=>(N.get(l.name).value||[]).includes(ae.value))})});const o=M.applyBindings(t,e);i(()=>o.dispose());const n=l=>{const d=l.target.closest("[data-multi]");if(!d||!l.target.matches("input[data-opt]"))return;const ae=N.get(b[Number(d.dataset.multi)].name),se=l.target.dataset.opt,Pe=ae.peek()||[];ae.value=l.target.checked?[...new Set([...Pe,se])]:Pe.filter(Ke=>Ke!==se)};e.addEventListener("change",n),i(()=>e.removeEventListener("change",n))}fe.value=ce.length>0,i(Ge(u,{position:"bottom"})),g.value=J,ee(J),ye.value++,K.value=!1,ve(m,s?"New user":f.name||f.email||"Edit"),I.scan(u),H.peek()||u.querySelector("#uex-name")?.focus();let C=null;try{C=S.get(Z)}catch{}if(C?.form&&!H.peek()){te();const e=C.at?` (${re(C.at)})`:"",t=!s&&C.basedOn&&f?.updatedAt&&C.basedOn!==f.updatedAt?" This user has been changed since - restoring puts your version over it.":"",o=s?"the new user you started":`"${p(f.name||f.email)}"`;await E.confirm(`You left unsaved changes to ${o}${e}.${t} Restore them?`)&&A()&&ee({...J,...C.form,profile:{...J.profile,...C.form.profile||{}}})}}};
@@ -1 +1 @@
1
- import{api as v}from"../api.js";let g=null,f=null,C={};async function T(e){const t=e.find("#view-project").get(0);if(t)try{(await v.projects.list()).forEach(a=>{const l=document.createElement("option");l.value=a.slug,l.textContent=a.name||a.slug,t.appendChild(l)})}catch{}}export const viewEditorView={templateUrl:"/admin/js/templates/view-editor.html",async onMount(e){g=null,f=null,C={};const t=window.location.hash.match(/\/views\/edit\/([^/?#]+)/);t&&(g=t[1]),E.tabs(e.find("#view-editor-tabs").get(0));const n=E.loader(e.get(0),{type:"dots"});try{await L(e),await D(e),await _(e),await O(e),await T(e),e.find("#view-source").get(0)?.addEventListener("change",async()=>{await k(e)}),g&&(e.find("#view-editor-title").text("Edit View"),await V(e,g))}finally{n.destroy()}e.find("#add-stage-btn").off("click").on("click",()=>{const a=e.find("#add-stage-type").val()||"$match";q(e,{type:a,config:{}})}),e.find("#save-view-btn").off("click").on("click",async()=>{await X(e)}),Q(e),H(e),Domma.icons.scan()}};async function L(e){const t=e.find("#view-source").get(0);if(t)try{(await v.collections.list()).forEach(n=>{const a=document.createElement("option");a.value=n.slug,a.textContent=`${n.title} (${n.slug})`,t.appendChild(a)})}catch{}}async function O(e){const t=e.find("#view-block-name").get(0);if(t)try{(await v.blocks.list()).forEach(n=>{const a=document.createElement("option");a.value=n.name,a.textContent=`${n.name}.html`,t.appendChild(a)})}catch{}}async function D(e){const t=e.find("#view-connection").get(0);if(t)try{const n=await v.collections.getConnections();Object.keys(n).forEach(a=>{if(!t.querySelector(`option[value="${a}"]`)){const l=document.createElement("option");l.value=a,l.textContent=a,t.appendChild(l)}})}catch{}}async function _(e){const t=e.find("#view-roles-checkboxes").get(0);t&&["admin","manager","editor","subscriber"].forEach(n=>{const a=document.createElement("label");a.style.cssText="display:flex;align-items:center;gap:.5rem;cursor:pointer;";const l=document.createElement("input");l.type="checkbox",l.value=n,l.dataset.role=n,l.className="view-role-cb",l.checked=n==="admin",a.appendChild(l),a.appendChild(document.createTextNode(n)),t.appendChild(a)})}async function k(e){const t=e.find("#view-source").val();if(!t){f=null;return}try{f=(await v.collections.get(t))?.fields||[]}catch{f=null}e.find(".stage-card[data-guided]").each(function(){const n=this.dataset.stageType,a=this.querySelector(".stage-guided");a&&(n==="$match"&&z(a),n==="$sort"&&J(a))}),A(e,null)}async function V(e,t){try{const n=await v.views.get(t);if(!n){E.toast("View not found.",{type:"error"}),R.navigate("/views");return}const a=n.pipeline?.source;a&&(e.find("#view-source").val(a),await k(e)),F(e,n)}catch(n){E.toast(n.message||"Failed to load view.",{type:"error"}),R.navigate("/views")}}function F(e,t){e.find("#view-title").val(t.title||""),e.find("#view-slug").val(t.slug||""),e.find("#view-description").val(t.description||""),e.find("#view-source").val(t.pipeline?.source||""),e.find("#view-bundled").prop("checked",!!t.bundled),C=t.meta||{},e.find("#view-project").val(t.meta?.project||""),e.find("#view-connection").val(t.connection||"default"),e.find("#view-display-mode").val(t.display?.mode||"table"),e.find("#view-page-size").val(t.display?.pageSize||25),e.find("#view-block-name").val(t.display?.block||"");const n=t.access?.roles||["admin"];e.find(".view-role-cb").each(function(){this.checked=n.includes(this.value)}),e.find("#view-public").prop("checked",t.access?.public||!1);const a=t.access?.rowLevel;a&&(e.find("#view-rowlevel-enabled").prop("checked",!0),e.find("#view-rowlevel-config").css("display","flex"),e.find("#view-rowlevel-mode").val(a.mode||"owner"),e.find("#view-rowlevel-userkey").val(a.userKey||"id"),a.mode==="field"&&(e.find("#view-rowlevel-field-group").css("display",""),e.find("#view-rowlevel-field").val(a.field||"")));const l=e.find("#pipeline-stages-list").get(0);if(l)for(;l.firstChild;)l.removeChild(l.firstChild);(t.pipeline?.stages||[]).forEach(i=>q(e,i)),A(e,t.display?.columns||[]),N(e)}const I=[{value:"eq",label:"= equals"},{value:"ne",label:"\u2260 not equals"},{value:"gt",label:"> greater than"},{value:"lt",label:"< less than"},{value:"gte",label:"\u2265 greater or equal"},{value:"lte",label:"\u2264 less or equal"},{value:"contains",label:"~ contains (regex)"},{value:"in",label:"\u2208 in (comma list)"}];function y(e=!1){const t=[];return e&&t.push({value:"",label:"- select field -"}),(f||[]).forEach(n=>{t.push({value:`data.${n.name}`,label:`${n.label||n.name} (${n.name})`})}),t.length===0||t.length===1&&e?(t.push({value:"__meta.createdAt",label:"Created At"}),t.push({value:"__meta.updatedAt",label:"Updated At"})):(t.push({value:"__meta.createdAt",label:"Created At (meta)"}),t.push({value:"__meta.updatedAt",label:"Updated At (meta)"})),t}function h(e,t){const n=document.createElement("select");return n.className="form-input form-input--sm",e.forEach(a=>{const l=document.createElement("option");l.value=a.value,l.textContent=a.label,String(a.value)===String(t)&&(l.selected=!0),n.appendChild(l)}),n}function S(e,t,n="text"){const a=document.createElement("input");return a.type=n,a.className="form-input form-input--sm",a.placeholder=e,a.value=t??"",a}function M(e){if(!f?.length)return null;const t=e?.startsWith("data.")?e.slice(5):null;return t&&f.find(n=>n.name===t)||null}function b(e,t,n=""){const a=M(e);if(a?.type==="select"&&(t==="eq"||t==="ne")&&a.options?.length){const d=document.createElement("select");d.className="form-input form-input--sm match-val",d.style.flex="1";const o=document.createElement("option");return o.value="",o.textContent="- select value -",d.appendChild(o),(a.options||[]).forEach(c=>{const s=typeof c=="string"?c:c.value??"",r=typeof c=="string"?c:c.label||s;if(!s||s==="undefined")return;const u=document.createElement("option");u.value=s,u.textContent=r,s===n&&(u.selected=!0),d.appendChild(u)}),d}const l=t==="in"?"val1, val2, val3":t==="contains"?"search pattern (regex)":t==="gt"||t==="lt"||t==="gte"||t==="lte"?"0":"value",i=document.createElement("input");return i.type="text",i.className="form-input form-input--sm match-val",i.style.flex="1",i.placeholder=l,i.value=n,i}function $(e){const t=[],n={$eq:"eq",$ne:"ne",$gt:"gt",$lt:"lt",$gte:"gte",$lte:"lte",$in:"in"};return Object.entries(e||{}).forEach(([a,l])=>{if(typeof l=="object"&&l!==null&&!Array.isArray(l)){if("$regex"in l){t.push({field:a,op:"contains",val:String(l.$regex??"")});return}Object.entries(l).forEach(([i,d])=>{const o=n[i];o&&t.push({field:a,op:o,val:Array.isArray(d)?d.join(", "):String(d)})})}else t.push({field:a,op:"eq",val:String(l??"")})}),t}function x(e,t,n="",a="eq",l=""){const i=document.createElement("div");i.className="match-condition-row",i.style.cssText="display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem;flex-wrap:wrap;";const d=h([{value:"",label:"- field -"},...y()],n);d.className+=" match-field",d.style.minWidth="140px";const o=h(I,a);o.className+=" match-op",o.style.minWidth="160px";const c=document.createElement("div");c.className="match-val-wrap",c.style.cssText="flex:1;min-width:120px;display:flex;",c.appendChild(b(n,a,l));const s=()=>{const m=c.querySelector(".match-val")?.value??"";for(;c.firstChild;)c.removeChild(c.firstChild);c.appendChild(b(d.value,o.value,m))};d.addEventListener("change",s),o.addEventListener("change",s);const r=document.createElement("button");r.type="button",r.className="btn btn-sm btn-ghost",r.title="Remove";const u=document.createElement("span");u.setAttribute("data-icon","x"),r.appendChild(u),r.addEventListener("click",()=>i.remove()),i.appendChild(d),i.appendChild(o),i.appendChild(c),i.appendChild(r),e.insertBefore(i,t),Domma.icons.scan(i)}function U(e,t={}){for(;e.firstChild;)e.removeChild(e.firstChild);const n=t.$match||t||{},a="$or"in n;let l=[];a?(n.$or||[]).forEach(r=>$(r).forEach(u=>l.push(u))):l=$(n);const i=document.createElement("div");i.style.cssText="display:flex;align-items:center;gap:.5rem;margin-bottom:.6rem;";const d=document.createElement("label");d.className="form-label",d.style.marginBottom="0",d.textContent="Match:";const o=document.createElement("select");o.className="form-input form-input--sm match-logic",o.style.width="auto",[{value:"and",label:"ALL conditions (AND)"},{value:"or",label:"ANY condition (OR)"}].forEach(r=>{const u=document.createElement("option");u.value=r.value,u.textContent=r.label,(a?"or":"and")===r.value&&(u.selected=!0),o.appendChild(u)}),i.appendChild(d),i.appendChild(o),e.appendChild(i);const c=document.createElement("button");c.type="button",c.className="btn btn-ghost btn-sm match-add-btn";const s=document.createElement("span");s.setAttribute("data-icon","plus"),c.appendChild(s),c.appendChild(document.createTextNode(" Add Condition")),c.addEventListener("click",()=>{x(e,c),Domma.icons.scan(e)}),e.appendChild(c),l.forEach(r=>x(e,c,r.field,r.op,r.val)),l.length===0&&x(e,c),Domma.icons.scan(e)}function z(e){e.querySelectorAll(".match-condition-row").forEach(t=>{const n=t.querySelector(".match-field");if(n){const d=n.value,o=h([{value:"",label:"- field -"},...y()],d);o.className=n.className,o.style.cssText=n.style.cssText;const c=t.querySelector(".match-val-wrap"),s=t.querySelector(".match-op");c&&s&&o.addEventListener("change",()=>{const r=c.querySelector(".match-val")?.value??"";for(;c.firstChild;)c.removeChild(c.firstChild);c.appendChild(b(o.value,s.value,r))}),n.parentNode.replaceChild(o,n)}const a=t.querySelector(".match-val-wrap"),l=t.querySelector(".match-field")?.value,i=t.querySelector(".match-op")?.value;if(a&&l&&i){const d=a.querySelector(".match-val")?.value??"";for(;a.firstChild;)a.removeChild(a.firstChild);a.appendChild(b(l,i,d))}})}function B(e){const t=e.querySelector(".match-logic")?.value||"and",n=l=>{const i=l.querySelector(".match-field")?.value?.trim(),d=l.querySelector(".match-op")?.value,o=l.querySelector(".match-val")?.value?.trim()??"";if(!i)return null;const c=i.startsWith("__")?i.slice(2):i,s={};switch(d){case"eq":s[c]=o;break;case"ne":s[c]={$ne:o};break;case"gt":s[c]={$gt:isNaN(o)?o:Number(o)};break;case"lt":s[c]={$lt:isNaN(o)?o:Number(o)};break;case"gte":s[c]={$gte:isNaN(o)?o:Number(o)};break;case"lte":s[c]={$lte:isNaN(o)?o:Number(o)};break;case"contains":s[c]={$regex:o,$options:"i"};break;case"in":s[c]={$in:o.split(",").map(r=>r.trim()).filter(Boolean)};break}return Object.keys(s).length?s:null},a=[];return e.querySelectorAll(".match-condition-row").forEach(l=>{const i=n(l);i&&a.push(i)}),a.length===0?{}:t==="or"?{$or:a}:Object.assign({},...a)}function W(e,t={}){for(;e.firstChild;)e.removeChild(e.firstChild);const n=t.$sort||t||{},a=Object.entries(n),l=document.createElement("button");l.type="button",l.className="btn btn-ghost btn-sm";const i=document.createElement("span");i.setAttribute("data-icon","plus"),l.appendChild(i),l.appendChild(document.createTextNode(" Add Sort")),e.appendChild(l);const d=(o="",c=1)=>{const s=document.createElement("div");s.className="sort-row",s.style.cssText="display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem;";const r=h([{value:"",label:"- field -"},...y()],o);r.className+=" sort-field";const u=h([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(c));u.className+=" sort-dir";const m=document.createElement("button");m.type="button",m.className="btn btn-sm btn-ghost",m.title="Remove";const p=document.createElement("span");p.setAttribute("data-icon","x"),m.appendChild(p),m.addEventListener("click",()=>{s.remove()}),s.appendChild(r),s.appendChild(u),s.appendChild(m),e.insertBefore(s,l),Domma.icons.scan(s)};l.addEventListener("click",()=>{d()}),a.forEach(([o,c])=>d(o,c)),a.length===0&&d(),Domma.icons.scan(e)}function J(e){e.querySelectorAll(".sort-row").forEach(t=>{const n=t.querySelector(".sort-field"),a=n?.value,l=h([{value:"",label:"- field -"},...y()],a);l.className=n.className,n.parentNode.replaceChild(l,n)})}function P(e){const t={};return e.querySelectorAll(".sort-row").forEach(n=>{const a=n.querySelector(".sort-field")?.value?.trim(),l=parseInt(n.querySelector(".sort-dir")?.value,10)||1;if(!a)return;const i=a.startsWith("__")?a.slice(2):a;t[i]=l}),t}function q(e,t){const n=e.find("#pipeline-stages-list").get(0);if(!n)return;const a=n.querySelector(".stage-empty-placeholder");a&&a.remove();const l=["$match","$sort"].includes(t.type),i=document.createElement("div");i.className="card mb-2 stage-card",i.dataset.stageType=t.type,l&&(i.dataset.guided="true");const d=document.createElement("div");d.className="card-header",d.style.cssText="display:flex;align-items:center;gap:.5rem;";const o=document.createElement("code");o.textContent=t.type,o.style.cssText="flex:1;font-size:.85rem;";const c=document.createElement("button");c.type="button",c.className="btn btn-sm btn-ghost";const s=document.createElement("span");s.setAttribute("data-icon","trash"),c.appendChild(s),c.addEventListener("click",()=>{i.remove(),n.querySelector(".stage-card")||n.appendChild(K())}),d.appendChild(o),d.appendChild(c);const r=document.createElement("div");if(r.className="card-body",l){const u=document.createElement("div");u.className="stage-guided",r.appendChild(u),t.type==="$match"&&U(u,t.config),t.type==="$sort"&&W(u,t.config)}else{const u=document.createElement("label");u.className="form-label",u.textContent="Stage Config (JSON)";const m=document.createElement("small");m.className="text-muted",m.style.cssText="display:block;margin-bottom:.4rem;",m.textContent=`Enter the inner config for ${t.type} - e.g. for $lookup: { from, localField, foreignField, as }. Do not wrap in { "${t.type}": ... }.`;const p=document.createElement("textarea");p.className="form-input stage-config",p.rows=5,p.style.cssText="font-family:monospace;font-size:.8rem;resize:vertical;",p.placeholder="{}",p.value=Object.keys(t.config||{}).length?JSON.stringify(t.config,null,2):"",r.appendChild(u),r.appendChild(m),r.appendChild(p)}i.appendChild(d),i.appendChild(r),n.appendChild(i),Domma.icons.scan(i)}function K(){const e=document.createElement("p");return e.className="text-muted stage-empty-placeholder",e.textContent="No stages yet. Add a stage to filter, join, or transform your data.",e.style.cssText="text-align:center;padding:2rem 0;",e}function Y(e){const t=[];return e.find(".stage-card").each(function(){const n=this.dataset.stageType,a=this.querySelector(".stage-guided");let l;if(a)n==="$match"&&(l=B(a)),n==="$sort"&&(l=P(a));else{const i=this.querySelector(".stage-config")?.value?.trim()||"{}";try{l=JSON.parse(i)}catch{throw new Error(`Invalid JSON in ${n} stage config`)}}t.push({type:n,config:l})}),t}function A(e,t){const n=e.find("#view-columns-builder").get(0);if(!n)return;for(;n.firstChild;)n.removeChild(n.firstChild);const a=t??(f||[]).map(o=>({key:`data.${o.name}`,label:o.label||o.name})),l=(o="",c="")=>{const s=document.createElement("div");s.className="col-row",s.style.cssText="display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem;";const r=f?.length?h([{value:"",label:"- field -"},...y()],o):S("data.fieldName",o);r.className+=" col-key";const u=S("Display label",c);u.className+=" col-label",u.style.flex="1",f?.length&&r.tagName==="SELECT"&&r.addEventListener("change",()=>{if(!u.value){const w=(f||[]).find(j=>`data.${j.name}`===r.value);w&&(u.value=w.label||w.name)}});const m=document.createElement("button");m.type="button",m.className="btn btn-sm btn-ghost",m.title="Remove column";const p=document.createElement("span");p.setAttribute("data-icon","x"),m.appendChild(p),m.addEventListener("click",()=>{s.remove(),Domma.icons.scan(n)}),s.appendChild(r),s.appendChild(u),s.appendChild(m),n.insertBefore(s,n.lastChild),Domma.icons.scan(s)};a.forEach(o=>l(o.key,o.label));const i=document.createElement("button");i.type="button",i.className="btn btn-ghost btn-sm";const d=document.createElement("span");d.setAttribute("data-icon","plus"),i.appendChild(d),i.appendChild(document.createTextNode(" Add Column")),i.addEventListener("click",()=>{l(),Domma.icons.scan(n)}),n.appendChild(i),Domma.icons.scan(n)}function G(e){const t=[];return e.find(".col-row").each(function(){const n=this.querySelector(".col-key")?.value?.trim(),a=this.querySelector(".col-label")?.value?.trim();n&&t.push({key:n,label:a||n})}),t}function H(e){const t=e.find("#view-display-mode").get(0);t&&(t.addEventListener("change",()=>N(e)),N(e))}function N(e){const t=e.find("#view-display-mode").val()||"table",n=e.find("#view-columns-section").get(0),a=e.find("#view-block-section").get(0);n&&(n.style.display=t==="table"?"":"none"),a&&(a.style.display=t==="block"?"":"none")}function Q(e){const t=e.find("#view-rowlevel-enabled").get(0),n=e.find("#view-rowlevel-config").get(0),a=e.find("#view-rowlevel-mode").get(0),l=e.find("#view-rowlevel-field-group").get(0);t&&(t.addEventListener("change",()=>{n&&(n.style.display=t.checked?"flex":"none")}),a&&a.addEventListener("change",()=>{l&&(l.style.display=a.value==="field"?"":"none")}))}async function X(e){const t=e.find("#view-title").val().trim();if(!t){E.toast("Title is required.",{type:"warning"});return}const n=e.find("#view-source").val();if(!n){E.toast("Source collection is required (Source tab).",{type:"warning"});return}let a;try{a=Y(e)}catch(u){E.toast(u.message,{type:"error"});return}const l=G(e),i=[];e.find(".view-role-cb:checked").each(function(){i.push(this.value)});const d=e.find("#view-rowlevel-enabled").is(":checked");let o=null;if(d){const u=e.find("#view-rowlevel-mode").val()||"owner",m=e.find("#view-rowlevel-userkey").val()||"id";if(o={mode:u,userKey:m},u==="field"){const p=e.find("#view-rowlevel-field").val().trim();if(!p){E.toast("Field name is required for Field Match mode.",{type:"warning"});return}o.field=p}}const c=e.find("#view-project").val()||"",s={title:t,slug:e.find("#view-slug").val().trim()||void 0,description:e.find("#view-description").val().trim(),...e.find("#view-bundled").is(":checked")?{bundled:!0}:{},connection:e.find("#view-connection").val()||"default",pipeline:{source:n,stages:a},display:{mode:e.find("#view-display-mode").val()||"table",columns:l,pageSize:parseInt(e.find("#view-page-size").val(),10)||25,block:e.find("#view-block-name").val()||""},access:{roles:i,public:e.find("#view-public").is(":checked"),rowLevel:o},meta:{...C||{},project:c||null}},r=e.find("#save-view-btn").get(0);r&&(r.disabled=!0);try{if(g)await v.views.update(g,s),E.toast("View updated.",{type:"success"});else{const u=await v.views.create(s);E.toast("View created.",{type:"success"}),R.navigate(`/views/edit/${u.slug}`)}}catch(u){E.toast(u.message||"Failed to save view.",{type:"error"})}finally{r&&(r.disabled=!1)}}
1
+ import{api as C,apiRequest as Fe}from"../api.js";import{addBannerAction as Y}from"../lib/plugin-chrome.js";import{helpPopovers as ht}from"../lib/help-popover.js";import{openMenuAt as ae,sidebarCount as he}from"../lib/tool-kit.js";import{OPERATORS as gt,STAGE_TYPES as _e,cellText as wt,checkView as bt,draftKey as w,errorCount as Be,isGuided as St,matchToRows as kt,move as He,parseStageJson as xt,resultColumns as Et,rowsToMatch as qt,rowsToSort as $t,slugify as Je,snapshot as P,sortToRows as Ct,stageSummary as Mt,stageText as Lt}from"../lib/view-editor-arrange.js";const Rt=/^\/views\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=v=>String(v||"").split("?")[0].replace(/\/$/,""),Tt=["super-admin","admin","user"],At=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function l(v,m={},...i){const h=document.createElement(v);for(const[q,d]of Object.entries(m))d==null||d===!1||(q==="class"?h.className=d:q==="text"?h.textContent=d:h.setAttribute(q,d===!0?"":d));for(const q of i)q&&h.append(q);return h}let A=null,Ye=!1;function Nt(){Ye||!window.R?.use||(Ye=!0,R.use((v,m,i)=>{const h=ne(m?.path);return!(Rt.test(m?.path||"")&&ne(v?.path)!==h)||!A?.dirty()?i():E.confirm("This view has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?i():history.replaceState(null,"",`#${m.path}`)})}),M.subscribe("router:afterChange",({to:v}={})=>{if(A&&ne(v?.path)!==A.path){const m=A;A=null,m.dispose()}}))}function Ot(v,m){const i=v.querySelector(".dm-plugin-banner-name");if(!i)return;const h=i.querySelector(".dm-plugin-banner-sep");if(h){for(;h.nextSibling;)h.nextSibling.remove();h.after(document.createTextNode(m))}}export const viewEditorView={templateUrl:"/admin/js/templates/view-editor.html",async onMount(v){if(Nt(),A){const e=A;A=null,e.dispose()}const m=v.get(0),i=m.querySelector(".vex")||m,h=$(i),q=[],d=e=>(q.push(e),e);let ge=!1;const j=()=>!ge&&i.isConnected,we=location.hash.match(/^#\/views\/edit\/([^/?#]+)/),f=we?decodeURIComponent(we[1]):null,b=!f,Ge=ne(location.hash.slice(1));let L=[],N=null,be=[];const Se=E.loader(m,{type:"dots"});try{const[e,t,o,a,s,c]=await Promise.all([C.collections.list().catch(()=>[]),C.collections.getConnections().catch(()=>({})),C.blocks.list().catch(()=>[]),C.projects.list().catch(()=>[]),Fe("/collections/roles/entries?limit=1000").catch(()=>null),C.views.list().catch(()=>[])]);be=(c||[]).map(n=>n.slug),F(i.querySelector("#view-source"),(e||[]).map(n=>[n.slug,`${n.title||n.slug} (${n.slug})`])),F(i.querySelector("#view-connection"),Object.keys(t||{}).filter(n=>n!=="default").map(n=>[n,n])),F(i.querySelector("#view-block-name"),(o||[]).map(n=>[n.name,`${n.name}.html`])),F(i.querySelector("#view-project"),(a||[]).map(n=>[n.slug,n.name||n.slug]));const r=(s?.entries||s?.data||[]).map(n=>n.data||n).filter(n=>n?.name).sort((n,p)=>(n.level??9)-(p.level??9)).map(n=>({name:n.name,label:n.label||n.name}));if(We(r.length?r:Tt.map(n=>({name:n,label:n}))),F(i.querySelector("#add-stage-type"),_e.map(n=>[n.type,`${n.type} - ${n.label}`])),!b&&(N=await C.views.get(f),!N))throw new Error("View not found")}catch(e){Se.destroy(),E.toast(e.message==="View not found"?"That view does not exist.":`Could not load the view: ${e.message||e}`,{type:"error"}),R.navigate("/views");return}if(Se.destroy(),!j())return;function F(e,t){if(e)for(const[o,a]of t)e.querySelector(`option[value="${CSS.escape(o)}"]`)||e.append(l("option",{value:o,text:a}))}function We(e){const t=i.querySelector("#view-roles-checkboxes");t.replaceChildren();for(const o of e)t.append(l("label",{},l("input",{type:"checkbox",class:"view-role-cb",value:o.name}),document.createTextNode(o.label)))}function Qe(){const e=(L||[]).map(t=>({value:`data.${t.name}`,label:`${t.label||t.name} (${t.name})`}));return e.push({value:"__meta.createdAt",label:"Created (meta)"},{value:"__meta.updatedAt",label:"Updated (meta)"}),e}function _(e,t,o,a){const s=l("select",{class:`form-input form-input--sm ${o||""}`,"aria-label":a});for(const c of e)s.append(l("option",{value:c.value,text:c.label}));return t&&!e.some(c=>String(c.value)===String(t))&&s.append(l("option",{value:t,text:t})),s.value=t??"",s}function G(e,t){return _([{value:"",label:"- field -"},...Qe()],e,t,"Field")}function ke(e,t,o){const a=(L||[]).find(r=>`data.${r.name}`===e);if(a?.type==="select"&&(t==="eq"||t==="ne")&&a.options?.length){const r=a.options.map(n=>typeof n=="string"?{value:n,label:n}:{value:n.value??"",label:n.label||n.value}).filter(n=>n.value&&n.value!=="undefined");return _([{value:"",label:"- value -"},...r],o,"match-val","Value")}const s=t==="in"?"a, b, c":t==="contains"?"text (a pattern)":["gt","lt","gte","lte"].includes(t)?"0":"value",c=l("input",{type:"text",class:"form-input form-input--sm match-val",placeholder:s,"aria-label":"Value"});return c.value=o??"",c}function Xe(e,t){const{logic:o,rows:a}=kt(t),s=_([{value:"and",label:"ALL conditions (and)"},{value:"or",label:"ANY condition (or)"}],o,"match-logic","Match"),c=l("div",{class:"vex-conds"}),r=l("button",{type:"button",class:"btn btn-sm btn-ghost"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Condition")),n=(p={field:"",op:"eq",val:""})=>{const oe=G(p.field,"match-field"),mt=_(gt,p.op,"match-op","Operator"),yt=l("span",{class:"vex-val"},ke(p.field,p.op,p.val)),ze=l("button",{type:"button",class:"vex-x",title:"Remove this condition","aria-label":"Remove this condition",text:"\xD7"});ze.addEventListener("click",()=>{Ue.remove(),g()});const Ue=l("div",{class:"vex-cond match-condition-row"},oe,mt,yt,ze);c.append(Ue)};r.addEventListener("click",()=>{n(),g()}),e.append(l("div",{class:"vex-logic"},l("span",{class:"vex-muted",text:"Keep rows matching"}),s),c,r),(a.length?a:[void 0]).forEach(p=>n(p))}function Ze(e,t){const o=l("div",{class:"vex-conds"}),a=l("button",{type:"button",class:"btn btn-sm btn-ghost"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Sort field")),s=(r={field:"",dir:1})=>{const n=l("button",{type:"button",class:"vex-x",title:"Remove","aria-label":"Remove this sort",text:"\xD7"}),p=l("div",{class:"vex-sort-row sort-row"},G(r.field,"sort-field"),_([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(r.dir),"sort-dir","Direction"),n);n.addEventListener("click",()=>{p.remove(),g()}),o.append(p)};a.addEventListener("click",()=>{s(),g()}),e.append(o,a);const c=Ct(t);(c.length?c:[void 0]).forEach(r=>s(r))}const k=i.querySelector("#pipeline-stages-list");let et=0;function xe(e){const t=l("div",{class:"vex-stage stage-card","data-stage-type":e.type,"data-sid":String(++et)}),o=l("div",{class:"vex-stage-head",tabindex:"0"},l("span",{class:"vex-stage-no"}),l("code",{class:"vex-stage-type",text:e.type}),l("span",{class:"vex-stage-sum"}),l("button",{type:"button",class:"vex-kebab","aria-label":`Actions for this ${e.type} stage`,title:"Actions",text:"\u22EE"})),a=l("div",{class:"vex-stage-body"});if(St(e.type)){t.dataset.guided="true";const s=l("div",{class:"stage-guided"});a.append(s),e.type==="$match"?Xe(s,e.config):Ze(s,e.config)}else{const s=_e.find(r=>r.type===e.type),c=l("textarea",{class:"form-input stage-config",rows:"5",spellcheck:"false","aria-label":`${e.type} settings`,placeholder:s?.example||"{}"});c.value=Lt(e.type,e.config),a.append(l("small",{class:"vex-hint",text:`The inside of ${e.type} only - e.g. ${(s?.example||"{}").replace(/\s+/g," ")}`}),c,l("small",{class:"vex-stage-err",hidden:!0}))}return t.append(o,a),t}function Ee(e,t=null){const o=xe(e);return t?t.after(o):k.append(o),I.scan(o),g(),o}function qe(e){const t=e.dataset.stageType;if(e.dataset.guided){const s=e.querySelector(".stage-guided");if(t==="$match"){const r=[...s.querySelectorAll(".match-condition-row")].map(n=>({field:n.querySelector(".match-field")?.value,op:n.querySelector(".match-op")?.value,val:n.querySelector(".match-val")?.value}));return{type:t,config:qt(s.querySelector(".match-logic")?.value,r),error:""}}const c=[...s.querySelectorAll(".sort-row")].map(r=>({field:r.querySelector(".sort-field")?.value,dir:r.querySelector(".sort-dir")?.value}));return{type:t,config:$t(c),error:""}}const{config:o,error:a}=xt(t,e.querySelector(".stage-config")?.value);return{type:t,config:o,error:a}}const V=()=>[...k.querySelectorAll(":scope > .stage-card")],x=i.querySelector("#view-columns-builder");let B=null;function $e(e={key:"",label:""}){const t=L.length?G(e.key,"col-key"):l("input",{type:"text",class:"form-input form-input--sm col-key font-mono",placeholder:"data.field","aria-label":"Field"});L.length||(t.value=e.key||"");const o=l("input",{type:"text",class:"form-input form-input--sm col-label",placeholder:"Heading","aria-label":"Heading"});o.value=e.label||"";const a=l("button",{type:"button",class:"vex-kebab","aria-label":"Actions for this column",title:"Actions",text:"\u22EE"});return l("div",{class:"vex-col col-row",tabindex:"0"},t,o,a)}function Ce(e){x.replaceChildren();const t=e??L.map(o=>({key:`data.${o.name}`,label:o.label||o.name}));for(const o of t)x.append($e(o));B=l("button",{type:"button",class:"btn btn-sm btn-ghost vex-col-add"},l("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Column")),B.addEventListener("click",()=>{B.before($e()),I.scan(x),g()}),x.append(B),I.scan(x)}const le=()=>[...x.querySelectorAll(":scope > .col-row")];async function Me(e){if(!e){L=[];return}try{L=(await C.collections.get(e))?.fields||[]}catch{L=[]}}function tt(){for(const e of i.querySelectorAll(".match-field, .sort-field, select.col-key")){const t=e.value;e.replaceWith(G(t,[...e.classList].find(o=>["match-field","sort-field","col-key"].includes(o))))}}const u=e=>i.querySelector(`#${e}`);let W={};function Q(){const e=[],t=[];V().forEach((r,n)=>{const p=qe(r);p.error&&t.push({index:n,type:p.type,error:p.error}),e.push({type:p.type,config:p.error?{}:p.config})});const o=le().map(r=>({key:r.querySelector(".col-key")?.value?.trim(),label:r.querySelector(".col-label")?.value?.trim()})).filter(r=>r.key).map(r=>({key:r.key,label:r.label||r.key})),a=u("view-rowlevel-enabled").checked,s=u("view-rowlevel-mode").value||"owner";return{payload:{title:u("view-title").value.trim(),slug:u("view-slug").value.trim()||void 0,description:u("view-description").value.trim(),...u("view-bundled").checked?{bundled:!0}:{},connection:u("view-connection").value||"default",pipeline:{source:u("view-source").value,stages:e},display:{mode:u("view-display-mode").value||"table",columns:o,pageSize:Number.parseInt(u("view-page-size").value,10),block:u("view-block-name").value||""},access:{roles:[...i.querySelectorAll(".view-role-cb:checked")].map(r=>r.value),public:u("view-public").checked,rowLevel:a?{mode:s,userKey:u("view-rowlevel-userkey").value||"id",...s==="field"?{field:u("view-rowlevel-field").value.trim()}:{}}:null},meta:{...W,project:u("view-project").value||null}},stageErrors:t}}async function se(e){W=e?.meta||{},u("view-title").value=e?.title||"",u("view-slug").value=e?.slug||"",u("view-description").value=e?.description||"",u("view-source").value=e?.pipeline?.source||"",u("view-bundled").checked=!!e?.bundled,u("view-project").value=e?.meta?.project||"",u("view-connection").value=e?.connection||"default",u("view-display-mode").value=e?.display?.mode||"table",u("view-page-size").value=String(e?.display?.pageSize??25),u("view-block-name").value=e?.display?.block||"";const t=e?.access?.roles||["admin"];i.querySelectorAll(".view-role-cb").forEach(a=>{a.checked=t.includes(a.value)});for(const a of t)i.querySelector(`.view-role-cb[value="${CSS.escape(a)}"]`)||i.querySelector("#view-roles-checkboxes").append(l("label",{title:"This role no longer exists"},l("input",{type:"checkbox",class:"view-role-cb",value:a,checked:!0}),document.createTextNode(`${a} (missing)`)));u("view-public").checked=!!e?.access?.public;const o=e?.access?.rowLevel;u("view-rowlevel-enabled").checked=!!o,u("view-rowlevel-mode").value=o?.mode||"owner",u("view-rowlevel-userkey").value=o?.userKey||"id",u("view-rowlevel-field").value=o?.field||"",await Me(e?.pipeline?.source||""),k.replaceChildren();for(const a of e?.pipeline?.stages||[])k.append(xe(a));I.scan(k),Ce(e?e.display?.columns||[]:null),Le(),g()}function Le(){const e=u("view-display-mode").value||"table";i.querySelector("#view-columns-section").hidden=e!=="table",i.querySelector("#view-block-section").hidden=e!=="block";const t=u("view-rowlevel-enabled").checked;i.querySelector("#view-rowlevel-config").hidden=!t,i.querySelector("#view-rowlevel-field-group").hidden=!t||u("view-rowlevel-mode").value!=="field",u("view-slug").readOnly=!b}const ie=M.observable(0);function g(){ie.value=ie.peek()+1}const y=M.computed(()=>(ie.value,Q())),O=M.observable(""),X=M.observable(!1),H=M.computed(()=>bt(y.value.payload,{stageErrors:y.value.stageErrors,isNew:b,taken:be})),Z=M.computed(()=>{if(P(y.value.payload)===O.value)return!1;if(b){const t=y.value.payload;return!!(t.title||t.description||t.pipeline.source||t.pipeline.stages.length)}return!0}),ot=M.computed(()=>y.value.payload.pipeline.stages.length),Re=M.computed(()=>{const e=Be(H.value);if(!e)return"";const t=Object.values(H.value).flat()[0];return e===1?t:`${t} (+${e-1} more)`}),at=M.computed(()=>{if(y.value,!b)return"Cannot change once the view exists.";const e=y.value.payload,t=e.slug?Je(e.slug):Je(e.title);return t?`Saved as "${t}".`:""});d(M.effect(()=>{const{payload:e,stageErrors:t}=y.value,o=new Set(t.map(a=>a.index));V().forEach((a,s)=>{a.querySelector(".vex-stage-no").textContent=String(s+1);const c=t.find(n=>n.index===s);a.querySelector(".vex-stage-sum").textContent=c?"":Mt(e.pipeline.stages[s]),a.classList.toggle("has-error",o.has(s));const r=a.querySelector(".vex-stage-err");r&&(r.hidden=!c,r.textContent=c?.error||"")})})),d(M.effect(()=>{const e=y.value.payload.title;Ot(m,e||(b?"New view":f))}));const J=i.querySelector(".vex-results"),T=M.observable(0),ee=M.observable(""),z=M.observable("Open this tab to run the pipeline."),re=M.observable("");let U=[],ce=!1,ue=0,de=null;async function pe(){const{payload:e,stageErrors:t}=y.peek(),o=++ue;if(re.value="",!e.pipeline.source){z.value="Choose the source collection (Source tab) to see results.",J.replaceChildren(),ee.value="";return}const a=T.peek()||e.pipeline.stages.length,s=t.find(c=>c.index<a);if(s){z.value=`Fix stage ${s.index+1} first: ${s.error}`;return}z.value="Running\u2026";try{const c=await Fe("/views/_preview",{method:"POST",body:JSON.stringify({source:e.pipeline.source,connection:e.connection,stages:e.pipeline.stages.slice(0,a),limit:Math.min(e.display.pageSize||10,25)})});if(o!==ue||!j())return;U=c.results||[],z.value=U.length?"":"No rows come out of the pipeline.",ee.value=`${c.total} row${c.total===1?"":"s"}${c.total>U.length?` - the first ${U.length}`:""}${T.peek()?` \xB7 up to stage ${T.peek()}`:""}`,nt(U,e)}catch(c){if(o!==ue)return;z.value="",ee.value="",J.replaceChildren(),re.value=`The pipeline did not run: ${c.message||c}`}}function nt(e,t){if(J.replaceChildren(),!e.length)return;const o=Et(e,!T.peek()&&t.display.mode==="table"?t.display.columns:[]),a=l("table");a.append(l("thead",{},l("tr",{},...o.map(c=>l("th",{text:c.label})))));const s=l("tbody");e.forEach((c,r)=>{const n=l("tr",{tabindex:"0","data-i":String(r)});for(const p of o){const oe=wt(c,p.key);n.append(l("td",{text:oe,title:oe}))}s.append(n)}),a.append(s),J.append(a)}d(M.effect(()=>{const{payload:e}=y.value;T.value;const t=P({s:e.pipeline,c:e.connection,d:e.display.columns,m:e.display.mode,p:e.display.pageSize});clearTimeout(de),ce&&(de=setTimeout(()=>{j()&&t&&pe()},600))})),d(()=>clearTimeout(de));const lt=E.contextMenu(J,{cursor:"glyph",match:"tbody tr",inherit:!1,density:"compact",items:e=>[{header:`Row ${Number(e.dataset.i)+1}`},{label:"Copy as JSON",icon:"copy",action:()=>st(JSON.stringify(U[Number(e.dataset.i)],null,2),"Row copied.")}]});d(()=>lt?.destroy?.());function st(e,t){navigator.clipboard?.writeText(e).then(()=>E.toast(t,{type:"success"}),()=>E.toast("Copy failed.",{type:"error"}))}const Te=E.tabs(i.querySelector(".vex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),it=[...i.querySelectorAll(".vex-tabs > .tab-list > .tab-item")],ve=4,Ae=()=>{Te.activate?.(ve),fe(ve)};function fe(e){ce=e===ve,ce&&pe()}it.forEach((e,t)=>e.addEventListener("click",()=>fe(t)));function rt(e){const t=V().indexOf(e),o=V().length;return[{header:`Stage ${t+1} \xB7 ${e.dataset.stageType}`},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>me(t,-1)},{label:"Move down",icon:"arrow-down",disabled:t===o-1,action:()=>me(t,1)},{label:"Duplicate",icon:"copy",action:()=>{const a=qe(e);if(a.error){E.toast(`Fix this stage first: ${a.error}`,{type:"warning"});return}Ee({type:a.type,config:a.config},e).querySelector(".vex-stage-head")?.focus()}},{label:"Results up to here",icon:"play",action:()=>{T.value=t+1,Ae()}},{type:"divider"},{label:"Delete stage",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}function me(e,t){const o=He(V(),e,t);o.forEach(a=>k.append(a)),g(),o[e+t]?.querySelector(".vex-stage-head")?.focus()}const ct=E.contextMenu(k,{cursor:"glyph",match:".stage-card",inherit:!1,density:"compact",items:e=>rt(e)});d(()=>ct?.destroy?.());const Ne=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Oe=e=>{const t=e.target.closest(".vex-stage-head");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ae(t);else if(e.altKey&&(e.key==="ArrowUp"||e.key==="ArrowDown")){e.preventDefault();const o=V().indexOf(t.closest(".stage-card"));me(o,e.key==="ArrowUp"?-1:1)}}};k.addEventListener("click",Ne),k.addEventListener("keydown",Oe),d(()=>{k.removeEventListener("click",Ne),k.removeEventListener("keydown",Oe)});const ut=E.contextMenu(x,{cursor:"glyph",match:".col-row",inherit:!1,density:"compact",items:e=>{const t=le(),o=t.indexOf(e),a=s=>{He(t,o,s).forEach(r=>B.before(r)),g()};return[{header:e.querySelector(".col-label")?.value||e.querySelector(".col-key")?.value||"Column"},{label:"Move up",icon:"arrow-up",disabled:o===0,action:()=>a(-1)},{label:"Move down",icon:"arrow-down",disabled:o===t.length-1,action:()=>a(1)},{type:"divider"},{label:"Remove column",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}});d(()=>ut?.destroy?.());const De=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Ie=e=>{const t=e.target.closest(".col-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ae(t))};x.addEventListener("click",De),x.addEventListener("keydown",Ie),d(()=>{x.removeEventListener("click",De),x.removeEventListener("keydown",Ie)});function dt(e){const t=e.querySelector(".vex-val"),o=t.querySelector(".match-val")?.value??"";t.replaceChildren(ke(e.querySelector(".match-field")?.value,e.querySelector(".match-op")?.value,o))}const te=e=>{if(!e.target.closest?.(".vex-results")){if(e.type==="change"&&e.target.matches?.(".match-field, .match-op")&&dt(e.target.closest(".match-condition-row")),e.type==="change"&&e.target.matches?.("select.col-key")){const t=e.target.closest(".col-row")?.querySelector(".col-label"),o=L.find(a=>`data.${a.name}`===e.target.value);t&&!t.value&&o&&(t.value=o.label||o.name)}e.target.id==="view-source"&&e.type==="change"&&Me(e.target.value).then(()=>{j()&&(tt(),le().length||Ce(null),g())}),["view-display-mode","view-rowlevel-enabled","view-rowlevel-mode"].includes(e.target.id)&&Le(),g()}};i.addEventListener("input",te),i.addEventListener("change",te);const Ke=e=>{e.target.closest?.("input, textarea, select")&&e.stopPropagation()};i.addEventListener("contextmenu",Ke),d(()=>{i.removeEventListener("input",te),i.removeEventListener("change",te),i.removeEventListener("contextmenu",Ke)}),i.querySelector("#add-stage-btn").addEventListener("click",()=>{Ee({type:i.querySelector("#add-stage-type").value||"$match",config:{}}).querySelector("select, textarea, input")?.focus()});async function ye(){if(X.peek())return;const{payload:e}=y.peek();if(Be(H.peek())){const o=["source","pipeline","display","access"].findIndex(a=>H.peek()[a].length);o>=0&&(Te.activate?.(o),fe(o)),E.toast(`Not saved - ${Re.peek()}`,{type:"warning",duration:6e3});return}X.value=!0;try{if(b){const a=await C.views.create(e);O.value=P(e);try{S.remove?S.remove(w(null)):S.set(w(null),null)}catch{}he("#/views"),E.toast(`Created "${a.title}".`,{type:"success"}),R.navigate(`/views/edit/${encodeURIComponent(a.slug)}`);return}const o=await C.views.update(f,e);N=o||N,W=o?.meta||W,O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("Saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{X.value=!1}}async function pt(){if(await E.confirm("Throw away the changes you have not saved?")){await se(N),O.value=P(Q().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}}}async function vt(){if(!(b||!N)&&await E.confirm(`Delete the view "${At(N.title||f)}"? Pages that show it with [view] will show nothing. This cannot be undone.`))try{await C.views.delete(f),O.value=P(y.peek().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}he("#/views"),E.toast("View deleted.",{type:"success"}),R.navigate("/views")}catch(e){E.toast(`Not deleted: ${e.message||e}`,{type:"error"})}}const ft=M.applyBindings({dirty:Z,saving:X,errs:H,stageCount:ot,errorSummary:Re,slugHint:at,upTo:T,previewMeta:ee,previewNote:z,previewError:re},i,{methods:{save:()=>ye(),discard:()=>pt(),runPreview:()=>pe(),allStages:()=>{T.value=0}}});d(()=>ft.dispose()),d(ht(i,{position:"bottom"})),Y(v,{id:"view-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>ye()}),Y(v,{id:"view-editor-results",icon:"play",title:"Results",onClick:()=>{T.value=0,Ae()}}),b||(Y(v,{id:"view-editor-preview",icon:"eye",title:"Open the preview page",onClick:()=>{location.hash=`#/views/${encodeURIComponent(f)}/preview`}}),Y(v,{id:"view-editor-delete",icon:"trash",title:"Delete this view",onClick:()=>vt()})),Y(v,{id:"view-editor-all",icon:"list",title:"All views",onClick:()=>{location.hash="#/views"}});const Pe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),ye())};if(document.addEventListener("keydown",Pe,!0),d(()=>document.removeEventListener("keydown",Pe,!0)),await se(N),!j())return;O.value=P(Q().payload);const je=()=>{if(Z.peek())try{S.set(w(f),{payload:y.peek().payload,base:O.peek(),at:new Date().toISOString()})}catch{}},Ve=e=>{Z.peek()&&(je(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ve),d(()=>window.removeEventListener("beforeunload",Ve)),A={path:Ge,dirty:()=>Z.peek(),dispose(){ge=!0,je(),q.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let K=null;try{K=S.get(w(f))}catch{}if(K?.payload){try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}const e=K.base&&K.base!==O.peek(),t=K.at?` (${D(K.at).format("D MMM HH:mm")})`:"",o=b?"the new view you started":"this view",a=e?`You left unsaved changes to ${o}${t}, but the saved view has changed since. Restore your version anyway?`:`You left unsaved changes to ${o}${t}. Restore them?`;if(await E.confirm(a)&&j()){const s=b?K.payload.slug:f;await se({...K.payload,slug:s})}}}};
@@ -1 +1 @@
1
- import{api as m}from"../api.js";let l=null,a=1,c=0;export const viewPreviewView={templateUrl:"/admin/js/templates/view-preview.html",async onMount(e){l=null,a=1,c=0;const i=window.location.hash.match(/\/views\/([^/?#]+)\/preview/),s=i?i[1]:null;if(!s){E.toast("No view selected.",{type:"error"}),R.navigate("/views");return}const t=E.loader(e.get(0),{type:"dots"});try{if(l=await m.views.get(s),!l){E.toast("View not found.",{type:"error"}),R.navigate("/views");return}}catch(r){E.toast(r.message||"Failed to load view.",{type:"error"});return}finally{t.destroy()}e.find("#preview-title").text(l.title),e.find("#preview-edit-btn").attr("href",`#/views/edit/${l.slug}`),e.find("#preview-source").text(l.pipeline?.source||"-"),e.find("#preview-connection").text(l.connection||"default"),e.find("#preview-mode").text(l.display?.mode||"table"),e.find("#preview-meta-card").show(),e.find("#preview-run-btn").off("click").on("click",async()=>{a=1,await v(e)}),e.find("#preview-prev").off("click").on("click",async()=>{a>1&&(a--,await v(e))}),e.find("#preview-next").off("click").on("click",async()=>{const r=l.display?.pageSize||25;a*r<c&&(a++,await v(e))}),Domma.icons.scan()}};async function v(e){const n=l.display?.pageSize||25,i=e.find("#preview-run-btn").get(0);i&&(i.disabled=!0,i.textContent="Running\u2026"),e.find("#preview-placeholder").hide(),e.find("#preview-table").hide(),e.find("#preview-list").hide(),e.find("#preview-empty").hide();try{const s=await m.views.execute(l.slug,{page:a,limit:n});c=s.total||0;const t=Math.max(1,Math.ceil(c/n));if(e.find("#preview-count").text(`${c} result${c!==1?"s":""}`),e.find("#preview-page-label").text(`Page ${a} of ${t}`),e.find("#preview-prev").prop("disabled",a<=1),e.find("#preview-next").prop("disabled",a>=t),e.find("#preview-pagination").show(),!s.results||s.results.length===0){e.find("#preview-empty").show();return}(l.display?.mode||"table")==="list"?g(s.results,e):y(s.results,e)}catch(s){E.toast(s.message||"Execution failed.",{type:"error"})}finally{i&&(i.disabled=!1,i.textContent="Run")}}function y(e,n){const i=l.display?.columns||[],s=i.length?i:Object.keys(u(e[0])).slice(0,6).map(t=>({key:t,label:t}));T.create("#preview-table",{data:e,columns:s.map(t=>({key:t.key,title:t.label||t.key,render:(r,f)=>{const w=t.key.split(".");let o=f;for(const h of w)o=o?.[h];const d=o!=null?String(o):"",p=document.createElement("span");return p.title=d,p.textContent=d.length>80?d.slice(0,80)+"\u2026":d,p.outerHTML}})),emptyMessage:"No results."}),n.find("#preview-table").show(),Domma.icons.scan()}function g(e,n){const i=n.find("#preview-list").get(0);if(i){for(;i.firstChild;)i.removeChild(i.firstChild);e.forEach(s=>{const t=document.createElement("div");t.style.cssText="padding:.75rem;border-bottom:1px solid var(--dm-border,#333);";const r=u(s);Object.entries(r).slice(0,6).forEach(([f,w])=>{const o=document.createElement("div");o.style.cssText="display:flex;gap:.5rem;font-size:.875rem;";const d=document.createElement("strong");d.textContent=f+": ",d.style.cssText="color:var(--dm-text-muted,#888);min-width:120px;flex-shrink:0;";const p=document.createElement("span");p.textContent=String(w??""),o.appendChild(d),o.appendChild(p),t.appendChild(o)}),i.appendChild(t)}),n.find("#preview-list").show()}}function u(e,n="",i={}){for(const[s,t]of Object.entries(e||{})){const r=n?`${n}.${s}`:s;t&&typeof t=="object"&&!Array.isArray(t)&&Object.keys(t).length<8?u(t,r,i):i[r]=t}return i}
1
+ import{api as O}from"../api.js";import{addBannerAction as T}from"../lib/plugin-chrome.js";import{helpPopovers as D}from"../lib/help-popover.js";import{openMenuAt as U,viewLifetime as F}from"../lib/tool-kit.js";import{cellText as j,flattenDoc as H,resultColumns as J}from"../lib/view-editor-arrange.js";const y=F(/^#\/views\/[^/?#]+\/preview\/?(\?.*)?$/),K='<span class="vpx-chip" data-bind-class="cls" title="{{title}}">{{text}}</span>';function a(c,p={},...n){const s=document.createElement(c);for(const[l,o]of Object.entries(p))o!=null&&(l==="class"?s.className=o:l==="text"?s.textContent=o:s.setAttribute(l,o));for(const l of n)l&&s.append(l);return s}function V(c,p){const n=c.querySelector(".dm-plugin-banner-name .dm-plugin-banner-sep");if(n){for(;n.nextSibling;)n.nextSibling.remove();n.after(document.createTextNode(p))}}export const viewPreviewView={templateUrl:"/admin/js/templates/view-preview.html",async onMount(c){y.reset();const p=c.get(0),n=p.querySelector(".vpx")||p,s=()=>n.isConnected&&/\/preview/.test(location.hash),l=location.hash.match(/^#\/views\/([^/?#]+)\/preview/),o=l?decodeURIComponent(l[1]):null;if(!o){R.navigate("/views");return}const r=M.observable(null),i=M.observable(1),d=M.observable(0),u=M.observable(!0),b=M.observable("");let v=[];const N=()=>r.peek()?.display?.pageSize||25,C=M.computed(()=>Math.max(1,Math.ceil(d.value/(r.value?.display?.pageSize||25)))),q=M.computed(()=>{const e=r.value;if(!e)return[];const t=e.access||{},w=e.pipeline?.stages||[];return[{text:`From ${e.pipeline?.source||"-"}`,title:"The collection it reads",cls:""},{text:`${w.length} stage${w.length===1?"":"s"}`,title:w.map(m=>m.type).join(" \u2192 ")||"No stages",cls:""},{text:`Shown as ${e.display?.mode||"table"}`,title:"Display mode",cls:""},{text:`${e.display?.pageSize||25} a page`,title:"Page size",cls:""},t.public?{text:"Public",title:"Anyone, signed in or not",cls:"is-warn"}:{text:`Roles: ${(t.roles||[]).join(", ")||"none"}`,title:"Who can see it",cls:""},...t.rowLevel?[{text:`Own rows only (${t.rowLevel.mode})`,title:"Row-level access - not applied for admins",cls:"is-ok"}]:[],...e.connection&&e.connection!=="default"?[{text:`Connection ${e.connection}`,title:"MongoDB connection",cls:""}]:[]]}),f=n.querySelector(".vpx-results");function P(){f.replaceChildren();const e=r.peek();if(!v.length||!e)return;if((e.display?.mode||"table")==="list"){v.forEach((m,$)=>{const h=a("dl",{class:"vpx-list-item",tabindex:"0","data-i":String($)});for(const[g,S]of Object.entries(H(m)).slice(0,6)){const A=j({[g]:S},g,200);h.append(a("dt",{text:g}),a("dd",{text:A,title:A}))}f.append(h)});return}const t=J(v,e.display?.columns),w=a("tbody");v.forEach((m,$)=>{const h=a("tr",{tabindex:"0","data-i":String($)});for(const g of t){const S=j(m,g.key);h.append(a("td",{text:S,title:S}))}w.append(h)}),f.append(a("table",{},a("thead",{},a("tr",{},...t.map(m=>a("th",{text:m.label})))),w))}let x=0;async function k(){const e=++x;u.value=!0,b.value="";try{const t=await O.views.execute(o,{page:i.peek(),limit:N()});if(e!==x||!s())return;v=t.results||[],d.value=t.total||0,P()}catch(t){if(e!==x)return;v=[],d.value=0,P(),b.value=`The view did not run: ${t.message||t}`}finally{e===x&&(u.value=!1)}}n.querySelector(".vpx-chips").innerHTML=K;const z=M.applyBindings({chips:q,total:d,loading:u,error:b,description:M.computed(()=>r.value?.description||""),countText:M.computed(()=>u.value&&!d.value?"":`${d.value} row${d.value===1?"":"s"}`),pageText:M.computed(()=>`Page ${i.value} of ${C.value}`),canPrev:M.computed(()=>i.value>1&&!u.value),canNext:M.computed(()=>i.value<C.value&&!u.value)},n,{methods:{prev:()=>{i.peek()>1&&(i.value=i.peek()-1,k())},next:()=>{i.peek()<C.peek()&&(i.value=i.peek()+1,k())}}});y.add(()=>z.dispose()),y.add(D(n,{position:"bottom"}));const B=E.contextMenu(f,{cursor:"glyph",match:"tr[data-i], dl[data-i]",inherit:!1,density:"compact",items:e=>{const t=v[Number(e.dataset.i)];return[{header:`Row ${(i.peek()-1)*N()+Number(e.dataset.i)+1}`},{label:"Copy as JSON",icon:"copy",action:()=>navigator.clipboard?.writeText(JSON.stringify(t,null,2)).then(()=>E.toast("Row copied.",{type:"success"}),()=>E.toast("Copy failed.",{type:"error"}))},...t?.id&&r.peek()?.pipeline?.source?[{label:"Open the collection",icon:"database",action:()=>{location.hash=`#/collections/${encodeURIComponent(r.peek().pipeline.source)}/entries`}}]:[]]}});y.add(()=>B?.destroy?.());const L=e=>{const t=e.target.closest("tr[data-i], dl[data-i]");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),U(t))};f.addEventListener("keydown",L),y.add(()=>f.removeEventListener("keydown",L)),T(c,{id:"view-preview-run",icon:"refresh-cw",title:"Run again",onClick:()=>k()}),T(c,{id:"view-preview-edit",icon:"edit",title:"Edit this view",onClick:()=>{location.hash=`#/views/edit/${encodeURIComponent(o)}`}}),T(c,{id:"view-preview-all",icon:"list",title:"All views",onClick:()=>{location.hash="#/views"}});try{const e=await O.views.get(o);if(!s())return;if(!e){E.toast("That view does not exist.",{type:"error"}),R.navigate("/views");return}r.value=e,V(p,e.title||o)}catch(e){if(!s())return;u.value=!1,b.value=`Could not load the view: ${e.message||e}`;return}I.scan(n),await k()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -11,6 +11,7 @@ import {fileURLToPath} from 'url';
11
11
  import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
12
12
  import {getHealth} from '../../services/health.js';
13
13
  import {listVersions} from '../../services/versions.js';
14
+ import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const ROOT = path.resolve(__dirname, '..', '..', '..');
@@ -62,6 +63,24 @@ function sumRange(daily, fromKey, toKey) {
62
63
  return total;
63
64
  }
64
65
 
66
+ /**
67
+ * Daily totals for the last `days` days, oldest first - the traffic chart.
68
+ * (The chart used to add up the top five pages' 7-day sparklines and call it
69
+ * "the last 30 days": everything but the last week read 0.)
70
+ *
71
+ * @param {object} daily
72
+ * @param {number} [days]
73
+ * @returns {{day: string, hits: number}[]}
74
+ */
75
+ export function buildSeries(daily, days = 30) {
76
+ const out = [];
77
+ for (let i = days - 1; i >= 0; i -= 1) {
78
+ const day = isoDaysAgo(i);
79
+ out.push({day, hits: sumDay(daily, day)});
80
+ }
81
+ return out;
82
+ }
83
+
65
84
  /**
66
85
  * Build the top-pages widget data, including a per-day sparkline and a
67
86
  * percentage delta versus the previous comparable window.
@@ -212,12 +231,19 @@ async function buildActivity() {
212
231
  } catch { /* no versions dir */ }
213
232
 
214
233
  // Collection entries - each slug has a single data.json with an array of
215
- // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
234
+ // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt. The
235
+ // system's own collections (roles, users' profiles, API tokens, projects…)
236
+ // are not activity anyone asked to see - they were listed as "received
237
+ // submission" before. An entry is `type: 'entry'` with its collection.
238
+ const SYSTEM = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
216
239
  const collectionsDir = path.join(ROOT, 'content', 'collections');
217
240
  try {
218
241
  const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
219
242
  for (const slug of slugs) {
220
- if (!slug.isDirectory()) continue;
243
+ if (!slug.isDirectory() || SYSTEM.has(slug.name)) continue;
244
+ let title = slug.name;
245
+ try { title = JSON.parse(await fs.readFile(path.join(collectionsDir, slug.name, 'schema.json'), 'utf8')).title || title; }
246
+ catch { /* no schema - keep the slug */ }
221
247
  const dataFile = path.join(collectionsDir, slug.name, 'data.json');
222
248
  let entries = [];
223
249
  try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
@@ -231,9 +257,10 @@ async function buildActivity() {
231
257
 
232
258
  for (const e of sorted) {
233
259
  items.push({
234
- type: 'form',
260
+ type: 'entry',
235
261
  at: e.meta.createdAt,
236
- form: slug.name
262
+ collection: slug.name,
263
+ title
237
264
  });
238
265
  }
239
266
  }
@@ -338,6 +365,7 @@ export async function dashboardRoutes(fastify, opts = {}) {
338
365
  }
339
366
 
340
367
  const topPages = buildTopPages(daily);
368
+ traffic.series = buildSeries(daily);
341
369
  const journeys = (await safe('analytics.journeys', () => {
342
370
  const end = new Date(); end.setUTCHours(0, 0, 0, 0);
343
371
  const start = new Date(end); start.setUTCDate(start.getUTCDate() - 6);