domma-cms 1.1.0 → 1.2.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.
@@ -0,0 +1 @@
1
+ const h=["ok","warn","danger","info","attention","live"],w=t=>Number.isFinite(Number(t))?Number(t).toLocaleString("en-GB"):String(t??"");function i(t,e,r){const n=document.createElement(t);return e&&(n.className=e),r!=null&&r!==""&&(n.textContent=String(r)),n}function l(t){const e=t==="warn"?"attention":t;return h.includes(e)?` is-${e}`:""}function u(t,e){const r=i("div","dbp-rows");if(!t.length)return r.append(i("p","dbp-empty",e||"Nothing to show.")),r;for(const n of t){const a=i(n.href?"a":"div",`dbp-row${l(n.tone)}`);n.href&&/^#\//.test(n.href)&&(a.href=n.href);const o=i("span","dbp-row-main");o.append(i("span","dbp-row-text",n.text)),n.meta&&o.append(i("small","dbp-row-meta",n.meta)),a.append(o),n.value!==void 0&&n.value!==""&&a.append(i("span","dbp-row-value",n.value)),r.append(a)}return r}function g(t,e){const r=i("div",`dbp-figure${l(e.tone)}`);r.append(i("span","dbp-figure-value",w(e.count??0))),e.label&&r.append(i("span","dbp-figure-label",e.label)),t.append(r);const n=e.details||{};if(Array.isArray(n.rows)&&n.rows.length){const a=i("dl","dbp-facts");for(const o of n.rows.slice(0,4))a.append(i("dt","",o.label),i("dd",l(o.tone).trim(),o.value));t.append(a)}Array.isArray(n.items)&&n.items.length&&t.append(u(n.items.slice(0,5),""))}function v(t,e){const r=i("div",`dbp-figure${l(e.tone)}`);r.append(i("span","dbp-figure-value",e.value??"-")),e.label&&r.append(i("span","dbp-figure-label",e.label)),t.append(r),e.sub&&t.append(i("p","dbp-sub",e.sub))}function C(t,e){t.append(u(Array.isArray(e.items)?e.items.slice(0,12):[],e.empty))}let d=null;const y="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js";export function loadChart(){return window.Chart?Promise.resolve(!0):(d||(d=new Promise(t=>{const e=document.createElement("script");e.src=y,e.async=!0,e.onload=()=>t(!0),e.onerror=()=>{d=null,t(!1)},document.head.appendChild(e)})),d)}async function A(t,e){const r=Array.isArray(e.labels)?e.labels:[],n=Array.isArray(e.values)?e.values.map(p=>Number(p)||0):[];if(!n.some(p=>p>0)){t.append(i("p","dbp-empty","Nothing recorded yet."));return}const a=i("div","dbp-chart"),o=document.createElement("canvas");if(a.append(o),t.append(a),!await loadChart()||!o.isConnected){a.replaceWith(i("p","dbp-empty","The chart could not load."));return}const f=getComputedStyle(t),s=(p,b)=>f.getPropertyValue(p).trim()||b,c=s("--dm-primary","#5b8cff"),m=new window.Chart(o,{type:"line",data:{labels:r,datasets:[{label:e.label||"",data:n,borderColor:c,backgroundColor:/^#[0-9a-f]{6}$/i.test(c)?`${c}26`:"transparent",fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:s("--dm-text-muted","#94a3b8")},grid:{color:s("--dm-border","rgba(127,127,127,.2)")}},y:{beginAtZero:!0,ticks:{precision:0,color:s("--dm-text-muted","#94a3b8")},grid:{color:s("--dm-border","rgba(127,127,127,.2)")}}}}});t.__dbpChart=m}const N={count:g,stat:v,list:C,chart:A};export async function drawWidget(t,e,r){destroyWidget(t),t.replaceChildren();const n=N[e];if(!n||!r||typeof r!="object"){t.append(i("p","dbp-empty","This widget has nothing to show."));return}await n(t,r)}export function drawError(t,e){destroyWidget(t),t.replaceChildren(i("p","dbp-empty is-error",e||"Could not load."))}export function destroyWidget(t){try{t.__dbpChart?.destroy()}catch{}t.__dbpChart=null}
@@ -0,0 +1,3 @@
1
+ <!-- My Dashboard - the Dashboard Pro board at its own address (admin/js/views/my-dashboard.js), for a site
2
+ whose `#/` belongs to another Tool (the fleet manager's Dashboard, a chosen admin home). -->
3
+ <div class="dbx"></div>
@@ -0,0 +1,30 @@
1
+ import{apiRequest as y}from"../api.js";import{addBannerAction as v}from"../lib/plugin-chrome.js";import{helpPopovers as Q}from"../lib/help-popover.js";import{openMenuAt as G}from"../lib/tool-kit.js";import{SIZE_LABELS as ee,SIZES as te,addWidget as ae,anyOpen as oe,boardCards as se,dropWidget as de,galleryGroups as re,removeWidget as Y,resizeWidget as ie,sameLayout as T,setAllCollapsed as ne,setCollapsed as le,shiftWidget as S,withToday as ce}from"../lib/dashboard-board-arrange.js";import{destroyWidget as pe,drawError as ue,drawWidget as be}from"../lib/widget-render.js";const fe=400,he=l=>String(l||"").replace(/^\/api(?=\/)/,""),n=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export async function mountBoard(l,b,{isAdmin:N=!1}={}){const q=l.get(0),c=q.querySelector(".dbx")||q;c.classList.add("dbp"),c.replaceChildren(),c.insertAdjacentHTML("beforeend",`
2
+ <div class="dbp-head">
3
+ <p class="dbp-hint">Your own Dashboard. Drag a card by its grip to move it; the chevron folds it; \u22EE or right-click to resize or remove it.
4
+ <span data-help-title="Dashboard Pro" data-help="Add widgets from the Tools you use with Add widget. Each card keeps itself up to date. This layout is yours - other people arrange their own. Reset goes back to the site's default; an administrator can make their layout that default for everyone who has not arranged their own."></span></p>
5
+ <span class="dbp-source" aria-live="polite"></span>
6
+ </div>
7
+ <div class="dbp-board" role="list" aria-label="Dashboard widgets"></div>
8
+ <div class="dbp-empty-board" hidden>
9
+ <p>Nothing on your Dashboard yet.</p>
10
+ <button type="button" class="btn btn-primary dbp-add-first"><span data-icon="plus" data-icon-size="14"></span> Add a widget</button>
11
+ </div>`);const i=c.querySelector(".dbp-board"),U=c.querySelector(".dbp-source"),D=()=>c.isConnected;let f=[],a={version:1,widgets:[]},w=a,k="built-in";const g=new Map;b.add(()=>{for(const e of g.values())clearInterval(e);g.clear()});async function _(){const[e,t]=await Promise.all([y("/widgets",{method:"GET"}).catch(()=>[]),y("/dashboard/layout",{method:"GET"}).catch(()=>null)]);f=Array.isArray(e)?e:[],a=t?.layout||{version:1,widgets:[]},w=a,k=t?.source||"built-in"}let A=null;function $(){clearTimeout(A),A=setTimeout(async()=>{if(!T(a,w))try{w=(await y("/dashboard/layout",{method:"PUT",body:JSON.stringify(a)}))?.layout||a,k="mine",M()}catch(e){E.toast(`Your layout was not saved: ${e.message||e}`,{type:"error"})}},fe)}b.add(()=>clearTimeout(A));function M(){U.textContent={mine:"Your layout",site:"The site's default layout","built-in":"The starting layout"}[k]||""}function p(e){T(e,a)||(a=e,C(),$())}function O(e,t){a=le(a,e,t);const s=i.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`);s&&x(s,t),$()}function x(e,t){const s=e.classList.contains("is-collapsed");e.classList.toggle("is-collapsed",t);const o=e.querySelector(".dbp-body");o&&(o.hidden=t);const d=e.querySelector(".dbp-fold"),r=e.getAttribute("aria-label")||"";d&&(d.setAttribute("aria-expanded",t?"false":"true"),d.title=t?"Open":"Fold",d.setAttribute("aria-label",`${t?"Open":"Fold"} ${r}`)),s&&!t&&h(e)}function j(e){a=ne(a,e),i.querySelectorAll(".dbp-card").forEach(t=>x(t,e)),$()}const H=4,K=16;function z(e){const t=e.getBoundingClientRect().height;t&&(e.style.gridRowEnd=`span ${Math.max(1,Math.ceil((t+K)/H))}`)}const L=typeof ResizeObserver=="function"?new ResizeObserver(e=>{for(const t of e)z(t.target)}):null;b.add(()=>L?.disconnect());async function h(e){const t=f.find(o=>o.id===e.dataset.id),s=e.querySelector(".dbp-body");if(!(!t||!s)){e.classList.add("is-loading");try{const o=t.kind==="count"?ce(t.dataUrl):t.dataUrl,d=await y(he(o),{method:"GET"});if(!D()||!e.isConnected)return;await be(s,t.kind,d)}catch(o){e.isConnected&&ue(s,o.status===403?"You cannot see this any more.":"Could not load.")}finally{e.classList.remove("is-loading")}}}function J(e){const t=e.href?`<a class="dbp-title" href="${n(e.href)}" title="Open ${n(e.title)}">${n(e.title)}</a>`:`<span class="dbp-title">${n(e.title)}</span>`;return`<section class="dbp-card dbp-size-${n(e.size)}${e.collapsed?" is-collapsed":""}" data-id="${n(e.id)}" role="listitem" tabindex="0" aria-label="${n(e.title)}">
12
+ <header class="dbp-card-head">
13
+ <span class="dbp-grip" title="Drag to move" aria-hidden="true"><span data-icon="menu" data-icon-size="14"></span></span>
14
+ <span class="dbp-icon" aria-hidden="true"><span data-icon="${n(e.icon||"grid")}" data-icon-size="16"></span></span>
15
+ ${t}
16
+ <small class="dbp-from">${e.source&&e.source!=="Built in"?n(e.source):""}</small>
17
+ <button type="button" class="dbp-fold" aria-expanded="${e.collapsed?"false":"true"}" aria-label="${e.collapsed?"Open":"Fold"} ${n(e.title)}" title="${e.collapsed?"Open":"Fold"}"><span data-icon="chevron-down" data-icon-size="14"></span></button>
18
+ <button type="button" class="dbp-kebab" aria-label="Actions for ${n(e.title)}" title="Actions">\u22EE</button>
19
+ </header>
20
+ <div class="dbp-body"${e.collapsed?" hidden":""}><div class="dbp-skeleton"></div></div>
21
+ </section>`}function C(){for(const t of g.values())clearInterval(t);g.clear(),i.querySelectorAll(".dbp-body").forEach(pe);const e=se(a,f);i.innerHTML=e.map(J).join(""),c.querySelector(".dbp-empty-board").hidden=e.length>0,L?.disconnect();for(const t of i.querySelectorAll(".dbp-card"))z(t),L?.observe(t);I.scan(i);for(const t of i.querySelectorAll(".dbp-card")){t.classList.contains("is-collapsed")||h(t);const s=f.find(o=>o.id===t.dataset.id);g.set(t.dataset.id,setInterval(()=>{!document.hidden&&t.isConnected&&!t.classList.contains("is-collapsed")&&h(t)},(s?.refresh||60)*1e3))}M()}const R=e=>{const t=e.detail?.url;if(t)for(const s of i.querySelectorAll(".dbp-card"))f.find(d=>d.id===s.dataset.id)?.href===t&&h(s)};window.addEventListener("dm:sidebar-count",R),b.add(()=>window.removeEventListener("dm:sidebar-count",R));function X(e){const t=e.dataset.id,s=f.find(r=>r.id===t),o=a.widgets.find(r=>r.id===t);if(!s||!o)return[];const d=a.widgets.findIndex(r=>r.id===t);return[{header:s.title},...s.href?[{label:`Open ${s.title}`,icon:"arrow-right",action:()=>{location.hash=s.href}}]:[],{label:o.collapsed?"Open":"Fold",icon:o.collapsed?"chevron-down":"chevron-up",action:()=>O(t,!o.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:o.collapsed===!0,action:()=>h(e)},{type:"divider"},...te.map(r=>({label:ee[r],icon:o.size===r?"check":"maximize-2",disabled:o.size===r,action:()=>p(ie(a,t,r))})),{type:"divider"},{label:"Move earlier",icon:"arrow-left",disabled:d<=0,action:()=>p(S(a,t,-1))},{label:"Move later",icon:"arrow-right",disabled:d>=a.widgets.length-1,action:()=>p(S(a,t,1))},{type:"divider"},{label:"Remove from the Dashboard",icon:"x",action:()=>p(Y(a,t))}]}const Z=E.contextMenu(i,{cursor:"glyph",match:".dbp-card",inherit:!1,density:"compact",items:e=>X(e)});b.add(()=>Z?.destroy?.());const W=e=>{const t=e.target.closest(".dbp-kebab");if(t){G(t);return}const s=e.target.closest(".dbp-fold");if(s){const o=s.closest(".dbp-card");o&&O(o.dataset.id,!o.classList.contains("is-collapsed"))}},F=e=>{const t=e.target.closest(".dbp-card");if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),G(t);return}e.target===t&&(e.altKey&&(e.key==="ArrowLeft"||e.key==="ArrowUp")?(e.preventDefault(),p(S(a,t.dataset.id,-1)),P(t.dataset.id)):e.altKey&&(e.key==="ArrowRight"||e.key==="ArrowDown")?(e.preventDefault(),p(S(a,t.dataset.id,1)),P(t.dataset.id)):e.key==="Delete"&&(e.preventDefault(),p(Y(a,t.dataset.id))))}},P=e=>requestAnimationFrame(()=>i.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`)?.focus());if(i.addEventListener("click",W),i.addEventListener("keydown",F),b.add(()=>{i.removeEventListener("click",W),i.removeEventListener("keydown",F)}),typeof E.sortable=="function"){const e=E.sortable(i,{items:".dbp-card",handle:".dbp-grip",live:!1,axis:"x",onDrop({key:t,targetKey:s,zone:o}){if(o!=="before"&&o!=="after")return!1;const d=de(a,t,s,o);return T(d,a)?!1:(p(d),!0)}});b.add(()=>e?.destroy?.())}function B(){const e=E.slideover({title:"Add a widget",size:"md",position:"right"}),t=document.createElement("div");t.className="dbp-gallery",t.innerHTML=`<input type="search" class="form-input dbp-gallery-search" placeholder="Search widgets" aria-label="Search widgets">
22
+ <p class="dbp-gallery-hint">Widgets from the Tools you can open. Each one is added at its usual size - resize it from its \u22EE menu.</p>
23
+ <div class="dbp-gallery-list"></div>`,e.element.appendChild(t);const s=t.querySelector(".dbp-gallery-list"),o=t.querySelector(".dbp-gallery-search");function d(){const r=re(f,a,o.value);s.innerHTML=r.length?r.map(m=>`
24
+ <h4 class="dbp-gallery-group">${n(m.source)}</h4>
25
+ ${m.widgets.map(u=>`
26
+ <div class="dbp-gallery-item">
27
+ <span class="dbp-icon"><span data-icon="${n(u.icon||"grid")}" data-icon-size="16"></span></span>
28
+ <span class="dbp-gallery-main"><strong>${n(u.title)}</strong><small>${n(u.description||"")}</small></span>
29
+ ${u.added?'<span class="dbp-gallery-added">On the board</span>':`<button type="button" class="btn btn-sm btn-primary" data-add="${n(u.id)}">Add</button>`}
30
+ </div>`).join("")}`).join(""):'<p class="dbp-empty">No widgets match.</p>',I.scan(s)}s.addEventListener("click",r=>{const m=r.target.closest("[data-add]")?.dataset.add,u=m&&f.find(V=>V.id===m);u&&(p(ae(a,u)),E.toast(`${u.title} added.`,{type:"success",duration:2e3}),d())}),o.addEventListener("input",d),e.open(),d(),setTimeout(()=>o.focus(),50)}c.querySelector(".dbp-add-first").addEventListener("click",B),v(l,{id:"dashboard-add-widget",icon:"plus",title:"Add widget",onClick:B}),v(l,{id:"dashboard-fold-all",icon:"chevrons-up",title:"Fold or open every widget",onClick:()=>j(oe(a))}),v(l,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh every widget",onClick:()=>i.querySelectorAll(".dbp-card").forEach(h)}),v(l,{id:"dashboard-reset",icon:"rotate-ccw",title:"Reset to the site's default layout",onClick:async()=>{if(await E.confirm("Go back to the site's default layout? Your own arrangement is forgotten."))try{const e=await y("/dashboard/layout",{method:"DELETE"});a=e?.layout||a,w=a,k=e?.source||"built-in",C(),E.toast("Back to the default layout.",{type:"success"})}catch(e){E.toast(`Could not reset: ${e.message||e}`,{type:"error"})}}}),N&&v(l,{id:"dashboard-make-default",icon:"users",title:"Make this the default for everyone",onClick:async()=>{if(await E.confirm("Make this layout the default? Everyone who has not arranged their own Dashboard sees it. (Widgets they may not use are left out for them.)"))try{await y("/dashboard/layout/default",{method:"PUT",body:JSON.stringify(a)}),E.toast("This is now the default layout.",{type:"success"})}catch(e){E.toast(`Could not set the default: ${e.message||e}`,{type:"error"})}}}),b.add(Q(c,{position:"bottom"})),I.scan(c),await _(),D()&&C()}
@@ -1,7 +1,7 @@
1
- import{api as L,apiRequest as m}from"../api.js";import{addBannerAction as ne}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as re,viewLifetime as Ee}from"../lib/tool-kit.js";import{SHAPES as ie,showLoading as j}from"../lib/loading.js";import{BADGE_SOURCES as Se,activityRows as Ae,attentionFrom as Pe,canAny as _,chartSeries as Re,isSitePath as O,kpisFrom as Te}from"../lib/dashboard-arrange.js";const le=/^(#\/?)?(\?.*)?$/,Le=3e4,je="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js",_e={type:"custom",template:'<div class="skeleton skeleton-rect" style="flex:1;aspect-ratio:auto;height:240px"></div>'},Oe=`
1
+ import{api as j,apiRequest as f}from"../api.js";import{addBannerAction as ie}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as le,viewLifetime as Se}from"../lib/tool-kit.js";import{SHAPES as ce,showLoading as _}from"../lib/loading.js";import{BADGE_SOURCES as Ae,activityRows as Pe,attentionFrom as Re,canAny as B,chartSeries as Te,isSitePath as O,kpisFrom as Le}from"../lib/dashboard-arrange.js";const q=/^(#\/?)?(\?.*)?$/,je=3e4,_e="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js",Be={type:"custom",template:'<div class="skeleton skeleton-rect" style="flex:1;aspect-ratio:auto;height:240px"></div>'},Oe=`
2
2
  <div class="dbx-row" data-id="{{id}}" data-href="{{href}}" data-url="{{url}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="dbx-main"><strong>{{text}}</strong><small data-bind-hidden="!meta">{{meta}}</small></span>
4
4
  <span class="dbx-value" data-bind-hidden="!value">{{value}}</span>
5
5
  <span class="dbx-chip" data-bind-hidden="!chip" data-bind-class="chipCls">{{chip}}</span>
6
6
  <button type="button" class="dbx-kebab" aria-label="Actions" title="Actions" data-bind-hidden="!menu">\u22EE</button>
7
- </div>`;let b=null;function Be(){return window.Chart?Promise.resolve(!0):(b||(b=new Promise(i=>{const o=document.createElement("script");o.src=je,o.async=!0,o.onload=()=>i(!0),o.onerror=()=>{b=null,i(!1)},document.head.appendChild(o)})),b)}const c=i=>Number(i||0).toLocaleString("en-GB"),qe=i=>`${Number(i)>=0?"+":""}${Number(i).toFixed(0)}%`,v=(i,o,d,l={})=>({id:`${i}${o}`,text:d,meta:"",href:"",url:d,value:"",chip:"",chipCls:"",rowCls:"",menu:!0,...l}),u=Ee(le);export const dashboardView={templateUrl:"/admin/js/templates/dashboard.html",async onMount(i){u.reset();const o=i.get(0).querySelector(".dbx")||i.get(0),d=()=>le.test(location.hash)&&o.isConnected,l=M.observable(null),g=M.observable({}),y=M.observable(!1),w=M.observable(""),x=M.observable(null),k=M.observable(Date.now()),p=M.observable(null),C=M.observable(!!window.Chart);let f=[],B=new Set;const ce=M.computed(()=>Te(l.value,g.value)),de=M.computed(()=>{const e=x.value;if(!e)return"";const a=Math.floor((k.value-e)/6e4);return a<1?"Updated just now":`Updated ${a} min ago`}),q=M.observable([]),F=M.observable([]),N=M.observable([]),D=M.observable([]),U=M.observable([]),H=M.observable([]),G=M.observable([]),K=M.observable([]);u.add(M.effect(()=>{const e=l.value,a=t=>t==="danger"?"is-danger":"is-warn";q.value=Pe(g.value,e?.health?.checks||[]).map(t=>({id:t.id,text:`${t.title}: ${t.text}`,meta:t.meta,href:t.href,url:"",value:"",chip:t.tone==="danger"?"Urgent":"Check",chipCls:a(t.tone),rowCls:a(t.tone)+(t.href?"":" is-static"),menu:!!t.href})),F.value=Ae(e?.activity||[],k.value).map(t=>({id:t.id,text:t.text,meta:t.meta,href:t.href,url:t.url,value:"",chip:"",chipCls:"",rowCls:"",menu:!0})),N.value=(e?.topPages||[]).map((t,r)=>v("t",r,t.url,{value:c(t.hits),chip:t.deltaPct==null?"":qe(t.deltaPct),chipCls:Number(t.deltaPct)>=0?"is-ok":"is-danger"})),D.value=(e?.spikes||[]).map((t,r)=>v("s",r,t.url,{value:c(t.hits),chip:`\xD7${Number(t.ratio)||0}`,chipCls:"is-warn"}));const s=e?.journeys;U.value=(s?.entry||[]).map((t,r)=>v("e",r,t.url,{value:c(t.count)})),H.value=(s?.exit||[]).map((t,r)=>v("x",r,t.url,{value:c(t.count)})),G.value=(s?.paths||[]).map((t,r)=>({id:`p${r}`,text:`${t.from} \u2192 ${t.to}`,meta:"",href:"",url:"",value:c(t.count),chip:"",chipCls:"",rowCls:"is-static",menu:!1}));const n={ok:["OK","is-ok"],warn:["Look","is-warn"],fail:["Failing","is-danger"]};K.value=(e?.health?.checks||[]).map((t,r)=>({id:`c${r}`,text:String(t.label||t.id),meta:Array.isArray(t.detail)?t.detail.slice(0,5).join(", "):"",href:"",url:"",value:String(t.value??""),chip:(n[t.level]||n.warn)[0],chipCls:(n[t.level]||n.warn)[1],rowCls:"is-static",menu:!1}))}));const ue=M.computed(()=>!!l.value?.journeys?.totalSessions),he=M.computed(()=>{const e=l.value?.journeys;return e?.totalSessions?`Bounce rate ${(Number(e.bounceRate)*100).toFixed(1)}%`:""}),$=M.computed(()=>Re(l.value?.traffic?.series)),S=M.computed(()=>C.value&&!$.value.empty),pe=M.computed(()=>y.value?l.value?C.value?"No page views recorded yet.":"The chart could not load (Chart.js).":"Traffic needs an administrator.":"Loading\u2026"),me=M.computed(()=>!!p.value?.enabled),fe=M.computed(()=>{const e=p.value||{},a=(e.hits||0)+(e.misses||0),s=n=>{if(!n)return"never";const t=Math.max(0,Math.floor((Date.now()-new Date(n).getTime())/1e3));return t<60?`${t}s ago`:t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} h ago`:`${Math.floor(t/86400)} days ago`};return{hitRate:e.hitRate==null?"-":`${(e.hitRate*100).toFixed(1)}%`,hitsMisses:a?`${c(e.hits)} hits \xB7 ${c(e.misses)} misses`:"no visits yet",size:e.maxItems?`${c(e.size)} / ${c(e.maxItems)}`:c(e.size),driver:e.driver?`driver: ${e.driver}`:"",writes:c(e.writes),invalidations:`${c(e.invalidations)} invalidations`,cleared:s(e.lastClearedAt)}});let A=0;async function V(){const e=Se.filter(s=>_(f,s.permission)),a=await Promise.all(e.map(s=>m(s.countUrl,{method:"GET"}).catch(()=>null)));d()&&(g.value=Object.fromEntries(e.map((s,n)=>[s.key,a[n]]).filter(([,s])=>s)))}async function z({first:e=!1}={}){const a=++A,s=e?[j(o.querySelectorAll(".dbx-kpi-value"),ie.figure),j(o.querySelectorAll(".dbx-loading"),{...ie.rows,count:3}),j(o.querySelector(".dbx-chart-note"),_e)]:[],n=()=>Promise.all(s.map(t=>t.done()));try{f=(await m("/auth/permissions",{method:"GET"}).catch(()=>({})))?.permissions||[];const[r,Me,oe]=await Promise.all([L.dashboard.summary().catch(T=>({__error:T})),m("/cache/status",{method:"GET"}).catch(()=>null),_(f,"pages")?L.pages.list().catch(()=>[]):Promise.resolve([]),V()]);if(await n(),a!==A||!d())return;B=new Set((Array.isArray(oe)?oe:[]).map(T=>T.urlPath).filter(Boolean)),r?.__error?(l.value=null,w.value=/403|forbidden/i.test(String(r.__error?.message))?"":`The dashboard could not load: ${r.__error.message}`):(l.value=r,w.value=r?.warnings?.length?`Some parts could not load: ${r.warnings.join(" \xB7 ")}`:""),p.value=Me,x.value=Date.now()}finally{await n(),a===A&&(y.value=!0)}}let be=0;async function ve(){if(!(!d()||!l.peek())){k.value=Date.now();try{const e=await L.dashboard.summaryLite();if(!d())return;const a={...l.peek()};e.traffic&&(a.traffic={...a.traffic,...e.traffic}),e.realtime&&(a.realtime=e.realtime),e.spikes&&(a.spikes=e.spikes),e.health&&(a.health={...a.health,status:e.health.status}),l.value=a,x.value=Date.now()}catch{}++be%2===0&&V(),m("/cache/status",{method:"GET"}).then(e=>{d()&&(p.value=e)}).catch(()=>{})}}const ge=setInterval(ve,Le);u.add(()=>clearInterval(ge));let h=null;const P=o.querySelector(".dbx-canvas"),ye=()=>{const e=getComputedStyle(o),a=(s,n)=>e.getPropertyValue(s).trim()||n;return{line:a("--dm-primary","#5b8cff"),text:a("--dm-text-muted","#94a3b8"),grid:a("--dm-border","rgba(127,127,127,.2)")}},we=e=>/^#[0-9a-f]{6}$/i.test(e)?`${e}26`:"transparent";function J(){if(!S.peek()||!P?.isConnected||!P.offsetParent)return;const{labels:e,values:a}=$.peek(),{line:s,text:n,grid:t}=ye();if(h){h.data.labels=e,h.data.datasets[0].data=a,h.update("none");return}h=new window.Chart(P,{type:"line",data:{labels:e,datasets:[{data:a,borderColor:s,backgroundColor:we(s),fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:n},grid:{color:t}},y:{beginAtZero:!0,ticks:{precision:0,color:n},grid:{color:t}}}}})}u.add(M.effect(()=>{$.value,S.value,requestAnimationFrame(J)})),u.add(()=>{try{h?.destroy()}catch{}h=null});const W=()=>[...document.body.classList].filter(e=>/^dm-(theme|custom)-/.test(e)).sort().join(" ");let Z=W();const Q=new MutationObserver(()=>{const e=W();if(e!==Z){Z=e;try{h?.destroy()}catch{}h=null,requestAnimationFrame(J)}});Q.observe(document.body,{attributes:!0,attributeFilter:["class"]}),u.add(()=>Q.disconnect());const R=e=>new URL(e,location.origin).href,X=()=>_(f,"pages"),Y=e=>O(e)&&B.has(e.split(/[?#]/)[0].replace(/\/$/,"")||"/");async function ee(e,a){try{await navigator.clipboard.writeText(e),E.toast(`${a} copied.`,{type:"success"})}catch{E.toast("Could not copy - select it and copy it by hand.",{type:"error"})}}function te(e){const{href:a,url:s}=e.dataset;if(a){location.hash=a;return}if(s&&X()&&Y(s)){location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`;return}s&&O(s)&&window.open(R(s),"_blank","noopener")}function xe(e){const{href:a,url:s}=e.dataset,n=[{header:(e.querySelector(".dbx-main strong")?.textContent||"").slice(0,60)}];return a&&n.push({label:"Open",icon:"arrow-right",action:()=>{location.hash=a}}),s&&O(s)?(n.push({label:"Open on the site",icon:"external-link",action:()=>window.open(R(s),"_blank","noopener")}),n.push({label:"Copy address",icon:"copy",action:()=>ee(R(s),"Address")}),X()&&n.push({label:"Edit the page",icon:"edit",disabled:!Y(s),action:()=>{location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`}})):s&&n.push({label:"Copy address",icon:"copy",action:()=>ee(s,"Address")}),n.length>1?n:[]}for(const e of[".dbx-attention",".dbx-activity",".dbx-top-pages",".dbx-spikes",".dbx-entry",".dbx-exit",".dbx-paths",".dbx-checks"]){const a=o.querySelector(e);a&&(a.innerHTML=Oe)}const ke=M.applyBindings({summary:l,loaded:y,warning:w,asOf:de,kpi:ce,attention:q,activity:F,topPages:N,spikes:D,entry:U,exit:H,paths:G,checks:K,hasJourneys:ue,bounce:he,hasChart:S,chartNote:pe,cache:p,cacheOn:me,cacheView:fe},o,{methods:{async toggleCache(e){const a=!!e?.target?.checked;try{p.value=await m("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})}),E.toast(`The cache is ${a?"on":"off"}.`,{type:"success"})}catch(s){e?.target&&(e.target.checked=!a),E.toast(s.message||"Could not change the cache.",{type:"error"})}},async clearCache(){if(await E.confirm("Clear the whole response cache? Pages render afresh until it fills again."))try{p.value=await m("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}}}});u.add(()=>ke.dispose()),u.add($e(o,{position:"bottom"}));const Ce=E.contextMenu(o,{cursor:"glyph",match:".dbx-row",inherit:!1,density:"compact",items:e=>xe(e)});u.add(()=>Ce?.destroy?.());const ae=e=>{const a=e.target.closest(".dbx-row");if(!a||!o.contains(a))return;const s=e.target.closest(".dbx-kebab");if(s){re(s);return}te(a)},se=e=>{const a=e.target.closest(".dbx-row");!a||!o.contains(a)||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(a)):e.key==="Enter"&&e.target===a&&te(a))};o.addEventListener("click",ae),o.addEventListener("keydown",se),u.add(()=>{o.removeEventListener("click",ae),o.removeEventListener("keydown",se)}),ne(i,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>z()}),ne(i,{id:"dashboard-new-page",icon:"plus",title:"New page",onClick:()=>{location.hash="#/pages/new"}}),I.scan(o),Be().then(e=>{d()&&(C.value=e)}),await z({first:!0}),d()&&I.scan(o)}};
7
+ </div>`;let v=null;function qe(){return window.Chart?Promise.resolve(!0):(v||(v=new Promise(i=>{const u=document.createElement("script");u.src=_e,u.async=!0,u.onload=()=>i(!0),u.onerror=()=>{v=null,i(!1)},document.head.appendChild(u)})),v)}const c=i=>Number(i||0).toLocaleString("en-GB"),Fe=i=>`${Number(i)>=0?"+":""}${Number(i).toFixed(0)}%`,g=(i,u,n,h={})=>({id:`${i}${u}`,text:n,meta:"",href:"",url:n,value:"",chip:"",chipCls:"",rowCls:"",menu:!0,...h}),d=Se(q);export const dashboardView={templateUrl:"/admin/js/templates/dashboard.html",async onMount(i){d.reset();const u=await f("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!q.test(location.hash))return;if(u?.enabled){const{mountBoard:e}=await import("./dashboard-board.js");await e(i,d,{isAdmin:u.isAdmin===!0});return}const n=i.get(0).querySelector(".dbx")||i.get(0),h=()=>q.test(location.hash)&&n.isConnected,l=M.observable(null),y=M.observable({}),w=M.observable(!1),x=M.observable(""),k=M.observable(null),C=M.observable(Date.now()),m=M.observable(null),$=M.observable(!!window.Chart);let b=[],F=new Set;const de=M.computed(()=>Le(l.value,y.value)),ue=M.computed(()=>{const e=k.value;if(!e)return"";const a=Math.floor((C.value-e)/6e4);return a<1?"Updated just now":`Updated ${a} min ago`}),N=M.observable([]),D=M.observable([]),U=M.observable([]),G=M.observable([]),H=M.observable([]),K=M.observable([]),V=M.observable([]),z=M.observable([]);d.add(M.effect(()=>{const e=l.value,a=t=>t==="danger"?"is-danger":"is-warn";N.value=Re(y.value,e?.health?.checks||[]).map(t=>({id:t.id,text:`${t.title}: ${t.text}`,meta:t.meta,href:t.href,url:"",value:"",chip:t.tone==="danger"?"Urgent":"Check",chipCls:a(t.tone),rowCls:a(t.tone)+(t.href?"":" is-static"),menu:!!t.href})),D.value=Pe(e?.activity||[],C.value).map(t=>({id:t.id,text:t.text,meta:t.meta,href:t.href,url:t.url,value:"",chip:"",chipCls:"",rowCls:"",menu:!0})),U.value=(e?.topPages||[]).map((t,r)=>g("t",r,t.url,{value:c(t.hits),chip:t.deltaPct==null?"":Fe(t.deltaPct),chipCls:Number(t.deltaPct)>=0?"is-ok":"is-danger"})),G.value=(e?.spikes||[]).map((t,r)=>g("s",r,t.url,{value:c(t.hits),chip:`\xD7${Number(t.ratio)||0}`,chipCls:"is-warn"}));const s=e?.journeys;H.value=(s?.entry||[]).map((t,r)=>g("e",r,t.url,{value:c(t.count)})),K.value=(s?.exit||[]).map((t,r)=>g("x",r,t.url,{value:c(t.count)})),V.value=(s?.paths||[]).map((t,r)=>({id:`p${r}`,text:`${t.from} \u2192 ${t.to}`,meta:"",href:"",url:"",value:c(t.count),chip:"",chipCls:"",rowCls:"is-static",menu:!1}));const o={ok:["OK","is-ok"],warn:["Look","is-warn"],fail:["Failing","is-danger"]};z.value=(e?.health?.checks||[]).map((t,r)=>({id:`c${r}`,text:String(t.label||t.id),meta:Array.isArray(t.detail)?t.detail.slice(0,5).join(", "):"",href:"",url:"",value:String(t.value??""),chip:(o[t.level]||o.warn)[0],chipCls:(o[t.level]||o.warn)[1],rowCls:"is-static",menu:!1}))}));const he=M.computed(()=>!!l.value?.journeys?.totalSessions),pe=M.computed(()=>{const e=l.value?.journeys;return e?.totalSessions?`Bounce rate ${(Number(e.bounceRate)*100).toFixed(1)}%`:""}),S=M.computed(()=>Te(l.value?.traffic?.series)),A=M.computed(()=>$.value&&!S.value.empty),me=M.computed(()=>w.value?l.value?$.value?"No page views recorded yet.":"The chart could not load (Chart.js).":"Traffic needs an administrator.":"Loading\u2026"),fe=M.computed(()=>!!m.value?.enabled),be=M.computed(()=>{const e=m.value||{},a=(e.hits||0)+(e.misses||0),s=o=>{if(!o)return"never";const t=Math.max(0,Math.floor((Date.now()-new Date(o).getTime())/1e3));return t<60?`${t}s ago`:t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} h ago`:`${Math.floor(t/86400)} days ago`};return{hitRate:e.hitRate==null?"-":`${(e.hitRate*100).toFixed(1)}%`,hitsMisses:a?`${c(e.hits)} hits \xB7 ${c(e.misses)} misses`:"no visits yet",size:e.maxItems?`${c(e.size)} / ${c(e.maxItems)}`:c(e.size),driver:e.driver?`driver: ${e.driver}`:"",writes:c(e.writes),invalidations:`${c(e.invalidations)} invalidations`,cleared:s(e.lastClearedAt)}});let P=0;async function J(){const e=Ae.filter(s=>B(b,s.permission)),a=await Promise.all(e.map(s=>f(s.countUrl,{method:"GET"}).catch(()=>null)));h()&&(y.value=Object.fromEntries(e.map((s,o)=>[s.key,a[o]]).filter(([,s])=>s)))}async function W({first:e=!1}={}){const a=++P,s=e?[_(n.querySelectorAll(".dbx-kpi-value"),ce.figure),_(n.querySelectorAll(".dbx-loading"),{...ce.rows,count:3}),_(n.querySelector(".dbx-chart-note"),Be)]:[],o=()=>Promise.all(s.map(t=>t.done()));try{b=(await f("/auth/permissions",{method:"GET"}).catch(()=>({})))?.permissions||[];const[r,Ee,re]=await Promise.all([j.dashboard.summary().catch(L=>({__error:L})),f("/cache/status",{method:"GET"}).catch(()=>null),B(b,"pages")?j.pages.list().catch(()=>[]):Promise.resolve([]),J()]);if(await o(),a!==P||!h())return;F=new Set((Array.isArray(re)?re:[]).map(L=>L.urlPath).filter(Boolean)),r?.__error?(l.value=null,x.value=/403|forbidden/i.test(String(r.__error?.message))?"":`The dashboard could not load: ${r.__error.message}`):(l.value=r,x.value=r?.warnings?.length?`Some parts could not load: ${r.warnings.join(" \xB7 ")}`:""),m.value=Ee,k.value=Date.now()}finally{await o(),a===P&&(w.value=!0)}}let ve=0;async function ge(){if(!(!h()||!l.peek())){C.value=Date.now();try{const e=await j.dashboard.summaryLite();if(!h())return;const a={...l.peek()};e.traffic&&(a.traffic={...a.traffic,...e.traffic}),e.realtime&&(a.realtime=e.realtime),e.spikes&&(a.spikes=e.spikes),e.health&&(a.health={...a.health,status:e.health.status}),l.value=a,k.value=Date.now()}catch{}++ve%2===0&&J(),f("/cache/status",{method:"GET"}).then(e=>{h()&&(m.value=e)}).catch(()=>{})}}const ye=setInterval(ge,je);d.add(()=>clearInterval(ye));let p=null;const R=n.querySelector(".dbx-canvas"),we=()=>{const e=getComputedStyle(n),a=(s,o)=>e.getPropertyValue(s).trim()||o;return{line:a("--dm-primary","#5b8cff"),text:a("--dm-text-muted","#94a3b8"),grid:a("--dm-border","rgba(127,127,127,.2)")}},xe=e=>/^#[0-9a-f]{6}$/i.test(e)?`${e}26`:"transparent";function Z(){if(!A.peek()||!R?.isConnected||!R.offsetParent)return;const{labels:e,values:a}=S.peek(),{line:s,text:o,grid:t}=we();if(p){p.data.labels=e,p.data.datasets[0].data=a,p.update("none");return}p=new window.Chart(R,{type:"line",data:{labels:e,datasets:[{data:a,borderColor:s,backgroundColor:xe(s),fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:o},grid:{color:t}},y:{beginAtZero:!0,ticks:{precision:0,color:o},grid:{color:t}}}}})}d.add(M.effect(()=>{S.value,A.value,requestAnimationFrame(Z)})),d.add(()=>{try{p?.destroy()}catch{}p=null});const Q=()=>[...document.body.classList].filter(e=>/^dm-(theme|custom)-/.test(e)).sort().join(" ");let X=Q();const Y=new MutationObserver(()=>{const e=Q();if(e!==X){X=e;try{p?.destroy()}catch{}p=null,requestAnimationFrame(Z)}});Y.observe(document.body,{attributes:!0,attributeFilter:["class"]}),d.add(()=>Y.disconnect());const T=e=>new URL(e,location.origin).href,ee=()=>B(b,"pages"),te=e=>O(e)&&F.has(e.split(/[?#]/)[0].replace(/\/$/,"")||"/");async function ae(e,a){try{await navigator.clipboard.writeText(e),E.toast(`${a} copied.`,{type:"success"})}catch{E.toast("Could not copy - select it and copy it by hand.",{type:"error"})}}function se(e){const{href:a,url:s}=e.dataset;if(a){location.hash=a;return}if(s&&ee()&&te(s)){location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`;return}s&&O(s)&&window.open(T(s),"_blank","noopener")}function ke(e){const{href:a,url:s}=e.dataset,o=[{header:(e.querySelector(".dbx-main strong")?.textContent||"").slice(0,60)}];return a&&o.push({label:"Open",icon:"arrow-right",action:()=>{location.hash=a}}),s&&O(s)?(o.push({label:"Open on the site",icon:"external-link",action:()=>window.open(T(s),"_blank","noopener")}),o.push({label:"Copy address",icon:"copy",action:()=>ae(T(s),"Address")}),ee()&&o.push({label:"Edit the page",icon:"edit",disabled:!te(s),action:()=>{location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`}})):s&&o.push({label:"Copy address",icon:"copy",action:()=>ae(s,"Address")}),o.length>1?o:[]}for(const e of[".dbx-attention",".dbx-activity",".dbx-top-pages",".dbx-spikes",".dbx-entry",".dbx-exit",".dbx-paths",".dbx-checks"]){const a=n.querySelector(e);a&&(a.innerHTML=Oe)}const Ce=M.applyBindings({summary:l,loaded:w,warning:x,asOf:ue,kpi:de,attention:N,activity:D,topPages:U,spikes:G,entry:H,exit:K,paths:V,checks:z,hasJourneys:he,bounce:pe,hasChart:A,chartNote:me,cache:m,cacheOn:fe,cacheView:be},n,{methods:{async toggleCache(e){const a=!!e?.target?.checked;try{m.value=await f("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})}),E.toast(`The cache is ${a?"on":"off"}.`,{type:"success"})}catch(s){e?.target&&(e.target.checked=!a),E.toast(s.message||"Could not change the cache.",{type:"error"})}},async clearCache(){if(await E.confirm("Clear the whole response cache? Pages render afresh until it fills again."))try{m.value=await f("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}}}});d.add(()=>Ce.dispose()),d.add($e(n,{position:"bottom"}));const Me=E.contextMenu(n,{cursor:"glyph",match:".dbx-row",inherit:!1,density:"compact",items:e=>ke(e)});d.add(()=>Me?.destroy?.());const oe=e=>{const a=e.target.closest(".dbx-row");if(!a||!n.contains(a))return;const s=e.target.closest(".dbx-kebab");if(s){le(s);return}se(a)},ne=e=>{const a=e.target.closest(".dbx-row");!a||!n.contains(a)||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),le(a)):e.key==="Enter"&&e.target===a&&se(a))};n.addEventListener("click",oe),n.addEventListener("keydown",ne),d.add(()=>{n.removeEventListener("click",oe),n.removeEventListener("keydown",ne)}),ie(i,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>W()}),ie(i,{id:"dashboard-new-page",icon:"plus",title:"New page",onClick:()=>{location.hash="#/pages/new"}}),I.scan(n),qe().then(e=>{h()&&($.value=e)}),await W({first:!0}),h()&&I.scan(n)}};
@@ -1 +1 @@
1
- import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{notificationsView as m}from"./notifications.js";import{layoutsView as p}from"./layouts.js";import{mediaView as n}from"./media.js";import{loginView as s}from"./login.js";import{usersView as f}from"./users.js";import{userEditorView as w}from"./user-editor.js";import{pluginsView as c}from"./plugins.js";import{pluginCodeView as V}from"./plugin-code.js";import{documentationView as a}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as d}from"./doc-pages.js";import{pluginGuidesView as u,pluginGuideView as E}from"./plugin-guides.js";import{apiReferenceView as g}from"./api-reference.js";import{collectionsView as v}from"./collections.js";import{collectionEditorView as b}from"./collection-editor.js";import{collectionEntriesView as h}from"./collection-entries.js";import{formsView as y}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as k}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as D}from"./actions-list.js";import{actionEditorView as G}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import{componentsView as A}from"./components.js";import{componentEditorView as C}from"./component-editor.js";import{myProfileView as N}from"./my-profile.js";import{rolesView as T}from"./roles.js";import{roleEditorView as U}from"./role-editor.js";import{effectsView as q}from"./effects.js";import{searchView as z}from"./search.js";import{contentApiView as B}from"./content-api.js";import{notesView as F}from"./notes.js";import{todoView as H}from"./todo.js";import{calculatorView as I}from"../lib/calculator.js";import{analyticsView as J}from"./analytics.js";import{seoView as K}from"./seo.js";import{contactsView as O}from"./contacts.js";import{callNewView as Q}from"./call-new.js";import{menusView as W}from"./menus.js";import{contextMenusView as X}from"./context-menus.js";import{contextMenuEditorView as Y}from"./context-menu-editor.js";import{menuEditorView as Z}from"./menu-editor.js";import{menuLocationsView as _}from"./menu-locations.js";import{projectsView as $}from"./projects.js";import{projectDetailView as oo}from"./project-detail.js";import{projectSettingsView as io}from"./project-settings.js";import{apiTokensView as ro}from"./api-tokens.js";import{apiEndpointsView as to}from"./api-endpoints.js";import{apiEndpointEditorView as eo}from"./api-endpoint-editor.js";const mo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:$,projectDetail:oo,projectSettings:io,apiTokens:ro,apiEndpoints:to,apiEndpointEditor:eo,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,layouts:p,media:n,login:s,users:f,userEditor:w,plugins:c,pluginCode:V,documentation:a,tutorials:l,apiReference:g,collections:v,collectionEditor:b,collectionEntries:h,forms:y,formEditor:j,formSubmissions:k,viewsList:L,viewEditor:x,viewPreview:M,actionsList:D,actionEditor:G,proDocs:P,blocks:R,blockEditor:S,components:A,componentEditor:C,myProfile:N,roles:T,roleEditor:U,effects:q,search:z,contentApi:B,notes:F,todo:H,calculator:I,analytics:J,seo:K,contacts:O,callNew:Q,notifications:m,menus:W,contextMenus:X,contextMenuEditor:Y,menuEditor:Z,menuLocations:_,menusRedirect:mo,pluginGuides:u,pluginGuide:E,...d};
1
+ import{dashboardView as o}from"./dashboard.js";import{myDashboardView as i}from"./my-dashboard.js";import{pagesView as r}from"./pages.js";import{pageEditorView as t}from"./page-editor.js";import{settingsView as e}from"./settings.js";import{themeView as m}from"./theme.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as n}from"./layouts.js";import{mediaView as s}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as l}from"./documentation.js";import{tutorialsView as d}from"./tutorials.js";import{docViews as u}from"./doc-pages.js";import{pluginGuidesView as E,pluginGuideView as g}from"./plugin-guides.js";import{apiReferenceView as b}from"./api-reference.js";import{collectionsView as h}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as y}from"./collection-entries.js";import{formsView as j}from"./forms.js";import{formEditorView as k}from"./form-editor.js";import{formSubmissionsView as D}from"./form-submissions.js";import{viewsListView as L}from"./views-list.js";import{viewEditorView as x}from"./view-editor.js";import{viewPreviewView as M}from"./view-preview.js";import{actionsListView as G}from"./actions-list.js";import{actionEditorView as P}from"./action-editor.js";import{proDocsView as R}from"./pro-docs.js";import{blocksView as S}from"./blocks.js";import{blockEditorView as A}from"./block-editor.js";import{componentsView as C}from"./components.js";import{componentEditorView as N}from"./component-editor.js";import{myProfileView as T}from"./my-profile.js";import{rolesView as U}from"./roles.js";import{roleEditorView as q}from"./role-editor.js";import{effectsView as z}from"./effects.js";import{searchView as B}from"./search.js";import{contentApiView as F}from"./content-api.js";import{notesView as H}from"./notes.js";import{todoView as I}from"./todo.js";import{calculatorView as J}from"../lib/calculator.js";import{analyticsView as K}from"./analytics.js";import{seoView as O}from"./seo.js";import{contactsView as Q}from"./contacts.js";import{callNewView as W}from"./call-new.js";import{menusView as X}from"./menus.js";import{contextMenusView as Y}from"./context-menus.js";import{contextMenuEditorView as Z}from"./context-menu-editor.js";import{menuEditorView as _}from"./menu-editor.js";import{menuLocationsView as $}from"./menu-locations.js";import{projectsView as oo}from"./projects.js";import{projectDetailView as io}from"./project-detail.js";import{projectSettingsView as ro}from"./project-settings.js";import{apiTokensView as to}from"./api-tokens.js";import{apiEndpointsView as eo}from"./api-endpoints.js";import{apiEndpointEditorView as mo}from"./api-endpoint-editor.js";const po={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:oo,projectDetail:io,projectSettings:ro,apiTokens:to,apiEndpoints:eo,apiEndpointEditor:mo,dashboard:o,myDashboard:i,pages:r,pageEditor:t,settings:e,theme:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:l,tutorials:d,apiReference:b,collections:h,collectionEditor:v,collectionEntries:y,forms:j,formEditor:k,formSubmissions:D,viewsList:L,viewEditor:x,viewPreview:M,actionsList:G,actionEditor:P,proDocs:R,blocks:S,blockEditor:A,components:C,componentEditor:N,myProfile:T,roles:U,roleEditor:q,effects:z,search:B,contentApi:F,notes:H,todo:I,calculator:J,analytics:K,seo:O,contacts:Q,callNew:W,notifications:p,menus:X,contextMenus:Y,contextMenuEditor:Z,menuEditor:_,menuLocations:$,menusRedirect:po,pluginGuides:E,pluginGuide:g,...u};
@@ -0,0 +1 @@
1
+ import{apiRequest as r}from"../api.js";import{viewLifetime as m}from"../lib/tool-kit.js";const o=/^#\/my-dashboard\/?(\?.*)?$/,a=m(o);export const myDashboardView={templateUrl:"/admin/js/templates/my-dashboard.html",async onMount(t){a.reset();const e=await r("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!o.test(location.hash))return;const i=t.get(0).querySelector(".dbx")||t.get(0);if(!e?.enabled){i.innerHTML='<p class="dbp-empty">My Dashboard comes with Dashboard Pro, which is not switched on for this site. Find it in the Marketplace.</p>';return}const{mountBoard:s}=await import("./dashboard-board.js");await s(t,a,{isAdmin:e.isAdmin===!0})}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -12,6 +12,12 @@ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin
12
12
  import {getHealth} from '../../services/health.js';
