domma-cms 0.18.0 → 0.21.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 (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +98 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +19 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +686 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +422 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +237 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
package/public/js/site.js CHANGED
@@ -1 +1 @@
1
- $(()=>{const w=window.__CMS_NAV__||{},d=window.__CMS_SITE__||{};if(d.autoTheme?.enabled){let e=function(s){const r=(s||"07:00").split(":");return+r[0]*60+(+r[1]||0)},o=function(){const s=new Date,r=s.getHours()*60+s.getMinutes();return r>=e(t.dayStart)&&r<e(t.nightStart)?t.dayTheme:t.nightTheme};var i=e,m=o;const t=d.autoTheme;Domma.theme.set(o()),setInterval(()=>Domma.theme.set(o()),6e4)}if($("#site-navbar").length&&w.brand){const t={...w.brand},e=t.size&&t.size!=="md"?` navbar-brand-${t.size}`:"";if(t.logo||t.icon||t.tagline){let o="";t.logo?o+=`<img src="${t.logo}" class="navbar-brand-logo" alt="${t.text||""}">`:t.icon&&(o+=`<span data-icon="${t.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),t.text&&(o+=`<span class="navbar-brand-text${e}">${t.text}</span>`),t.tagline&&(o+=`<small class="navbar-brand-tagline">${t.tagline}</small>`),t.html=o}else e&&t.text&&(t.html=`<span class="navbar-brand-text${e}">${t.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:t,items:w.items||[],variant:w.variant||"dark",position:w.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const b=$("#site-footer");if(b.length){const t=d.social||{},e={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let o='<div class="footer-inner container">';if(d.footer){const u=d.footer;o+=`<p>${u.copyright||""}</p>`,u.links?.length&&(o+='<nav class="footer-links">',u.links.forEach(n=>{o+=`<a href="${n.url}">${n.text}</a>`}),o+="</nav>");const f=Object.keys(e).filter(n=>t[n]);f.length&&(o+='<div class="footer-social">',f.forEach(n=>{const{label:a,svg:c}=e[n];o+=`<a href="${t[n]}" target="_blank" rel="noopener noreferrer" aria-label="${a}" class="footer-social-link">${c}</a>`}),o+="</div>")}o+="</div>",b.html(o);const s=S.get("reduced_motion"),r=s!==null?!!s:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),g=b.get(0).querySelector(".footer-inner");if(g){const u=document.createElement("input");u.type="checkbox",u.className="form-switch-input",u.id="dm-motion-switch",u.checked=r,u.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const f=document.createElement("span");f.className="form-switch-label",f.textContent="Reduce motion";const n=document.createElement("label");n.className="form-switch footer-motion-switch",n.title="Reduce motion",n.appendChild(u),n.appendChild(f),g.appendChild(n)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan();const h=$(".page-body");if(h.length){h.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),h.find(".tabs").each(function(){Domma.elements.tabs(this)}),h.find(".carousel").each(function(){const t={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"};if(this.dataset.animationDuration){const e=parseInt(this.dataset.animationDuration,10);Number.isFinite(e)&&(t.animationDuration=e)}this.dataset.animationEasing&&(t.animationEasing=this.dataset.animationEasing),Domma.elements.carousel(this,t)}),h.find(".dm-countdown").each(function(){const t={autoStart:!0};this.dataset.to&&(t.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(t.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(t.format=this.dataset.format),Domma.elements.timer(this,t)}),h.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),h.find(".dm-progression").each(function(){Domma.elements.progression(this,{layout:this.dataset.layout||"vertical",theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0})});try{Domma.effects.reveal(".page-body .hero",{animation:"slide-up",duration:480,threshold:.06,stagger:60,once:!1})}catch{}document.querySelectorAll(".page-body .row[data-reveal]").forEach(t=>{const e=t.dataset.revealMode||"stagger",o=t.dataset.revealAnimation||"slide-up",s=parseInt(t.dataset.revealDuration,10)||400,r=parseInt(t.dataset.revealStagger,10)||60,g=parseInt(t.dataset.revealDelay,10)||0,u=t.dataset.revealDirection==="rtl",f=Array.from(t.children),n={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};f.forEach((a,c)=>{a.style.opacity="0",a.style.transform=n[o]||"",a.style.transition=`opacity ${s}ms ease, transform ${s}ms ease`;const l=u?f.length-1-c:c;a.style.transitionDelay=e==="stagger"?`${g+l*r}ms`:`${g}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=new IntersectionObserver(c=>{c.forEach(l=>{l.isIntersecting&&(l.target.offsetWidth,l.target.style.opacity="1",l.target.style.transform="none",a.unobserve(l.target))})},{threshold:.1});f.forEach(c=>a.observe(c))}))}),h.find(".card[data-collapsible]").each(function(){const t=this.querySelector(".card-header");t&&t.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),h.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const t=this.dataset.soTarget,e=document.getElementById(t);if(!e)return;const o=E.slideover({title:e.dataset.soTitle||"",size:e.dataset.soSize||"md",position:e.dataset.soPosition||"right"});e.style.display="",o.setContent(e),o.open()})})}if(typeof $.setup=="function"){const t=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(e=>{try{const o=atob(e.dataset.config),s=JSON.parse(o);Object.assign(t,s)}catch{}}),Object.keys(t).length>0){const e={};for(const[o,s]of Object.entries(t)){const r=s?.events?.click,{confirm:g,toast:u,alert:f,prompt:n,...a}=r||{};g||u||f||n?($(o).on("click",async function(l){if(l.preventDefault(),g&&!await E.confirm(g))return;let p=null;if(!(n&&(p=await E.prompt(n,{inputPlaceholder:a.promptPlaceholder||"",inputValue:a.promptDefault||""}),p===null))){if(a.target){const y=$(a.target);a.toggleClass&&y.toggleClass(a.toggleClass),a.addClass&&y.addClass(a.addClass),a.removeClass&&y.removeClass(a.removeClass),p!==null&&(a.setText&&y.text(p),a.setVal&&y.val(p),a.setAttr&&y.attr(a.setAttr,p))}a.href&&(window.location.href=a.href),u&&E.toast(u,{type:a.toastType||"success"}),f&&E.alert(f)}}),Object.keys(a).length&&(e[o]={...s,events:{...s.events,click:a}})):e[o]=s}$.setup(e)}}h.length&&wireCTAButtons(h.get(0))});function wireCTAButtons(w){w.querySelectorAll(".dm-cta-trigger").forEach(d=>{d.addEventListener("click",async()=>{const C=d.dataset.action,b=d.dataset.entry,v=d.dataset.confirm;let h=S.get("auth_token");if(!h){E.toast("Please log in to perform this action.",{type:"warning"});return}if(v&&!await E.confirm(v))return;const i=Array.from(d.childNodes).map(t=>t.cloneNode(!0));d.disabled=!0,d.textContent="Running\u2026";const m=t=>fetch(`/api/actions/${C}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({entryId:b})});try{let t=await m(h);if(t.status===401){const o=S.get("auth_refresh_token");if(o){const s=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:o})});if(s.ok){const{token:r}=await s.json();S.set("auth_token",r),h=r,t=await m(h)}}}const e=await t.json().catch(()=>({}));if(!t.ok)throw new Error(e.error||e.message||`Error ${t.status}`);E.toast(e.message||"Action completed.",{type:"success"})}catch(t){E.toast(t.message||"Action failed.",{type:"error"})}finally{d.disabled=!1,d.textContent="",i.forEach(t=>d.appendChild(t)),Domma.icons.scan(d)}})})}(function(){const d=document.querySelectorAll("[data-collection-table]");!d.length||typeof T>"u"||!T.create||d.forEach(C=>{let b;try{b=JSON.parse(atob(C.dataset.payload))}catch{return}const{columns:v,rows:h,search:i,sortable:m,exportable:t,pageSize:e,empty:o,ctaConfig:s}=b;if(!v?.length)return;if(s){const u=v.findIndex(f=>f.key==="_cta");u!==-1&&(v[u]={key:"_cta",title:"",render:(f,n)=>{const a=document.createElement("button");if(a.className=`btn btn-${s.style||"primary"} dm-cta-trigger`,a.dataset.action=s.action||"",a.dataset.entry=n._entryId||"",s.confirm&&(a.dataset.confirm=s.confirm),s.icon){const c=document.createElement("span");c.dataset.icon=s.icon,a.appendChild(c),a.appendChild(document.createTextNode(" "))}return a.appendChild(document.createTextNode(s.label||"Run")),a}})}const r="col-table-"+Math.random().toString(36).slice(2,7),g=document.createElement("div");g.id=r,C.replaceChildren(g),T.create("#"+r,{data:h,columns:v,search:i,sortable:m,exportable:t,pageSize:e,emptyMessage:o}),s&&wireCTAButtons(g)})})(),(function(){const d=document.querySelectorAll("[data-form-inline]");if(!d.length||typeof F>"u")return;function C(i,m){const t={};return(i||[]).forEach(e=>{if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const o=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,s={...e.formConfig||{}};s.span==="full"&&m&&(s.span=m);const r={type:o,label:e.label,required:e.required,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{hint:e.helper},...s}};e.type==="chooser"&&(e.variant&&(r.variant=e.variant),e.multiple&&(r.multiple=!0),e.density&&(r.density=e.density),e.columns&&(r.columns=e.columns),e.accent&&(r.accent=e.accent),e.accentStyle&&(r.accentStyle=e.accentStyle),e.glow&&(r.glow=!0),e.glowColour&&(r.glowColour=e.glowColour),e.shadow&&(r.shadow=e.shadow),e.shadowColour&&(r.shadowColour=e.shadowColour)),t[e.name]=r}),t}function b(i){const m={};return(i||[]).forEach(t=>{if(!(!t.name||t.type==="page-break"||t.type==="spacer")&&(t.type==="select"||t.type==="multiselect")&&t.required){const e=(t.options||[])[0];e&&(m[t.name]=typeof e=="object"?e.value:e)}}),m}function v(i,m){(m||[]).forEach(t=>{if(t.type!=="date"||!t.name)return;const e=i.querySelector(`[name="${t.name}"]`);e&&e.type!=="date"&&(e.type="date")})}function h(i,m,t){let e=i.querySelector(".cms-form-message");e||(e=document.createElement("p"),e.className="cms-form-message",i.appendChild(e)),e.textContent=m,e.style.cssText=t?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}d.forEach(i=>{let m;try{m=JSON.parse(atob(i.dataset.formInline))}catch{return}const t=m.fields||[],e=m.settings||{},o=e.columns||1,s=e.layout||"stacked",r=t.some(n=>n.type==="page-break"),g=async n=>{try{const a=i.querySelector('[name="website"]'),c=i.querySelector('[name="_t"]'),l=Object.assign({},n);a!==null&&(l._hp=a.value),c!==null&&(l._t=c.value);const p=await H.post(`/api/forms/submit/${m.slug}`,l);if(p?.redirect){window.location.href=p.redirect;return}for(;i.firstChild;)i.removeChild(i.firstChild);h(i,p?.message||e.successMessage||"Thank you for your submission.",!1)}catch(a){throw h(i,a.message||"Submission failed. Please try again.",!0),a}};function u(n){const a=n.querySelector("form");if(!a)return;const c=document.createElement("div");c.className="fb-form-honeypot",c.setAttribute("aria-hidden","true");const l=document.createElement("input");l.name="website",l.type="text",l.tabIndex=-1,l.autocomplete="url",l.placeholder="https://",c.appendChild(l);const p=document.createElement("input");p.name="_t",p.type="hidden",p.value=Date.now(),c.appendChild(p),a.appendChild(c)}function f(){if(!window.FormLogicEngine||!t.some(a=>a.logic))return;const n=new window.FormLogicEngine.FormLogicRuntime(m,i);if(n.init(),i._formLogicRuntime=n,i.parentNode&&typeof MutationObserver<"u"){const a=new MutationObserver(function(c){for(const l of c)for(const p of l.removedNodes)if(p===i||p.nodeType===1&&p.contains&&p.contains(i)){n.destroy(),a.disconnect();return}});a.observe(i.parentNode,{childList:!0,subtree:!1})}}if(r&&F.wizard){const n=[];let a=[],c=m.title||"Step 1",l="";t.forEach(y=>{y.type==="page-break"?(n.push({title:c,description:l,fields:C(a,o)}),a=[],c=y.label||`Step ${n.length+1}`,l=y.description||""):y.type!=="spacer"&&a.push(y)}),n.push({title:c,description:l,fields:C(a,o)});const p=F.wizard(i,{schema:{steps:n},onSubmit:g});Promise.resolve(p).then(function(){v(i,t),e.honeypot!==!1&&u(i),f()})}else if(F.render){const n=F.render(i,C(t,o),b(t),{submitText:e.submitText||"Submit",layout:s,columns:o,onSubmit:g});Promise.resolve(n).then(function(){if(s==="grid"&&e.submitSpan==="full"){const a=i.querySelector(".form-buttons");a&&a.classList.add("col-span-full")}v(i,t),e.honeypot!==!1&&u(i),f()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
1
+ $(()=>{const x=window.__CMS_NAV__||{},p=window.__CMS_SITE__||{};if(p.autoTheme?.enabled){let n=function(h){const g=(h||"07:00").split(":");return+g[0]*60+(+g[1]||0)},r=function(){const h=new Date,g=h.getHours()*60+h.getMinutes();return g>=n(e.dayStart)&&g<n(e.nightStart)?e.dayTheme:e.nightTheme};var s=n,d=r;const e=p.autoTheme;Domma.theme.set(r()),setInterval(()=>Domma.theme.set(r()),6e4)}if($("#site-navbar").length&&x.brand){const e={...x.brand},n=e.size&&e.size!=="md"?` navbar-brand-${e.size}`:"";if(e.logo||e.icon||e.tagline){let r="";e.logo?r+=`<img src="${e.logo}" class="navbar-brand-logo" alt="${e.text||""}">`:e.icon&&(r+=`<span data-icon="${e.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),e.text&&(r+=`<span class="navbar-brand-text${n}">${e.text}</span>`),e.tagline&&(r+=`<small class="navbar-brand-tagline">${e.tagline}</small>`),e.html=r}else n&&e.text&&(e.html=`<span class="navbar-brand-text${n}">${e.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:e,items:x.items||[],variant:x.variant||"dark",position:x.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const b=$("#site-footer");if(b.length){let g=function(c){return String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")},m=function(c){return g(c).replace(/'/g,"&#39;")},i=function(c){const y=m(c.url||"#"),v=g(c.text||""),_=Array.isArray(c.items)&&c.items.length?`<ul>${c.items.map(i).join("")}</ul>`:"";return`<li><a href="${y}">${v}</a>${_}</li>`};var a=g,t=m,w=i;const e=p.social||{},n={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let r='<div class="footer-inner container">';const h=window.__CMS_FOOTER__||{primary:[],legal:[],copyright:""};if(h.copyright&&(r+=`<p>${g(h.copyright)}</p>`),h.primary.length&&(r+=`<nav class="footer-nav footer-nav--primary"><ul>${h.primary.map(i).join("")}</ul></nav>`),h.legal.length&&(r+=`<nav class="footer-nav footer-nav--legal"><ul>${h.legal.map(i).join("")}</ul></nav>`),p.footer){const c=Object.keys(n).filter(y=>e[y]);c.length&&(r+='<div class="footer-social">',c.forEach(y=>{const{label:v,svg:_}=n[y];r+=`<a href="${e[y]}" target="_blank" rel="noopener noreferrer" aria-label="${v}" class="footer-social-link">${_}</a>`}),r+="</div>")}r+="</div>",b.html(r);const u=S.get("reduced_motion"),f=u!==null?!!u:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),o=b.get(0).querySelector(".footer-inner");if(o){const c=document.createElement("input");c.type="checkbox",c.className="form-switch-input",c.id="dm-motion-switch",c.checked=f,c.addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()});const y=document.createElement("span");y.className="form-switch-label",y.textContent="Reduce motion";const v=document.createElement("label");v.className="form-switch footer-motion-switch",v.title="Reduce motion",v.appendChild(c),v.appendChild(y),o.appendChild(v)}}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan();const l=$(".page-body");if(l.length){l.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),l.find(".tabs").each(function(){Domma.elements.tabs(this)}),l.find(".carousel").each(function(){const e={autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"};if(this.dataset.animationDuration){const n=parseInt(this.dataset.animationDuration,10);Number.isFinite(n)&&(e.animationDuration=n)}this.dataset.animationEasing&&(e.animationEasing=this.dataset.animationEasing),Domma.elements.carousel(this,e)}),l.find(".dm-countdown").each(function(){const e={autoStart:!0};this.dataset.to&&(e.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(e.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(e.format=this.dataset.format),Domma.elements.timer(this,e)}),l.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),l.find(".dm-progression").each(function(){Domma.elements.progression(this,{layout:this.dataset.layout||"vertical",theme:this.dataset.theme||"minimal",mode:this.dataset.mode||"timeline",statusIcons:!0})});try{Domma.effects.reveal(".page-body .hero",{animation:"slide-up",duration:480,threshold:.06,stagger:60,once:!1})}catch{}document.querySelectorAll(".page-body .row[data-reveal]").forEach(e=>{const n=e.dataset.revealMode||"stagger",r=e.dataset.revealAnimation||"slide-up",h=parseInt(e.dataset.revealDuration,10)||400,g=parseInt(e.dataset.revealStagger,10)||60,m=parseInt(e.dataset.revealDelay,10)||0,i=e.dataset.revealDirection==="rtl",u=Array.from(e.children),f={"slide-up":"translateY(30px)","slide-down":"translateY(-30px)","slide-left":"translateX(30px)","slide-right":"translateX(-30px)",zoom:"scale(0.85)",flip:"perspective(600px) rotateX(15deg)"};u.forEach((o,c)=>{o.style.opacity="0",o.style.transform=f[r]||"",o.style.transition=`opacity ${h}ms ease, transform ${h}ms ease`;const y=i?u.length-1-c:c;o.style.transitionDelay=n==="stagger"?`${m+y*g}ms`:`${m}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const o=new IntersectionObserver(c=>{c.forEach(y=>{y.isIntersecting&&(y.target.offsetWidth,y.target.style.opacity="1",y.target.style.transform="none",o.unobserve(y.target))})},{threshold:.1});u.forEach(c=>o.observe(c))}))}),l.find(".card[data-collapsible]").each(function(){const e=this.querySelector(".card-header");e&&e.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),l.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const e=this.dataset.soTarget,n=document.getElementById(e);if(!n)return;const r=E.slideover({title:n.dataset.soTitle||"",size:n.dataset.soSize||"md",position:n.dataset.soPosition||"right"});n.style.display="",r.setContent(n),r.open()})})}if(typeof $.setup=="function"){const e=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(n=>{try{const r=atob(n.dataset.config),h=JSON.parse(r);Object.assign(e,h)}catch{}}),Object.keys(e).length>0){const n={};for(const[r,h]of Object.entries(e)){const g=h?.events?.click,{confirm:m,toast:i,alert:u,prompt:f,...o}=g||{};m||i||u||f?($(r).on("click",async function(y){if(y.preventDefault(),m&&!await E.confirm(m))return;let v=null;if(!(f&&(v=await E.prompt(f,{inputPlaceholder:o.promptPlaceholder||"",inputValue:o.promptDefault||""}),v===null))){if(o.target){const _=$(o.target);o.toggleClass&&_.toggleClass(o.toggleClass),o.addClass&&_.addClass(o.addClass),o.removeClass&&_.removeClass(o.removeClass),v!==null&&(o.setText&&_.text(v),o.setVal&&_.val(v),o.setAttr&&_.attr(o.setAttr,v))}o.href&&(window.location.href=o.href),i&&E.toast(i,{type:o.toastType||"success"}),u&&E.alert(u)}}),Object.keys(o).length&&(n[r]={...h,events:{...h.events,click:o}})):n[r]=h}$.setup(n)}}l.length&&wireCTAButtons(l.get(0))});function wireCTAButtons(x){x.querySelectorAll(".dm-cta-trigger").forEach(p=>{p.__ctaWired||(p.__ctaWired=!0,p.addEventListener("click",async()=>{const k=p.dataset.action,b=p.dataset.entry,C=p.dataset.confirm;let l=S.get("auth_token");if(!l){E.toast("Please log in to perform this action.",{type:"warning"});return}if(C&&!await E.confirm(C))return;const s=Array.from(p.childNodes).map(a=>a.cloneNode(!0));p.disabled=!0,p.textContent="Running\u2026";const d=a=>fetch(`/api/actions/${k}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({entryId:b})});try{let a=await d(l);if(a.status===401){const w=S.get("auth_refresh_token");if(w){const e=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:w})});if(e.ok){const{token:n}=await e.json();S.set("auth_token",n),l=n,a=await d(l)}}}const t=await a.json().catch(()=>({}));if(!a.ok)throw new Error(t.error||t.message||`Error ${a.status}`);E.toast(t.message||"Action completed.",{type:"success"})}catch(a){E.toast(a.message||"Action failed.",{type:"error"})}finally{p.disabled=!1,p.textContent="",s.forEach(a=>p.appendChild(a)),Domma.icons.scan(p)}}))})}(function(){const p=document.querySelectorAll('.dm-collection-hydrate[data-collection-scope="mine"]');if(!p.length)return;const k=S.get("auth_token");function b(l,s){const d=document.createElement("div");d.className="dm-collection-display dm-collection-empty";const a=document.createElement("p");if(s){a.appendChild(document.createTextNode("Please "));const t=document.createElement("a");t.href="/login",t.textContent="sign in",a.appendChild(t),a.appendChild(document.createTextNode(" to view your entries."))}else a.textContent=l;return d.appendChild(a),d}function C(l,s){const d=document.createElement("template");d.innerHTML=s||"",l.replaceWith(d.content)}p.forEach(async l=>{const s=l.dataset.collectionAttrs;if(!s)return;if(!k){l.replaceChildren(b("",!0));return}const d=a=>fetch("/api/collections/render-scope",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${a}`},body:JSON.stringify({attrs:s})});try{let a=k,t=await d(a);if(t.status===401){const e=S.get("auth_refresh_token");if(e){const n=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refreshToken:e})});if(n.ok){const{token:r}=await n.json();S.set("auth_token",r),a=r,t=await d(a)}}}if(!t.ok){const{error:e}=await t.json().catch(()=>({}));l.replaceChildren(b(e||"Unable to load entries.",!1));return}const{html:w}=await t.json();C(l,w)}catch{l.replaceChildren(b("Unable to load entries.",!1))}}),setTimeout(()=>{typeof Domma<"u"&&Domma.icons?.scan&&Domma.icons.scan(),wireCTAButtons(document.body)},100)})(),(function(){const p=document.querySelectorAll("[data-collection-table]");!p.length||typeof T>"u"||!T.create||p.forEach(k=>{let b;try{b=JSON.parse(atob(k.dataset.payload))}catch{return}const{columns:C,rows:l,search:s,sortable:d,exportable:a,pageSize:t,empty:w,ctaConfig:e}=b;if(!C?.length)return;if(e){const h=C.findIndex(g=>g.key==="_cta");h!==-1&&(C[h]={key:"_cta",title:"",render:(g,m)=>{const i=document.createElement("button");if(i.className=`btn btn-${e.style||"primary"} dm-cta-trigger`,i.dataset.action=e.action||"",i.dataset.entry=m._entryId||"",e.confirm&&(i.dataset.confirm=e.confirm),e.icon){const u=document.createElement("span");u.dataset.icon=e.icon,i.appendChild(u),i.appendChild(document.createTextNode(" "))}return i.appendChild(document.createTextNode(e.label||"Run")),i}})}const n="col-table-"+Math.random().toString(36).slice(2,7),r=document.createElement("div");r.id=n,k.replaceChildren(r),T.create("#"+n,{data:l,columns:C,search:s,sortable:d,exportable:a,pageSize:t,emptyMessage:w}),e&&wireCTAButtons(r)})})(),(function(){const p=document.querySelectorAll("[data-form-inline]");if(!p.length||typeof F>"u")return;function k(s,d){const a={};return(s||[]).forEach(t=>{if(t.type==="page-break"||t.type==="spacer"||!t.name)return;const w=t.type==="checkbox"?"boolean":t.type==="date"?"string":t.type,e={...t.formConfig||{}};e.span==="full"&&d&&(e.span=d);const n={type:w,label:t.label,required:t.required,options:t.options,formConfig:{...t.placeholder&&{placeholder:t.placeholder},...t.helper&&{hint:t.helper},...e}};t.type==="chooser"&&(t.variant&&(n.variant=t.variant),t.multiple&&(n.multiple=!0),t.density&&(n.density=t.density),t.columns&&(n.columns=t.columns),t.accent&&(n.accent=t.accent),t.accentStyle&&(n.accentStyle=t.accentStyle),t.glow&&(n.glow=!0),t.glowColour&&(n.glowColour=t.glowColour),t.shadow&&(n.shadow=t.shadow),t.shadowColour&&(n.shadowColour=t.shadowColour)),a[t.name]=n}),a}function b(s){const d={};return(s||[]).forEach(a=>{if(!(!a.name||a.type==="page-break"||a.type==="spacer")&&(a.type==="select"||a.type==="multiselect")&&a.required){const t=(a.options||[])[0];t&&(d[a.name]=typeof t=="object"?t.value:t)}}),d}function C(s,d){(d||[]).forEach(a=>{if(a.type!=="date"||!a.name)return;const t=s.querySelector(`[name="${a.name}"]`);t&&t.type!=="date"&&(t.type="date")})}function l(s,d,a){let t=s.querySelector(".cms-form-message");t||(t=document.createElement("p"),t.className="cms-form-message",s.appendChild(t)),t.textContent=d,t.style.cssText=a?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}p.forEach(s=>{let d;try{d=JSON.parse(atob(s.dataset.formInline))}catch{return}const a=d.fields||[],t=d.settings||{},w=t.columns||1,e=t.layout||"stacked",n=a.some(m=>m.type==="page-break"),r=async m=>{try{const i=s.querySelector('[name="website"]'),u=s.querySelector('[name="_t"]'),f=Object.assign({},m);i!==null&&(f._hp=i.value),u!==null&&(f._t=u.value);const o=await H.post(`/api/forms/submit/${d.slug}`,f);if(o&&o.error){l(s,o.error,!0);const c=new Error(o.error);throw c.formSubmitFailed=!0,c}if(o?.redirect){window.location.href=o.redirect;return}for(;s.firstChild;)s.removeChild(s.firstChild);l(s,o?.message||t.successMessage||"Thank you for your submission.",!1)}catch(i){throw i.formSubmitFailed||l(s,i.message||"Submission failed. Please try again.",!0),i}};function h(m){const i=m.querySelector("form");if(!i)return;const u=document.createElement("div");u.className="fb-form-honeypot",u.setAttribute("aria-hidden","true");const f=document.createElement("input");f.name="website",f.type="text",f.tabIndex=-1,f.autocomplete="url",f.placeholder="https://",u.appendChild(f);const o=document.createElement("input");o.name="_t",o.type="hidden",o.value=Date.now(),u.appendChild(o),i.appendChild(u)}function g(){if(!window.FormLogicEngine||!a.some(i=>i.logic))return;const m=new window.FormLogicEngine.FormLogicRuntime(d,s);if(m.init(),s._formLogicRuntime=m,s.parentNode&&typeof MutationObserver<"u"){const i=new MutationObserver(function(u){for(const f of u)for(const o of f.removedNodes)if(o===s||o.nodeType===1&&o.contains&&o.contains(s)){m.destroy(),i.disconnect();return}});i.observe(s.parentNode,{childList:!0,subtree:!1})}}if(n&&F.wizard){const m=[];let i=[],u=d.title||"Step 1",f="";a.forEach(c=>{c.type==="page-break"?(m.push({title:u,description:f,fields:k(i,w)}),i=[],u=c.label||`Step ${m.length+1}`,f=c.description||""):c.type!=="spacer"&&i.push(c)}),m.push({title:u,description:f,fields:k(i,w)});const o=F.wizard(s,{schema:{steps:m},onSubmit:r});Promise.resolve(o).then(function(){C(s,a),t.honeypot!==!1&&h(s),g()})}else if(F.render){const m=F.render(s,k(a,w),b(a),{submitText:t.submitText||"Submit",layout:e,columns:w,onSubmit:r});Promise.resolve(m).then(function(){if(e==="grid"&&t.submitSpan==="full"){const i=s.querySelector(".form-buttons");i&&i.classList.add("col-span-full")}C(s,a),t.honeypot!==!1&&h(s),g()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
@@ -4,6 +4,7 @@
4
4
  * Role data is read from the roles cache (not config.auth.roles).
5
5
  */
6
6
  import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
+ import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
7
8
 
8
9
  /**
9
10
  * Verify JWT Bearer token. Populates request.user on success.
@@ -78,8 +79,12 @@ export function requirePermission(resource, action) {
78
79
  });
79
80
  }
80
81
 
82
+ // Check ANY of the user's effective roles (primary + additional) against
83
+ // the resource's permission list — union semantics so a user with both
84
+ // candidate and recruiter roles can do either role's actions.
81
85
  const allowed = getPermissionsFor(resource, action);
82
- if (!allowed.includes(request.user.role)) {
86
+ const roles = getEffectiveRoles(request.user);
87
+ if (!roles.some(r => allowed.includes(r))) {
83
88
  return reply.code(403).send({
84
89
  statusCode: 403,
85
90
  error: 'Forbidden',
@@ -111,7 +116,9 @@ export async function requireAdmin(request, reply) {
111
116
  if (!request.user) {
112
117
  return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
113
118
  }
114
- if (getRoleLevel(request.user.role) > 1) {
119
+ // Use effective level — a user with "additionalRoles: ['admin']" can do admin work
120
+ // even if their primary role is something lower-privilege.
121
+ if (getEffectiveLevel(request.user) > 1) {
115
122
  return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
116
123
  }
117
124
  }
@@ -120,53 +127,112 @@ export async function requireAdmin(request, reply) {
120
127
  * Determine whether an actor can manage a target user.
121
128
  * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
122
129
  *
123
- * @param {string} actorRole - Role of the user performing the action
124
- * @param {string} targetRole - Role of the user being acted upon
130
+ * Accepts either user objects (preferred uses effective level across all roles)
131
+ * or bare role-name strings (legacy compat). Strings are looked up via
132
+ * `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
133
+ * targets are compared by their HIGHEST-privilege role.
134
+ *
135
+ * @param {string|object} actor - Role name OR user object
136
+ * @param {string|object} target - Role name OR user object
125
137
  * @returns {boolean}
126
138
  */
127
- export function canManageUser(actorRole, targetRole) {
128
- return getRoleLevel(actorRole) < getRoleLevel(targetRole);
139
+ export function canManageUser(actor, target) {
140
+ const actorLevel = typeof actor === 'string' ? getRoleLevel(actor) : getEffectiveLevel(actor);
141
+ const targetLevel = typeof target === 'string' ? getRoleLevel(target) : getEffectiveLevel(target);
142
+ return actorLevel < targetLevel;
129
143
  }
130
144
 
131
145
  /**
132
146
  * Check whether a user role satisfies a visibility requirement.
133
147
  * Used by both requireVisibility() and the public page renderer.
134
148
  *
135
- * @param {string|null} userRole - The visitor's role, or null if unauthenticated
136
- * @param {string} visibility - Required visibility ('public', 'private', or a role name)
149
+ * Visibility may be either:
150
+ * - A single string ('public', 'private', or a role name)
151
+ * - An array of role names — granted if ANY entry passes the per-role check
152
+ *
153
+ * Per-role semantics are unchanged: each role check passes if the visitor's
154
+ * role level is at or above the required role (lower or equal level number).
155
+ * 'private' resolves to super-admin only (Infinity → level 0).
156
+ *
157
+ * The "any of" semantics for arrays means siblings at different tiers of the
158
+ * hierarchy are all granted access — e.g. `visibility: [candidate, employer]`
159
+ * lets both roles in, plus anyone more privileged than either (typically
160
+ * admins inherit access automatically via the level comparison).
161
+ *
162
+ * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
+ * @param {string|string[]} visibility - Required visibility — single value or array
137
164
  * @returns {boolean} true if access is granted
138
165
  */
139
- export function checkVisibility(userRole, visibility) {
140
- if (!visibility || visibility === 'public') return true;
141
- if (!userRole) return false; // unauthenticated, non-public page
142
- const userLevel = getRoleLevel(userRole);
143
- const requiredLevel = getRoleLevel(visibility);
144
- const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
166
+ export function checkVisibility(userRoleOrObj, visibility) {
167
+ if (!visibility) return true;
168
+
169
+ // Accept either a bare role name (legacy) or a user object with multi-role
170
+ // support. When given an object we walk every effective role and grant
171
+ // access if ANY satisfies same union semantics as permissions.
172
+ const roles = typeof userRoleOrObj === 'string'
173
+ ? (userRoleOrObj ? [userRoleOrObj] : [])
174
+ : getEffectiveRoles(userRoleOrObj);
175
+
176
+ if (Array.isArray(visibility)) {
177
+ if (visibility.length === 0) return true;
178
+ if (visibility.includes('public')) return true;
179
+ if (!roles.length) return false;
180
+ return roles.some(r => visibility.some(v => checkSingleVisibility(r, v)));
181
+ }
182
+
183
+ if (visibility === 'public') return true;
184
+ if (!roles.length) return false;
185
+ return roles.some(r => checkSingleVisibility(r, visibility));
186
+ }
187
+
188
+ /**
189
+ * Internal helper — single-role visibility check.
190
+ * Returns true if the user role is at or above the required role's level.
191
+ *
192
+ * @param {string} userRole - Must not be null
193
+ * @param {string} visibility - Single visibility token (role name or 'private')
194
+ * @returns {boolean}
195
+ */
196
+ function checkSingleVisibility(userRole, visibility) {
197
+ const userLevel = getRoleLevel(userRole);
198
+ const requiredLevel = getRoleLevel(visibility);
199
+ const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
145
200
  return userLevel <= threshold;
146
201
  }
147
202
 
148
203
  /**
149
204
  * Fastify preHandler factory — gates a route by visibility level.
150
- * Works identically to the content-page visibility system.
151
- * Returns a no-op for 'public' so it is safe to apply unconditionally.
205
+ * Works identically to the content-page visibility system; accepts the same
206
+ * single-string or array-of-roles syntax as checkVisibility().
152
207
  *
153
- * @param {string} visibility - 'public' | 'private' | any role name
208
+ * Returns a no-op for 'public' (or any array containing 'public') so it is
209
+ * safe to apply unconditionally.
210
+ *
211
+ * @param {string|string[]} visibility - 'public' | 'private' | role name | array of role names
154
212
  * @returns {Function} Fastify preHandler
155
213
  */
156
214
  export function requireVisibility(visibility) {
157
- if (!visibility || visibility === 'public') {
215
+ const isPublic = !visibility
216
+ || visibility === 'public'
217
+ || (Array.isArray(visibility) && (visibility.length === 0 || visibility.includes('public')));
218
+
219
+ if (isPublic) {
158
220
  return (_request, _reply, done) => { if (done) done(); };
159
221
  }
160
222
 
161
223
  return async (request, reply) => {
162
- let userRole = null;
224
+ // Build a user-shaped object so checkVisibility can see multi-role
225
+ // (primary + additional). Unauthenticated → null → public-only access.
226
+ let userObj = null;
163
227
  try {
164
228
  const decoded = await request.jwtVerify();
165
- if (decoded.type === 'access') userRole = decoded.role;
229
+ if (decoded.type === 'access') {
230
+ userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
231
+ }
166
232
  } catch { /* unauthenticated */ }
167
233
 
168
- if (!checkVisibility(userRole, visibility)) {
169
- const code = userRole ? 403 : 401;
234
+ if (!checkVisibility(userObj, visibility)) {
235
+ const code = userObj ? 403 : 401;
170
236
  return reply.code(code).send({
171
237
  statusCode: code,
172
238
  error: code === 403 ? 'Forbidden' : 'Unauthorised',
@@ -20,11 +20,13 @@ import {
20
20
  getAction,
21
21
  listActions,
22
22
  listActionsForCollection,
23
+ listTransitionsForEntry,
23
24
  updateAction
24
25
  } from '../../services/actions.js';
25
26
  import {getEntry} from '../../services/collections.js';
26
27
  import {authenticate, requirePermission} from '../../middleware/auth.js';
27
28
  import {getRoleLevel} from '../../services/roles.js';
29
+ import {getEffectiveLevel} from '../../services/userRoles.js';
28
30
  import {checkEntryAccess} from '../../services/rowAccess.js';
29
31
 
30
32
  export async function actionsRoutes(fastify) {
@@ -39,7 +41,10 @@ export async function actionsRoutes(fastify) {
39
41
 
40
42
  fastify.get('/actions', canRead, async (request, reply) => {
41
43
  try {
42
- return await listActions();
44
+ const {canSeeArtefact} = await import('../../services/projects.js');
45
+ const all = await listActions();
46
+ // listActions returns full records with meta inline — filter directly
47
+ return all.filter(a => canSeeArtefact(request.user, a));
43
48
  } catch (err) {
44
49
  return reply.status(503).send({ error: err.message });
45
50
  }
@@ -68,6 +73,10 @@ export async function actionsRoutes(fastify) {
68
73
  try {
69
74
  const action = await getAction(request.params.slug);
70
75
  if (!action) return reply.status(404).send({ error: 'Action not found' });
76
+ const {canSeeArtefact} = await import('../../services/projects.js');
77
+ if (!canSeeArtefact(request.user, action)) {
78
+ return reply.status(403).send({ error: 'Access denied for this project' });
79
+ }
71
80
  return action;
72
81
  } catch (err) {
73
82
  return reply.status(503).send({ error: err.message });
@@ -136,8 +145,8 @@ export async function actionsRoutes(fastify) {
136
145
  const rowLevel = action.access?.rowLevel;
137
146
  const user = request.user;
138
147
 
139
- // Admin or no row-level config → all IDs allowed
140
- if (!rowLevel || getRoleLevel(user?.role) === 0) {
148
+ // Admin (across primary OR additional roles) or no row-level config → all IDs allowed
149
+ if (!rowLevel || getEffectiveLevel(user) === 0) {
141
150
  return {allowed: entryIds};
142
151
  }
143
152
 
@@ -146,7 +155,7 @@ export async function actionsRoutes(fastify) {
146
155
  entryIds.map(async (id) => {
147
156
  try {
148
157
  const entry = await getEntry(action.collection, id);
149
- return entry && checkEntryAccess(entry, user, rowLevel) ? id : null;
158
+ return (entry && (await checkEntryAccess(entry, user, rowLevel))) ? id : null;
150
159
  } catch {
151
160
  return null;
152
161
  }
@@ -179,7 +188,8 @@ export async function actionsRoutes(fastify) {
179
188
 
180
189
  const user = request.user;
181
190
  const allowedRoles = action.access?.roles || ['admin', 'super-admin'];
182
- const userLevel = getRoleLevel(user?.role);
191
+ // Use effective level — a candidate-with-also-admin can run admin-tier actions.
192
+ const userLevel = getEffectiveLevel(user);
183
193
  const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
184
194
 
185
195
  if (userLevel > minAllowed) {
@@ -197,4 +207,47 @@ export async function actionsRoutes(fastify) {
197
207
  return reply.status(status).send({ error: err.message });
198
208
  }
199
209
  });
210
+
211
+ /*
212
+ * GET /api/actions/transitions?collection=<slug>&entryId=<id>
213
+ *
214
+ * Returns the actions valid as *next-state transitions* for the given
215
+ * entry, filtered by the requesting user's role. Used by the per-row
216
+ * transitions UI in the Collection Browser and the `[transitions]`
217
+ * shortcode to render only the buttons that will actually succeed.
218
+ *
219
+ * Auth: optional. Anonymous calls return actions accessible to anonymous
220
+ * users (rare — most transitions require a role). With a JWT we narrow
221
+ * to what the signed-in user can do.
222
+ *
223
+ * Response: { transitions: [{slug, title, trigger, transition}, ...] }
224
+ * Returns 503 if MongoDB is unavailable (actions are a Pro feature).
225
+ */
226
+ fastify.get('/actions/transitions', async (request, reply) => {
227
+ const { collection: collectionSlug, entryId } = request.query;
228
+ if (!collectionSlug || !entryId) {
229
+ return reply.status(400).send({ error: 'collection and entryId are required' });
230
+ }
231
+
232
+ let user = null;
233
+ try {
234
+ const decoded = await request.jwtVerify();
235
+ if (decoded.type === 'access') user = {id: decoded.id, role: decoded.role};
236
+ } catch { /* anonymous */ }
237
+
238
+ let entry;
239
+ try {
240
+ entry = await getEntry(collectionSlug, entryId);
241
+ } catch {
242
+ return reply.status(404).send({ error: 'Entry not found' });
243
+ }
244
+ if (!entry) return reply.status(404).send({ error: 'Entry not found' });
245
+
246
+ try {
247
+ const transitions = await listTransitionsForEntry(collectionSlug, entry, user);
248
+ return { transitions };
249
+ } catch (err) {
250
+ return reply.status(503).send({ error: err.message, transitions: [] });
251
+ }
252
+ });
200
253
  }
@@ -108,7 +108,7 @@ export async function authRoutes(fastify) {
108
108
 
109
109
  await touchLastLogin(user.id);
110
110
 
111
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
111
+ const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
112
112
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
113
113
  const { token, refreshToken } = signTokens(fastify, safeUser);
114
114
  return { token, refreshToken, user: safeUser };
@@ -271,7 +271,7 @@ export async function authRoutes(fastify) {
271
271
  return reply.status(401).send({ error: 'User not found or inactive' });
272
272
  }
273
273
 
274
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
274
+ const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
275
275
  const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
276
276
  return { token };
277
277
  });
@@ -20,20 +20,35 @@ export async function blocksRoutes(fastify) {
20
20
  const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
21
21
 
22
22
  // List all blocks
23
- fastify.get('/blocks', canRead, async () => {
24
- return listBlocks();
23
+ fastify.get('/blocks', canRead, async (request) => {
24
+ const {canSeeArtefact} = await import('../../services/projects.js');
25
+ const all = await listBlocks();
26
+ const filtered = [];
27
+ for (const b of all) {
28
+ // listBlocks returns metadata only; getBlock returns full record with meta
29
+ let full = null;
30
+ try { full = await getBlock(b.name); } catch { /* skip */ }
31
+ if (canSeeArtefact(request.user, full)) filtered.push(b);
32
+ }
33
+ return filtered;
25
34
  });
26
35
 
27
36
  // Get single block
28
37
  fastify.get('/blocks/:name', canRead, async (request, reply) => {
29
38
  const {name} = request.params;
39
+ let block;
30
40
  try {
31
- return await getBlock(name);
41
+ block = await getBlock(name);
32
42
  } catch (err) {
33
43
  if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
34
44
  if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
35
45
  throw err;
36
46
  }
47
+ const {canSeeArtefact} = await import('../../services/projects.js');
48
+ if (!canSeeArtefact(request.user, block)) {
49
+ return reply.status(403).send({error: 'Access denied for this project'});
50
+ }
51
+ return block;
37
52
  });
38
53
 
39
54
  // Export single block as downloadable .dmblock.json bundle