domma-cms 0.73.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 (64) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/actions-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  6. package/admin/js/lib/blocks-arrange.js +1 -0
  7. package/admin/js/lib/collections-arrange.js +1 -0
  8. package/admin/js/lib/components-arrange.js +1 -0
  9. package/admin/js/lib/context-menus-arrange.js +1 -0
  10. package/admin/js/lib/forms-arrange.js +1 -0
  11. package/admin/js/lib/media-arrange.js +1 -0
  12. package/admin/js/lib/menus-arrange.js +1 -0
  13. package/admin/js/lib/pages-arrange.js +1 -0
  14. package/admin/js/lib/projects-arrange.js +1 -0
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/theme-settings-arrange.js +1 -0
  17. package/admin/js/lib/views-arrange.js +1 -0
  18. package/admin/js/templates/actions-list.html +73 -14
  19. package/admin/js/templates/api-endpoints.html +63 -10
  20. package/admin/js/templates/blocks.html +76 -15
  21. package/admin/js/templates/collections.html +108 -52
  22. package/admin/js/templates/components.html +82 -15
  23. package/admin/js/templates/context-menus.html +55 -13
  24. package/admin/js/templates/forms.html +67 -14
  25. package/admin/js/templates/media.html +99 -13
  26. package/admin/js/templates/menus.html +57 -11
  27. package/admin/js/templates/pages.html +78 -23
  28. package/admin/js/templates/projects.html +69 -10
  29. package/admin/js/templates/theme.html +164 -132
  30. package/admin/js/templates/views-list.html +63 -9
  31. package/admin/js/views/actions-list.js +37 -1
  32. package/admin/js/views/api-endpoints.js +11 -7
  33. package/admin/js/views/blocks.js +34 -5
  34. package/admin/js/views/collections.js +36 -1
  35. package/admin/js/views/components.js +27 -5
  36. package/admin/js/views/context-menus.js +10 -5
  37. package/admin/js/views/forms.js +57 -1
  38. package/admin/js/views/index.js +1 -1
  39. package/admin/js/views/media.js +40 -1
  40. package/admin/js/views/menus.js +11 -5
  41. package/admin/js/views/pages.js +28 -8
  42. package/admin/js/views/projects.js +58 -3
  43. package/admin/js/views/theme.js +55 -1
  44. package/admin/js/views/views-list.js +11 -1
  45. package/package.json +1 -1
  46. package/server/routes/api/actions.js +64 -2
  47. package/server/routes/api/api-endpoints.js +41 -0
  48. package/server/routes/api/blocks.js +170 -8
  49. package/server/routes/api/collections.js +53 -1
  50. package/server/routes/api/components.js +46 -0
  51. package/server/routes/api/context-menus.js +8 -0
  52. package/server/routes/api/forms.js +123 -10
  53. package/server/routes/api/media.js +56 -1
  54. package/server/routes/api/menus.js +53 -6
  55. package/server/routes/api/pages.js +46 -0
  56. package/server/routes/api/projects.js +16 -2
  57. package/server/routes/api/theme.js +55 -0
  58. package/server/routes/api/views.js +27 -3
  59. package/server/server.js +19 -0
  60. package/server/services/contextMenus.js +41 -0
  61. package/server/services/projects.js +86 -26
  62. package/server/services/views.js +38 -0
  63. package/admin/js/templates/project-editor.html +0 -45
  64. package/admin/js/views/project-editor.js +0 -1
