domma-cms 0.74.0 → 0.75.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 (55) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +2 -2
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/blocks-arrange.js +1 -0
  6. package/admin/js/lib/collections-arrange.js +1 -0
  7. package/admin/js/lib/components-arrange.js +1 -0
  8. package/admin/js/lib/context-menus-arrange.js +1 -0
  9. package/admin/js/lib/forms-arrange.js +1 -0
  10. package/admin/js/lib/media-arrange.js +1 -0
  11. package/admin/js/lib/menus-arrange.js +1 -0
  12. package/admin/js/lib/pages-arrange.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +3 -3
  14. package/admin/js/lib/theme-settings-arrange.js +1 -0
  15. package/admin/js/lib/views-arrange.js +1 -0
  16. package/admin/js/templates/actions-list.html +73 -14
  17. package/admin/js/templates/api-endpoints.html +63 -10
  18. package/admin/js/templates/blocks.html +76 -15
  19. package/admin/js/templates/collections.html +108 -52
  20. package/admin/js/templates/components.html +82 -15
  21. package/admin/js/templates/context-menus.html +55 -13
  22. package/admin/js/templates/forms.html +67 -14
  23. package/admin/js/templates/media.html +99 -13
  24. package/admin/js/templates/menus.html +57 -11
  25. package/admin/js/templates/pages.html +78 -23
  26. package/admin/js/templates/theme.html +164 -132
  27. package/admin/js/templates/views-list.html +63 -9
  28. package/admin/js/views/actions-list.js +37 -1
  29. package/admin/js/views/api-endpoints.js +11 -7
  30. package/admin/js/views/blocks.js +34 -5
  31. package/admin/js/views/collections.js +36 -1
  32. package/admin/js/views/components.js +27 -5
  33. package/admin/js/views/context-menus.js +10 -5
  34. package/admin/js/views/forms.js +57 -1
  35. package/admin/js/views/media.js +40 -1
  36. package/admin/js/views/menus.js +11 -5
  37. package/admin/js/views/pages.js +28 -8
  38. package/admin/js/views/theme.js +55 -1
  39. package/admin/js/views/views-list.js +11 -1
  40. package/package.json +1 -1
  41. package/server/routes/api/actions.js +64 -2
  42. package/server/routes/api/api-endpoints.js +41 -0
  43. package/server/routes/api/blocks.js +170 -8
  44. package/server/routes/api/collections.js +53 -1
  45. package/server/routes/api/components.js +46 -0
  46. package/server/routes/api/context-menus.js +8 -0
  47. package/server/routes/api/forms.js +123 -10
  48. package/server/routes/api/media.js +56 -1
  49. package/server/routes/api/menus.js +53 -6
  50. package/server/routes/api/pages.js +46 -0
  51. package/server/routes/api/theme.js +55 -0
  52. package/server/routes/api/views.js +27 -3
  53. package/server/server.js +16 -1
  54. package/server/services/contextMenus.js +41 -0
  55. package/server/services/views.js +38 -0
