domma-cms 0.92.1 → 0.94.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 (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -1 +1 @@
1
- import{api as C,apiRequest as _e}from"../api.js";import{addBannerAction as Y}from"../lib/plugin-chrome.js";import{helpPopovers as gt}from"../lib/help-popover.js";import{openMenuAt as ae,sidebarCount as he}from"../lib/tool-kit.js";import{OPERATORS as wt,STAGE_TYPES as Be,adminTierRoles as bt,cellText as St,checkView as kt,draftKey as w,errorCount as He,isGuided as xt,matchToRows as Et,move as Je,parseStageJson as qt,resultColumns as $t,rowsToMatch as Ct,rowsToSort as Mt,slugify as Ye,snapshot as P,sortToRows as Rt,stageSummary as Lt,stageText as Tt}from"../lib/view-editor-arrange.js";const At=/^\/views\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ne=v=>String(v||"").split("?")[0].replace(/\/$/,""),Nt=["super-admin","admin","user"],Ot=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function s(v,m={},...r){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 r)q&&h.append(q);return h}let A=null,Ge=!1;function Dt(){Ge||!window.R?.use||(Ge=!0,R.use((v,m,r)=>{const h=ne(m?.path);return!(At.test(m?.path||"")&&ne(v?.path)!==h)||!A?.dirty()?r():E.confirm("This view has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?r():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 It(v,m){const r=v.querySelector(".dm-plugin-banner-name");if(!r)return;const h=r.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(Dt(),A){const e=A;A=null,e.dispose()}const m=v.get(0),r=m.querySelector(".vex")||m,h=$(r),q=[],d=e=>(q.push(e),e);let ge=!1;const j=()=>!ge&&r.isConnected,we=location.hash.match(/^#\/views\/edit\/([^/?#]+)/),f=we?decodeURIComponent(we[1]):null,b=!f,We=ne(location.hash.slice(1));let L=[],N=null,be=[],Se=[];const ke=E.loader(m,{type:"dots"});try{const[e,t,o,n,l,c]=await Promise.all([C.collections.list().catch(()=>[]),C.collections.getConnections().catch(()=>({})),C.blocks.list().catch(()=>[]),C.projects.list().catch(()=>[]),_e("/collections/roles/entries?limit=1000").catch(()=>null),C.views.list().catch(()=>[])]);be=(c||[]).map(a=>a.slug),F(r.querySelector("#view-source"),(e||[]).map(a=>[a.slug,`${a.title||a.slug} (${a.slug})`])),F(r.querySelector("#view-connection"),Object.keys(t||{}).filter(a=>a!=="default").map(a=>[a,a])),F(r.querySelector("#view-block-name"),(o||[]).map(a=>[a.name,`${a.name}.html`])),F(r.querySelector("#view-project"),(n||[]).map(a=>[a.slug,a.name||a.slug]));const i=(l?.entries||l?.data||[]).map(a=>a.data||a).filter(a=>a?.name).sort((a,p)=>(a.level??9)-(p.level??9)).map(a=>({name:a.name,label:a.label||a.name,level:a.level}));if(Se=bt(i),Qe(i.length?i:Nt.map(a=>({name:a,label:a}))),F(r.querySelector("#add-stage-type"),Be.map(a=>[a.type,`${a.type} - ${a.label}`])),!b&&(N=await C.views.get(f),!N))throw new Error("View not found")}catch(e){ke.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(ke.destroy(),!j())return;function F(e,t){if(e)for(const[o,n]of t)e.querySelector(`option[value="${CSS.escape(o)}"]`)||e.append(s("option",{value:o,text:n}))}function Qe(e){const t=r.querySelector("#view-roles-checkboxes");t.replaceChildren();for(const o of e)t.append(s("label",{},s("input",{type:"checkbox",class:"view-role-cb",value:o.name}),document.createTextNode(o.label)))}function Xe(){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,n){const l=s("select",{class:`form-input form-input--sm ${o||""}`,"aria-label":n});for(const c of e)l.append(s("option",{value:c.value,text:c.label}));return t&&!e.some(c=>String(c.value)===String(t))&&l.append(s("option",{value:t,text:t})),l.value=t??"",l}function G(e,t){return _([{value:"",label:"- field -"},...Xe()],e,t,"Field")}function xe(e,t,o){const n=(L||[]).find(i=>`data.${i.name}`===e);if(n?.type==="select"&&(t==="eq"||t==="ne")&&n.options?.length){const i=n.options.map(a=>typeof a=="string"?{value:a,label:a}:{value:a.value??"",label:a.label||a.value}).filter(a=>a.value&&a.value!=="undefined");return _([{value:"",label:"- value -"},...i],o,"match-val","Value")}const l=t==="in"?"a, b, c":t==="contains"?"text (a pattern)":["gt","lt","gte","lte"].includes(t)?"0":"value",c=s("input",{type:"text",class:"form-input form-input--sm match-val",placeholder:l,"aria-label":"Value"});return c.value=o??"",c}function Ze(e,t){const{logic:o,rows:n}=Et(t),l=_([{value:"and",label:"ALL conditions (and)"},{value:"or",label:"ANY condition (or)"}],o,"match-logic","Match"),c=s("div",{class:"vex-conds"}),i=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Condition")),a=(p={field:"",op:"eq",val:""})=>{const oe=G(p.field,"match-field"),yt=_(wt,p.op,"match-op","Operator"),ht=s("span",{class:"vex-val"},xe(p.field,p.op,p.val)),Ue=s("button",{type:"button",class:"vex-x",title:"Remove this condition","aria-label":"Remove this condition",text:"\xD7"});Ue.addEventListener("click",()=>{Fe.remove(),g()});const Fe=s("div",{class:"vex-cond match-condition-row"},oe,yt,ht,Ue);c.append(Fe)};i.addEventListener("click",()=>{a(),g()}),e.append(s("div",{class:"vex-logic"},s("span",{class:"vex-muted",text:"Keep rows matching"}),l),c,i),(n.length?n:[void 0]).forEach(p=>a(p))}function et(e,t){const o=s("div",{class:"vex-conds"}),n=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Sort field")),l=(i={field:"",dir:1})=>{const a=s("button",{type:"button",class:"vex-x",title:"Remove","aria-label":"Remove this sort",text:"\xD7"}),p=s("div",{class:"vex-sort-row sort-row"},G(i.field,"sort-field"),_([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(i.dir),"sort-dir","Direction"),a);a.addEventListener("click",()=>{p.remove(),g()}),o.append(p)};n.addEventListener("click",()=>{l(),g()}),e.append(o,n);const c=Rt(t);(c.length?c:[void 0]).forEach(i=>l(i))}const k=r.querySelector("#pipeline-stages-list");let tt=0;function Ee(e){const t=s("div",{class:"vex-stage stage-card","data-stage-type":e.type,"data-sid":String(++tt)}),o=s("div",{class:"vex-stage-head",tabindex:"0"},s("span",{class:"vex-stage-no"}),s("code",{class:"vex-stage-type",text:e.type}),s("span",{class:"vex-stage-sum"}),s("button",{type:"button",class:"vex-kebab","aria-label":`Actions for this ${e.type} stage`,title:"Actions",text:"\u22EE"})),n=s("div",{class:"vex-stage-body"});if(xt(e.type)){t.dataset.guided="true";const l=s("div",{class:"stage-guided"});n.append(l),e.type==="$match"?Ze(l,e.config):et(l,e.config)}else{const l=Be.find(i=>i.type===e.type),c=s("textarea",{class:"form-input stage-config",rows:"5",spellcheck:"false","aria-label":`${e.type} settings`,placeholder:l?.example||"{}"});c.value=Tt(e.type,e.config),n.append(s("small",{class:"vex-hint",text:`The inside of ${e.type} only - e.g. ${(l?.example||"{}").replace(/\s+/g," ")}`}),c,s("small",{class:"vex-stage-err",hidden:!0}))}return t.append(o,n),t}function qe(e,t=null){const o=Ee(e);return t?t.after(o):k.append(o),I.scan(o),g(),o}function $e(e){const t=e.dataset.stageType;if(e.dataset.guided){const l=e.querySelector(".stage-guided");if(t==="$match"){const i=[...l.querySelectorAll(".match-condition-row")].map(a=>({field:a.querySelector(".match-field")?.value,op:a.querySelector(".match-op")?.value,val:a.querySelector(".match-val")?.value}));return{type:t,config:Ct(l.querySelector(".match-logic")?.value,i),error:""}}const c=[...l.querySelectorAll(".sort-row")].map(i=>({field:i.querySelector(".sort-field")?.value,dir:i.querySelector(".sort-dir")?.value}));return{type:t,config:Mt(c),error:""}}const{config:o,error:n}=qt(t,e.querySelector(".stage-config")?.value);return{type:t,config:o,error:n}}const V=()=>[...k.querySelectorAll(":scope > .stage-card")],x=r.querySelector("#view-columns-builder");let B=null;function Ce(e={key:"",label:""}){const t=L.length?G(e.key,"col-key"):s("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=s("input",{type:"text",class:"form-input form-input--sm col-label",placeholder:"Heading","aria-label":"Heading"});o.value=e.label||"";const n=s("button",{type:"button",class:"vex-kebab","aria-label":"Actions for this column",title:"Actions",text:"\u22EE"});return s("div",{class:"vex-col col-row",tabindex:"0"},t,o,n)}function Me(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(Ce(o));B=s("button",{type:"button",class:"btn btn-sm btn-ghost vex-col-add"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Column")),B.addEventListener("click",()=>{B.before(Ce()),I.scan(x),g()}),x.append(B),I.scan(x)}const le=()=>[...x.querySelectorAll(":scope > .col-row")];async function Re(e){if(!e){L=[];return}try{L=(await C.collections.get(e))?.fields||[]}catch{L=[]}}function ot(){for(const e of r.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=>r.querySelector(`#${e}`);let W={};function Q(){const e=[],t=[];V().forEach((i,a)=>{const p=$e(i);p.error&&t.push({index:a,type:p.type,error:p.error}),e.push({type:p.type,config:p.error?{}:p.config})});const o=le().map(i=>({key:i.querySelector(".col-key")?.value?.trim(),label:i.querySelector(".col-label")?.value?.trim()})).filter(i=>i.key).map(i=>({key:i.key,label:i.label||i.key})),n=u("view-rowlevel-enabled").checked,l=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:[...r.querySelectorAll(".view-role-cb:checked")].map(i=>i.value),public:u("view-public").checked,rowLevel:n?{mode:l,userKey:u("view-rowlevel-userkey").value||"id",...l==="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=Array.isArray(e?.access?.roles)?e.access.roles:[],o=t.length||e?.access?.public?t:Se;r.querySelectorAll(".view-role-cb").forEach(l=>{l.checked=o.includes(l.value)});for(const l of o)r.querySelector(`.view-role-cb[value="${CSS.escape(l)}"]`)||r.querySelector("#view-roles-checkboxes").append(s("label",{title:"This role no longer exists"},s("input",{type:"checkbox",class:"view-role-cb",value:l,checked:!0}),document.createTextNode(`${l} (missing)`)));u("view-public").checked=!!e?.access?.public;const n=e?.access?.rowLevel;u("view-rowlevel-enabled").checked=!!n,u("view-rowlevel-mode").value=n?.mode||"owner",u("view-rowlevel-userkey").value=n?.userKey||"id",u("view-rowlevel-field").value=n?.field||"",await Re(e?.pipeline?.source||""),k.replaceChildren();for(const l of e?.pipeline?.stages||[])k.append(Ee(l));I.scan(k),Me(e?e.display?.columns||[]:null),Le(),g()}function Le(){const e=u("view-display-mode").value||"table";r.querySelector("#view-columns-section").hidden=e!=="table",r.querySelector("#view-block-section").hidden=e!=="block";const t=u("view-rowlevel-enabled").checked;r.querySelector("#view-rowlevel-config").hidden=!t,r.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(()=>kt(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}),at=M.computed(()=>y.value.payload.pipeline.stages.length),Te=M.computed(()=>{const e=He(H.value);if(!e)return"";const t=Object.values(H.value).flat()[0];return e===1?t:`${t} (+${e-1} more)`}),nt=M.computed(()=>{if(y.value,!b)return"Cannot change once the view exists.";const e=y.value.payload,t=e.slug?Ye(e.slug):Ye(e.title);return t?`Saved as "${t}".`:""});d(M.effect(()=>{const{payload:e,stageErrors:t}=y.value,o=new Set(t.map(n=>n.index));V().forEach((n,l)=>{n.querySelector(".vex-stage-no").textContent=String(l+1);const c=t.find(a=>a.index===l);n.querySelector(".vex-stage-sum").textContent=c?"":Lt(e.pipeline.stages[l]),n.classList.toggle("has-error",o.has(l));const i=n.querySelector(".vex-stage-err");i&&(i.hidden=!c,i.textContent=c?.error||"")})})),d(M.effect(()=>{const e=y.value.payload.title;It(m,e||(b?"New view":f))}));const J=r.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 n=T.peek()||e.pipeline.stages.length,l=t.find(c=>c.index<n);if(l){z.value=`Fix stage ${l.index+1} first: ${l.error}`;return}z.value="Running\u2026";try{const c=await _e("/views/_preview",{method:"POST",body:JSON.stringify({source:e.pipeline.source,connection:e.connection,stages:e.pipeline.stages.slice(0,n),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()}`:""}`,lt(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 lt(e,t){if(J.replaceChildren(),!e.length)return;const o=$t(e,!T.peek()&&t.display.mode==="table"?t.display.columns:[]),n=s("table");n.append(s("thead",{},s("tr",{},...o.map(c=>s("th",{text:c.label})))));const l=s("tbody");e.forEach((c,i)=>{const a=s("tr",{tabindex:"0","data-i":String(i)});for(const p of o){const oe=St(c,p.key);a.append(s("td",{text:oe,title:oe}))}l.append(a)}),n.append(l),J.append(n)}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 st=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:()=>it(JSON.stringify(U[Number(e.dataset.i)],null,2),"Row copied.")}]});d(()=>st?.destroy?.());function it(e,t){navigator.clipboard?.writeText(e).then(()=>E.toast(t,{type:"success"}),()=>E.toast("Copy failed.",{type:"error"}))}const Ae=E.tabs(r.querySelector(".vex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),rt=[...r.querySelectorAll(".vex-tabs > .tab-list > .tab-item")],ve=4,Ne=()=>{Ae.activate?.(ve),fe(ve)};function fe(e){ce=e===ve,ce&&pe()}rt.forEach((e,t)=>e.addEventListener("click",()=>fe(t)));function ct(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 n=$e(e);if(n.error){E.toast(`Fix this stage first: ${n.error}`,{type:"warning"});return}qe({type:n.type,config:n.config},e).querySelector(".vex-stage-head")?.focus()}},{label:"Results up to here",icon:"play",action:()=>{T.value=t+1,Ne()}},{type:"divider"},{label:"Delete stage",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}function me(e,t){const o=Je(V(),e,t);o.forEach(n=>k.append(n)),g(),o[e+t]?.querySelector(".vex-stage-head")?.focus()}const ut=E.contextMenu(k,{cursor:"glyph",match:".stage-card",inherit:!1,density:"compact",items:e=>ct(e)});d(()=>ut?.destroy?.());const Oe=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},De=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",Oe),k.addEventListener("keydown",De),d(()=>{k.removeEventListener("click",Oe),k.removeEventListener("keydown",De)});const dt=E.contextMenu(x,{cursor:"glyph",match:".col-row",inherit:!1,density:"compact",items:e=>{const t=le(),o=t.indexOf(e),n=l=>{Je(t,o,l).forEach(i=>B.before(i)),g()};return[{header:e.querySelector(".col-label")?.value||e.querySelector(".col-key")?.value||"Column"},{label:"Move up",icon:"arrow-up",disabled:o===0,action:()=>n(-1)},{label:"Move down",icon:"arrow-down",disabled:o===t.length-1,action:()=>n(1)},{type:"divider"},{label:"Remove column",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}});d(()=>dt?.destroy?.());const Ie=e=>{const t=e.target.closest(".vex-kebab");t&&ae(t)},Ke=e=>{const t=e.target.closest(".col-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),ae(t))};x.addEventListener("click",Ie),x.addEventListener("keydown",Ke),d(()=>{x.removeEventListener("click",Ie),x.removeEventListener("keydown",Ke)});function pt(e){const t=e.querySelector(".vex-val"),o=t.querySelector(".match-val")?.value??"";t.replaceChildren(xe(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")&&pt(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(n=>`data.${n.name}`===e.target.value);t&&!t.value&&o&&(t.value=o.label||o.name)}e.target.id==="view-source"&&e.type==="change"&&Re(e.target.value).then(()=>{j()&&(ot(),le().length||Me(null),g())}),["view-display-mode","view-rowlevel-enabled","view-rowlevel-mode"].includes(e.target.id)&&Le(),g()}};r.addEventListener("input",te),r.addEventListener("change",te);const Pe=e=>{e.target.closest?.("input, textarea, select")&&e.stopPropagation()};r.addEventListener("contextmenu",Pe),d(()=>{r.removeEventListener("input",te),r.removeEventListener("change",te),r.removeEventListener("contextmenu",Pe)}),r.querySelector("#add-stage-btn").addEventListener("click",()=>{qe({type:r.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(He(H.peek())){const o=["source","pipeline","display","access"].findIndex(n=>H.peek()[n].length);o>=0&&(Ae.activate?.(o),fe(o)),E.toast(`Not saved - ${Te.peek()}`,{type:"warning",duration:6e3});return}X.value=!0;try{if(b){const n=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 "${n.title}".`,{type:"success"}),R.navigate(`/views/edit/${encodeURIComponent(n.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 vt(){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 ft(){if(!(b||!N)&&await E.confirm(`Delete the view "${Ot(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 mt=M.applyBindings({dirty:Z,saving:X,errs:H,stageCount:at,errorSummary:Te,slugHint:nt,upTo:T,previewMeta:ee,previewNote:z,previewError:re},r,{methods:{save:()=>ye(),discard:()=>vt(),runPreview:()=>pe(),allStages:()=>{T.value=0}}});d(()=>mt.dispose()),d(gt(r,{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,Ne()}}),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:()=>ft()})),Y(v,{id:"view-editor-all",icon:"list",title:"All views",onClick:()=>{location.hash="#/views"}});const je=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),ye())};if(document.addEventListener("keydown",je,!0),d(()=>document.removeEventListener("keydown",je,!0)),await se(N),!j())return;O.value=P(Q().payload);const Ve=()=>{if(Z.peek())try{S.set(w(f),{payload:y.peek().payload,base:O.peek(),at:new Date().toISOString()})}catch{}},ze=e=>{Z.peek()&&(Ve(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ze),d(()=>window.removeEventListener("beforeunload",ze)),A={path:We,dirty:()=>Z.peek(),dispose(){ge=!0,Ve(),q.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(r);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",n=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(n)&&j()){const l=b?K.payload.slug:f;await se({...K.payload,slug:l})}}}};
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 oe,sidebarCount as ye}from"../lib/tool-kit.js";import{OPERATORS as gt,STAGE_TYPES as _e,adminTierRoles as wt,cellText as bt,checkView as St,draftKey as w,errorCount as Be,isGuided as kt,matchToRows as xt,move as Je,parseStageJson as Et,resultColumns as qt,rowsToMatch as $t,rowsToSort as Ct,slugify as Ye,snapshot as K,sortToRows as Mt,stageSummary as Rt,stageText as Tt}from"../lib/view-editor-arrange.js";import{fmtDateTime as Lt}from"../lib/dates.js";const At=/^\/views\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,ae=v=>String(v||"").split("?")[0].replace(/\/$/,""),Nt=["super-admin","admin","user"],Ot=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function s(v,m={},...r){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 r)q&&h.append(q);return h}let A=null,He=!1;function Dt(){He||!window.R?.use||(He=!0,R.use((v,m,r)=>{const h=ae(m?.path);return!(At.test(m?.path||"")&&ae(v?.path)!==h)||!A?.dirty()?r():E.confirm("This view has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?r():history.replaceState(null,"",`#${m.path}`)})}),M.subscribe("router:afterChange",({to:v}={})=>{if(A&&ae(v?.path)!==A.path){const m=A;A=null,m.dispose()}}))}function It(v,m){const r=v.querySelector(".dm-plugin-banner-name");if(!r)return;const h=r.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(Dt(),A){const e=A;A=null,e.dispose()}const m=v.get(0),r=m.querySelector(".vex")||m,h=$(r),q=[],d=e=>(q.push(e),e);let he=!1;const P=()=>!he&&r.isConnected,ge=location.hash.match(/^#\/views\/edit\/([^/?#]+)/),f=ge?decodeURIComponent(ge[1]):null,b=!f,Ge=ae(location.hash.slice(1));let T=[],N=null,we=[],be=[];const Se=E.loader(m,{type:"dots"});try{const[e,t,o,n,l,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(()=>[])]);we=(c||[]).map(a=>a.slug),U(r.querySelector("#view-source"),(e||[]).map(a=>[a.slug,`${a.title||a.slug} (${a.slug})`])),U(r.querySelector("#view-connection"),Object.keys(t||{}).filter(a=>a!=="default").map(a=>[a,a])),U(r.querySelector("#view-block-name"),(o||[]).map(a=>[a.name,`${a.name}.html`])),U(r.querySelector("#view-project"),(n||[]).map(a=>[a.slug,a.name||a.slug]));const i=(l?.entries||l?.data||[]).map(a=>a.data||a).filter(a=>a?.name).sort((a,p)=>(a.level??9)-(p.level??9)).map(a=>({name:a.name,label:a.label||a.name,level:a.level}));if(be=wt(i),We(i.length?i:Nt.map(a=>({name:a,label:a}))),U(r.querySelector("#add-stage-type"),_e.map(a=>[a.type,`${a.type} - ${a.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(),!P())return;function U(e,t){if(e)for(const[o,n]of t)e.querySelector(`option[value="${CSS.escape(o)}"]`)||e.append(s("option",{value:o,text:n}))}function We(e){const t=r.querySelector("#view-roles-checkboxes");t.replaceChildren();for(const o of e)t.append(s("label",{},s("input",{type:"checkbox",class:"view-role-cb",value:o.name}),document.createTextNode(o.label)))}function Qe(){const e=(T||[]).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 F(e,t,o,n){const l=s("select",{class:`form-input form-input--sm ${o||""}`,"aria-label":n});for(const c of e)l.append(s("option",{value:c.value,text:c.label}));return t&&!e.some(c=>String(c.value)===String(t))&&l.append(s("option",{value:t,text:t})),l.value=t??"",l}function H(e,t){return F([{value:"",label:"- field -"},...Qe()],e,t,"Field")}function ke(e,t,o){const n=(T||[]).find(i=>`data.${i.name}`===e);if(n?.type==="select"&&(t==="eq"||t==="ne")&&n.options?.length){const i=n.options.map(a=>typeof a=="string"?{value:a,label:a}:{value:a.value??"",label:a.label||a.value}).filter(a=>a.value&&a.value!=="undefined");return F([{value:"",label:"- value -"},...i],o,"match-val","Value")}const l=t==="in"?"a, b, c":t==="contains"?"text (a pattern)":["gt","lt","gte","lte"].includes(t)?"0":"value",c=s("input",{type:"text",class:"form-input form-input--sm match-val",placeholder:l,"aria-label":"Value"});return c.value=o??"",c}function Xe(e,t){const{logic:o,rows:n}=xt(t),l=F([{value:"and",label:"ALL conditions (and)"},{value:"or",label:"ANY condition (or)"}],o,"match-logic","Match"),c=s("div",{class:"vex-conds"}),i=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Condition")),a=(p={field:"",op:"eq",val:""})=>{const te=H(p.field,"match-field"),mt=F(gt,p.op,"match-op","Operator"),yt=s("span",{class:"vex-val"},ke(p.field,p.op,p.val)),ze=s("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=s("div",{class:"vex-cond match-condition-row"},te,mt,yt,ze);c.append(Ue)};i.addEventListener("click",()=>{a(),g()}),e.append(s("div",{class:"vex-logic"},s("span",{class:"vex-muted",text:"Keep rows matching"}),l),c,i),(n.length?n:[void 0]).forEach(p=>a(p))}function Ze(e,t){const o=s("div",{class:"vex-conds"}),n=s("button",{type:"button",class:"btn btn-sm btn-ghost"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Sort field")),l=(i={field:"",dir:1})=>{const a=s("button",{type:"button",class:"vex-x",title:"Remove","aria-label":"Remove this sort",text:"\xD7"}),p=s("div",{class:"vex-sort-row sort-row"},H(i.field,"sort-field"),F([{value:"1",label:"\u2191 Ascending"},{value:"-1",label:"\u2193 Descending"}],String(i.dir),"sort-dir","Direction"),a);a.addEventListener("click",()=>{p.remove(),g()}),o.append(p)};n.addEventListener("click",()=>{l(),g()}),e.append(o,n);const c=Mt(t);(c.length?c:[void 0]).forEach(i=>l(i))}const k=r.querySelector("#pipeline-stages-list");let et=0;function xe(e){const t=s("div",{class:"vex-stage stage-card","data-stage-type":e.type,"data-sid":String(++et)}),o=s("div",{class:"vex-stage-head",tabindex:"0"},s("span",{class:"vex-stage-no"}),s("code",{class:"vex-stage-type",text:e.type}),s("span",{class:"vex-stage-sum"}),s("button",{type:"button",class:"vex-kebab","aria-label":`Actions for this ${e.type} stage`,title:"Actions",text:"\u22EE"})),n=s("div",{class:"vex-stage-body"});if(kt(e.type)){t.dataset.guided="true";const l=s("div",{class:"stage-guided"});n.append(l),e.type==="$match"?Xe(l,e.config):Ze(l,e.config)}else{const l=_e.find(i=>i.type===e.type),c=s("textarea",{class:"form-input stage-config",rows:"5",spellcheck:"false","aria-label":`${e.type} settings`,placeholder:l?.example||"{}"});c.value=Tt(e.type,e.config),n.append(s("small",{class:"vex-hint",text:`The inside of ${e.type} only - e.g. ${(l?.example||"{}").replace(/\s+/g," ")}`}),c,s("small",{class:"vex-stage-err",hidden:!0}))}return t.append(o,n),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 l=e.querySelector(".stage-guided");if(t==="$match"){const i=[...l.querySelectorAll(".match-condition-row")].map(a=>({field:a.querySelector(".match-field")?.value,op:a.querySelector(".match-op")?.value,val:a.querySelector(".match-val")?.value}));return{type:t,config:$t(l.querySelector(".match-logic")?.value,i),error:""}}const c=[...l.querySelectorAll(".sort-row")].map(i=>({field:i.querySelector(".sort-field")?.value,dir:i.querySelector(".sort-dir")?.value}));return{type:t,config:Ct(c),error:""}}const{config:o,error:n}=Et(t,e.querySelector(".stage-config")?.value);return{type:t,config:o,error:n}}const j=()=>[...k.querySelectorAll(":scope > .stage-card")],x=r.querySelector("#view-columns-builder");let _=null;function $e(e={key:"",label:""}){const t=T.length?H(e.key,"col-key"):s("input",{type:"text",class:"form-input form-input--sm col-key font-mono",placeholder:"data.field","aria-label":"Field"});T.length||(t.value=e.key||"");const o=s("input",{type:"text",class:"form-input form-input--sm col-label",placeholder:"Heading","aria-label":"Heading"});o.value=e.label||"";const n=s("button",{type:"button",class:"vex-kebab","aria-label":"Actions for this column",title:"Actions",text:"\u22EE"});return s("div",{class:"vex-col col-row",tabindex:"0"},t,o,n)}function Ce(e){x.replaceChildren();const t=e??T.map(o=>({key:`data.${o.name}`,label:o.label||o.name}));for(const o of t)x.append($e(o));_=s("button",{type:"button",class:"btn btn-sm btn-ghost vex-col-add"},s("span",{"data-icon":"plus","data-icon-size":"13"}),document.createTextNode(" Column")),_.addEventListener("click",()=>{_.before($e()),I.scan(x),g()}),x.append(_),I.scan(x)}const ne=()=>[...x.querySelectorAll(":scope > .col-row")];async function Me(e){if(!e){T=[];return}try{T=(await C.collections.get(e))?.fields||[]}catch{T=[]}}function tt(){for(const e of r.querySelectorAll(".match-field, .sort-field, select.col-key")){const t=e.value;e.replaceWith(H(t,[...e.classList].find(o=>["match-field","sort-field","col-key"].includes(o))))}}const u=e=>r.querySelector(`#${e}`);let G={};function W(){const e=[],t=[];j().forEach((i,a)=>{const p=qe(i);p.error&&t.push({index:a,type:p.type,error:p.error}),e.push({type:p.type,config:p.error?{}:p.config})});const o=ne().map(i=>({key:i.querySelector(".col-key")?.value?.trim(),label:i.querySelector(".col-label")?.value?.trim()})).filter(i=>i.key).map(i=>({key:i.key,label:i.label||i.key})),n=u("view-rowlevel-enabled").checked,l=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:[...r.querySelectorAll(".view-role-cb:checked")].map(i=>i.value),public:u("view-public").checked,rowLevel:n?{mode:l,userKey:u("view-rowlevel-userkey").value||"id",...l==="field"?{field:u("view-rowlevel-field").value.trim()}:{}}:null},meta:{...G,project:u("view-project").value||null}},stageErrors:t}}async function le(e){G=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=Array.isArray(e?.access?.roles)?e.access.roles:[],o=t.length||e?.access?.public?t:be;r.querySelectorAll(".view-role-cb").forEach(l=>{l.checked=o.includes(l.value)});for(const l of o)r.querySelector(`.view-role-cb[value="${CSS.escape(l)}"]`)||r.querySelector("#view-roles-checkboxes").append(s("label",{title:"This role no longer exists"},s("input",{type:"checkbox",class:"view-role-cb",value:l,checked:!0}),document.createTextNode(`${l} (missing)`)));u("view-public").checked=!!e?.access?.public;const n=e?.access?.rowLevel;u("view-rowlevel-enabled").checked=!!n,u("view-rowlevel-mode").value=n?.mode||"owner",u("view-rowlevel-userkey").value=n?.userKey||"id",u("view-rowlevel-field").value=n?.field||"",await Me(e?.pipeline?.source||""),k.replaceChildren();for(const l of e?.pipeline?.stages||[])k.append(xe(l));I.scan(k),Ce(e?e.display?.columns||[]:null),Re(),g()}function Re(){const e=u("view-display-mode").value||"table";r.querySelector("#view-columns-section").hidden=e!=="table",r.querySelector("#view-block-section").hidden=e!=="block";const t=u("view-rowlevel-enabled").checked;r.querySelector("#view-rowlevel-config").hidden=!t,r.querySelector("#view-rowlevel-field-group").hidden=!t||u("view-rowlevel-mode").value!=="field",u("view-slug").readOnly=!b}const se=M.observable(0);function g(){se.value=se.peek()+1}const y=M.computed(()=>(se.value,W())),O=M.observable(""),Q=M.observable(!1),B=M.computed(()=>St(y.value.payload,{stageErrors:y.value.stageErrors,isNew:b,taken:we})),X=M.computed(()=>{if(K(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),Te=M.computed(()=>{const e=Be(B.value);if(!e)return"";const t=Object.values(B.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?Ye(e.slug):Ye(e.title);return t?`Saved as "${t}".`:""});d(M.effect(()=>{const{payload:e,stageErrors:t}=y.value,o=new Set(t.map(n=>n.index));j().forEach((n,l)=>{n.querySelector(".vex-stage-no").textContent=String(l+1);const c=t.find(a=>a.index===l);n.querySelector(".vex-stage-sum").textContent=c?"":Rt(e.pipeline.stages[l]),n.classList.toggle("has-error",o.has(l));const i=n.querySelector(".vex-stage-err");i&&(i.hidden=!c,i.textContent=c?.error||"")})})),d(M.effect(()=>{const e=y.value.payload.title;It(m,e||(b?"New view":f))}));const J=r.querySelector(".vex-results"),L=M.observable(0),Z=M.observable(""),V=M.observable("Open this tab to run the pipeline."),ie=M.observable("");let z=[],re=!1,ce=0,ue=null;async function de(){const{payload:e,stageErrors:t}=y.peek(),o=++ce;if(ie.value="",!e.pipeline.source){V.value="Choose the source collection (Source tab) to see results.",J.replaceChildren(),Z.value="";return}const n=L.peek()||e.pipeline.stages.length,l=t.find(c=>c.index<n);if(l){V.value=`Fix stage ${l.index+1} first: ${l.error}`;return}V.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,n),limit:Math.min(e.display.pageSize||10,25)})});if(o!==ce||!P())return;z=c.results||[],V.value=z.length?"":"No rows come out of the pipeline.",Z.value=`${c.total} row${c.total===1?"":"s"}${c.total>z.length?` - the first ${z.length}`:""}${L.peek()?` \xB7 up to stage ${L.peek()}`:""}`,nt(z,e)}catch(c){if(o!==ce)return;V.value="",Z.value="",J.replaceChildren(),ie.value=`The pipeline did not run: ${c.message||c}`}}function nt(e,t){if(J.replaceChildren(),!e.length)return;const o=qt(e,!L.peek()&&t.display.mode==="table"?t.display.columns:[]),n=s("table");n.append(s("thead",{},s("tr",{},...o.map(c=>s("th",{text:c.label})))));const l=s("tbody");e.forEach((c,i)=>{const a=s("tr",{tabindex:"0","data-i":String(i)});for(const p of o){const te=bt(c,p.key);a.append(s("td",{text:te,title:te}))}l.append(a)}),n.append(l),J.append(n)}d(M.effect(()=>{const{payload:e}=y.value;L.value;const t=K({s:e.pipeline,c:e.connection,d:e.display.columns,m:e.display.mode,p:e.display.pageSize});clearTimeout(ue),re&&(ue=setTimeout(()=>{P()&&t&&de()},600))})),d(()=>clearTimeout(ue));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(z[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 Le=E.tabs(r.querySelector(".vex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),it=[...r.querySelectorAll(".vex-tabs > .tab-list > .tab-item")],pe=4,Ae=()=>{Le.activate?.(pe),ve(pe)};function ve(e){re=e===pe,re&&de()}it.forEach((e,t)=>e.addEventListener("click",()=>ve(t)));function rt(e){const t=j().indexOf(e),o=j().length;return[{header:`Stage ${t+1} \xB7 ${e.dataset.stageType}`},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>fe(t,-1)},{label:"Move down",icon:"arrow-down",disabled:t===o-1,action:()=>fe(t,1)},{label:"Duplicate",icon:"copy",action:()=>{const n=qe(e);if(n.error){E.toast(`Fix this stage first: ${n.error}`,{type:"warning"});return}Ee({type:n.type,config:n.config},e).querySelector(".vex-stage-head")?.focus()}},{label:"Results up to here",icon:"play",action:()=>{L.value=t+1,Ae()}},{type:"divider"},{label:"Delete stage",icon:"trash",danger:!0,action:()=>{e.remove(),g()}}]}function fe(e,t){const o=Je(j(),e,t);o.forEach(n=>k.append(n)),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&&oe(t)},Oe=e=>{const t=e.target.closest(".vex-stage-head");if(t){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),oe(t);else if(e.altKey&&(e.key==="ArrowUp"||e.key==="ArrowDown")){e.preventDefault();const o=j().indexOf(t.closest(".stage-card"));fe(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=ne(),o=t.indexOf(e),n=l=>{Je(t,o,l).forEach(i=>_.before(i)),g()};return[{header:e.querySelector(".col-label")?.value||e.querySelector(".col-key")?.value||"Column"},{label:"Move up",icon:"arrow-up",disabled:o===0,action:()=>n(-1)},{label:"Move down",icon:"arrow-down",disabled:o===t.length-1,action:()=>n(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&&oe(t)},Ie=e=>{const t=e.target.closest(".col-row");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),oe(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 ee=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=T.find(n=>`data.${n.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(()=>{P()&&(tt(),ne().length||Ce(null),g())}),["view-display-mode","view-rowlevel-enabled","view-rowlevel-mode"].includes(e.target.id)&&Re(),g()}};r.addEventListener("input",ee),r.addEventListener("change",ee);const Ke=e=>{e.target.closest?.("input, textarea, select")&&e.stopPropagation()};r.addEventListener("contextmenu",Ke),d(()=>{r.removeEventListener("input",ee),r.removeEventListener("change",ee),r.removeEventListener("contextmenu",Ke)}),r.querySelector("#add-stage-btn").addEventListener("click",()=>{Ee({type:r.querySelector("#add-stage-type").value||"$match",config:{}}).querySelector("select, textarea, input")?.focus()});async function me(){if(Q.peek())return;const{payload:e}=y.peek();if(Be(B.peek())){const o=["source","pipeline","display","access"].findIndex(n=>B.peek()[n].length);o>=0&&(Le.activate?.(o),ve(o)),E.toast(`Not saved - ${Te.peek()}`,{type:"warning",duration:6e3});return}Q.value=!0;try{if(b){const n=await C.views.create(e);O.value=K(e);try{S.remove?S.remove(w(null)):S.set(w(null),null)}catch{}ye("#/views"),E.toast(`Created "${n.title}".`,{type:"success"}),R.navigate(`/views/edit/${encodeURIComponent(n.slug)}`);return}const o=await C.views.update(f,e);N=o||N,G=o?.meta||G,O.value=K(W().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}ye("#/views"),E.toast("Saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{Q.value=!1}}async function pt(){if(await E.confirm("Throw away the changes you have not saved?")){await le(N),O.value=K(W().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 "${Ot(N.title||f)}"? Pages that show it with [view] will show nothing. This cannot be undone.`))try{await C.views.delete(f),O.value=K(y.peek().payload);try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}ye("#/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:X,saving:Q,errs:B,stageCount:ot,errorSummary:Te,slugHint:at,upTo:L,previewMeta:Z,previewNote:V,previewError:ie},r,{methods:{save:()=>me(),discard:()=>pt(),runPreview:()=>de(),allStages:()=>{L.value=0}}});d(()=>ft.dispose()),d(ht(r,{position:"bottom"})),Y(v,{id:"view-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>me()}),Y(v,{id:"view-editor-results",icon:"play",title:"Results",onClick:()=>{L.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(),me())};if(document.addEventListener("keydown",Pe,!0),d(()=>document.removeEventListener("keydown",Pe,!0)),await le(N),!P())return;O.value=K(W().payload);const je=()=>{if(X.peek())try{S.set(w(f),{payload:y.peek().payload,base:O.peek(),at:new Date().toISOString()})}catch{}},Ve=e=>{X.peek()&&(je(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ve),d(()=>window.removeEventListener("beforeunload",Ve)),A={path:Ge,dirty:()=>X.peek(),dispose(){he=!0,je(),q.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(r);let D=null;try{D=S.get(w(f))}catch{}if(D?.payload){try{S.remove?S.remove(w(f)):S.set(w(f),null)}catch{}const e=D.base&&D.base!==O.peek(),t=D.at?` (${Lt(D.at)})`:"",o=b?"the new view you started":"this view",n=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(n)&&P()){const l=b?D.payload.slug:f;await le({...D.payload,slug:l})}}}};
package/bin/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Domma CMS - Project Scaffolder
4
- * Usage: npx domma-cms <project-name> [--no-install] [--no-setup] [--seed]
4
+ * Usage: npx domma-cms <project-name> [--no-install] [--no-setup] [--no-seed]
5
5
  */
6
6
 
7
7
  import {cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
@@ -9,6 +9,7 @@ import {spawnSync} from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import {fileURLToPath} from 'node:url';
11
11
  import {composeSmtp} from './lib/smtp-defaults.js';
12
+ import {minNodeMajor, nodeVersionOk} from './lib/node-version.js';
12
13
  import {buildGitignore} from '../server/services/siteGitignore.js';
13
14
 
14
15
  // ---------------------------------------------------------------------------
@@ -23,9 +24,13 @@ const PACKAGE_ROOT = path.resolve(__dirname, '..');
23
24
  // Node version check
24
25
  // ---------------------------------------------------------------------------
25
26
 
26
- const [major] = process.versions.node.split('.').map(Number);
27
- if (major < 18) {
28
- console.error('\n ✗ Domma CMS requires Node.js 18 or higher.\n');
27
+ // The minimum comes from package.json `engines`, and below it the CLI stops:
28
+ // scaffolding or updating on an older Node leaves a site that will not run.
29
+ // The fleet (the manager and every site it forks) runs on Node 24, so fleet
30
+ // rolls pass this check.
31
+ const NODE_RANGE = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).engines?.node;
32
+ if (!nodeVersionOk(process.versions.node, NODE_RANGE)) {
33
+ console.error(`\n ✗ Domma CMS requires Node.js ${minNodeMajor(NODE_RANGE)} or higher (this is ${process.versions.node}). Upgrade Node and run this again.\n`);
29
34
  process.exit(1);
30
35
  }
31
36
 
@@ -244,14 +249,9 @@ const DEFAULT_NAV = {
244
249
  position: 'sticky'
245
250
  };
246
251
 
247
- // Plugins enabled by default in a fresh project
248
- const ENABLED_BY_DEFAULT = new Set([
249
- 'domma-effects',
250
- 'analytics',
251
- 'theme-roller',
252
- ]);
253
-
254
- // Discover all plugins and set enabled state accordingly
252
+ // Discover the bundled plugins and list them in plugins.json, all switched off:
253
+ // a fresh project enables what it wants from the Marketplace. (Analytics, the
254
+ // theme switcher and the effects are core now, not plugins.)
255
255
  const DEFAULT_PLUGINS = {};
256
256
  try {
257
257
  const pluginsRoot = path.join(target, 'plugins');
@@ -262,7 +262,7 @@ try {
262
262
  const manifestPath = path.join(pluginsRoot, name, 'plugin.json');
263
263
  try {
264
264
  JSON.parse(readFileSync(manifestPath, 'utf8')); // validate manifest exists
265
- DEFAULT_PLUGINS[name] = {enabled: ENABLED_BY_DEFAULT.has(name), settings: {}};
265
+ DEFAULT_PLUGINS[name] = {enabled: false, settings: {}};
266
266
  } catch { /* no valid plugin.json - skip */
267
267
  }
268
268
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Node.js version gate for the scaffolder - reads the minimum from package.json
3
+ * `engines.node` so the check and the declared requirement cannot drift apart.
4
+ */
5
+
6
+ /**
7
+ * The minimum major version in an `engines.node` range such as ">=24" or ">=24.1.0".
8
+ *
9
+ * @param {string|undefined} range
10
+ * @returns {number|null} null when the range names no minimum
11
+ */
12
+ export function minNodeMajor(range) {
13
+ const match = /(\d+)/.exec(String(range || ''));
14
+ return match ? Number(match[1]) : null;
15
+ }
16
+
17
+ /**
18
+ * Whether a running Node version (e.g. "24.3.0") meets the range's minimum major.
19
+ *
20
+ * @param {string} version - process.versions.node
21
+ * @param {string|undefined} range - package.json engines.node
22
+ * @returns {boolean}
23
+ */
24
+ export function nodeVersionOk(version, range) {
25
+ const min = minNodeMajor(range);
26
+ if (min === null) return true;
27
+ const [major] = String(version).split('.').map(Number);
28
+ return major >= min;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.92.1",
3
+ "version": "0.94.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",
@@ -70,6 +70,7 @@ function formatSize(bytes) {
70
70
 
71
71
  import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identity.js';
72
72
  import {describeWhen, quickTimes} from './scheduling.js';
73
+ import {fmtDateTime} from '../ui/dates.js';
73
74
  import {applyTemplate, expandTemplate} from './templates.js';
74
75
  import {makeResizable} from './resizable.js';
75
76
  import {whenClosed} from './when-closed.js';
@@ -433,7 +434,7 @@ export function openCompose({
433
434
  const at = scheduledAt.value;
434
435
  sendLabel.value = at ? `Send ${describeWhen(at)}` : 'Send';
435
436
  sendTitle.value = at
436
- ? new Date(at).toLocaleString()
437
+ ? fmtDateTime(at)
437
438
  : (undoSeconds > 0 ? `Held for ${undoSeconds} seconds so you can take it back.` : '');
438
439
  })
439
440
  ];
@@ -734,7 +735,7 @@ export function openCompose({
734
735
  button.type = 'button';
735
736
  button.className = 'btn btn-sm btn-ghost';
736
737
  button.textContent = option.label;
737
- button.title = new Date(option.at).toLocaleString();
738
+ button.title = fmtDateTime(option.at);
738
739
  button.addEventListener('click', () => choose(option.at));
739
740
  pick('choices').appendChild(button);
740
741
  }
@@ -36,6 +36,7 @@ const {
36
36
  const {imageSendersSection} = await import(versioned('image-senders-section.js'));
37
37
  const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
38
38
  const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
39
+ const {fmtDate, fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
39
40
  // Renamed: onMount wraps it in its own `openCompose`, which tracks each window it opens.
40
41
  const {openCompose: openComposeWindow} = await import(versioned('compose-window.js'));
41
42
  const {makeResizable} = await import(versioned('resizable.js'));
@@ -92,9 +93,9 @@ async function api(url, method = 'GET', body) {
92
93
  function formatDate(value) {
93
94
  if (!value) return '';
94
95
  const d = D(value);
95
- return d.format('DD MMM YYYY') === D().format('DD MMM YYYY')
96
+ return d.format('YYYY-MM-DD') === D().format('YYYY-MM-DD')
96
97
  ? d.format('HH:mm')
97
- : d.format('DD MMM YYYY');
98
+ : fmtDate(value);
98
99
  }
99
100
 
100
101
  /**
@@ -3177,7 +3178,7 @@ return {
3177
3178
  const button = document.createElement('button');
3178
3179
  button.className = 'btn btn-sm btn-ghost';
3179
3180
  button.textContent = option.label;
3180
- button.title = new Date(option.at).toLocaleString();
3181
+ button.title = fmtDateTime(option.at);
3181
3182
  button.addEventListener('click', () => finish(option.at));
3182
3183
  choices.appendChild(button);
3183
3184
  }
@@ -3237,7 +3238,7 @@ return {
3237
3238
 
3238
3239
  removeFromList(message.uid);
3239
3240
  offerUndo(
3240
- `Snoozed until ${new Date(result.at).toLocaleString()}.`,
3241
+ `Snoozed until ${fmtDateTime(result.at)}.`,
3241
3242
  // Bringing it straight back is the undo. The message has
3242
3243
  // already moved, so there is no "leave it where it was".
3243
3244
  async () => {
@@ -3511,7 +3512,7 @@ return {
3511
3512
  text.textContent = [
3512
3513
  from && `From ${from}`,
3513
3514
  formatAddresses(message.to) && `to ${formatAddresses(message.to)}`,
3514
- message.date && D(message.date).format('DD MMM YYYY HH:mm')
3515
+ message.date && fmtDateTime(message.date)
3515
3516
  ].filter(Boolean).join(' \u00b7 ');
3516
3517
  summary.appendChild(text);
3517
3518
 
@@ -3536,7 +3537,7 @@ return {
3536
3537
  // "Priority: Normal" is saying nothing at length.
3537
3538
  ['Priority', message.priority === 'high' ? 'High'
3538
3539
  : (message.priority === 'low' ? 'Low' : '')],
3539
- ['Date', message.date ? D(message.date).format('DD MMM YYYY HH:mm:ss') : ''],
3540
+ ['Date', message.date ? `${fmtDateTime(message.date)}:${D(message.date).format('ss')}` : ''],
3540
3541
  ['Subject', message.subject],
3541
3542
  ['Message ID', message.messageId]
3542
3543
  ];
@@ -20,6 +20,8 @@
20
20
  * @module _lib/admin/mail/scheduling
21
21
  */
22
22
 
23
+ import {fmtDate, fmtTime} from '../ui/dates.js';
24
+
23
25
  /** How long a sent message may be held back so it can be recalled. */
24
26
  export const UNDO_SECONDS = [0, 5, 10, 20, 30];
25
27
 
@@ -168,7 +170,7 @@ export function describeWhen(value, now = new Date()) {
168
170
  const minutes = Math.round(seconds / 60);
169
171
  if (minutes < 60) return `in ${minutes} minutes`;
170
172
 
171
- const clock = when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
173
+ const clock = fmtTime(when);
172
174
  const sameDay = when.toDateString() === now.toDateString();
173
175
  if (sameDay) return `at ${clock}`;
174
176
 
@@ -180,7 +182,7 @@ export function describeWhen(value, now = new Date()) {
180
182
  // date is - "Thursday" three weeks out tells you nothing.
181
183
  const days = Math.round((when.getTime() - now.getTime()) / 86_400_000);
182
184
  if (days < 7) return `on ${when.toLocaleDateString([], {weekday: 'long'})} at ${clock}`;
183
- return `on ${when.toLocaleDateString()} at ${clock}`;
185
+ return `on ${fmtDate(when)} at ${clock}`;
184
186
  }
185
187
 
186
188
  /**
@@ -26,6 +26,7 @@
26
26
  * @module _lib/admin/mail/templates
27
27
  */
28
28
  import {signatureToText, textToHtml} from './identity.js';
29
+ import {fmtDate, fmtTime} from '../ui/dates.js';
29
30
 
30
31
  /** How many templates one mailbox may hold. */
31
32
  export const MAX_TEMPLATES = 50;
@@ -144,10 +145,9 @@ export function templateValues({to = [], subject = '', identity = null, fromAddr
144
145
  'my.name': String(identity?.displayName ?? '').trim(),
145
146
  'my.email': String(identity?.address || fromAddress || '').trim(),
146
147
  subject: String(subject ?? '').trim(),
147
- // The reader's own locale, not the server's - a template written in
148
- // London and read in London should not say 9/20/2026.
149
- date: now.toLocaleDateString(),
150
- time: now.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
148
+ // dd/mm/yyyy and 24-hour, whatever the browser's locale - never 9/20/2026.
149
+ date: fmtDate(now),
150
+ time: fmtTime(now)
151
151
  };
152
152
  }
153
153
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dates as a plugin shows them to a person: dd/mm/yyyy, and dd/mm/yyyy HH:mm
3
+ * where a time matters. The engine's twins are admin/js/lib/dates.js and
4
+ * server/services/dates.js; plugins must not import either (the updater
5
+ * replaces admin/ and server/), so they import this one.
6
+ *
7
+ * Isomorphic: a plugin's admin views import it over HTTP
8
+ * (`/plugins/_lib/admin/ui/dates.js`), its server code by relative path
9
+ * (`../_lib/admin/ui/dates.js`).
10
+ *
11
+ * Display only. Stored values, API payloads, `<input type="date">` values,
12
+ * sitemap/RSS/iCal/JSON-LD, filenames, logs and sort keys stay ISO.
13
+ *
14
+ * Local time unless `{utc: true}` is passed.
15
+ */
16
+
17
+ const pad = (n) => String(n).padStart(2, '0');
18
+
19
+ /** A bare calendar day. Parsed as a LOCAL day: `new Date('2026-09-21')` is UTC midnight. */
20
+ const DAY_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
21
+
22
+ /**
23
+ * A Date from whatever the API sent - ISO string, bare 'YYYY-MM-DD', epoch ms
24
+ * or a Date. Null for empty or unparseable input.
25
+ *
26
+ * @param {string|number|Date|null|undefined} value
27
+ * @returns {Date|null}
28
+ */
29
+ export function toDate(value) {
30
+ if (value == null || value === '') return null;
31
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
32
+ if (typeof value === 'string') {
33
+ const m = DAY_ONLY.exec(value.trim());
34
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
35
+ }
36
+ const d = new Date(value);
37
+ return Number.isNaN(d.getTime()) ? null : d;
38
+ }
39
+
40
+ const isDayOnly = (value) => typeof value === 'string' && DAY_ONLY.test(value.trim());
41
+
42
+ /** Empty for nothing; the raw text for something that is not a date, so it is never silently lost. */
43
+ const unparsed = (value) => (value == null ? '' : String(value));
44
+
45
+ /**
46
+ * 'dd/mm/yyyy'.
47
+ *
48
+ * @param {string|number|Date|null|undefined} value
49
+ * @param {{utc?: boolean}} [opts]
50
+ * @returns {string}
51
+ */
52
+ export function fmtDate(value, {utc = false} = {}) {
53
+ const d = toDate(value);
54
+ if (!d) return unparsed(value);
55
+ if (utc && !isDayOnly(value)) return `${pad(d.getUTCDate())}/${pad(d.getUTCMonth() + 1)}/${d.getUTCFullYear()}`;
56
+ return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
57
+ }
58
+
59
+ /**
60
+ * 'HH:mm' (24-hour).
61
+ *
62
+ * @param {string|number|Date|null|undefined} value
63
+ * @param {{utc?: boolean}} [opts]
64
+ * @returns {string}
65
+ */
66
+ export function fmtTime(value, {utc = false} = {}) {
67
+ const d = toDate(value);
68
+ if (!d) return unparsed(value);
69
+ if (utc) return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
70
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
71
+ }
72
+
73
+ /**
74
+ * 'dd/mm/yyyy HH:mm'. A bare 'YYYY-MM-DD' has no time to show, so it gets the date alone.
75
+ *
76
+ * @param {string|number|Date|null|undefined} value
77
+ * @param {{utc?: boolean}} [opts]
78
+ * @returns {string}
79
+ */
80
+ export function fmtDateTime(value, {utc = false} = {}) {
81
+ if (isDayOnly(value)) return fmtDate(value);
82
+ const d = toDate(value);
83
+ if (!d) return unparsed(value);
84
+ return `${fmtDate(d, {utc})} ${fmtTime(d, {utc})}`;
85
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Public-facing blog for the CMS. Supports posts with categories, tags, and optional moderated comments. Authors can draft posts; only blog-editors (and admins) can publish or schedule them. Public SSR routes serve an archive index, category/tag/author archives, individual post pages, and an RSS feed. All collection I/O goes through `server/services/collections.js` (FileAdapter by default).
5
+ Public-facing blog for the CMS. Supports posts with categories, tags, and optional moderated comments. Authors can draft posts; only holders of the `blog-posts.publish` permission (Blog Editor, admins) can publish or schedule them. Public SSR routes serve an archive index, category/tag/author archives, individual post pages, and an RSS feed. All collection I/O goes through `server/services/collections.js` (FileAdapter by default).
6
6
 
7
7
  ## Files
8
8
 
@@ -33,26 +33,27 @@ All routes are under `/api/plugins/blog`. All require authentication unless note
33
33
 
34
34
  | Method | Path | Purpose | Auth |
35
35
  |---|---|---|---|
36
- | GET | `/posts` | List posts; blog-authors see only their own | authenticate |
37
- | POST | `/posts` | Create a new post (draft by default) | authenticate |
38
- | GET | `/posts/:id` | Get a single post by ID | authenticate |
39
- | PUT | `/posts/:id` | Update post; blog-authors may only edit their own | authenticate |
40
- | DELETE | `/posts/:id` | Delete post; blog-editor / admin only | authenticate |
41
- | POST | `/posts/:id/publish` | Set status to `published`; blog-editor / admin only | authenticate |
42
- | POST | `/posts/:id/schedule` | Set status to `scheduled` + `publishedAt`; blog-editor / admin only | authenticate |
43
- | GET | `/categories` | List all categories | authenticate |
44
- | POST | `/categories` | Create category; blog-editor / admin only | authenticate |
45
- | PUT | `/categories/:id` | Update category; blog-editor / admin only | authenticate |
46
- | DELETE | `/categories/:id` | Delete category; blog-editor / admin only | authenticate |
47
- | GET | `/comments` | List comments; supports `?status=` and `?postId=` filters | authenticate |
36
+ | GET | `/posts` | List posts; a writer without publish sees only their own | `blog-posts.read` |
37
+ | POST | `/posts` | Create a new post (draft by default) | `blog-posts.create` |
38
+ | GET | `/posts/:id` | Get a single post by ID (others' posts 404 for a writer) | `blog-posts.read` |
39
+ | PUT | `/posts/:id` | Update post; a writer without publish may only edit their own | `blog-posts.update` |
40
+ | DELETE | `/posts/:id` | Delete post | `blog-posts.delete` |
41
+ | POST | `/posts/:id/publish` | Set status to `published` | `blog-posts.publish` |
42
+ | POST | `/posts/:id/schedule` | Set status to `scheduled` + `publishedAt` | `blog-posts.publish` |
43
+ | GET | `/categories` | List all categories | `blog-categories.read` |
44
+ | POST | `/categories` | Create category | `blog-categories.create` |
45
+ | PUT | `/categories/:id` | Update category | `blog-categories.update` |
46
+ | DELETE | `/categories/:id` | Delete category | `blog-categories.delete` |
47
+ | GET | `/comments` | List comments; supports `?status=` and `?postId=` filters | `blog-comments.read` |
48
+ | GET | `/comments/pending-count` | The sidebar badge | `blog-comments.read` |
48
49
  | POST | `/comments` | Submit a comment; **no authentication required** (public route) | - |
49
- | PUT | `/comments/:id` | Moderate comment (update status field) | authenticate |
50
- | DELETE | `/comments/:id` | Delete comment | authenticate |
51
- | GET | `/layouts` | Post layouts and listing displays on offer, plus the defaults | authenticate |
52
- | POST | `/preview` | A post page from UNSAVED fields, in any layout (`{html, layout}`) | authenticate |
50
+ | PUT | `/comments/:id` | Moderate comment (update status field) | `blog-comments.update` |
51
+ | DELETE | `/comments/:id` | Delete comment | `blog-comments.delete` |
52
+ | GET | `/layouts` | Post layouts and listing displays on offer, plus the defaults | `blog-posts.read` |
53
+ | POST | `/preview` | A post page from UNSAVED fields, in any layout (`{html, layout}`) | `blog-posts.read` |
53
54
  | POST | `/samples` | Add the sample posts (idempotent by slug) | authenticate + requireAdmin |
54
55
  | DELETE | `/samples` | Remove `sample: true` posts, and sample categories nothing uses | authenticate + requireAdmin |
55
- | GET | `/settings` | Read blog plugin settings | authenticate |
56
+ | GET | `/settings` | Read blog plugin settings | `blog-posts.read` |
56
57
  | PUT | `/settings` | Save blog plugin settings | authenticate + requireAdmin |
57
58
 
58
59
  ## Public Routes
@@ -134,7 +135,9 @@ Two custom roles are seeded for this plugin:
134
135
  - **`blog-author`** - can create posts and edit their own; cannot publish, schedule, or delete
135
136
  - **`blog-editor`** - can do everything including publish, schedule, delete, and manage categories/comments
136
137
 
137
- Both roles are at **level 2** in the role hierarchy (same as `editor`). The publish gate is therefore a **named-role check**, not a level comparison.
138
+ Both roles are at **level 2** in the role hierarchy (same as `editor`), so no level comparison can tell them apart.
139
+ What they may do is decided by **permission** since 1.8.0 (see "Access - by permission" below), never by role name
140
+ or level: any role can be given the same powers under Roles.
138
141
 
139
142
  ## Embed Shortcodes
140
143
 
@@ -155,7 +158,7 @@ A post page and a listing card are BLOCKS. `blog-post-<name>` is a post layout,
155
158
  card; any block with those prefixes is offered automatically (`lib/layouts.js`). The shipped ones are
156
159
  copied into content/blocks/ on start, once each (remembered as `seededBlocks` in config/plugins.json),
157
160
  so a site edits them in the Blocks tool; a deleted one falls back to `blocks/` here. Placeholders are
158
- documented in each block's header comment: `{{x}}` escaped, `{{x:D MMM YYYY}}` a date, `{{{x}}}` raw
161
+ documented in each block's header comment: `{{x}}` escaped, `{{x:DD/MM/YYYY}}` a date (any format; the shipped blocks use DD/MM/YYYY), `{{{x}}}` raw
159
162
  for fragments this plugin builds and escapes (content, image, categories, tags, toc, share, prevNext,
160
163
  related, comments). Block CSS is scoped under `[data-block="name"]` like every other block.
161
164
 
@@ -252,7 +255,13 @@ with `?display=` and swaps the cards, since each display is different block mark
252
255
  `createEntry` and came back 500 where the categories and comments routes both
253
256
  answer 400.
254
257
 
255
- - **Named-role publish gate**: `canPublish()` uses `['blog-editor', 'admin', 'super-admin'].includes(user.role)` - NOT a level comparison. Do not refactor this to `getRoleLevel() <= N`; `blog-author` and `blog-editor` are both level 2, so a level check would give blog-authors publish access.
258
+ - **The publish gate is the `blog-posts.publish` permission** (1.8.0, `lib/access.js`). Before that it was a
259
+ named-role list (`['blog-editor', 'admin', 'super-admin'].includes(user.role)`). Never go back to either a role
260
+ name or a level check: `blog-author` and `blog-editor` are both level 2, so a level check would give authors
261
+ publish access, and a role list ignores additional roles and roles an admin made.
262
+
263
+ - **Settings has no sidebar item, on purpose**: it is the banner cog on every Blog screen (`admin/views/settings-cog.js`),
264
+ the house rule in `domma-cms/plugins/CLAUDE.md`. The old `#/plugins/blog/settings` page still opens it.
256
265
 
257
266
  - **The base path is read per request, so the public pages are an `onRequest` hook, not routes.**
258
267
  Fastify routes are fixed once the server is ready, so pages registered at `/blog` stayed there after
@@ -274,7 +283,7 @@ with `?display=` and swaps the cards, since each display is different block mark
274
283
 
275
284
  - **plugin.public.js registered at Fastify root**: This file is registered without the `/api/plugins/blog` prefix. Routes use the full absolute path starting from `base` (default `/blog`). The server registers it separately from `plugin.js`.
276
285
 
277
- - **`storage.adapter: 'file'` default**: Unlike the messages plugin (which defaults to `mongodb`), blog defaults to `file` so it works on free installs without MongoDB. Do not change this default.
286
+ - **`storage.adapter: 'file'` default**: blog defaults to `file` so it works on free installs without MongoDB. Do not change this default. (Messages likewise has no MongoDB default - older notes said it had.)
278
287
 
279
288
  - **`listEntries` called with bare slug strings**: In `plugin.public.js`, `listEntries('posts')`, `listEntries('categories')`, and `listEntries('comments')` are called with plain string slugs (not constants). This is consistent with the rest of the public file but differs from `plugin.js`, which uses `POSTS_SLUG` / `CATEGORIES_SLUG` / `COMMENTS_SLUG` constants.
280
289
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  // The loader's cache-busting token, passed on so helpers are refetched with this view.
14
+ import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
14
15
  const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
15
16
 
16
17
  const STATUSES = [
@@ -96,8 +97,8 @@ export const blogView = {
96
97
  status: p.status ?? 'draft',
97
98
  badge: BADGE[p.status] || 'badge-secondary',
98
99
  cats: (p.categories || []).map(catName).join(', '),
99
- date: at ? D(at).format('DD MMM YYYY') : '',
100
- dateTitle: at ? `${label} ${D(at).format('DD MMM YYYY HH:mm')}` : ''
100
+ date: at ? fmtDate(at) : '',
101
+ dateTitle: at ? `${label} ${fmtDateTime(at)}` : ''
101
102
  };
102
103
  });
103
104
  }));
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  // The loader's cache-busting token, passed on so helpers are refetched with this view.
14
+ import {fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
14
15
  const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
15
16
 
16
17
  const STATUSES = [
@@ -93,7 +94,7 @@ export const commentsView = {
93
94
  status: c.status || 'pending',
94
95
  badge: BADGE[c.status] || 'badge-secondary',
95
96
  date: c.createdAt ? D(c.createdAt).fromNow() : '',
96
- dateTitle: c.createdAt ? D(c.createdAt).format('DD MMM YYYY HH:mm') : ''
97
+ dateTitle: c.createdAt ? fmtDateTime(c.createdAt) : ''
97
98
  }));
98
99
  }));
99
100
  add(M.effect(() => {