domma-cms 0.16.0 → 0.18.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.
- package/CLAUDE.md +2 -0
- package/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +2 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +26 -44
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +42 -37
- package/admin/js/views/settings.js +3 -3
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +231 -16
- package/plugins/analytics/public/inject-body.html +26 -2
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/dashboard.js +239 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/public.js +11 -3
- package/server/server.js +18 -3
- package/server/services/blocks.js +3 -0
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +17 -4
- package/server/services/content.js +7 -2
- package/server/services/email.js +60 -20
- package/server/services/forms.js +3 -0
- package/server/services/health.js +282 -0
- package/server/services/markdown.js +25 -15
- package/server/services/plugins.js +37 -5
- package/server/services/views.js +4 -0
- package/server/templates/page.html +130 -130
package/public/js/site.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
$(()=>{const w=window.__CMS_NAV__||{},l=window.__CMS_SITE__||{};if(l.autoTheme?.enabled){let t=function(s){const h=(s||"07:00").split(":");return+h[0]*60+(+h[1]||0)},o=function(){const s=new Date,h=s.getHours()*60+s.getMinutes();return h>=t(e.dayStart)&&h<t(e.nightStart)?e.dayTheme:e.nightTheme};var i=t,d=o;const e=l.autoTheme;Domma.theme.set(o()),setInterval(()=>Domma.theme.set(o()),6e4)}if($("#site-navbar").length&&w.brand){const e={...w.brand},t=e.size&&e.size!=="md"?` navbar-brand-${e.size}`:"";if(e.logo||e.icon||e.tagline){let o="";e.logo?o+=`<img src="${e.logo}" class="navbar-brand-logo" alt="${e.text||""}">`:e.icon&&(o+=`<span data-icon="${e.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`),e.text&&(o+=`<span class="navbar-brand-text${t}">${e.text}</span>`),e.tagline&&(o+=`<small class="navbar-brand-tagline">${e.tagline}</small>`),e.html=o}else t&&e.text&&(e.html=`<span class="navbar-brand-text${t}">${e.text}</span>`);Domma.elements.navbar("#site-navbar",{brand:e,items:w.items||[],variant:w.variant||"dark",position:w.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const v=$("#site-footer");if(v.length){const e=l.social||{},t={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(l.footer){const m=l.footer;o+=`<p>${m.copyright||""}</p>`,m.links?.length&&(o+='<nav class="footer-links">',m.links.forEach(n=>{o+=`<a href="${n.url}">${n.text}</a>`}),o+="</nav>");const f=Object.keys(t).filter(n=>e[n]);f.length&&(o+='<div class="footer-social">',f.forEach(n=>{const{label:a,svg:r}=t[n];o+=`<a href="${e[n]}" target="_blank" rel="noopener noreferrer" aria-label="${a}" class="footer-social-link">${r}</a>`}),o+="</div>")}o+="</div>",v.html(o);const s=S.get("reduced_motion"),h=s!==null?!!s:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches),g=v.get(0).querySelector(".footer-inner");if(g){const m=document.createElement("input");m.type="checkbox",m.className="form-switch-input",m.id="dm-motion-switch",m.checked=h,m.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(m),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 p=$(".page-body");if(p.length){p.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),p.find(".tabs").each(function(){Domma.elements.tabs(this)}),p.find(".carousel").each(function(){Domma.elements.carousel(this,{autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"})}),p.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)}),p.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),p.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 t=e.dataset.revealMode||"stagger",o=e.dataset.revealAnimation||"slide-up",s=parseInt(e.dataset.revealDuration,10)||400,h=parseInt(e.dataset.revealStagger,10)||60,g=parseInt(e.dataset.revealDelay,10)||0,m=e.dataset.revealDirection==="rtl",f=Array.from(e.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,r)=>{a.style.opacity="0",a.style.transform=n[o]||"",a.style.transition=`opacity ${s}ms ease, transform ${s}ms ease`;const c=m?f.length-1-r:r;a.style.transitionDelay=t==="stagger"?`${g+c*h}ms`:`${g}ms`}),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=new IntersectionObserver(r=>{r.forEach(c=>{c.isIntersecting&&(c.target.offsetWidth,c.target.style.opacity="1",c.target.style.transform="none",a.unobserve(c.target))})},{threshold:.1});f.forEach(r=>a.observe(r))}))}),p.find(".card[data-collapsible]").each(function(){const e=this.querySelector(".card-header");e&&e.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),p.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const e=this.dataset.soTarget,t=document.getElementById(e);if(!t)return;const o=E.slideover({title:t.dataset.soTitle||"",size:t.dataset.soSize||"md",position:t.dataset.soPosition||"right"});t.style.display="",o.setContent(t),o.open()})})}if(typeof $.setup=="function"){const e=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(t=>{try{const o=atob(t.dataset.config),s=JSON.parse(o);Object.assign(e,s)}catch{}}),Object.keys(e).length>0){const t={};for(const[o,s]of Object.entries(e)){const h=s?.events?.click,{confirm:g,toast:m,alert:f,prompt:n,...a}=h||{};g||m||f||n?($(o).on("click",async function(c){if(c.preventDefault(),g&&!await E.confirm(g))return;let u=null;if(!(n&&(u=await E.prompt(n,{inputPlaceholder:a.promptPlaceholder||"",inputValue:a.promptDefault||""}),u===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),u!==null&&(a.setText&&y.text(u),a.setVal&&y.val(u),a.setAttr&&y.attr(a.setAttr,u))}a.href&&(window.location.href=a.href),m&&E.toast(m,{type:a.toastType||"success"}),f&&E.alert(f)}}),Object.keys(a).length&&(t[o]={...s,events:{...s.events,click:a}})):t[o]=s}$.setup(t)}}p.length&&wireCTAButtons(p.get(0))});function wireCTAButtons(w){w.querySelectorAll(".dm-cta-trigger").forEach(l=>{l.addEventListener("click",async()=>{const C=l.dataset.action,v=l.dataset.entry,b=l.dataset.confirm;let p=S.get("auth_token");if(!p){E.toast("Please log in to perform this action.",{type:"warning"});return}if(b&&!await E.confirm(b))return;const i=Array.from(l.childNodes).map(e=>e.cloneNode(!0));l.disabled=!0,l.textContent="Running\u2026";const d=e=>fetch(`/api/actions/${C}/public`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify({entryId:v})});try{let e=await d(p);if(e.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:h}=await s.json();S.set("auth_token",h),p=h,e=await d(p)}}}const t=await e.json().catch(()=>({}));if(!e.ok)throw new Error(t.error||t.message||`Error ${e.status}`);E.toast(t.message||"Action completed.",{type:"success"})}catch(e){E.toast(e.message||"Action failed.",{type:"error"})}finally{l.disabled=!1,l.textContent="",i.forEach(e=>l.appendChild(e)),Domma.icons.scan(l)}})})}(function(){const l=document.querySelectorAll("[data-collection-table]");!l.length||typeof T>"u"||!T.create||l.forEach(C=>{let v;try{v=JSON.parse(atob(C.dataset.payload))}catch{return}const{columns:b,rows:p,search:i,sortable:d,exportable:e,pageSize:t,empty:o,ctaConfig:s}=v;if(!b?.length)return;if(s){const m=b.findIndex(f=>f.key==="_cta");m!==-1&&(b[m]={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 r=document.createElement("span");r.dataset.icon=s.icon,a.appendChild(r),a.appendChild(document.createTextNode(" "))}return a.appendChild(document.createTextNode(s.label||"Run")),a}})}const h="col-table-"+Math.random().toString(36).slice(2,7),g=document.createElement("div");g.id=h,C.replaceChildren(g),T.create("#"+h,{data:p,columns:b,search:i,sortable:d,exportable:e,pageSize:t,emptyMessage:o}),s&&wireCTAButtons(g)})})(),(function(){const l=document.querySelectorAll("[data-form-inline]");if(!l.length||typeof F>"u")return;function C(i,d){const e={};return(i||[]).forEach(t=>{if(t.type==="page-break"||t.type==="spacer"||!t.name)return;const o=t.type==="checkbox"?"boolean":t.type==="date"?"string":t.type,s={...t.formConfig||{}};s.span==="full"&&d&&(s.span=d),e[t.name]={type:o,label:t.label,required:t.required,options:t.options,formConfig:{...t.placeholder&&{placeholder:t.placeholder},...t.helper&&{hint:t.helper},...s}}}),e}function v(i){const d={};return(i||[]).forEach(e=>{if(!(!e.name||e.type==="page-break"||e.type==="spacer")&&(e.type==="select"||e.type==="multiselect")&&e.required){const t=(e.options||[])[0];t&&(d[e.name]=typeof t=="object"?t.value:t)}}),d}function b(i,d){(d||[]).forEach(e=>{if(e.type!=="date"||!e.name)return;const t=i.querySelector(`[name="${e.name}"]`);t&&t.type!=="date"&&(t.type="date")})}function p(i,d,e){let t=i.querySelector(".cms-form-message");t||(t=document.createElement("p"),t.className="cms-form-message",i.appendChild(t)),t.textContent=d,t.style.cssText=e?"color:var(--danger,#f87171);margin-top:.75rem;":"color:var(--success,#4ade80);margin-top:.75rem;"}l.forEach(i=>{let d;try{d=JSON.parse(atob(i.dataset.formInline))}catch{return}const e=d.fields||[],t=d.settings||{},o=t.columns||1,s=t.layout||"stacked",h=e.some(n=>n.type==="page-break"),g=async n=>{try{const a=i.querySelector('[name="website"]'),r=i.querySelector('[name="_t"]'),c=Object.assign({},n);a!==null&&(c._hp=a.value),r!==null&&(c._t=r.value);const u=await H.post(`/api/forms/submit/${d.slug}`,c);if(u?.redirect){window.location.href=u.redirect;return}for(;i.firstChild;)i.removeChild(i.firstChild);p(i,u?.message||t.successMessage||"Thank you for your submission.",!1)}catch(a){throw p(i,a.message||"Submission failed. Please try again.",!0),a}};function m(n){const a=n.querySelector("form");if(!a)return;const r=document.createElement("div");r.className="fb-form-honeypot",r.setAttribute("aria-hidden","true");const c=document.createElement("input");c.name="website",c.type="text",c.tabIndex=-1,c.autocomplete="url",c.placeholder="https://",r.appendChild(c);const u=document.createElement("input");u.name="_t",u.type="hidden",u.value=Date.now(),r.appendChild(u),a.appendChild(r)}function f(){if(!window.FormLogicEngine||!e.some(a=>a.logic))return;const n=new window.FormLogicEngine.FormLogicRuntime(d,i);if(n.init(),i._formLogicRuntime=n,i.parentNode&&typeof MutationObserver<"u"){const a=new MutationObserver(function(r){for(const c of r)for(const u of c.removedNodes)if(u===i||u.nodeType===1&&u.contains&&u.contains(i)){n.destroy(),a.disconnect();return}});a.observe(i.parentNode,{childList:!0,subtree:!1})}}if(h&&F.wizard){const n=[];let a=[],r=d.title||"Step 1",c="";e.forEach(y=>{y.type==="page-break"?(n.push({title:r,description:c,fields:C(a,o)}),a=[],r=y.label||`Step ${n.length+1}`,c=y.description||""):y.type!=="spacer"&&a.push(y)}),n.push({title:r,description:c,fields:C(a,o)});const u=F.wizard(i,{schema:{steps:n},onSubmit:g});Promise.resolve(u).then(function(){b(i,e),t.honeypot!==!1&&m(i),f()})}else if(F.render){const n=F.render(i,C(e,o),v(e),{submitText:t.submitText||"Submit",layout:s,columns:o,onSubmit:g});Promise.resolve(n).then(function(){if(s==="grid"&&t.submitSpan==="full"){const a=i.querySelector(".form-buttons");a&&a.classList.add("col-span-full")}b(i,e),t.honeypot!==!1&&m(i),f()})}}),$(document).on("click",".dm-banner__dismiss",function(){$(this).closest(".dm-banner").remove()})})();
|
|
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()})})();
|
package/server/config.js
CHANGED
|
@@ -49,8 +49,19 @@ export function saveConfig(name, data) {
|
|
|
49
49
|
const serverConfig = loadJson('server');
|
|
50
50
|
if (process.env.PORT) serverConfig.port = parseInt(process.env.PORT, 10);
|
|
51
51
|
|
|
52
|
+
const cacheDefaults = {
|
|
53
|
+
enabled: process.env.NODE_ENV === 'production',
|
|
54
|
+
driver: 'memory',
|
|
55
|
+
memory: {maxItems: 1000, defaultTtlSeconds: 3600}
|
|
56
|
+
};
|
|
57
|
+
const cacheFile = path.join(CONFIG_DIR, 'cache.json');
|
|
58
|
+
const cacheConfig = fs.existsSync(cacheFile)
|
|
59
|
+
? {...cacheDefaults, ...JSON.parse(fs.readFileSync(cacheFile, 'utf8'))}
|
|
60
|
+
: cacheDefaults;
|
|
61
|
+
|
|
52
62
|
export const config = {
|
|
53
63
|
server: serverConfig,
|
|
54
64
|
auth: loadJson('auth'),
|
|
55
|
-
content: loadJson('content')
|
|
65
|
+
content: loadJson('content'),
|
|
66
|
+
cache: cacheConfig
|
|
56
67
|
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache API
|
|
3
|
+
* GET /api/cache/status - report enabled flag plus live stats
|
|
4
|
+
* GET /api/cache/keys - list cached keys with their tags (no values)
|
|
5
|
+
* POST /api/cache/toggle - flip enabled on/off, persist to config/cache.json
|
|
6
|
+
* POST /api/cache/clear - flush every cached entry
|
|
7
|
+
*
|
|
8
|
+
* All endpoints are admin-only.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import {fileURLToPath} from 'url';
|
|
13
|
+
import * as cache from '../../services/cache/index.js';
|
|
14
|
+
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const CACHE_CONFIG_FILE = path.resolve(__dirname, '../../../config/cache.json');
|
|
18
|
+
|
|
19
|
+
const CACHE_DEFAULTS = {
|
|
20
|
+
enabled: process.env.NODE_ENV === 'production',
|
|
21
|
+
driver: 'memory',
|
|
22
|
+
memory: {maxItems: 1000, defaultTtlSeconds: 3600}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function readCacheConfig() {
|
|
26
|
+
try {
|
|
27
|
+
return {...CACHE_DEFAULTS, ...JSON.parse(await fs.readFile(CACHE_CONFIG_FILE, 'utf8'))};
|
|
28
|
+
} catch {
|
|
29
|
+
return {...CACHE_DEFAULTS};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function writeCacheConfig(cfg) {
|
|
34
|
+
await fs.writeFile(CACHE_CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function cacheRoutes(fastify) {
|
|
38
|
+
const adminOnly = {preHandler: [authenticate, requireAdmin]};
|
|
39
|
+
|
|
40
|
+
fastify.get('/cache/status', adminOnly, async () => cache.getStats());
|
|
41
|
+
|
|
42
|
+
fastify.get('/cache/keys', adminOnly, async () => ({entries: cache.listEntries()}));
|
|
43
|
+
|
|
44
|
+
fastify.post('/cache/toggle', adminOnly, async (request, reply) => {
|
|
45
|
+
const enabled = !!request.body?.enabled;
|
|
46
|
+
const cfg = await readCacheConfig();
|
|
47
|
+
cfg.enabled = enabled;
|
|
48
|
+
await writeCacheConfig(cfg);
|
|
49
|
+
await cache.setEnabled(enabled);
|
|
50
|
+
return reply.send(cache.getStats());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
fastify.post('/cache/clear', adminOnly, async () => {
|
|
54
|
+
await cache.clear();
|
|
55
|
+
return cache.getStats();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard API
|
|
3
|
+
* Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
|
|
4
|
+
*
|
|
5
|
+
* GET /api/dashboard/summary full payload
|
|
6
|
+
* GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {fileURLToPath} from 'url';
|
|
11
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
|
|
12
|
+
import {getHealth} from '../../services/health.js';
|
|
13
|
+
import {listVersions} from '../../services/versions.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
17
|
+
|
|
18
|
+
const ACTIVITY_LIMIT = 10;
|
|
19
|
+
const VERSIONS_PER_PAGE = 3;
|
|
20
|
+
const ENTRIES_PER_COLLECTION = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Date} [d]
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function todayKey(d = new Date()) {
|
|
27
|
+
return d.toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {number} n
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function isoDaysAgo(n) {
|
|
35
|
+
const d = new Date();
|
|
36
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
37
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
38
|
+
return todayKey(d);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} daily
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
function sumDay(daily, key) {
|
|
47
|
+
return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} daily
|
|
52
|
+
* @param {string} fromKey
|
|
53
|
+
* @param {string} toKey
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function sumRange(daily, fromKey, toKey) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const [day, urls] of Object.entries(daily)) {
|
|
59
|
+
if (day < fromKey || day > toKey) continue;
|
|
60
|
+
for (const n of Object.values(urls)) total += n;
|
|
61
|
+
}
|
|
62
|
+
return total;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the top-pages widget data, including a per-day sparkline and a
|
|
67
|
+
* percentage delta versus the previous comparable window.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} daily
|
|
70
|
+
* @param {number} [days]
|
|
71
|
+
* @returns {Array<{url: string, hits: number, deltaPct: number|null, spark: number[]}>}
|
|
72
|
+
*/
|
|
73
|
+
function buildTopPages(daily, days = 7) {
|
|
74
|
+
const totals = {};
|
|
75
|
+
const perDay = {};
|
|
76
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
77
|
+
const k = isoDaysAgo(i);
|
|
78
|
+
for (const [url, n] of Object.entries(daily[k] || {})) {
|
|
79
|
+
totals[url] = (totals[url] || 0) + n;
|
|
80
|
+
if (!perDay[url]) perDay[url] = new Array(days).fill(0);
|
|
81
|
+
perDay[url][days - 1 - i] = n;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const prevTotals = {};
|
|
85
|
+
for (let i = 2 * days - 1; i >= days; i -= 1) {
|
|
86
|
+
const k = isoDaysAgo(i);
|
|
87
|
+
for (const [url, n] of Object.entries(daily[k] || {})) {
|
|
88
|
+
prevTotals[url] = (prevTotals[url] || 0) + n;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Object.entries(totals)
|
|
92
|
+
.map(([url, hits]) => {
|
|
93
|
+
const prev = prevTotals[url] || 0;
|
|
94
|
+
const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
|
|
95
|
+
return { url, hits, deltaPct, spark: perDay[url] };
|
|
96
|
+
})
|
|
97
|
+
.sort((a, b) => b.hits - a.hits)
|
|
98
|
+
.slice(0, 5);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Recent activity feed — combines page version events with recent collection
|
|
103
|
+
* entries (form submissions). Returns at most 10 items, newest first.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<Array<object>>}
|
|
106
|
+
*/
|
|
107
|
+
async function buildActivity() {
|
|
108
|
+
const items = [];
|
|
109
|
+
|
|
110
|
+
// Versions — read each page's _meta.json via the service so the schema
|
|
111
|
+
// stays in one place. listVersions returns newest-first.
|
|
112
|
+
const versionsDir = path.join(ROOT, 'content', 'versions');
|
|
113
|
+
try {
|
|
114
|
+
const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
|
|
115
|
+
for (const d of dirs) {
|
|
116
|
+
if (!d.isDirectory()) continue;
|
|
117
|
+
const urlPath = '/' + (d.name === '_index' ? '' : d.name);
|
|
118
|
+
let versions = [];
|
|
119
|
+
try { versions = await listVersions(urlPath); }
|
|
120
|
+
catch { continue; }
|
|
121
|
+
for (const v of versions.slice(0, VERSIONS_PER_PAGE)) {
|
|
122
|
+
items.push({
|
|
123
|
+
type: v.type === 'manual' ? 'publish' : 'edit',
|
|
124
|
+
at: v.createdAt,
|
|
125
|
+
actor: v.author || 'unknown',
|
|
126
|
+
target: urlPath
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch { /* no versions dir */ }
|
|
131
|
+
|
|
132
|
+
// Collection entries — each slug has a single data.json with an array of
|
|
133
|
+
// entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
|
|
134
|
+
const collectionsDir = path.join(ROOT, 'content', 'collections');
|
|
135
|
+
try {
|
|
136
|
+
const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
|
|
137
|
+
for (const slug of slugs) {
|
|
138
|
+
if (!slug.isDirectory()) continue;
|
|
139
|
+
const dataFile = path.join(collectionsDir, slug.name, 'data.json');
|
|
140
|
+
let entries = [];
|
|
141
|
+
try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
|
|
142
|
+
catch { continue; }
|
|
143
|
+
if (!Array.isArray(entries)) continue;
|
|
144
|
+
|
|
145
|
+
const sorted = entries
|
|
146
|
+
.filter(e => e && e.meta && e.meta.createdAt)
|
|
147
|
+
.sort((a, b) => (b.meta.createdAt || '').localeCompare(a.meta.createdAt || ''))
|
|
148
|
+
.slice(0, ENTRIES_PER_COLLECTION);
|
|
149
|
+
|
|
150
|
+
for (const e of sorted) {
|
|
151
|
+
items.push({
|
|
152
|
+
type: 'form',
|
|
153
|
+
at: e.meta.createdAt,
|
|
154
|
+
form: slug.name
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch { /* no collections dir */ }
|
|
159
|
+
|
|
160
|
+
return items
|
|
161
|
+
.filter(i => i.at)
|
|
162
|
+
.sort((a, b) => (b.at || '').localeCompare(a.at || ''))
|
|
163
|
+
.slice(0, ACTIVITY_LIMIT);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register the dashboard routes.
|
|
168
|
+
*
|
|
169
|
+
* Auth middlewares are accepted as options so tests can supply no-ops without
|
|
170
|
+
* needing to register `@fastify/jwt` on the test instance. In production the
|
|
171
|
+
* defaults are the real `authenticate` / `requireAdmin` from middleware/auth.js.
|
|
172
|
+
*
|
|
173
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
174
|
+
* @param {{authenticate?: Function, requireAdmin?: Function}} [opts]
|
|
175
|
+
* @returns {Promise<void>}
|
|
176
|
+
*/
|
|
177
|
+
export async function dashboardRoutes(fastify, opts = {}) {
|
|
178
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
179
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
180
|
+
const guard = { preHandler: [authenticate, requireAdmin] };
|
|
181
|
+
|
|
182
|
+
fastify.get('/dashboard/summary', guard, async (request) => {
|
|
183
|
+
const lite = request.query.lite === '1' || request.query.lite === 'true';
|
|
184
|
+
const warnings = [];
|
|
185
|
+
const safe = async (label, fn) => {
|
|
186
|
+
try { return await fn(); }
|
|
187
|
+
catch (e) {
|
|
188
|
+
// Strip absolute paths from error messages so we don't disclose deployment layout.
|
|
189
|
+
const msg = String(e.message || e).replace(/(\/|[A-Za-z]:\\)[^\s'"]+/g, '<path>');
|
|
190
|
+
warnings.push(`${label}: ${msg}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const analytics = fastify.analytics;
|
|
196
|
+
const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
|
|
197
|
+
|
|
198
|
+
const today = todayKey();
|
|
199
|
+
const yesterday = isoDaysAgo(1);
|
|
200
|
+
const todayHits = sumDay(daily, today);
|
|
201
|
+
const yesterdayHits = sumDay(daily, yesterday);
|
|
202
|
+
const deltaPct = yesterdayHits === 0
|
|
203
|
+
? null
|
|
204
|
+
: +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
|
|
205
|
+
const traffic = {
|
|
206
|
+
today: todayHits,
|
|
207
|
+
yesterday: yesterdayHits,
|
|
208
|
+
deltaPct,
|
|
209
|
+
weekToDate: sumRange(daily, isoDaysAgo(6), today),
|
|
210
|
+
previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const realtime = analytics
|
|
214
|
+
? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
|
|
215
|
+
: { activeSessions: 0 };
|
|
216
|
+
const spikes = analytics
|
|
217
|
+
? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
|
|
218
|
+
: [];
|
|
219
|
+
const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
|
|
220
|
+
|
|
221
|
+
if (lite) {
|
|
222
|
+
return {
|
|
223
|
+
traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
|
|
224
|
+
realtime,
|
|
225
|
+
spikes,
|
|
226
|
+
health: { status: health.status },
|
|
227
|
+
warnings
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const topPages = buildTopPages(daily);
|
|
232
|
+
const journeys = analytics
|
|
233
|
+
? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
|
|
234
|
+
: null;
|
|
235
|
+
const activity = (await safe('activity', () => buildActivity())) || [];
|
|
236
|
+
|
|
237
|
+
return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import {getConfig, saveConfig} from '../../config.js';
|
|
7
7
|
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
+
import * as cache from '../../services/cache/index.js';
|
|
8
9
|
|
|
9
10
|
export async function navigationRoutes(fastify) {
|
|
10
11
|
const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
|
|
@@ -35,6 +36,7 @@ export async function navigationRoutes(fastify) {
|
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
saveConfig('navigation', data);
|
|
39
|
+
await cache.invalidateTags(['nav']);
|
|
38
40
|
return { success: true };
|
|
39
41
|
});
|
|
40
42
|
}
|
|
@@ -10,6 +10,7 @@ import nodemailer from 'nodemailer';
|
|
|
10
10
|
import fs from 'fs/promises';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import {fileURLToPath} from 'url';
|
|
13
|
+
import * as cache from '../../services/cache/index.js';
|
|
13
14
|
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
|
|
@@ -64,6 +65,7 @@ export async function settingsRoutes(fastify) {
|
|
|
64
65
|
merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
|
|
65
66
|
}
|
|
66
67
|
saveConfig('site', merged);
|
|
68
|
+
await cache.invalidateTags(['site']);
|
|
67
69
|
return { success: true };
|
|
68
70
|
});
|
|
69
71
|
|
|
@@ -133,6 +135,7 @@ export async function settingsRoutes(fastify) {
|
|
|
133
135
|
return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
|
|
134
136
|
}
|
|
135
137
|
await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
|
|
138
|
+
await cache.invalidateTags(['site']);
|
|
136
139
|
return { success: true };
|
|
137
140
|
});
|
|
138
141
|
}
|
package/server/routes/public.js
CHANGED
|
@@ -8,6 +8,7 @@ import {getPage} from '../services/content.js';
|
|
|
8
8
|
import {renderPage} from '../services/renderer.js';
|
|
9
9
|
import {checkVisibility} from '../middleware/auth.js';
|
|
10
10
|
import {hooks} from '../services/hooks.js';
|
|
11
|
+
import * as cache from '../services/cache/index.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Escape user-controlled strings before interpolating into HTML.
|
|
@@ -64,9 +65,10 @@ export async function publicRoutes(fastify) {
|
|
|
64
65
|
return reply.type('text/html').send(await render404(urlPath));
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
// Enforce page visibility
|
|
68
|
+
// Enforce page visibility — role only resolved for gated pages,
|
|
69
|
+
// so public pages share a single cache entry keyed `roleanon`.
|
|
70
|
+
let userRole = null;
|
|
68
71
|
if (page.visibility && page.visibility !== 'public') {
|
|
69
|
-
let userRole = null;
|
|
70
72
|
try {
|
|
71
73
|
const decoded = await request.jwtVerify();
|
|
72
74
|
if (decoded.type === 'access') userRole = decoded.role;
|
|
@@ -78,7 +80,13 @@ export async function publicRoutes(fastify) {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
const
|
|
83
|
+
const cacheKey = `page:${urlPath}:role${userRole ?? 'anon'}`;
|
|
84
|
+
const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
|
|
85
|
+
const html = await cache.wrap(
|
|
86
|
+
cacheKey,
|
|
87
|
+
() => renderPage(page),
|
|
88
|
+
{tags: cacheTags}
|
|
89
|
+
);
|
|
82
90
|
hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
|
|
83
91
|
return reply.type('text/html').send(html);
|
|
84
92
|
});
|
package/server/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import {seedAll as seedPresetCollections} from './services/presetCollections.js'
|
|
|
24
24
|
import {seedDefaultBlocks} from './services/blocks.js';
|
|
25
25
|
import {seedDefaultComponents} from './services/components.js';
|
|
26
26
|
import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
27
|
+
import * as cache from './services/cache/index.js';
|
|
27
28
|
|
|
28
29
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -122,13 +123,19 @@ await app.register(staticPlugin, {
|
|
|
122
123
|
decorateReply: false
|
|
123
124
|
});
|
|
124
125
|
|
|
125
|
-
// Serve admin panel assets — no-
|
|
126
|
+
// Serve admin panel assets — no-store on JS/CSS so ESM module URLs are never
|
|
127
|
+
// cached. `no-cache` revalidates but browsers may keep the parsed ESM module
|
|
128
|
+
// keyed by URL across reloads, which means a hard refresh can still load the
|
|
129
|
+
// stale code path. `no-store` skips the cache entirely. Other admin assets
|
|
130
|
+
// (images, html) keep no-cache. Local-only impact — admin is not public.
|
|
126
131
|
await app.register(staticPlugin, {
|
|
127
132
|
root: path.join(ROOT, 'admin'),
|
|
128
133
|
prefix: '/admin/',
|
|
129
134
|
decorateReply: false,
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
cacheControl: false,
|
|
136
|
+
setHeaders: (res, filePath) => {
|
|
137
|
+
const noStore = /\.(?:js|mjs|css)$/i.test(filePath);
|
|
138
|
+
res.setHeader('Cache-Control', noStore ? 'no-store' : 'no-cache');
|
|
132
139
|
}
|
|
133
140
|
});
|
|
134
141
|
|
|
@@ -172,6 +179,10 @@ await seedDefaultBlocks();
|
|
|
172
179
|
await seedDefaultComponents();
|
|
173
180
|
await refreshComponentTagAllowlist();
|
|
174
181
|
|
|
182
|
+
// Initialise response cache (Memory/None/Redis driver per config.cache).
|
|
183
|
+
await cache.initCache(config.cache);
|
|
184
|
+
console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
|
|
185
|
+
|
|
175
186
|
// Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
|
|
176
187
|
await app.register(staticPlugin, {
|
|
177
188
|
root: mediaDir,
|
|
@@ -249,6 +260,8 @@ const {componentsRoutes} = await import('./routes/api/components.js');
|
|
|
249
260
|
const {versionsRoutes} = await import('./routes/api/versions.js');
|
|
250
261
|
const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
251
262
|
const {notificationsRoutes} = await import('./routes/api/notifications.js');
|
|
263
|
+
const {dashboardRoutes} = await import('./routes/api/dashboard.js');
|
|
264
|
+
const {cacheRoutes} = await import('./routes/api/cache.js');
|
|
252
265
|
|
|
253
266
|
await app.register(pagesRoutes, { prefix: '/api' });
|
|
254
267
|
await app.register(settingsRoutes, { prefix: '/api' });
|
|
@@ -267,6 +280,8 @@ await app.register(componentsRoutes, {prefix: '/api'});
|
|
|
267
280
|
await app.register(versionsRoutes, {prefix: '/api'});
|
|
268
281
|
await app.register(effectsRoutes, {prefix: '/api'});
|
|
269
282
|
await app.register(notificationsRoutes, {prefix: '/api'});
|
|
283
|
+
await app.register(dashboardRoutes, {prefix: '/api'});
|
|
284
|
+
await app.register(cacheRoutes, {prefix: '/api'});
|
|
270
285
|
|
|
271
286
|
// ---------------------------------------------------------------------------
|
|
272
287
|
// CMS Plugins (server-side Fastify plugins from plugins/ directory)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {fileURLToPath} from 'url';
|
|
9
|
+
import * as cache from './cache/index.js';
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
|
|
@@ -341,6 +342,7 @@ export async function saveBlock(name, content, {bundled, css} = {}) {
|
|
|
341
342
|
});
|
|
342
343
|
}
|
|
343
344
|
}
|
|
345
|
+
await cache.invalidateTags([`block:${name}`]);
|
|
344
346
|
return {success: true, name};
|
|
345
347
|
}
|
|
346
348
|
|
|
@@ -368,6 +370,7 @@ export async function deleteBlock(name) {
|
|
|
368
370
|
});
|
|
369
371
|
await fs.unlink(blockMetaPath(name)).catch(() => {
|
|
370
372
|
});
|
|
373
|
+
await cache.invalidateTags([`block:${name}`]);
|
|
371
374
|
}
|
|
372
375
|
|
|
373
376
|
// ---------------------------------------------------------------------------
|