@@ -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,3 +1,58 @@
1
- import{api as l}from"../api.js";import{keepTableIconsAlive as m}from"../lib/table-icons.js";function s(n){return String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(n){const d=E.loader(n.get(0),{type:"dots"});let i,r;try{i=await l.projects.list().catch(()=>[]),r=await Promise.all(i.map(t=>l.projects.artefacts(t.slug).then(e=>Object.values(e).reduce((a,c)=>a+c.length,0)).catch(()=>0)))}finally{d.destroy()}const b=i.map((t,e)=>({...t,artefactCount:r[e]})),o=t=>t.enabled===!1;T.create("#projects-table",{pageSize:25,data:b,emptyMessage:'No projects yet. Click "New project" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:(t,e)=>o(e)?`<span class="text-muted">${s(t)}</span>`:s(t)},{key:"name",title:"Name",sortable:!0,render:(t,e)=>{const a=o(e)?`<span class="text-muted">${s(t)}</span>`:s(t),c=e.protected?' <span class="badge badge-secondary" data-tooltip="Built-in project - cannot be deleted">System</span>':"",p=o(e)?' <span class="badge badge-warning" data-tooltip="Disabled - public pages and external API are off">Disabled</span>':"";return a+c+p}},{key:"icon",title:"Icon",render:(t,e)=>t?`<span class="${o(e)?"text-muted":""}" data-icon="${s(t)}"></span>`:""},{key:"rootUrl",title:"Root URL",render:t=>t?`<code>${s(t)}</code>`:'<span class="text-muted">-</span>'},{key:"artefactCount",title:"Artefacts",render:t=>`<span class="badge badge-info">${t??0}</span>`},{key:"_actions",title:"Actions",render:(t,e)=>{const a=encodeURIComponent(e.slug),c=o(e)?`<span class="btn btn-sm btn-ghost disabled" aria-disabled="true" style="opacity:.4;pointer-events:none;"><span data-icon="eye" data-icon-size="14"></span></span>
2
- <span class="btn btn-sm btn-ghost disabled" aria-disabled="true" style="opacity:.4;pointer-events:none;"><span data-icon="settings" data-icon-size="14"></span></span>`:`<a class="btn btn-sm btn-ghost" href="#/projects/${a}" data-tooltip="Overview"><span data-icon="eye" data-icon-size="14"></span></a>
3
- <a class="btn btn-sm btn-ghost" href="#/projects/${a}/settings" data-tooltip="Settings"><span data-icon="settings" data-icon-size="14"></span></a>`,g=e.slug==="core"?"":o(e)?`<button class="btn btn-sm btn-ghost btn-toggle-project" data-slug="${s(e.slug)}" data-enable="1" data-tooltip="Enable"><span class="text-success" data-icon="play" data-icon-size="14"></span></button>`:`<button class="btn btn-sm btn-ghost btn-toggle-project" data-slug="${s(e.slug)}" data-enable="0" data-tooltip="Disable"><span class="text-warning" data-icon="pause" data-icon-size="14"></span></button>`,u=e.protected?"":`<button class="btn btn-sm btn-ghost btn-delete-project" data-slug="${s(e.slug)}" data-tooltip="Delete (refused while tagged)"><span data-icon="trash" data-icon-size="14"></span></button>`;return`<span style="display:inline-flex;gap:0.25rem;">${c}${g}${u}</span>`}}]}),Domma.icons.scan(n.get(0)),m(n.get(0)),document.querySelectorAll("#projects-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),n.off("click",".btn-toggle-project").on("click",".btn-toggle-project",async function(){const t=$(this).data("slug"),e=String($(this).data("enable"))==="1";if(!(!e&&!await E.confirm(`Disable project "${t}"? Its public pages will 404 and its external API will stop responding. Admin editing is unaffected.`)))try{await l.projects.update(t,{enabled:e}),E.toast(e?"Project enabled.":"Project disabled.",{type:"success"}),location.reload()}catch(a){E.toast(`Failed: ${a.message||a}`,{type:"error"})}}),n.off("click",".btn-delete-project").on("click",".btn-delete-project",async function(){const t=$(this).data("slug");if(await E.confirm(`Delete project "${t}"? This cannot be undone.`))try{await l.projects.remove(t),E.toast("Project deleted.",{type:"success"}),location.reload()}catch(e){E.toast(`Delete refused: ${e.message||e}`,{type:"error"})}})}};
1
+ import{api as y}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as ce}from"../lib/help-popover.js";import{onceClosed as Le,openMenuAt as de,sidebarCount as Oe,viewLifetime as $e}from"../lib/tool-kit.js";import{makeIconInput as Te}from"../lib/shortcode-modal.js";import{CORE as ue,FILTERS as Ie,arrangeProjects as Ue,checkProject as Ae,projectRow as De,slugify as Re}from"../lib/projects-arrange.js";const pe=/^#\/projects(\/new|\/edit\/[^/?#]+)?\/?(\?.*)?$/,Ne="#/projects",T="projects.draft",_="projects.filter",Pe=`
2
+ <div class="pjx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="pjx-icon" aria-hidden="true"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
4
+ <span class="pjx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
5
+ <span class="pjx-chips">
6
+ <span class="pjx-chip" data-bind-hidden="!system" title="Built in - cannot be deleted">Built in</span>
7
+ <span class="pjx-chip is-warn" data-bind-hidden="!off" title="Public pages and external API are off">Disabled</span>
8
+ </span>
9
+ <span class="pjx-count" title="Artefacts filed under it">{{total}}</span>
10
+ <button type="button" class="pjx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,C=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function qe(m){const c=document.createElement("template");return c.innerHTML=m.trim(),c.content.firstElementChild}const l=$e(pe);function U(){Oe(Ne);try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}}export const projectsView={templateUrl:"/admin/js/templates/projects.html",async onMount(m){l.reset();const c=m.get(0).querySelector(".pjx")||m.get(0),L=()=>pe.test(location.hash)&&c.isConnected,f=M.observable([]),z=M.observable(!0),A=M.observable("");let H="all";try{H=Ie.includes(S.get(_))?S.get(_):"all"}catch{}const g=M.observable(H),Y=M.observable("");l.add(M.effect(()=>{const e=g.value;try{S.set(_,e)}catch{}}));const D=M.observable([]);l.add(M.effect(()=>{D.value=Ue(f.value,{filter:g.value,query:Y.value}).map(De)}));const me=M.computed(()=>{const e=f.value,t=e.filter(a=>a.enabled===!1).length;return{all:e.length,enabled:e.length-t,disabled:t}}),fe=M.computed(()=>f.value.length?"No projects match.":"No projects yet - New project in the banner makes one."),j=e=>f.peek().find(t=>t.slug===e);async function w(){try{const e=await y.projects.listWithCounts();if(!L())return;f.value=Array.isArray(e)?e:[],A.value=""}catch(e){A.value=`Could not load the projects: ${e.message||e}`}finally{z.value=!1}}async function V(e,t){if(!(!t&&!await E.confirm(`Disable "${C(e.name||e.slug)}"? Its public pages will 404 and its external API will stop answering. Editing it in the admin carries on.`)))try{await y.projects.update(e.slug,{enabled:t}),E.toast(`"${e.name||e.slug}" ${t?"enabled":"disabled"}.`,{type:"success"}),U(),await w()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function be(e){const t=Object.values(e.counts||{}).reduce((a,s)=>a+s,0);if(await E.confirm(`Untag ${t?`all ${t} artefacts`:"everything"} filed under "${C(e.name||e.slug)}"? They stay where they are and belong to Core from then on. The project itself is kept.`))try{const a=await y.projects.untagAll(e.slug),s=Object.values(a?.untagged||{}).reduce((k,v)=>k+(Number(v)||0),0);E.toast(`Untagged ${s} artefact${s===1?"":"s"}.`,{type:"success"}),U(),await w()}catch(a){E.toast(`Untag failed: ${a.message||a}`,{type:"error"})}}async function ge(e){if(await E.confirm(`Delete the project "${C(e.name||e.slug)}"? This cannot be undone.`))try{await y.projects.remove(e.slug),E.toast(`Deleted "${e.name||e.slug}".`,{type:"success"}),U(),await w()}catch(t){E.toast(`Not deleted: ${t.message||t} - untag them first (\u22EE \u2192 Untag all).`,{type:"error",duration:7e3})}}const W=e=>{location.hash=`#/projects/${encodeURIComponent(e)}`};let b=null;const R=new Set;let J=!1;function x(e=null,t=null){const a=e?j(e):null;if(e&&!a){E.toast(`There is no project "${e}".`,{type:"warning"});return}const s=!a,k=a?.slug===ue,v=a?.protected===!0,O={slug:a?.slug??"",name:a?.name??"",description:a?.description??"",icon:a?.icon??"folder",rootUrl:a?.rootUrl??"",sortOrder:String(a?.sortOrder??0),enabled:a?a.enabled!==!1:!0,...t||{}},i=Object.fromEntries(Object.entries(O).map(([n,p])=>[n,M.observable(p)]));let ee=!!t?.slug||!s;const te=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([p,F])=>[p,typeof F=="boolean"?F:String(F??"")]))),ae=()=>Object.fromEntries(Object.keys(i).map(n=>[n,i[n].peek()])),je=te(O),ne=()=>te(ae())!==je||!!t,$=M.observable(!1),P=M.computed(()=>Ae(Object.fromEntries(Object.keys(i).map(n=>[n,i[n].value])),{isNew:s,isProtected:v,slug:a?.slug,taken:f.value.map(n=>n.slug)})),q=n=>M.computed(()=>P.value.errors[n]||""),se={...i,saving:$,isNew:s,notNew:!s,isProtected:v,canToggle:!k,slugErr:q("slug"),nameErr:q("name"),rootErr:q("rootUrl"),cannot:M.computed(()=>$.value||Object.keys(P.value.errors).length>0),saveText:s?"Create project":"Save"},we=s?M.effect(()=>{const n=i.name.value;ee||(i.slug.value=Re(n))}):()=>{},o=E.slideover({title:s?"New project":`Edit ${C(a.name||a.slug)}`,size:"md",position:"right"}),u=qe(`
12
+ <div class="pjx-form">
13
+ <div class="pjx-grid">
14
+ <div>
15
+ <label class="form-label" for="pjx-f-name">Name <span class="text-danger">*</span></label>
16
+ <input id="pjx-f-name" type="text" class="form-input" maxlength="80" placeholder="Members area" data-model="name.value">
17
+ <small class="pjx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
18
+ </div>
19
+ <div>
20
+ <label class="form-label" for="pjx-f-slug">Slug
21
+ <span data-help="The project's id - in its admin addresses and in every artefact filed under it. Made from the name as you type; it cannot be changed once the project exists."></span></label>
22
+ <input id="pjx-f-slug" type="text" class="form-input font-mono" maxlength="64" data-model="slug.value" data-bind-disabled="notNew">
23
+ <small class="pjx-err" data-bind-hidden="!slugErr.value" data-bind-text="slugErr.value"></small>
24
+ </div>
25
+ <div class="pjx-wide">
26
+ <label class="form-label" for="pjx-f-desc">Description</label>
27
+ <textarea id="pjx-f-desc" class="form-input" rows="2" maxlength="300" data-model="description.value"></textarea>
28
+ </div>
29
+ <div>
30
+ <label class="form-label" for="pjx-f-icon">Icon</label>
31
+ <div class="pjx-icon-mount"></div>
32
+ </div>
33
+ <div>
34
+ <label class="form-label" for="pjx-f-root">Root URL
35
+ <span data-help-title="Root URL" data-help="Pages under this address are filed under the project without being tagged one by one - with /members, a page at /members/join belongs here. Leave it empty to file pages by hand. The core project owns /."></span></label>
36
+ <input id="pjx-f-root" type="text" class="form-input font-mono" placeholder="/members" data-model="rootUrl.value" data-bind-disabled="isProtected">
37
+ <small class="pjx-err" data-bind-hidden="!rootErr.value" data-bind-text="rootErr.value"></small>
38
+ <small class="pjx-hint" data-bind-hidden="!isProtected">Locked - a built-in project.</small>
39
+ </div>
40
+ <div>
41
+ <label class="form-label" for="pjx-f-sort">Sort order
42
+ <span data-help="Lower numbers come first, here and in the sidebar."></span></label>
43
+ <input id="pjx-f-sort" type="number" class="form-input" data-model="sortOrder.value">
44
+ </div>
45
+ <div class="pjx-setting" data-bind-hidden="!canToggle">
46
+ <label class="form-label" for="pjx-f-enabled">Enabled
47
+ <span data-help="Off is a hard switch: the project's public pages 404 and its external API stops answering. Editing it in the admin carries on."></span></label>
48
+ <input id="pjx-f-enabled" type="checkbox" class="form-switch" data-model="enabled.value">
49
+ </div>
50
+ </div>
51
+ <div class="pjx-actions">
52
+ <small class="pjx-hint">Ctrl+Enter saves.</small>
53
+ <span class="pjx-spacer"></span>
54
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
55
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
56
+ <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
57
+ </div>
58
+ </div>`),h=Te("folder, users, box\u2026",i.icon.peek());h.input.id="pjx-f-icon",h.input.classList.add("form-input");const oe=()=>{i.icon.value=h.input.value};h.input.addEventListener("input",oe),u.querySelector(".pjx-icon-mount").appendChild(h.el);async function le(){if(se.cannot.peek())return;$.value=!0;const{payload:n}=P.peek();try{s?await y.projects.create(n):await y.projects.update(a.slug,n),E.toast(s?`Created "${n.name}".`:"Saved.",{type:"success"}),B=!0,o.close(),U(),L()&&await w()}catch(p){E.toast(`Not saved: ${p.message||p}`,{type:"error"})}finally{$.value=!1}}o.element.appendChild(u);const xe=ce(u),Ee=M.applyBindings(se,u,{methods:{save:le,cancel:()=>o.close()}}),re=u.querySelector("#pjx-f-slug"),ke=()=>{ee=!!re.value};re.addEventListener("input",ke);const Se=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),le())};u.addEventListener("keydown",Se);const ie={slug:a?.slug??null,dirty:ne,values:ae};b=ie,R.add(o),Le(o,()=>{we(),Ee.dispose(),xe(),h.input.removeEventListener("input",oe),R.delete(o),b===ie&&(b=null),!J&&/^#\/projects\/(new|edit\/)/.test(location.hash)&&(history.replaceState(null,"","#/projects"),document.title="Projects - Domma CMS")});const Me=o.close.bind(o);let B=!1,K=!1;o.close=async n=>{if(!B&&n!==!0&&ne()){if(K)return;K=!0;const p=await E.confirm("Discard the changes you have not saved?");if(K=!1,!p)return}return B=!0,Me()},I.scan(u),o.open(),u.querySelector("#pjx-f-name")?.focus()}function ve(e){const t=j(e);if(!t)return[];const a=t.enabled===!1,s=t.slug===ue,k=Object.values(t.counts||{}).reduce((v,O)=>v+O,0);return[{header:t.name||t.slug},{label:"Open overview",icon:"eye",disabled:a,action:()=>W(t.slug)},{label:"Edit\u2026",icon:"edit",action:()=>x(t.slug)},{label:"Settings page",icon:"settings",disabled:a,action:()=>{location.hash=`#/projects/${encodeURIComponent(t.slug)}/settings`}},...s?[]:[{type:"divider"},a?{label:"Enable",icon:"play",action:()=>V(t,!0)}:{label:"Disable\u2026",icon:"pause",action:()=>V(t,!1)}],...t.protected?[]:[{type:"divider"},{label:"Untag all\u2026",icon:"tag",disabled:!k,action:()=>be(t)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ge(t)}]]}const d=c.querySelector(".pjx-list");d.innerHTML=Pe;const he=M.applyBindings({projects:f,loading:z,loadError:A,filter:g,query:Y,rows:D,counts:me,emptyText:fe},c,{methods:{showAll:()=>{g.value="all"},showEnabled:()=>{g.value="enabled"},showDisabled:()=>{g.value="disabled"}}});l.add(()=>he.dispose()),l.add(ce(c,{position:"bottom"})),l.add(M.effect(()=>{D.value,requestAnimationFrame(()=>{d.isConnected&&I.scan(d)})}));const ye=E.contextMenu(d,{cursor:"glyph",match:".pjx-row",inherit:!1,density:"compact",items:e=>ve(e.dataset.slug)});l.add(()=>ye?.destroy?.());const G=e=>{const t=j(e);t&&(t.enabled===!1?x(e):W(e))},Q=e=>{const t=e.target.closest(".pjx-row");if(!t)return;const a=e.target.closest(".pjx-kebab");if(a){de(a);return}G(t.dataset.slug)},X=e=>{const t=e.target.closest(".pjx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),de(t)):e.key==="Enter"&&e.target===t&&G(t.dataset.slug))};d.addEventListener("click",Q),d.addEventListener("keydown",X),l.add(()=>{d.removeEventListener("click",Q),d.removeEventListener("keydown",X)}),Ce(m,{id:"projects-new",icon:"plus",title:"New project",onClick:()=>x()});const Z=e=>{b?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(b?.dirty())try{S.set(T,{slug:b.slug,values:b.values(),at:new Date().toISOString()})}catch{}J=!0,[...R].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(c),await w(),!L())return;const N=location.hash.match(/^#\/projects\/(new|edit\/([^/?#]+))/);let r=null;try{r=S.get(T)}catch{}if(r?.values){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=r.slug?`your changes to "${C(j(r.slug)?.name||r.slug)}"`:"the new project you started";if(await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&L()){x(r.slug&&j(r.slug)?r.slug:null,r.values);return}}N&&x(N[2]?decodeURIComponent(N[2]):null)}};
@@ -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.73.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'});