@@ -1,8 +1,28 @@
1
- import{api as c}from"../api.js";import{carryProjectContext as m,filterByProject as f,getProjectFromHash as y,renderProjectFilterNotice as v}from"../lib/project-context.js";export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(n){const g=E.loader(n.get(0),{type:"dots"});let o=await c.pages.list().catch(()=>[]);g.destroy();const r=y();if(r){const s=o.length;o=f(o,r),m(n,r),v(n,{total:s,shown:o.length,slug:r,allHref:"#/pages",noun:"pages"})}const d=s=>{T.create("#pages-table",{pageSize:25,data:s,columns:[{key:"title",title:"Title",render:(t,e)=>{const a=e.bundled?'<span class="badge badge-outline" style="font-size:0.65rem;padding:1px 6px;color:var(--dm-info,#2563eb);border-color:var(--dm-info,#2563eb);margin-left:.35rem;">Bundled</span>':"",i=e.plugin?`<span class="badge badge-outline" style="font-size:0.65rem;padding:1px 6px;color:var(--dm-warning,#d97706);border-color:var(--dm-warning,#d97706);margin-left:.35rem;" title="Managed by the ${e.plugin} plugin">${e.plugin}</span>`:"";return`<a href="#/pages/edit${e.urlPath}" style="font-weight:600;">${t}</a>${a}${i}`}},{key:"urlPath",title:"URL",render:t=>`<code>${t}</code>`},{key:"layout",title:"Layout"},{key:"status",title:"Status",render:t=>`<span class="badge badge-${t==="published"?"success":"warning"}">${t}</span>`},{key:"tags",title:"Tags",render:t=>Array.isArray(t)&&t.length?t.map(e=>`<span class="badge badge-info badge-pill badge-sm">${e}</span>`).join(" "):"-"},{key:"versionCount",title:"Revisions",render:t=>Number.isFinite(t)&&t>0?`<span class="badge badge-secondary badge-pill badge-sm">${t}</span>`:'<span style="color:var(--dm-text-muted);">0</span>'},{key:"updatedAt",title:"Updated",render:t=>t?D(t).format("DD MMM YYYY"):"-"},{key:"actions",title:"Actions",render:(t,e)=>`
2
- <a href="#/pages/edit${e.urlPath}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit"></span></a>
3
- <a href="${e.urlPath}" target="_blank" class="btn btn-sm btn-ghost" data-tooltip="View"><span data-icon="external-link"></span></a>
4
- <button class="btn btn-sm btn-ghost btn-prune" data-path="${e.urlPath}" data-count="${e.versionCount||0}" data-tooltip="Prune revisions"${(e.versionCount||0)===0?" disabled":""}><span data-icon="archive"></span></button>
5
- <button class="btn btn-sm btn-ghost btn-delete" data-path="${e.urlPath}" data-plugin="${e.plugin||""}" data-tooltip="Delete"><span data-icon="trash"></span></button>
6
- `}],emptyMessage:'No pages found. <a href="#/pages/new">Create one</a>.'}),Domma.icons.scan(),document.querySelectorAll("#pages-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),Domma.effects.reveal(".card",{animation:"fade",duration:350})};d(o);const b=s=>{const t=n.find("#pages-tree").empty().get(0);if(!s.length){t.textContent="No pages found.";return}const e=s.map(a=>{const i=a.urlPath.split("/").filter(Boolean),l=i.length>1?"/"+i.slice(0,-1).join("/"):null,p=l&&s.some(h=>h.urlPath===l);return{id:a.urlPath,parent_id:p?l:null,name:a.title||a.urlPath,icon:a.status==="published"?"check-circle":"file-text"}});E.treeView(t,{data:e,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>R.navigate(`/pages/edit${a}`)}),Domma.icons.scan(t)};n.find("#view-table-btn").on("click",function(){n.find("#pages-table").show(),n.find("#pages-tree").hide(),$(this).addClass("btn-primary").removeClass("btn-ghost"),n.find("#view-tree-btn").addClass("btn-ghost").removeClass("btn-primary")}),n.find("#view-tree-btn").on("click",function(){n.find("#pages-table").hide(),n.find("#pages-tree").show(),$(this).addClass("btn-primary").removeClass("btn-ghost"),n.find("#view-table-btn").addClass("btn-ghost").removeClass("btn-primary"),b(o)}),n.find("#view-table-btn, #view-tree-btn").each(function(){E.tooltip(this,{content:this.getAttribute("data-tooltip"),position:"top"})});const u=()=>{const s=n.find("#status-filter").val(),t=n.find("#pages-search").val().toLowerCase().trim(),e=o.filter(a=>!(s&&a.status!==s||t&&!`${a.title} ${a.urlPath} ${(a.tags||[]).join(" ")}`.toLowerCase().includes(t)));d(e)};n.find("#status-filter").off("change").on("change",u),n.find("#pages-search").get(0).addEventListener("input",u),n.off("click",".btn-delete").on("click",".btn-delete",async function(){const s=$(this).data("path"),t=$(this).data("plugin"),e=t?`This page is managed by the <strong>${t}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete page at <strong>${s}</strong>? This cannot be undone.`;if(await E.confirm(e))try{await c.pages.delete(s),E.toast("Page deleted.",{type:"success"}),o=o.filter(a=>a.urlPath!==s),d(o)}catch{E.toast("Failed to delete page.",{type:"error"})}}),n.off("click",".btn-prune").on("click",".btn-prune",async function(){const s=$(this).data("path"),t=Number($(this).data("count"))||0,e=window.prompt(`This page has ${t} revision${t===1?"":"s"}.
7
-
8
- How many of the most recent revisions do you want to keep?`,"5");if(e===null)return;const a=Number.parseInt(e,10);if(!Number.isFinite(a)||a<0){E.toast("Please enter a non-negative number.",{type:"error"});return}try{const i=await c.versions.prune(s,a);E.toast(`Pruned ${i.deleted} revision${i.deleted===1?"":"s"}; kept ${i.kept}.`,{type:"success"});const l=o.find(p=>p.urlPath===s);l&&(l.versionCount=i.kept),d(o)}catch(i){E.toast(i.message||"Failed to prune revisions.",{type:"error"})}})}};
1
+ import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Z,settingsStore as we,sidebarCount as ke,viewLifetime as xe}from"../lib/tool-kit.js";import{filterByProject as Me,getProjectFromHash as Pe}from"../lib/project-context.js";import{arrangePages as Ee,checkPrune as Se,normaliseSettings as ee,pageRow as Ce,statusCounts as $e,treeRows as Te}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,Le="#/pages",P="pages.prune.draft",De=`
2
+ <div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
4
+ <span class="pgx-chips">
5
+ <span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
6
+ <span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
7
+ <span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
8
+ <span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
9
+ <span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
10
+ </span>
11
+ <button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
12
+ </div>`,y=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ae(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}const ae=we("pages.settings",ee),s=xe(te),je=i=>`#/pages/edit${i}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(i){s.reset();const l=i.get(0).querySelector(".pgx")||i.get(0),C=()=>te.test(location.hash)&&l.isConnected,d=Pe(),$=ae.read(),o=M.observable([]),N=M.observable(0),q=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),H=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const D=M.observable([]);s.add(M.effect(()=>{D.value=Ee(o.value,{status:p.value,query:H.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=D.value.map(e=>Ce(e))}));const ne=M.computed(()=>$e(o.value)),se=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=N.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>o.peek().find(t=>t.urlPath===e);async function re(){try{const e=await K.pages.list();if(!C())return;const t=Array.isArray(e)?e:[],a=d?Me(t,d):t;N.value=t.length-a.length,o.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{q.value=!1}}const w=e=>{location.hash=je(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),ke(Le)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const A=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,m=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Se(m.value,a)),Y={keep:m,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(m.peek()),G=()=>String(m.peek())!==he||!!t,r=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Ae(`
13
+ <div class="pgx-form">
14
+ <p class="pgx-note" data-bind-text="intro"></p>
15
+ <div>
16
+ <label class="form-label" for="pgx-f-keep">Keep the newest
17
+ <span data-help="Revisions are kept each time the page is saved. Trimming removes the oldest ones for good; the page itself is not changed. 0 removes them all."></span></label>
18
+ <input id="pgx-f-keep" type="number" min="0" step="1" class="form-input" data-model="keep.value">
19
+ <small class="pgx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
20
+ <small class="pgx-hint" data-bind-hidden="!summary.value" data-bind-text="summary.value"></small>
21
+ </div>
22
+ <div class="pgx-actions">
23
+ <span class="pgx-spacer"></span>
24
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
25
+ <button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
26
+ <span data-icon="archive" data-icon-size="13"></span> Trim</button>
27
+ </div>
28
+ </div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,r.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}r.element.appendChild(h);const ge=X(h),ve=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>r.close()}}),me=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",me);const Q={path:e.urlPath,dirty:G,values:()=>({keep:m.peek()})};c=Q,A.add(r),ye(r,()=>{ve.dispose(),ge(),A.delete(r),c===Q&&(c=null)});const fe=r.close.bind(r);let R=!1,B=!1;r.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),r.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=l.querySelector(".pgx-list");u.innerHTML=De;const pe=M.applyBindings({pages:o,loading:q,loadError:T,status:p,sort:L,mode:g,query:H,rows:U,counts:ne,emptyText:se,projectNote:oe},l,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(l,{position:"bottom"}));const j=l.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Te(D.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),be(i,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...A].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await re(),!C())return;let v=null;try{v=S.get(P)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,v.values)}}};
@@ -1 +1,55 @@
1
- import{api as w}from"../api.js";import{populateThemeSelect as A}from"../lib/themes.js";const he=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}];export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(s){E.tabs(s.find("#theme-tabs").get(0));const X=E.loader(s.get(0),{type:"dots"});let m,q;try{[m,q]=await Promise.all([w.theme.get(),w.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){X.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}X.destroy();const D=q.tokens||[],Y=Array.isArray(q.custom)?q.custom:[];function x(e){if(!Y.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const n of Y){const o=document.createElement("option");o.value=n.id,o.textContent=`${n.label} (on ${n.baseTheme})`,t.appendChild(o)}e.appendChild(t)}function T(e,t){const n=e.get?e.get(0):e;if(t&&![...n.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,n.appendChild(o)}n.value=t}const M=new Map;async function H(e){if(!e)return null;if(M.has(e))return M.get(e);const t=await w.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return M.set(e,n),n}function ae(){return(s.find("#theme-auto-enabled").prop("checked")?S.val():f.val())||m.theme}const U=["--dm-card-bg","--dm-surface"],W=[...U,"--dm-text","--dm-primary","--dm-background"];function G(e){const t=document.createElement("span");return t.style.cssText="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;border:1px solid var(--dm-border);vertical-align:middle;margin-right:.25rem;",/^#[0-9a-f]{3,8}$/i.test(e)?t.style.background=e:t.style.background="transparent",t}async function j(e){const t=e.querySelector(".ov-theme-diff");if(!t)return;for(;t.firstChild;)t.removeChild(t.firstChild);const n=e.querySelector(".ov-theme")?.value;if(!n)return;const o=ae();if(n===o){const l=document.createElement("p");l.className="form-hint text-muted",l.style.cssText="margin:.4rem 0 0;font-size:.8rem;",l.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",t.appendChild(l);return}const[a,r]=await Promise.all([H(o),H(n)]);if(!a||!r)return;const c=Object.keys(r).filter(l=>String(a[l]??"").toLowerCase()!==String(r[l]).toLowerCase()),d=U.filter(l=>c.includes(l)),i=document.createElement("div");i.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:var(--dm-radius-md);background:var(--dm-surface-raised);font-size:.8rem;";const h=document.createElement("p");if(h.style.cssText="margin:0 0 .35rem;",h.textContent=`Changes ${c.length} of ${Object.keys(r).length} tokens here.`,i.appendChild(h),!d.length){const l=document.createElement("p");l.style.cssText="margin:0 0 .35rem;color:var(--dm-warning);",l.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",i.appendChild(l)}const p=document.createElement("div");p.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[...W.filter(l=>c.includes(l)),...c.filter(l=>!W.includes(l))];for(const l of v.slice(0,6)){const u=document.createElement("span");u.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",u.appendChild(G(a[l]||""));const C=document.createElement("span");C.textContent=`${l} `,u.appendChild(C),u.appendChild(G(r[l])),p.appendChild(u)}if(v.length>6){const l=document.createElement("span");l.style.cssText="color:var(--dm-text-muted);",l.textContent=`+${v.length-6} more`,p.appendChild(l)}i.appendChild(p),t.appendChild(i)}let b=Array.isArray(m.overrides)?m.overrides.map($):[];const f=s.find("#theme-frontend"),O=s.find("#theme-admin");A(f.get(0)),A(O.get(0),{includeAdmin:!0}),x(f.get(0)),x(O.get(0)),T(f,m.theme||"charcoal-dark"),T(O,m.adminTheme||"charcoal-dark");const S=s.find("#theme-day"),I=s.find("#theme-night");A(S.get(0)),A(I.get(0)),x(S.get(0)),x(I.get(0));const g=m.autoTheme||{};T(S,g.dayTheme||"charcoal-light"),T(I,g.nightTheme||"charcoal-dark"),s.find("#theme-day-start").val(g.dayStart||"07:00"),s.find("#theme-night-start").val(g.nightStart||"19:00");const z=m.switcher||{},B=s.find("#theme-switcher-enabled"),le=s.find("#theme-switcher-fields");B.prop("checked",z.enabled===!0),s.find("#theme-switcher-position").val(z.position||"right-center"),s.find("#theme-switcher-offset-x").val(z.offsetX??32),s.find("#theme-switcher-offset-y").val(z.offsetY??32);const J=()=>{le.get(0).style.display=B.prop("checked")?"":"none"};J(),B.on("change",J);const Q=s.find("#theme-auto-enabled"),Z=s.find("#theme-auto-fields");Q.prop("checked",!!g.enabled),g.enabled&&(Z.show(),f.prop("disabled",!0)),Q.on("change",function(){Z.toggle(this.checked),f.prop("disabled",this.checked)}),s.find("#theme-font-family").val(m.font?.family||"Roboto"),s.find("#theme-font-size").val(String(m.font?.size??16));function $(e){return{id:e.id||`ov-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,label:e.label||"",enabled:e.enabled!==!1,scope:e.scope||"",theme:e.theme||"",tokens:{...e.tokens||{}},css:e.css||""}}const y=document.getElementById("theme-overrides"),re=document.getElementById("theme-overrides-empty");function R(){for(;y.firstChild;)y.removeChild(y.firstChild);re.style.display=b.length?"none":"",b.forEach((e,t)=>y.appendChild(ce(e,t))),Domma.icons.scan("#theme-overrides")}function ce(e,t){const n=document.createElement("div");n.className="card mb-3",n.dataset.i=String(t);const o=document.createElement("div");o.className="card-header";const a=document.createElement("h3");a.className="m-0",a.style.fontSize="1rem",a.textContent=e.label||e.scope||`Override ${t+1}`,o.appendChild(a);const r=document.createElement("div");r.style.cssText="display:flex;gap:.5rem;align-items:center;";const c=document.createElement("label");c.style.cssText="display:flex;align-items:center;gap:.35rem;font-size:.85rem;cursor:pointer;";const d=document.createElement("input");d.type="checkbox",d.className="form-check ov-enabled",d.checked=e.enabled,c.appendChild(d),c.appendChild(document.createTextNode("Enabled")),r.appendChild(c);const i=document.createElement("button");i.type="button",i.className="btn btn-ghost btn-sm ov-remove",i.textContent="Remove",r.appendChild(i),o.appendChild(r),n.appendChild(o);const h=document.createElement("div");h.className="card-body";const p=document.createElement("div");p.className="row mb-3",p.appendChild(V("col-4","Name",ne("ov-label",e.label,"Navbar island"))),p.appendChild(V("col-8","Target selector",de(e.scope),"The region to re-theme. Everything inside it follows.")),h.appendChild(p);const v=document.createElement("div");v.className="row mb-3";const l=document.createElement("select");l.className="form-select ov-theme",A(l,{includeDefault:!0,defaultLabel:"Keep the site theme"}),x(l),T(l,e.theme||""),v.appendChild(V("col-6","Adopt a theme here",l,"Copies every token of that theme onto this region.")),h.appendChild(v);const u=document.createElement("div");u.className="ov-theme-diff",h.appendChild(u);const C=document.createElement("div");C.className="ov-tokens mb-2",Object.entries(e.tokens||{}).forEach(([ie,me])=>{C.appendChild(ee(ie,me))}),h.appendChild(te("Token overrides",C));const L=document.createElement("button");L.type="button",L.className="btn btn-ghost btn-sm ov-add-token",L.textContent="+ Add token",h.appendChild(L);const N=document.createElement("details");N.style.marginTop="1rem";const _=document.createElement("summary");_.style.cssText="cursor:pointer;font-size:.85rem;",_.textContent="Custom CSS for this override",N.appendChild(_);const k=document.createElement("textarea");k.className="form-input ov-css",k.rows=4,k.placeholder="#site-navbar .brand { letter-spacing: .04em; }",k.value=e.css||"",k.style.marginTop=".5rem",N.appendChild(k);const P=document.createElement("p");return P.className="form-hint text-muted",P.style.cssText="font-size:.8rem;margin-top:.35rem;",P.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",N.appendChild(P),h.appendChild(N),n.appendChild(h),setTimeout(()=>j(n),0),n}function ee(e,t){const n=document.createElement("div");n.className="ov-token-row",n.style.cssText="display:flex;gap:.5rem;align-items:center;margin-bottom:.5rem;";const o=document.createElement("select");if(o.className="form-select ov-token-name",o.style.flex="1 1 auto",D.length){for(const d of D){const i=document.createElement("option");i.value=d,i.textContent=d,o.appendChild(i)}o.value=e||D[0]}else{const d=document.createElement("option");d.value=e||"",d.textContent=e||"(no tokens found)",o.appendChild(d)}const a=document.createElement("input");a.type="text",a.className="form-input ov-token-value",a.style.flex="0 0 150px",a.placeholder="#0096c7",a.value=t||"";const r=document.createElement("input");r.type="color",r.className="ov-token-swatch",r.style.cssText="flex:0 0 38px;height:34px;padding:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);background:none;cursor:pointer;",/^#[0-9a-f]{6}$/i.test(t||"")&&(r.value=t),r.addEventListener("input",()=>{a.value=r.value,a.dispatchEvent(new Event("input",{bubbles:!0}))});const c=document.createElement("button");return c.type="button",c.className="btn btn-ghost btn-sm ov-token-remove",c.textContent="\xD7",n.append(o,a,r,c),n}function V(e,t,n,o){const a=document.createElement("div");return a.className=e,a.appendChild(te(t,n,o)),a}function te(e,t,n){const o=document.createDocumentFragment(),a=document.createElement("label");if(a.className="form-label",a.textContent=e,o.appendChild(a),o.appendChild(t),n){const r=document.createElement("p");r.className="form-hint text-muted",r.style.cssText="margin-top:.35rem;font-size:.8rem;",r.textContent=n,o.appendChild(r)}return o}function ne(e,t,n){const o=document.createElement("input");return o.type="text",o.className=`form-input ${e}`,o.value=t||"",o.placeholder=n||"",o}function de(e){const t=document.createElement("div"),n=ne("ov-scope",e,"#site-navbar");if(n.setAttribute("list","theme-scope-options"),t.appendChild(n),!document.getElementById("theme-scope-options")){const o=document.createElement("datalist");o.id="theme-scope-options";for(const a of he){const r=document.createElement("option");r.value=a.value,r.label=a.label,o.appendChild(r)}t.appendChild(o)}return t}function F(){const e=y.querySelectorAll(".card"),t=[];return e.forEach((n,o)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(r=>{const c=r.querySelector(".ov-token-name")?.value?.trim(),d=r.querySelector(".ov-token-value")?.value?.trim();c&&d&&(a[c]=d)}),t.push({id:b[o]?.id,label:n.querySelector(".ov-label")?.value?.trim()||"",enabled:!!n.querySelector(".ov-enabled")?.checked,scope:n.querySelector(".ov-scope")?.value?.trim()||"",theme:n.querySelector(".ov-theme")?.value||"",tokens:a,css:n.querySelector(".ov-css")?.value||""})}),b=t,t}s.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);F(),b.splice(e,1),R()}),s.on("change",".ov-theme",function(){j(this.closest(".card"))}),s.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{y.querySelectorAll(".card").forEach(j)}),s.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(ee("",""))}),s.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),s.find("#theme-add-override").on("click",()=>{F(),b.push($({label:"",scope:"",enabled:!0})),R()}),R();async function K(){const e=document.getElementById("theme-css-preview"),t={...oe(),overrides:F()};try{const n=await w.theme.compile(t);e.textContent=n.css?.trim()||"(no overrides - nothing is injected)",e.style.color=""}catch(n){e.textContent=n.message||String(n),e.style.color="var(--dm-danger)"}}s.find("#theme-refresh-css").on("click",K);function oe(){const e=s.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:I.val()||"charcoal-dark",dayStart:s.find("#theme-day-start").val()||"07:00",nightStart:s.find("#theme-night-start").val()||"19:00"};return{theme:e?t.dayTheme:f.val(),adminTheme:O.val(),autoTheme:t,font:{family:s.find("#theme-font-family").val()||"Roboto",size:parseInt(s.find("#theme-font-size").val(),10)||16},switcher:{enabled:B.prop("checked"),position:s.find("#theme-switcher-position").val()||"right-center",offsetX:parseInt(s.find("#theme-switcher-offset-x").val(),10)||0,offsetY:parseInt(s.find("#theme-switcher-offset-y").val(),10)||0,themes:Array.isArray(m.switcher?.themes)?m.switcher.themes:[]}}}function se(e){const t=document.getElementById("theme-errors");for(;t.firstChild;)t.removeChild(t.firstChild);if(!e||!e.length){t.style.display="none";return}t.style.display="";for(const n of e){const o=document.createElement("div");o.textContent=n,t.appendChild(o)}t.scrollIntoView({behavior:"smooth",block:"nearest"})}s.find("#theme-save-btn").on("click",async function(){se(null);const e={...oe(),overrides:F()};this.disabled=!0;try{m=await w.theme.save(e);const{refreshAdminTheme:n}=await import("../lib/admin-theme.js");await n(w),E.toast("Theme saved.",{type:"success"}),await K()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];se(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await K()}};
1
+ import{api as N}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Pe}from"../lib/help-popover.js";import{onceClosed as lt,openMenuAt as Ke,sidebarCount as it}from"../lib/tool-kit.js";import{populateThemeSelect as Fe}from"../lib/themes.js";import{cloneOverride as Q,dayOrNight as ct,formFromConfig as X,knownThemeIds as dt,moveItem as He,overrideRow as mt,payloadFromForm as Z,snapshot as pe,swatchColour as ze,themeDiff as ht,themeLabel as ut,themeProblems as pt}from"../lib/theme-settings-arrange.js";const fe=/^\/theme(\?.*)?$/,ft="#/theme",L="theme.draft",vt=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}],bt=`
2
+ <div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
3
+ <span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
4
+ <span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
5
+ <span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
6
+ <span class="thx-chip" data-bind-hidden="!off">Off</span>
7
+ <button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
8
+ </div>`,ve=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function yt(m){const y=document.createElement("template");return y.innerHTML=m.trim(),y.content.firstElementChild}let x=null,Be=!1;function kt(){Be||!window.R?.use||(Be=!0,R.use((m,y,u)=>!(fe.test(y?.path||"")&&!fe.test(m?.path||""))||!x?.dirty()?u():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?u():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:m}={})=>{if(x&&!fe.test(m?.path||"")){const y=x;x=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(m){if(kt(),x){const e=x;x=null,e.dispose()}const y=[],u=e=>(y.push(e),e);let ee=!1;const p=m.get(0).querySelector(".thx")||m.get(0),j=()=>!ee&&p.isConnected,be=E.loader(m.get(0),{type:"dots"});let P,q;try{[P,q]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){be.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(be.destroy(),!p.isConnected)return;const te=q.tokens||[],K=Array.isArray(q.custom)?q.custom:[],ye=dt(q),f=M.observable(P),$=X(P),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ke=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),oe=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),F=M.observable(pe($,P)),H=M.observable(!1),z=M.observable([]),ne=M.observable("(nothing yet)"),ae=M.observable(!1),_e=M.computed(()=>pe(ke(),f.value)),B=M.computed(()=>_e.value!==F.value),ge=M.computed(()=>pt(ke(),ye)),we=M.observable([]);u(M.effect(()=>{we.value=s.overrides.value.map((e,t)=>mt(e,t,{custom:K,known:ye}))}));const _=e=>ut(e,K),Ve={...s,saving:H,rows:we,cssText:ne,cssError:ae,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ge.value.length?`${ge.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=f.value;if(e.autoTheme?.enabled){const t=ct(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(f.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=f.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>f.value.switcher?.enabled?"Theme switcher on":"")};function xe(e){if(!K.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of K){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function Ee(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const V=(e,t,o)=>{const a=p.querySelector(e);return Fe(a,o),xe(a),Ee(a,t),a};V("#thx-site",$.theme),V("#thx-admin",$.adminTheme,{includeAdmin:!0}),V("#thx-day",$.dayTheme),V("#thx-night",$.nightTheme);let se=null,re=0;async function Y(){const e=++re;try{const t=await N.theme.compile(Z(oe(),f.peek()));if(e!==re||!j())return;ne.value=t.css?.trim()||"(no overrides - nothing is injected)",ae.value=!1}catch(t){if(e!==re||!j())return;ne.value=t.message||String(t),ae.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(se),se=setTimeout(Y,400)})),u(()=>clearTimeout(se));async function le(){if(!H.peek()){H.value=!0,z.value=[];try{const e=await N.theme.save(Z(oe(),f.peek()));if(!j())return;f.value=e;const t=X(e);for(const[a,l]of Object.entries(t))s[a].value=l;F.value=pe(t,e);try{S.remove?S.remove(L):S.set(L,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),it(ft),E.toast("Theme saved.",{type:"success"}),Y()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{H.value=!1}}}async function Ye(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=X(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Se=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},ie=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ue=e=>s.overrides.peek().findIndex(t=>t.id===e),ce=new Map;async function Ce(e){if(!e)return null;if(ce.has(e))return ce.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ce.set(e,o),o}const Je=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",U=new Set;function J(e){const t=e?Se(e):Q({});if(!t)return;const o=!e,a=Q(t),l={label:M.observable(a.label),scope:M.observable(a.scope),enabled:M.observable(a.enabled),theme:M.observable(a.theme),css:M.observable(a.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${ve(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
9
+ <div class="thx-form">
10
+ <div class="thx-grid">
11
+ <div>
12
+ <label class="form-label" for="thx-o-label">Name</label>
13
+ <input id="thx-o-label" type="text" class="form-input" maxlength="80" placeholder="Navbar island" data-model="label.value">
14
+ </div>
15
+ <div class="thx-setting" style="align-self:end">
16
+ <label class="form-label" for="thx-o-on">On
17
+ <span data-help="Off keeps the override but leaves it out of the page."></span></label>
18
+ <input id="thx-o-on" type="checkbox" class="form-switch" data-model="enabled.value">
19
+ </div>
20
+ </div>
21
+ <div>
22
+ <label class="form-label" for="thx-o-scope">Target selector
23
+ <span data-help-title="Target selector" data-help="The region to re-theme - a CSS selector. Everything inside it follows. Pick a common region from the list or type your own (#site-navbar, .card, .page-body \u2026)."></span></label>
24
+ <input id="thx-o-scope" type="text" class="form-input font-mono" list="thx-scope-options" placeholder="#site-navbar" data-model="scope.value">
25
+ <datalist id="thx-scope-options">${vt.map(n=>`<option value="${n.value}" label="${n.label}"></option>`).join("")}</datalist>
26
+ </div>
27
+ <div>
28
+ <label class="form-label" for="thx-o-theme">Adopt a theme here
29
+ <span data-help-title="Adopt a theme" data-help="Copies every token of that theme onto this region. Two themes often share a surface colour, so the panel below says what really changes - set --dm-card-bg or --dm-surface as a token when the fill should change too."></span></label>
30
+ <select id="thx-o-theme" class="form-select" data-model="theme.value"></select>
31
+ <div class="thx-diff-host"></div>
32
+ </div>
33
+ <div>
34
+ <label class="form-label">Token overrides
35
+ <span data-help-title="Tokens" data-help="Individual --dm-* design tokens for this region, set after any adopted theme. Only tokens the shipped stylesheet defines are accepted - start typing to see them. The colour box fills in a hex value; lengths and keywords go in the text box."></span></label>
36
+ <div class="thx-toks"></div>
37
+ <datalist id="thx-token-names"></datalist>
38
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="addToken">
39
+ <span data-icon="plus" data-icon-size="13"></span> Add token</button>
40
+ <small class="thx-err" data-bind-hidden="!tokenErr.value" data-bind-text="tokenErr.value"></small>
41
+ </div>
42
+ <details class="thx-o-css"${a.css?" open":""}>
43
+ <summary>Custom CSS for this override</summary>
44
+ <textarea class="form-input font-mono" rows="4" style="margin-top:.5rem" placeholder="#site-navbar .brand { letter-spacing: .04em; }" data-model="css.value"></textarea>
45
+ <small class="thx-hint">Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade; hand-written rules fight the cascade.</small>
46
+ </details>
47
+ <div class="thx-actions">
48
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="remove"${o?" hidden":""}>
49
+ <span data-icon="trash" data-icon-size="13"></span> Remove</button>
50
+ <span class="thx-spacer"></span>
51
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
52
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="done">
53
+ <span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
54
+ </div>
55
+ </div>`),de=v.querySelector("#thx-o-theme");Fe(de,{includeDefault:!0,defaultLabel:"Keep the site theme"}),xe(de),Ee(de,a.theme);const Xe=v.querySelector("#thx-token-names");for(const n of te){const i=document.createElement("option");i.value=n,Xe.appendChild(i)}const me=v.querySelector(".thx-toks");function Ne(n="",i=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=n,r.setAttribute("aria-label","Token");const h=document.createElement("input");h.type="text",h.className="form-input thx-tok-value",h.placeholder="#0096c7",h.value=i,h.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=ze(i);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{h.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,h,k,w),b}for(const[n,i]of Object.entries(a.tokens))me.appendChild(Ne(n,i));function De(){const n={},i=[];return me.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(te.length&&!te.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const Le=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:De().tokens}),Ze=JSON.stringify(Z({overrides:[a]}).overrides),et=()=>JSON.stringify(Z({overrides:[Le()]}).overrides)!==Ze,he=v.querySelector(".thx-diff-host");let Re=0;async function tt(){const n=++Re;he.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Je(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",i===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),he.appendChild(r);return}const[h,k]=await Promise.all([Ce(b),Ce(i)]);if(n!==Re||!h||!k)return;const g=ht(h,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",r.appendChild(d)}const G=document.createElement("div");G.className="thx-diff-list";const je=d=>{const A=document.createElement("span");return A.className="thx-sw",A.style.background=ze(d)||"transparent",A};for(const d of g.shown){const A=document.createElement("span");A.append(je(d.from),document.createTextNode(`${d.name} `),je(d.to)),G.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,G.appendChild(d)}r.appendChild(G),he.appendChild(r)}const ot=M.effect(()=>{l.theme.value,tt()});let W=!1,ue=!1;const qe={addToken:()=>{const n=Ne();me.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),W=!0,c.close())},done(){const{bad:n}=De();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=Le();o?C([...s.overrides.peek(),i]):ie(t.id,i),W=!0,c.close()}};c.element.appendChild(v);const nt=Pe(v),at=M.applyBindings(l,v,{methods:qe}),st=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),qe.done())};v.addEventListener("keydown",st),U.add(c),lt(c,()=>{ot(),at.dispose(),nt(),U.delete(c)});const rt=c.close.bind(c);c.close=async n=>{if(!W&&n!==!0&&et()){if(ue)return;ue=!0;const i=await E.confirm("Discard your changes to this override?");if(ue=!1,!i)return}return W=!0,rt()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function We(e){const t=Se(e);if(!t)return[];const o=Ue(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>J(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>ie(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>ie(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=Q({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...s.overrides.peek()];c.splice(o+1,0,l),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(He(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(He(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(s.overrides.peek().filter(l=>l.id!==e))}}]}const O=p.querySelector(".thx-list");O.innerHTML=bt,E.tabs(p.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const Ge=M.applyBindings(Ve,p,{methods:{save:le,discard:Ye,refreshCss:Y,addOverride:()=>J(null)}});u(()=>Ge.dispose()),u(Pe(p,{position:"bottom"}));const Qe=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>We(e.dataset.id)});u(()=>Qe?.destroy?.());const Te=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Ke(e.target.closest(".thx-kebab"));return}J(t.dataset.id)}},Me=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ke(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.id))};O.addEventListener("click",Te),O.addEventListener("keydown",Me),u(()=>{O.removeEventListener("click",Te),O.removeEventListener("keydown",Me)}),Ie(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),Ie(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const $e=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||U.size||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",$e,!0),u(()=>document.removeEventListener("keydown",$e,!0));const Oe=()=>{if(B.peek())try{S.set(L,{form:oe(),base:F.peek(),at:new Date().toISOString()})}catch{}},Ae=e=>{B.peek()&&(Oe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ae),u(()=>window.removeEventListener("beforeunload",Ae)),x={dirty:()=>B.peek(),dispose(){ee=!0,Oe(),[...U].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),Y();let T=null;try{T=S.get(L)}catch{}if(T?.form){try{S.remove?S.remove(L):S.set(L,null)}catch{}const e=T.base&&T.base!==F.peek(),t=T.at?` (${D(T.at).format("D MMM HH:mm")})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&j()){const a={...X(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>Q(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
@@ -1 +1,11 @@
1
- import{api as c}from"../api.js";import{filterByProject as m,getProjectFromHash as u,renderProjectFilterNotice as w}from"../lib/project-context.js";import{keepTableIconsAlive as g}from"../lib/table-icons.js";function s(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(t){await d(t),t.find("#create-view-btn").off("click").on("click",()=>{R.navigate("/views/new")}),Domma.icons.scan(),g(t.get(0))}};async function d(t){const p=E.loader(t.get(0),{type:"dots"});let a=[];try{a=await c.views.list()}catch{E.toast("Could not load views.",{type:"error"})}finally{p.destroy()}const r=u();if(r){const e=a.length;a=m(a,r),w(t,{total:e,shown:a.length,slug:r,allHref:"#/views",noun:"views"})}T.create("#views-table",{pageSize:25,data:a,columns:[{key:"title",title:"Title",render:(e,i)=>{const o=document.createElement("a");return o.href=`#/views/${s(i.slug)}/preview`,o.textContent=e,o.style.fontWeight="600",o.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${s(e)}</code>`},{key:"pipeline",title:"Source",render:e=>`<code>${s(e?.source||"-")}</code>`},{key:"display",title:"Mode",render:e=>s(e?.mode||"table")},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(i=>`<span class="badge badge-secondary">${s(i)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const i=document.createElement("div");i.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const o=document.createElement("a");o.href=`#/views/${s(e)}/preview`,o.className="btn btn-sm btn-ghost",o.setAttribute("data-tooltip","Preview"),o.innerHTML='<span data-icon="eye" data-icon-size="14"></span>';const n=document.createElement("a");n.href=`#/views/edit/${s(e)}`,n.className="btn btn-sm btn-ghost",n.setAttribute("data-tooltip","Edit"),n.innerHTML='<span data-icon="edit" data-icon-size="14"></span>';const l=document.createElement("button");return l.className="btn btn-sm btn-ghost js-delete-view",l.dataset.slug=e,l.setAttribute("data-tooltip","Delete"),l.innerHTML='<span data-icon="trash" data-icon-size="14"></span>',i.appendChild(o),i.appendChild(n),i.appendChild(l),i.outerHTML}}],emptyMessage:'No views yet. Click "New View" to create your first view.'}),t.off("click",".js-delete-view").on("click",".js-delete-view",async function(){const e=this.dataset.slug;if(await E.confirm(`Delete view "${e}"? This cannot be undone.`))try{await c.views.delete(e),E.toast("View deleted.",{type:"success"}),await d(t)}catch{E.toast("Failed to delete view.",{type:"error"})}}),Domma.icons.scan(),document.querySelectorAll("#views-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}
1
+ import{apiRequest as m}from"../api.js";import{addBannerAction as W}from"../lib/plugin-chrome.js";import{helpPopovers as G}from"../lib/help-popover.js";import{openMenuAt as L,sidebarCount as g,viewLifetime as J}from"../lib/tool-kit.js";import{FILTERS as Y,arrangeViews as z,countViews as Q,newViewHref as X,projectOf as y,projectScope as Z,shortcodeFor as ee,viewRow as te}from"../lib/views-arrange.js";const A=/^#\/(views|projects\/[^/]+\/views)\/?(\?.*)?$/,b="#/views",f="views.filter",se=`
2
+ <div class="vwx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="vwx-main"><strong>{{title}}</strong><small>{{meta}}</small>
4
+ <small class="is-bad" data-bind-hidden="!broken">{{brokenText}}</small></span>
5
+ <span class="vwx-chips">
6
+ <span class="vwx-chip is-info" data-bind-hidden="!engine">{{engine}}</span>
7
+ <span class="vwx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
8
+ <span class="vwx-chip" data-bind-class="accessCls" title="Who can see it">{{access}}</span>
9
+ </span>
10
+ <button type="button" class="vwx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
11
+ </div>`,x=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),o=J(A);export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(c){o.reset();const r=c.get(0).querySelector(".vwx")||c.get(0),u=()=>A.test(location.hash)&&r.isConnected,a=Z(location.hash),i=M.observable([]),p=M.observable(!0),w=M.observable("");let k="all";try{k=Y.includes(S.get(f))?S.get(f):"all"}catch{}const s=M.observable(k),$=M.observable("");o.add(M.effect(()=>{const e=s.value;try{S.set(f,e)}catch{}}));const T=M.observable([]);o.add(M.effect(()=>{T.value=z(i.value,{filter:s.value,query:$.value,project:a}).map(te)}));const d=M.computed(()=>Q(i.value,{project:a}));o.add(M.effect(()=>{s.value==="broken"&&!d.value.broken&&!p.value&&(s.value="all")}));const D=M.computed(()=>{const e=i.value.length-d.value.all;return`Showing only views in ${a}.${e?` ${e} other${e===1?" is":"s are"} filed elsewhere.`:""}`}),N=M.computed(()=>d.value.all?"No views match.":"No views yet - New view in the banner makes one."),P=e=>i.peek().find(t=>t.slug===e);async function v(){try{const e=await m("/views?sources=1",{method:"GET"});if(!u())return;i.value=Array.isArray(e)?e:[],w.value=""}catch(e){w.value=`Could not load the views: ${e.message||e}`}finally{p.value=!1}}const h=e=>{location.hash=`#/views/edit/${encodeURIComponent(e)}`},U=e=>{location.hash=`#/views/${encodeURIComponent(e)}/preview`};async function q(e){const t=ee(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this: ${t}`,{type:"info",duration:8e3})}}async function O(e){const t=await E.prompt("Title of the copy",{inputValue:x(`${e.title||e.slug} copy`)});if(!(!t||!String(t).trim()||!u()))try{const{id:l,slug:oe,meta:R,sourceInfo:ae,...K}=e,H=await m("/views",{method:"POST",body:JSON.stringify({...K,title:String(t).trim(),...R?.project!=null&&{meta:{project:R.project}}})});E.toast(`Made "${H.title}".`,{type:"success"}),g(b),await v()}catch(l){E.toast(`Not duplicated: ${l.message||l}`,{type:"error"})}}async function _(e){if(await E.confirm(`Delete the view "${x(e.title||e.slug)}"? Pages that show it with [view slug="${x(e.slug)}"] will show nothing. This cannot be undone.`))try{await m(`/views/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),g(b),await v()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}function V(e){const t=P(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>h(t.slug)},{label:"Preview results",icon:"eye",action:()=>U(t.slug)},{label:"Copy shortcode",icon:"copy",action:()=>q(t)},...y(t)!=="core"&&!a?[{label:`Open project "${y(t)}"`,icon:"folder",action:()=>{location.hash=`#/projects/${encodeURIComponent(y(t))}/views`}}]:[],{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>_(t)}]:[]}const n=r.querySelector(".vwx-list");n.innerHTML=se;const B=M.applyBindings({views:i,loading:p,loadError:w,filter:s,query:$,rows:T,counts:d,emptyText:N,scopeText:D,project:a},r,{methods:{showAll:()=>{s.value="all"},showPublic:()=>{s.value="public"},showRestricted:()=>{s.value="restricted"},showBroken:()=>{s.value="broken"}}});o.add(()=>B.dispose()),o.add(G(r,{position:"bottom"}));const F=E.contextMenu(n,{cursor:"glyph",match:".vwx-row",inherit:!1,density:"compact",items:e=>V(e.dataset.slug)});o.add(()=>F?.destroy?.());const C=e=>{const t=e.target.closest(".vwx-row");if(!t)return;const l=e.target.closest(".vwx-kebab");if(l){L(l);return}h(t.dataset.slug)},j=e=>{const t=e.target.closest(".vwx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.slug))};n.addEventListener("click",C),n.addEventListener("keydown",j),o.add(()=>{n.removeEventListener("click",C),n.removeEventListener("keydown",j)}),W(c,{id:"views-new",icon:"plus",title:"New view",onClick:()=>{location.hash=X(a)}}),I.scan(r),await v(),u()&&g(b)}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.74.0",
3
+ "version": "0.75.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",
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Admin endpoints (authenticated + actions permission):
5
5
  * GET /actions - List all action configs
6
+ * GET /actions/_count - Sidebar badge (never 503: file mode answers count 0, available: false)
6
7
  * POST /actions - Create action config
7
8
  * GET /actions/:slug - Get action config
8
9
  * PUT /actions/:slug - Update action config
@@ -24,12 +25,63 @@ import {
24
25
  updateAction
25
26
  } from '../../services/actions.js';
26
27
  import {getEntry} from '../../services/collections.js';
27
- import {authenticate, requirePermission} from '../../middleware/auth.js';
28
+ import {
29
+ authenticate as defaultAuthenticate,
30
+ requirePermission as defaultRequirePermission
31
+ } from '../../middleware/auth.js';
28
32
  import {getRoleLevel} from '../../services/roles.js';
29
33
  import {getEffectiveLevel} from '../../services/userRoles.js';
30
34
  import {checkEntryAccess} from '../../services/rowAccess.js';
31
35
 
32
- export async function actionsRoutes(fastify) {
36
+ /**
37
+ * The sidebar badge on Actions (its countUrl): how many actions this user can
38
+ * see, which collections they act on, which are state-machine transitions.
39
+ * Plain text throughout - the popover sets it with textContent. `null` (no
40
+ * MongoDB) answers count 0, so the sidebar shows no badge and logs nothing;
41
+ * `available` lets the list screen find that out without a 503 of its own.
42
+ *
43
+ * @param {object[]|null} actions
44
+ * @returns {{count: number, label: string, tone: string, details: object}}
45
+ */
46
+ export function actionsBadge(actions) {
47
+ if (!Array.isArray(actions)) {
48
+ return {
49
+ count: 0, label: 'Actions need MongoDB', tone: 'info', available: false,
50
+ details: {title: 'Actions', rows: [], items: [],
51
+ empty: 'Actions are a Pro feature: they need a MongoDB connection.'}
52
+ };
53
+ }
54
+ const n = actions.length;
55
+ const collections = new Set(actions.map(a => a.collection).filter(Boolean));
56
+ const transitions = actions.filter(a => a.transition?.field).length;
57
+ const sorted = [...actions].sort((a, b) => String(a.title || a.slug).localeCompare(String(b.title || b.slug)));
58
+ return {
59
+ count: n,
60
+ label: `${n} action${n === 1 ? '' : 's'}`,
61
+ tone: 'info',
62
+ available: true,
63
+ details: {
64
+ title: 'Actions',
65
+ rows: [
66
+ {label: 'Collections', value: String(collections.size)},
67
+ {label: 'Transitions', value: String(transitions)}
68
+ ],
69
+ items: sorted.slice(0, 8).map(a => ({
70
+ text: String(a.title || a.slug),
71
+ meta: [a.collection, `${(a.steps || []).length} step${(a.steps || []).length === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
72
+ })),
73
+ empty: 'No actions yet.'
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @param {import('fastify').FastifyInstance} fastify
80
+ * @param {{authenticate?: Function, requirePermission?: Function}} [opts] - auth as DI, so tests can stub it
81
+ */
82
+ export async function actionsRoutes(fastify, opts = {}) {
83
+ const authenticate = opts.authenticate || defaultAuthenticate;
84
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
33
85
  const canRead = {preHandler: [authenticate, requirePermission('actions', 'read')]};
34
86
  const canCreate = {preHandler: [authenticate, requirePermission('actions', 'create')]};
35
87
  const canUpdate = {preHandler: [authenticate, requirePermission('actions', 'update')]};
@@ -60,6 +112,16 @@ export async function actionsRoutes(fastify) {
60
112
  }
61
113
  });
62
114
 
115
+ // `_count`: slugify() turns '_' into '-', so no action slug can shadow it.
116
+ fastify.get('/actions/_count', canRead, async (request) => {
117
+ try {
118
+ const {canSeeArtefact} = await import('../../services/projects.js');
119
+ return actionsBadge((await listActions()).filter(a => canSeeArtefact(request.user, a)));
120
+ } catch {
121
+ return actionsBadge(null);
122
+ }
123
+ });
124
+
63
125
  // Static sub-route declared before parameterised :slug for radix-tree priority
64
126
  fastify.get('/actions/collection/:collectionSlug', canRead, async (request, reply) => {
65
127
  try {
@@ -2,6 +2,7 @@
2
2
  * API Endpoints API (admin management of custom endpoint definitions)
3
3
  *
4
4
  * GET /api/api-endpoints - list definitions visible to the caller
5
+ * GET /api/api-endpoints/_count - the sidebar badge (ids are uuids - `_count` is never one)
5
6
  * GET /api/api-endpoints/:id - single definition (the builder editor)
6
7
  * POST /api/api-endpoints - create (400 validation, 409 duplicate path shape)
7
8
  * PUT /api/api-endpoints/:id - update (project binding immutable)
@@ -42,6 +43,43 @@ async function canUseCollection(user, slug) {
42
43
  return canSeeArtefact(user, schema);
43
44
  }
44
45
 
46
+ /**
47
+ * The sidebar badge on API Builder (its countUrl): how many endpoints the
48
+ * caller can see, with the public ones called out - they answer anyone, so
49
+ * they are the ones worth a glance. Plain text throughout: the sidebar
50
+ * popover sets it with textContent.
51
+ *
52
+ * @param {object[]} endpoints - sanitised definitions
53
+ * @returns {{count: number, label: string, tone: string, details: object}}
54
+ */
55
+ export function endpointsBadge(endpoints = []) {
56
+ const list = [...endpoints].sort((a, b) => String(a.project).localeCompare(String(b.project))
57
+ || String(a.path).localeCompare(String(b.path)));
58
+ const n = list.length;
59
+ const off = list.filter(e => e.enabled === false).length;
60
+ const pub = list.filter(e => e.enabled !== false && e.auth === 'public').length;
61
+ const url = (e) => `/api/x/${e.project}${e.path}`;
62
+ return {
63
+ count: n,
64
+ label: `${n} endpoint${n === 1 ? '' : 's'}${pub ? `, ${pub} public` : ''}${off ? `, ${off} disabled` : ''}`,
65
+ tone: 'info',
66
+ details: {
67
+ title: 'API Builder',
68
+ rows: [
69
+ {label: 'Enabled', value: String(n - off)},
70
+ {label: 'Public', value: String(pub)},
71
+ {label: 'Disabled', value: String(off)}
72
+ ],
73
+ items: list.slice(0, 8).map(e => ({
74
+ text: `GET ${url(e)}`,
75
+ meta: [e.name, e.auth === 'public' ? 'public' : e.auth === 'token' ? 'API token' : `role ${e.auth}`,
76
+ e.enabled === false ? 'disabled' : ''].filter(Boolean).join(' · ')
77
+ })),
78
+ empty: 'No endpoints yet.'
79
+ }
80
+ };
81
+ }
82
+
45
83
  /**
46
84
  * Register the api-endpoints routes.
47
85
  *
@@ -64,6 +102,9 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
64
102
  return listEndpointsSanitised(request.user);
65
103
  });
66
104
 
105
+ // Before /:id in intent (Fastify matches the static segment first anyway).
106
+ fastify.get('/api-endpoints/_count', canRead, async (request) => endpointsBadge(await listEndpointsSanitised(request.user)));
107
+
67
108
  fastify.get('/api-endpoints/:id', canRead, async (request, reply) => {
68
109
  const def = await getEndpointSanitised(request.params.id);
69
110
  if (!def) return reply.status(404).send({error: 'Endpoint not found'});
@@ -1,15 +1,150 @@
1
1
  /**
2
2
  * Blocks API
3
3
  * CRUD for reusable HTML block templates stored in content/blocks/*.html.
4
- * GET /api/blocks - list all blocks
4
+ * GET /api/blocks - list all blocks (each with hasCss, description, example;
5
+ * ?usage=1 adds where each is used: pages and views)
6
+ * GET /api/blocks/_count - the sidebar badge (`_` is outside the block-name alphabet)
5
7
  * GET /api/blocks/:name - get block content
6
8
  * GET /api/blocks/:name/export - download block as .dmblock.json bundle
7
9
  * POST /api/blocks/import - import a .dmblock.json bundle
8
10
  * PUT /api/blocks/:name - create or update block
9
11
  * DELETE /api/blocks/:name - delete block
10
12
  */
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
11
15
  import {authenticate, requirePermission} from '../../middleware/auth.js';
12
16
  import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
17
+ import {config} from '../../config.js';
18
+
19
+ /**
20
+ * A block's own doc comment - the one every block opens with - read for the
21
+ * list: its first line ("contact-card - Contact entry summary card.") gives a
22
+ * description, and the line after "Example:" a shortcode to copy. Plain text;
23
+ * a block without a comment gives neither.
24
+ *
25
+ * @param {string} html
26
+ * @param {string} name
27
+ * @returns {{description: string, example: string}}
28
+ */
29
+ export function blockDoc(html, name = '') {
30
+ const m = /^\s*<!--([\s\S]*?)-->/.exec(String(html || ''));
31
+ if (!m) return {description: '', example: ''};
32
+ const lines = m[1].split('\n').map(l => l.trim());
33
+ // The first paragraph - a description may wrap over several lines.
34
+ const start = lines.findIndex(Boolean);
35
+ const para = [];
36
+ // …ending at a blank line or the next heading ("Example:", "Parameters (all optional):").
37
+ for (let i = Math.max(0, start); start >= 0 && i < lines.length && lines[i]; i++) {
38
+ if (i > start && /^[\w ()/-]+:$/.test(lines[i])) break;
39
+ para.push(lines[i]);
40
+ }
41
+ const first = para.join(' ');
42
+ const lead = new RegExp(`^${name.replace(/[-]/g, '\\-')}\\s*[-–:]\\s*`, 'i');
43
+ const description = name && lead.test(first) ? first.replace(lead, '') : (/^[\w-]+\s*[-–:]\s*/.test(first) ? first.replace(/^[\w-]+\s*[-–:]\s*/, '') : first);
44
+ // The example may wrap over several lines: from its `[` to the line ending `]`.
45
+ let example = '';
46
+ const at = lines.findIndex(l => /^example:?$/i.test(l));
47
+ if (at >= 0) {
48
+ const from = lines.findIndex((l, i) => i > at && l.startsWith('['));
49
+ if (from > 0) {
50
+ const parts = [];
51
+ for (let i = from; i < lines.length && lines[i]; i++) {
52
+ parts.push(lines[i]);
53
+ if (lines[i].endsWith(']')) break;
54
+ }
55
+ example = parts.join(' ');
56
+ }
57
+ }
58
+ return {description: description.slice(0, 200), example: example.slice(0, 300)};
59
+ }
60
+
61
+ /** Escape a block name for a RegExp. Names are [a-z0-9-] so only `-` needs care. */
62
+ const reName = (n) => n.replace(/-/g, '\\-');
63
+
64
+ /**
65
+ * Where each block is used, by reading - not rendering - the page sources and
66
+ * view configs: `block="x"` (collection/view/carousel/listgroup displays),
67
+ * `template="x"` (the [block] shortcode), the `[x …/]` short tag, and a view's
68
+ * `display.block`. Cheap enough for the list and the badge; a block pulled in
69
+ * only by a plugin's own code is not seen (so "unused" means "not used by a
70
+ * page or view").
71
+ *
72
+ * @param {string[]} names
73
+ * @param {{url: string, raw: string}[]} pages
74
+ * @param {{slug: string, name?: string, display?: {block?: string}}[]} views
75
+ * @returns {Record<string, {pages: string[], views: string[]}>}
76
+ */
77
+ export function blockUsage(names, pages = [], views = []) {
78
+ const out = Object.fromEntries(names.map(n => [n, {pages: [], views: []}]));
79
+ for (const n of names) {
80
+ const re = new RegExp(`(?:\\b(?:block|template)\\s*=\\s*["']${reName(n)}["'])|(?:\\[${reName(n)}(?=[\\s/\\]]))`);
81
+ for (const p of pages) if (re.test(p.raw)) out[n].pages.push(p.url);
82
+ for (const v of views) if (v?.display?.block === n) out[n].views.push(v.slug || v.name || '');
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Every page's source, with its URL - read from disk, never rendered. */
88
+ async function readPageSources() {
89
+ const root = path.join(config.content.contentDir, 'pages');
90
+ const out = [];
91
+ async function walk(dir) {
92
+ let entries = [];
93
+ try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch { return; }
94
+ for (const e of entries) {
95
+ const full = path.join(dir, e.name);
96
+ if (e.isDirectory()) await walk(full);
97
+ else if (e.name.endsWith('.md')) {
98
+ const rel = path.relative(root, full).replace(/\.md$/, '').split(path.sep).join('/');
99
+ const url = rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '');
100
+ try { out.push({url, raw: await fs.readFile(full, 'utf8')}); } catch { /* unreadable - skip */ }
101
+ }
102
+ }
103
+ }
104
+ await walk(root);
105
+ return out;
106
+ }
107
+
108
+ async function usageFor(names) {
109
+ const [pages, views] = await Promise.all([
110
+ readPageSources(),
111
+ import('../../services/views.js').then(m => m.listViews()).catch(() => [])
112
+ ]);
113
+ return blockUsage(names, pages, views);
114
+ }
115
+
116
+ /**
117
+ * The sidebar badge on Blocks: how many there are, with the unused ones and
118
+ * the latest changes in the popover. A count to glance at, never a warning.
119
+ * Plain text throughout - the popover sets it with textContent.
120
+ *
121
+ * @param {{name: string, updatedAt?: string, hasCss?: boolean, bundled?: boolean, usage?: {pages: string[], views: string[]}}[]} blocks
122
+ */
123
+ export function blocksBadge(blocks = []) {
124
+ const n = blocks.length;
125
+ const used = (b) => (b.usage?.pages?.length || 0) + (b.usage?.views?.length || 0);
126
+ const unused = blocks.filter(b => b.usage && !used(b)).length;
127
+ const recent = [...blocks].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, 6);
128
+ return {
129
+ count: n,
130
+ label: `${n} block${n === 1 ? '' : 's'}${unused ? `, ${unused} not used on any page` : ''}`,
131
+ tone: 'info',
132
+ details: {
133
+ title: 'Blocks',
134
+ rows: [
135
+ {label: 'Blocks', value: String(n)},
136
+ {label: 'With CSS', value: String(blocks.filter(b => b.hasCss).length)},
137
+ {label: 'Not used on a page or view', value: String(unused)},
138
+ {label: 'Built in', value: String(blocks.filter(b => b.bundled).length)}
139
+ ],
140
+ items: recent.map(b => ({
141
+ text: b.name,
142
+ meta: [b.updatedAt ? `changed ${String(b.updatedAt).slice(0, 10)}` : '', b.usage ? `used ${used(b)} time${used(b) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')
143
+ })),
144
+ empty: 'No blocks yet.'
145
+ }
146
+ };
147
+ }
13
148
 
14
149
  /** Current export bundle schema version. Bump when the shape changes. */
15
150
  const BUNDLE_FORMAT = 1;
@@ -19,18 +154,45 @@ export async function blocksRoutes(fastify) {
19
154
  const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
20
155
  const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
21
156
 
22
- // List all blocks
23
- fastify.get('/blocks', canRead, async (request) => {
157
+ /**
158
+ * The blocks this user may see, each with what the list shows beyond the
159
+ * file: whether it has CSS, and its doc comment's description and example.
160
+ * getBlock was already read per block for the project check, so these come free.
161
+ */
162
+ async function visibleBlocks(user) {
24
163
  const {canSeeArtefact} = await import('../../services/projects.js');
25
- const all = await listBlocks();
26
- const filtered = [];
27
- for (const b of all) {
164
+ const out = [];
165
+ for (const b of await listBlocks()) {
28
166
  // listBlocks returns metadata only; getBlock returns full record with meta
29
167
  let full = null;
30
168
  try { full = await getBlock(b.name); } catch { /* skip */ }
31
- if (canSeeArtefact(request.user, full)) filtered.push(b);
169
+ if (!canSeeArtefact(user, full)) continue;
170
+ out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name)});
171
+ }
172
+ return out;
173
+ }
174
+
175
+ // List all blocks (?usage=1: where each is used)
176
+ fastify.get('/blocks', canRead, async (request) => {
177
+ const blocks = await visibleBlocks(request.user);
178
+ if (request.query?.usage !== '1') return blocks;
179
+ const usage = await usageFor(blocks.map(b => b.name));
180
+ return blocks.map(b => ({...b, usage: usage[b.name]}));
181
+ });
182
+
183
+ // The sidebar badge. Registered before /blocks/:name; `_` is not a valid block name.
184
+ // Usage reads every page's source, and the badge is asked for every minute from
185
+ // every admin tab: it is kept a minute (the list's ?usage=1 always reads afresh).
186
+ let usageCache = {at: 0, key: '', value: null};
187
+ fastify.get('/blocks/_count', canRead, async (request) => {
188
+ const blocks = await visibleBlocks(request.user);
189
+ const names = blocks.map(b => b.name);
190
+ const key = names.join('\n');
191
+ if (!usageCache.value || usageCache.key !== key || Date.now() - usageCache.at > 60_000) {
192
+ usageCache = {at: Date.now(), key, value: await usageFor(names)};
32
193
  }
33
- return filtered;
194
+ const usage = usageCache.value;
195
+ return blocksBadge(blocks.map(b => ({...b, usage: usage[b.name]})));
34
196
  });
35
197
 
36
198
  // Get single block