13
13
  import {listVersions} from '../../services/versions.js';
14
14
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
15
+ import {canAny as widgetCanAny, isDashboardPro} from '../../services/widgets.js';
16
+ import {getPermissionsForRoles} from '../../services/roles.js';
17
+ import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
18
+ import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
19
+ // The fixed Dashboard's own rules, so a widget says exactly what its card says.
20
+ import {BADGE_SOURCES, activityRows, attentionFrom, chartSeries} from '../../../admin/js/lib/dashboard-arrange.js';
15
21
 
16
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
23
  const ROOT = path.resolve(__dirname, '..', '..', '..');
@@ -290,6 +296,14 @@ export async function dashboardRoutes(fastify, opts = {}) {
290
296
 
291
297
  fastify.get('/dashboard/summary', guard, async (request) => {
292
298
  const lite = request.query.lite === '1' || request.query.lite === 'true';
299
+ return buildSummary(lite);
300
+ });
301
+
302
+ /**
303
+ * The summary the fixed Dashboard reads - and, piece by piece, core's own
304
+ * widgets on the Dashboard Pro board (GET /dashboard/widget/:name below).
305
+ */
306
+ async function buildSummary(lite) {
293
307
  const warnings = [];
294
308
  const safe = async (label, fn) => {
295
309
  try { return await fn(); }
@@ -374,5 +388,76 @@ export async function dashboardRoutes(fastify, opts = {}) {
374
388
  const activity = (await safe('activity', () => buildActivity())) || [];
375
389
 
376
390
  return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
391
+ }
392
+
393
+ /**
394
+ * Core's own widgets (services/widgets.js CORE_WIDGETS), each in its kind's
395
+ * shape. Dashboard Pro only - a site without it answers 404, as for every
396
+ * widget route. "Needs attention" asks the same sidebar badges the fixed
397
+ * Dashboard does, as the signed-in user, so nobody sees a Tool they cannot open.
398
+ */
399
+ fastify.get('/dashboard/widget/:name', {preHandler: [authenticate]}, async (request, reply) => {
400
+ if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
401
+ const name = String(request.params.name || '');
402
+ const admin = getEffectiveLevel(request.user) <= 1;
403
+ if (name !== 'attention' && !admin) return reply.status(403).send({error: 'Administrators only'});
404
+ const fmtN = (n) => Number(n || 0).toLocaleString('en-GB');
405
+
406
+ if (name === 'attention') {
407
+ const permissions = getPermissionsForRoles(getEffectiveRoles(request.user), getEffectiveRegistry().map(r => r.key));
408
+ const want = BADGE_SOURCES.filter(src => widgetCanAny(permissions, src.permission));
409
+ const got = await Promise.all(want.map(src => fastify.inject({
410
+ method: 'GET', url: `/api${src.countUrl}`,
411
+ headers: {authorization: request.headers.authorization || ''}
412
+ }).then(r => (r.statusCode === 200 ? r.json() : null)).catch(() => null)));
413
+ const badges = Object.fromEntries(want.map((src, i) => [src.key, got[i]]).filter(([, b]) => b));
414
+ const checks = admin ? ((await getHealth().catch(() => null))?.checks || []) : [];
415
+ const rows = attentionFrom(badges, checks);
416
+ return {
417
+ items: rows.map(r => ({text: `${r.title}: ${r.text}`, meta: r.meta, href: r.href, tone: r.tone})),
418
+ empty: 'Nothing needs you right now.'
419
+ };
420
+ }
421
+
422
+ const s = await buildSummary(false);
423
+ if (name === 'traffic') {
424
+ const c = chartSeries(s.traffic?.series);
425
+ return {labels: c.labels, values: c.values, label: 'Page views'};
426
+ }
427
+ if (name === 'visitors') {
428
+ const delta = s.traffic?.deltaPct;
429
+ return {
430
+ value: fmtN(s.realtime?.activeSessions), label: 'on the site now',
431
+ sub: `${fmtN(s.traffic?.today)} page views today${delta == null ? '' : ` (${delta >= 0 ? '+' : ''}${Number(delta).toFixed(0)}% on yesterday)`}`
432
+ };
433
+ }
434
+ if (name === 'top-pages') {
435
+ return {
436
+ items: (s.topPages || []).map(p => ({
437
+ text: p.url, value: fmtN(p.hits),
438
+ meta: p.deltaPct == null ? '' : `${p.deltaPct >= 0 ? '+' : ''}${Number(p.deltaPct).toFixed(0)}% on the week before`,
439
+ tone: p.deltaPct == null ? '' : (p.deltaPct >= 0 ? 'ok' : 'danger')
440
+ })),
441
+ empty: 'No page views in the last week.'
442
+ };
443
+ }
444
+ if (name === 'activity') {
445
+ return {
446
+ items: activityRows(s.activity || []).map(a => ({text: a.text, meta: a.meta, href: a.href})),
447
+ empty: 'Nothing has changed lately.'
448
+ };
449
+ }
450
+ if (name === 'health') {
451
+ const LEVEL = {ok: 'ok', warn: 'warn', fail: 'danger'};
452
+ return {
453
+ items: (s.health?.checks || []).map(c => ({
454
+ text: String(c.label || c.id), value: String(c.value ?? ''),
455
+ meta: Array.isArray(c.detail) ? c.detail.slice(0, 3).join(', ') : '',
456
+ tone: LEVEL[c.level] || 'warn'
457
+ })),
458
+ empty: 'No health checks ran.'
459
+ };
460
+ }
461
+ return reply.status(404).send({error: 'Not found'});
377
462
  });
378
463
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Dashboard Pro - the widget list and the board layouts.
3
+ *
4
+ * GET /api/dashboard/pro {enabled} - whether this site has the board (any signed-in user)
5
+ * GET /api/widgets the widgets this user may add (Pro)
6
+ * GET /api/dashboard/layout {layout, source: mine|site|built-in} (Pro)
7
+ * PUT /api/dashboard/layout save mine (Pro)
8
+ * DELETE /api/dashboard/layout go back to the site's default (Pro)
9
+ * GET /api/dashboard/layout/default the site default (Pro, admins)
10
+ * PUT /api/dashboard/layout/default set it (Pro, admins)
11
+ *
12
+ * Every route but /dashboard/pro answers 404 while no Pro Tool has switched the
13
+ * board on (services/widgets.js) - a free site has none of this.
14
+ *
15
+ * A layout is {version, widgets: [{id, size}]}, in board order. Mine is kept in
16
+ * content/dashboard/layouts/<user id>.json (ignored in a site repo - it is per
17
+ * user), the site default in content/dashboard/default.json (tracked).
18
+ */
19
+ import fs from 'fs/promises';
20
+ import path from 'path';
21
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
22
+ import {getPermissionsForRoles} from '../../services/roles.js';
23
+ import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
24
+ import {getEffectiveRegistry} from '../../services/permissionRegistry.js';
25
+ import {
26
+ DEFAULT_LAYOUT, getWidgets, isDashboardPro, layoutProblems, normaliseLayout, widgetsFor
27
+ } from '../../services/widgets.js';
28
+
29
+ const DIR = path.resolve('content', 'dashboard');
30
+ const LAYOUTS = path.join(DIR, 'layouts');
31
+ const DEFAULT_FILE = path.join(DIR, 'default.json');
32
+ const USER_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
33
+
34
+ async function readJson(file) {
35
+ try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
36
+ }
37
+
38
+ /** Write via a temporary file, so a crash never leaves half a layout. */
39
+ async function writeJson(file, data) {
40
+ await fs.mkdir(path.dirname(file), {recursive: true});
41
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
42
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
43
+ await fs.rename(tmp, file);
44
+ }
45
+
46
+ const userFile = (user) => (USER_ID_RE.test(String(user?.id || '')) ? path.join(LAYOUTS, `${user.id}.json`) : null);
47
+
48
+ /** The site default, or the built-in one. */
49
+ async function siteDefault() {
50
+ const stored = await readJson(DEFAULT_FILE);
51
+ return stored ? {layout: normaliseLayout(stored), source: 'site'} : {layout: normaliseLayout(DEFAULT_LAYOUT), source: 'built-in'};
52
+ }
53
+
54
+ /** Who is asking, in the terms widgetsFor() takes. */
55
+ function who(user) {
56
+ const permissions = getPermissionsForRoles(getEffectiveRoles(user), getEffectiveRegistry().map(r => r.key));
57
+ return {permissions, isAdmin: getEffectiveLevel(user) <= 1};
58
+ }
59
+
60
+ export async function widgetRoutes(fastify, opts = {}) {
61
+ const authenticate = opts.authenticate || defaultAuthenticate;
62
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
63
+ const pro = async (request, reply) => {
64
+ if (!isDashboardPro()) return reply.status(404).send({error: 'Not found'});
65
+ };
66
+
67
+ // isAdmin: whether this user may set the site's default layout.
68
+ fastify.get('/dashboard/pro', {preHandler: [authenticate]}, async (request) => ({
69
+ enabled: isDashboardPro(),
70
+ isAdmin: getEffectiveLevel(request.user) <= 1
71
+ }));
72
+
73
+ fastify.get('/widgets', {preHandler: [authenticate, pro]}, async (request) => widgetsFor(getWidgets(), who(request.user)));
74
+
75
+ fastify.get('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
76
+ const file = userFile(request.user);
77
+ const mine = file ? await readJson(file) : null;
78
+ return mine ? {layout: normaliseLayout(mine), source: 'mine'} : siteDefault();
79
+ });
80
+
81
+ fastify.put('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request, reply) => {
82
+ const problems = layoutProblems(request.body);
83
+ if (problems.length) return reply.status(400).send({error: problems[0]});
84
+ const file = userFile(request.user);
85
+ if (!file) return reply.status(400).send({error: 'Your account cannot keep a layout.'});
86
+ const layout = normaliseLayout(request.body);
87
+ await writeJson(file, layout);
88
+ return {layout, source: 'mine'};
89
+ });
90
+
91
+ fastify.delete('/dashboard/layout', {preHandler: [authenticate, pro]}, async (request) => {
92
+ const file = userFile(request.user);
93
+ if (file) await fs.rm(file, {force: true});
94
+ return siteDefault();
95
+ });
96
+
97
+ fastify.get('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async () => siteDefault());
98
+
99
+ fastify.put('/dashboard/layout/default', {preHandler: [authenticate, pro, requireAdmin]}, async (request, reply) => {
100
+ const problems = layoutProblems(request.body);
101
+ if (problems.length) return reply.status(400).send({error: problems[0]});
102
+ const layout = normaliseLayout(request.body);
103
+ await writeJson(DEFAULT_FILE, layout);
104
+ return {layout, source: 'site'};
105
+ });
106
+ }
package/server/server.js CHANGED
@@ -523,6 +523,7 @@ const {effectsRoutes} = await import('./routes/api/effects.js');
523
523
  const {searchRoutes} = await import('./routes/api/search.js');
524
524
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
525
525
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
526
+ const {widgetRoutes} = await import('./routes/api/widgets.js');
526
527
  const {cacheRoutes} = await import('./routes/api/cache.js');
527
528
  const {scaffoldRoutes} = await import('./routes/api/scaffold.js');
528
529
 
@@ -558,6 +559,7 @@ await app.register(effectsRoutes, {prefix: '/api'});
558
559
  await app.register(searchRoutes, {prefix: '/api'});
559
560
  await app.register(notificationsRoutes, {prefix: '/api'});
560
561
  await app.register(dashboardRoutes, {prefix: '/api'});
562
+ await app.register(widgetRoutes, {prefix: '/api'});
561
563
  await app.register(cacheRoutes, {prefix: '/api'});
562
564
  await app.register(scaffoldRoutes, {prefix: '/api'});
563
565
  // Tools promoted out of plugins/ - their own prefixes, not /api/plugins/*.
@@ -38,6 +38,7 @@ import {registerThemeProvider} from './themeSettings.js';
38
38
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
39
39
  import {notifierFor} from './notify.js';
40
40
  import {disableInstantCalls, enableInstantCalls, grantInstantCallsOnce} from './instantCalls.js';
41
+ import {disableDashboardPro, enableDashboardPro, registerWidget, unregisterWidgetsFrom} from './widgets.js';
41
42
  import {createCollection, getCollection} from './collections.js';
42
43
  import * as defaultRolesService from './roles.js';
43
44
  import {getAdminScope, normaliseAdminScope} from './roles.js';
@@ -560,6 +561,10 @@ export async function registerPlugins(fastify) {
560
561
  // them on at boot. Bound to the plugin's name, tier and closedSource: only a paid plugin that
561
562
  // loads under a licence may.
562
563
  enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
564
+ // Dashboard Pro (services/widgets.js) - the widget board; the same contract as instant calls.
565
+ enableDashboardPro: () => enableDashboardPro({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
566
+ // A widget from code (anything richer than a manifest's admin.widgets entry), owned by this plugin.
567
+ registerWidget: (def) => registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name}),
563
568
  // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
564
569
  registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
565
570
  // Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
@@ -594,6 +599,14 @@ export async function registerPlugins(fastify) {
594
599
  }
595
600
  }
596
601
 
602
+ // Dashboard widgets the manifest offers (services/widgets.js) - drawn by the admin
603
+ // from their `kind` and `dataUrl`, so a Tool needs no code to have one.
604
+ for (const def of manifest.admin?.widgets || []) {
605
+ if (!registerWidget({...def, owner: manifest.name, source: manifest.displayName || manifest.name})) {
606
+ fastify.log.warn(`[plugins] widget "${def?.id}" for "${manifest.name}" skipped: not a valid widget`);
607
+ }
608
+ }
609
+
597
610
  // Ensure plugin collections exist (idempotent - skips if already present)
598
611
  await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
599
612
  .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
@@ -1123,6 +1136,8 @@ export async function runLifecycleHook(name, hook, fastify) {
1123
1136
  // A Tool with calls switched off stops keeping instant calls on - at once, not at the next restart.
1124
1137
  if (hook === 'onDisable') {
1125
1138
  disableInstantCalls(name);
1139
+ disableDashboardPro(name);
1140
+ unregisterWidgetsFrom(name);
1126
1141
  unregisterContactAccess(name);
1127
1142
  unregisterDirectMessenger(name);
1128
1143
  }
@@ -317,6 +317,8 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
317
317
  '/content/.migrations.json',
318
318
  '/content/instances_log.json',
319
319
  '/content/preview-links.json',
320
+ // Each admin's own Dashboard Pro layout (keyed by user id); the site default beside it is tracked.
321
+ '/content/dashboard/layouts/',
320
322
  '/config/connections.json*',
321
323
  '/config/keys/',
322
324
  '/plugins/*/data/*.jsonl',