domma-cms 0.25.12 → 0.25.14
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/admin/css/dashboard.css +1 -1
- package/admin/js/templates/dashboard/traffic-chart.html +1 -1
- package/admin/js/templates/menu-editor.html +7 -0
- package/admin/js/views/menu-editor.js +8 -8
- package/package.json +1 -1
- package/server/routes/api/dashboard.js +115 -10
- package/server/services/menus.js +2 -0
package/admin/css/dashboard.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.dashboard-grid{display:flex;flex-direction:column;gap:var(--dm-spacing-4, 16px)}.dash-row{display:grid;gap:var(--dm-spacing-4, 16px)}.dash-row-kpi{grid-template-columns:repeat(4,1fr)}.dash-row-traffic{grid-template-columns:2fr 1fr}.dash-row-content,.dash-row-health{grid-template-columns:1fr 1fr}@media(max-width:960px){.dash-row-kpi{grid-template-columns:repeat(2,1fr)}.dash-row-traffic,.dash-row-content,.dash-row-health{grid-template-columns:1fr}}.dash-card{background:var(--dm-surface, #fff);border:1px solid var(--dm-border, #e5e7eb);border-radius:var(--dm-radius, 8px);padding:var(--dm-spacing-4, 16px)}.dash-kpi{display:flex;align-items:center;gap:var(--dm-spacing-3, 12px)}.dash-kpi-icon{font-size:24px;opacity:.8}.dash-kpi-value{font-size:28px;font-weight:600;line-height:1}.dash-kpi-label{font-size:12px;color:var(--dm-muted, #6b7280);text-transform:uppercase;letter-spacing:.05em}.dash-kpi-delta{font-size:12px;margin-top:4px}.dash-kpi-delta.up{color:var(--dm-success, #16a34a)}.dash-kpi-delta.down{color:var(--dm-danger, #dc2626)}.dash-health-pill{display:inline-flex;align-items:center;gap:6px}.dash-health-pill[data-level=ok]{color:var(--dm-success, #16a34a)}.dash-health-pill[data-level=warn]{color:var(--dm-warning, #d97706)}.dash-health-pill[data-level=fail]{color:var(--dm-danger, #dc2626)}.dash-spike-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed var(--dm-border, #e5e7eb)}.dash-spike-row:last-child{border-bottom:0}.dash-spark{display:inline-block;width:80px;height:24px;vertical-align:middle;margin-left:8px}.dash-warnings{background:var(--dm-warning-bg, #fef3c7);border:1px solid var(--dm-warning, #d97706);color:var(--dm-warning-fg, #78350f);padding:8px 12px;border-radius:var(--dm-radius, 8px);margin-bottom:12px}.dash-updated{font-size:12px;color:var(--dm-muted, #6b7280);margin-right:8px}.dash-journey-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}@media(max-width:720px){.dash-journey-grid{grid-template-columns:1fr}}.dash-journey-grid h4{font-size:12px;text-transform:uppercase;color:var(--dm-muted, #6b7280);margin:0 0 6px}.dash-bounce{margin-top:12px;font-size:14px;color:var(--dm-muted, #6b7280)}.dash-empty{font-size:13px;color:var(--dm-muted, #6b7280);margin:8px 0 0}.dash-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.dash-card-header h3{margin:0}.dash-cache-toggle{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--dm-muted, #6b7280);cursor:pointer}.dash-cache-toggle input[type=checkbox]{transform:scale(1.2);cursor:pointer}.dash-cache-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:12px}@media(max-width:720px){.dash-cache-grid{grid-template-columns:repeat(2,1fr)}}.dash-cache-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-muted, #6b7280);margin-bottom:4px}.dash-cache-stat-value{font-size:22px;font-weight:600;line-height:1.1}.dash-cache-stat-sub{font-size:12px;color:var(--dm-muted, #6b7280);margin-top:4px;min-height:14px}[data-field=size-bar]{height:4px;background:var(--dm-border, #e5e7eb);border-radius:2px;overflow:hidden;margin-top:6px}[data-field=size-fill]{display:block;height:100%;background:var(--dm-primary, #2563eb);width:0;transition:width .3s ease}.dash-cache-actions{display:flex;gap:8px;justify-content:flex-end}
|
|
1
|
+
.dashboard-grid{display:flex;flex-direction:column;gap:var(--dm-spacing-4, 16px)}.dash-row{display:grid;gap:var(--dm-spacing-4, 16px)}.dash-row-kpi{grid-template-columns:repeat(4,1fr)}.dash-row-traffic{grid-template-columns:2fr 1fr}.dash-row-content,.dash-row-health{grid-template-columns:1fr 1fr}@media(max-width:960px){.dash-row-kpi{grid-template-columns:repeat(2,1fr)}.dash-row-traffic,.dash-row-content,.dash-row-health{grid-template-columns:1fr}}.dash-card{background:var(--dm-surface, #fff);border:1px solid var(--dm-border, #e5e7eb);border-radius:var(--dm-radius, 8px);padding:var(--dm-spacing-4, 16px)}.dash-kpi{display:flex;align-items:center;gap:var(--dm-spacing-3, 12px)}.dash-kpi-icon{font-size:24px;opacity:.8}.dash-kpi-value{font-size:28px;font-weight:600;line-height:1}.dash-kpi-label{font-size:12px;color:var(--dm-muted, #6b7280);text-transform:uppercase;letter-spacing:.05em}.dash-kpi-delta{font-size:12px;margin-top:4px}.dash-kpi-delta.up{color:var(--dm-success, #16a34a)}.dash-kpi-delta.down{color:var(--dm-danger, #dc2626)}.dash-health-pill{display:inline-flex;align-items:center;gap:6px}.dash-health-pill[data-level=ok]{color:var(--dm-success, #16a34a)}.dash-health-pill[data-level=warn]{color:var(--dm-warning, #d97706)}.dash-health-pill[data-level=fail]{color:var(--dm-danger, #dc2626)}.dash-spike-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed var(--dm-border, #e5e7eb)}.dash-spike-row:last-child{border-bottom:0}.dash-spark{display:inline-block;width:80px;height:24px;vertical-align:middle;margin-left:8px}.dash-warnings{background:var(--dm-warning-bg, #fef3c7);border:1px solid var(--dm-warning, #d97706);color:var(--dm-warning-fg, #78350f);padding:8px 12px;border-radius:var(--dm-radius, 8px);margin-bottom:12px}.dash-updated{font-size:12px;color:var(--dm-muted, #6b7280);margin-right:8px}.dash-journey-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}@media(max-width:720px){.dash-journey-grid{grid-template-columns:1fr}}.dash-journey-grid h4{font-size:12px;text-transform:uppercase;color:var(--dm-muted, #6b7280);margin:0 0 6px}.dash-bounce{margin-top:12px;font-size:14px;color:var(--dm-muted, #6b7280)}.dash-empty{font-size:13px;color:var(--dm-muted, #6b7280);margin:8px 0 0}.dash-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.dash-card-header h3{margin:0}.dash-cache-toggle{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--dm-muted, #6b7280);cursor:pointer}.dash-cache-toggle input[type=checkbox]{transform:scale(1.2);cursor:pointer}.dash-cache-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:12px}@media(max-width:720px){.dash-cache-grid{grid-template-columns:repeat(2,1fr)}}.dash-cache-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--dm-muted, #6b7280);margin-bottom:4px}.dash-cache-stat-value{font-size:22px;font-weight:600;line-height:1.1}.dash-cache-stat-sub{font-size:12px;color:var(--dm-muted, #6b7280);margin-top:4px;min-height:14px}[data-field=size-bar]{height:4px;background:var(--dm-border, #e5e7eb);border-radius:2px;overflow:hidden;margin-top:6px}[data-field=size-fill]{display:block;height:100%;background:var(--dm-primary, #2563eb);width:0;transition:width .3s ease}.dash-cache-actions{display:flex;gap:8px;justify-content:flex-end}.dash-chart-wrap{position:relative;height:240px}.dash-chart-wrap>canvas{position:absolute;inset:0}
|
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
<option value="floating">Floating</option>
|
|
49
49
|
</select>
|
|
50
50
|
</div>
|
|
51
|
+
<div class="form-group">
|
|
52
|
+
<label>Open on hover</label>
|
|
53
|
+
<label style="display:flex;align-items:center;gap:.5rem;font-weight:400;cursor:pointer">
|
|
54
|
+
<input type="checkbox" id="me-appearOnHover" name="appearOnHover" style="width:auto;margin:0">
|
|
55
|
+
Reveal dropdowns on hover (desktop navbar; click still works)
|
|
56
|
+
</label>
|
|
57
|
+
</div>
|
|
51
58
|
<div class="form-group">
|
|
52
59
|
<label for="me-fontFamily">Font family</label>
|
|
53
60
|
<input type="text" id="me-fontFamily" name="fontFamily" class="form-input" placeholder="Inter">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
<div class="menu-tree-row menu-tree-row--separator" data-id="${
|
|
1
|
+
import{api as x}from"../api.js";import{colourToCss as U}from"/public/js/menu-decor.mjs";import{openIconPicker as V}from"../lib/card-builder.js";let G=1;const C=()=>"i_"+G++,K=760;function q(t,l=null,a=[]){for(const n of t||[]){const e=C();if(n&&n.type==="separator"){a.push({id:e,parentId:l,type:"separator"});continue}a.push({id:e,parentId:l,text:n.text||"",url:n.url||"",icon:n.icon||"",visibility:n.visibility||"",permission:n.permission||"",hidden:!!n.hidden,bundled:!!n.bundled,badge:n.badge?{...n.badge}:null,pill:n.pill?{...n.pill}:null,colour:n.colour||""}),Array.isArray(n.items)&&n.items.length&&q(n.items,e,a)}return a}function X(t){const l=new Map;for(const n of t)l.has(n.parentId)||l.set(n.parentId,[]),l.get(n.parentId).push(n);function a(n){return(l.get(n)||[]).map(e=>{if(e.type==="separator")return{type:"separator"};const r=a(e.id);return{text:(e.text||"").trim(),url:(e.url||"").trim(),...e.icon&&{icon:e.icon.trim()},...e.visibility&&{visibility:e.visibility},...e.permission&&{permission:e.permission},...e.hidden&&{hidden:!0},...e.bundled&&{bundled:!0},...e.badge&&(e.badge.text||e.badge.countFrom)?{badge:{...e.badge.text&&{text:e.badge.text},...e.badge.countFrom&&{countFrom:e.badge.countFrom},...e.badge.variant&&{variant:e.badge.variant}}}:{},...e.pill&&e.pill.variant?{pill:{style:e.pill.style||"filled",variant:e.pill.variant}}:{},...e.colour&&{colour:e.colour},...r.length&&{items:r}}})}return a(null)}function O(t){return String(t||"").replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<")}function J(t,l){let a=0,n=l.find(e=>e.id===t);for(;n&&n.parentId;)a++,n=l.find(e=>e.id===n.parentId);return a}function w(t,l,a=()=>""){t.html("");function n(e){for(const r of l.filter(f=>f.parentId===e)){const f=J(r.id,l);if(r.type==="separator"){t.append(`
|
|
2
|
+
<div class="menu-tree-row menu-tree-row--separator" data-id="${r.id}" style="margin-left:${f*28}px">
|
|
3
3
|
<span class="menu-tree-grip" data-icon="move"></span>
|
|
4
4
|
<span class="me-sep-line"><span class="me-sep-label">Separator</span></span>
|
|
5
5
|
<button class="btn btn-sm btn-ghost me-ins-item" data-tooltip="Add item below"><span data-icon="plus"></span></button>
|
|
@@ -10,19 +10,19 @@ import{api as k}from"../api.js";import{colourToCss as U}from"/public/js/menu-dec
|
|
|
10
10
|
<button class="btn btn-sm btn-ghost me-down" data-tooltip="Move down"><span data-icon="arrow-down"></span></button>
|
|
11
11
|
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
12
12
|
</div>
|
|
13
|
-
`);continue}const
|
|
14
|
-
<div class="menu-tree-row menu-tree-row--slim${
|
|
13
|
+
`);continue}const S=r.badge&&(r.badge.text||r.badge.countFrom)?`<span class="dm-menu-badge">${O(String(r.badge.text||"#"))}</span>`:"",y=U(r.colour),F=y?` style="color:${O(y)}"`:"";t.append(`
|
|
14
|
+
<div class="menu-tree-row menu-tree-row--slim${r.hidden?" menu-tree-row--hidden":""}" data-id="${r.id}" style="margin-left:${f*28}px">
|
|
15
15
|
<span class="menu-tree-grip" data-icon="move"></span>
|
|
16
|
-
<span class="me-row-label"${
|
|
17
|
-
<span class="me-row-url">${
|
|
16
|
+
<span class="me-row-label"${F}>${O(r.text||"(untitled)")}${S}</span>
|
|
17
|
+
<span class="me-row-url">${O(r.url||"")}</span>
|
|
18
18
|
<button class="btn btn-sm btn-ghost me-ins-item" data-tooltip="Add item below"><span data-icon="plus"></span></button>
|
|
19
19
|
<button class="btn btn-sm btn-ghost me-ins-sep" data-tooltip="Add separator below"><span data-icon="minus"></span></button>
|
|
20
20
|
<button class="btn btn-sm btn-ghost me-outdent" data-tooltip="Outdent"><span data-icon="chevron-left"></span></button>
|
|
21
21
|
<button class="btn btn-sm btn-ghost me-indent" data-tooltip="Indent"><span data-icon="chevron-right"></span></button>
|
|
22
22
|
<button class="btn btn-sm btn-ghost me-up" data-tooltip="Move up"><span data-icon="arrow-up"></span></button>
|
|
23
23
|
<button class="btn btn-sm btn-ghost me-down" data-tooltip="Move down"><span data-icon="arrow-down"></span></button>
|
|
24
|
-
<button class="btn btn-sm btn-ghost me-hidden ${
|
|
24
|
+
<button class="btn btn-sm btn-ghost me-hidden ${r.hidden?"active":""}" data-tooltip="${r.hidden?"Show":"Hide"}"><span data-icon="eye-off"></span></button>
|
|
25
25
|
<button class="btn btn-sm btn-secondary me-edit" data-tooltip="Edit"><span data-icon="edit"></span></button>
|
|
26
26
|
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
27
27
|
</div>
|
|
28
|
-
`),n(d.id)}}n(null),Domma.icons.scan(t.get(0)),t.get(0).querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}function S(t,l){t.find(".menu-tree-row").each(function(){const a=$(this).data("id"),n=l.find(_=>_.id===a);if(!n||n.type==="separator")return;const e=$(this).find(".me-text");e.length&&(n.text=e.val());const d=$(this).find(".me-url");d.length&&(n.url=d.val());const f=$(this).find(".me-icon");f.length&&(n.icon=f.val());const F=$(this).find(".me-vis");F.length&&(n.visibility=F.val());const v=$(this).find(".me-perm");v.length&&(n.permission=v.val())})}function it(t,l){const a={};for(const n of l){const e=t.querySelector(`[name="${n}"]`);e&&(a[n]=e.type==="checkbox"?e.checked:e.value)}return a}function g(t,l={},...a){const n=document.createElement(t);Object.assign(n,l);for(const e of a)e!=null&&n.append(e);return n}function I(t,l,a){const n=g("label",{className:"form-field"},g("span",{className:"form-label",textContent:t}),l);return a&&n.append(g("small",{className:"form-hint",textContent:a,style:"display:block;font-size:.78em;opacity:.7;margin-top:.25rem;line-height:1.35"})),n}function Q(t,l){const a=[["","\u2014 Everyone \u2014"],["public","Public \u2014 anyone, incl. logged-out"],...(l||[]).map(e=>[e.name,`${e.label||e.name} and above`]),["private","Private \u2014 super-admin only"]];t&&!a.some(([e])=>e===t)&&a.push([t,`${t} (custom)`]);const n=N(a,t||"");return n.name="visibility",n}function N(t,l){const a=g("select",{className:"form-select"});return t.forEach(([n,e])=>a.add(new Option(e,n))),a.value=l??"",a}function B(t,l){const a=typeof l=="string"&&l.startsWith("#"),n=N([["","\u2014 none \u2014"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],a?"__custom__":l||""),e=g("input",{type:"color",className:"form-input me-colour-hex",value:a?l:"#000000"});e.style.display=a?"":"none",n.addEventListener("change",()=>{e.style.display=n.value==="__custom__"?"":"none"});const d=I(t,n);return d.append(e),d._read=()=>n.value==="__custom__"?e.value:n.value,d}function Y(t,l){const a=g("input",{className:"form-input me-icon",name:"icon",value:l||""}),n=g("span",{className:"me-icon-preview"});l&&n.setAttribute("data-icon",l);const e=g("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),d=f=>{f?n.setAttribute("data-icon",f):n.removeAttribute("data-icon"),Domma.icons.scan(n.parentNode||n)};return e.addEventListener("click",()=>V(f=>{a.value=f,d(f)})),a.addEventListener("input",()=>d(a.value.trim())),I(t,g("div",{className:"me-icon-field"},a,e,n))}function W(...t){return g("div",{className:"me-field-grid"},...t)}async function Z(t,l,a,n,e){const d=b=>g("h4",{className:"me-form-section",textContent:b}),f=I("Label",g("input",{className:"form-input",name:"text",value:t.text||""}),"The text shown for this item."),F=I("URL",g("input",{className:"form-input",name:"url",value:t.url||""}),"Where it links. Must start with /, #, https://, or mailto: . Leave blank for a parent that only groups sub-items."),v=Y("Icon",t.icon||""),_=I("Visibility",Q(t.visibility||"",n),"Who can see this item, by role seniority. Picking a role shows it to that role and anyone more senior. Blank = everyone."),L=N([["","\u2014 no permission gate \u2014"],...(l||[]).map(b=>[b.key,b.label||b.key])],t.permission||"");L.name="permission";const R=I("Permission",L,"Hide unless the viewer holds this specific permission. Independent of Visibility \u2014 both must pass."),h=I("Badge text",g("input",{className:"form-input",name:"badgeText",value:t.badge?.text||""})),i=N([["","\u2014 none \u2014"],...(a||[]).map(b=>[b.slug,b.title||b.slug])],t.badge?.countFrom||"");i.name="badgeCount";const p=I("Badge count from",i,"Show a live count of this collection\u2019s entries as the badge."),j=B("Badge colour",t.badge?.variant||""),P=N([["link","Link"],["pill","Pill"]],t.pill?"pill":"link");P.name="renderAs";const z=I("Render as",P,"Pill draws the item as a rounded chip. Pill styling applies to top-level navbar items."),C=I("Pill style",N([["filled","Filled"],["outline","Outline"]],t.pill?.style||"filled"));C.querySelector("select").name="pillStyle";const s=B("Pill colour",t.pill?.variant||""),o=g("div",{className:"me-pill-wrap"},W(C,s));o.style.display=t.pill?"":"none",P.addEventListener("change",()=>{o.style.display=P.value==="pill"?"":"none"});const c=B("Text colour",t.colour||""),u=g("button",{className:"btn btn-primary",textContent:"Apply"}),m=g("div",{className:"me-item-form"},d("Link"),f,F,v,d("Access"),W(_,R),d("Badge"),W(h,j),p,d("Appearance"),z,o,c,u),r=E.slideover({title:"Edit menu item",size:"lg",position:"right"});r.element.appendChild(m),r.open();const y=r.element.closest(".dm-slideover")||r.element.querySelector(".dm-slideover")||r.element;y.style.setProperty("width",K+"px","important"),y.style.setProperty("max-width","95vw","important"),Domma.icons.scan(m);const w=b=>m.querySelector(`[name="${b}"]`)?.value||"";u.addEventListener("click",()=>{t.text=w("text"),t.url=w("url"),t.icon=w("icon"),t.visibility=w("visibility"),t.permission=w("permission");const b=w("badgeText"),T=w("badgeCount"),M=j._read();if(t.badge=b||T?{...b&&{text:b},...T&&{countFrom:T},...M&&{variant:M}}:null,w("renderAs")==="pill"){const D=s._read();t.pill={style:w("pillStyle")||"filled",...D&&{variant:D}}}else t.pill=null;t.colour=c._read(),r.close(),e()})}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(t){const l=location.hash.match(/^#\/menus\/edit\/(.+)$/),a=l?decodeURIComponent(l[1]):null,n=!a;let e;if(n)e={slug:"",name:"",description:"",items:[]};else try{e=await k.menus.get(a)}catch(s){E.toast(`Failed to load menu: ${s.message||s}`,{type:"error"}),location.hash="#/menus";return}const d=await k.projects.list().catch(()=>[]),f=await H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),F=await k.collections.list().catch(()=>[]),v=await k.get("/collections/roles/entries?limit=100").catch(()=>null),L=(Array.isArray(v)?v:v?.entries||[]).map(s=>({name:s.data?.name,label:s.data?.label,level:s.data?.level??99})).filter(s=>s.name).sort((s,o)=>o.level-s.level),R='<option value="">\u2014 no permission gate \u2014</option>',h=s=>R+(f.resources||[]).map(o=>{const c=(s||"")===o.key?" selected":"";return`<option value="${o.key}"${c}>${o.label||o.key}</option>`}).join("");t.find("#me-title").text(n?"New menu":`Edit "${e.slug}"`);let i=q(e.items);const p=t.find("#me-items-list");x(p,i,h),t.find("#me-add-item").on("click",()=>{S(p,i),i.push({id:O(),parentId:null,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}),x(p,i,h)}),t.find("#me-add-separator").on("click",()=>{S(p,i),i.push({id:O(),parentId:null,type:"separator"}),x(p,i,h)});function j(s){return function(){S(p,i);const o=$(this).closest(".menu-tree-row").data("id"),c=i.findIndex(r=>r.id===o);if(c===-1)return;const u=i[c],m=s==="separator"?{id:O(),parentId:u.parentId,type:"separator"}:{id:O(),parentId:u.parentId,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1};i.splice(c+1,0,m),x(p,i,h)}}t.off("click",".me-ins-item").on("click",".me-ins-item",j("item")),t.off("click",".me-ins-sep").on("click",".me-ins-sep",j("separator")),t.off("click",".me-remove").on("click",".me-remove",function(){const s=$(this).closest(".menu-tree-row").data("id");i=i.filter(o=>o.id!==s&&o.parentId!==s),x(p,i,h)}),t.off("click",".me-indent").on("click",".me-indent",function(){S(p,i);const s=$(this).closest(".menu-tree-row").data("id"),o=i.find(m=>m.id===s);if(!o)return;const c=i.filter(m=>m.parentId===o.parentId),u=c.findIndex(m=>m.id===s);u<=0||c[u-1].type!=="separator"&&(o.parentId=c[u-1].id,x(p,i,h))}),t.off("click",".me-outdent").on("click",".me-outdent",function(){S(p,i);const s=$(this).closest(".menu-tree-row").data("id"),o=i.find(u=>u.id===s);if(!o||o.parentId==null)return;const c=i.find(u=>u.id===o.parentId);o.parentId=c?c.parentId:null,x(p,i,h)}),t.off("click",".me-up").on("click",".me-up",function(){S(p,i);const s=$(this).closest(".menu-tree-row").data("id"),o=i.filter(r=>r.parentId===i.find(y=>y.id===s)?.parentId),c=o.findIndex(r=>r.id===s);if(c<=0)return;const u=i.indexOf(o[c]),m=i.indexOf(o[c-1]);[i[u],i[m]]=[i[m],i[u]],x(p,i,h)}),t.off("click",".me-down").on("click",".me-down",function(){S(p,i);const s=$(this).closest(".menu-tree-row").data("id"),o=i.filter(r=>r.parentId===i.find(y=>y.id===s)?.parentId),c=o.findIndex(r=>r.id===s);if(c===-1||c>=o.length-1)return;const u=i.indexOf(o[c]),m=i.indexOf(o[c+1]);[i[u],i[m]]=[i[m],i[u]],x(p,i,h)}),t.off("click",".me-hidden").on("click",".me-hidden",function(){S(p,i);const s=$(this).closest(".menu-tree-row").data("id"),o=i.find(c=>c.id===s);o&&(o.hidden=!o.hidden),x(p,i,h)}),t.off("click",".me-edit").on("click",".me-edit",function(){const s=$(this).closest(".menu-tree-row").data("id"),o=i.find(c=>c.id===s);!o||o.type==="separator"||Z(o,f.resources||[],F,L,()=>x(p,i,h))}),E.tabs(t.find("#me-tabs").get(0)),t.find("#me-variant").val(e.variant||""),t.find("#me-position").val(e.position||""),t.find("#me-fontFamily").val(e.style?.fontFamily||""),t.find("#me-fontSize").val(e.style?.fontSize||""),t.find("#me-fontWeight").val(e.style?.fontWeight||""),t.find("#me-letterSpacing").val(e.style?.letterSpacing||""),t.find("#me-iconSize").val(e.style?.iconSize||"");const{getProjectFromHash:P}=await import("../lib/project-context.js"),z=n?P():null,C=t.find("#me-project");C.html('<option value="">\u2014 none \u2014</option>'+d.map(s=>`<option value="${A(s.slug)}">${A(s.name||s.slug)}</option>`).join("")),t.find("#me-slug").val(e.slug||""),t.find("#me-name").val(e.name||""),t.find("#me-description").val(e.description||""),C.val(e.meta?.project||z||""),Domma.icons.scan(t.get(0)),t.find("#me-save").on("click",async()=>{S(p,i);const s={variant:t.find("#me-variant").val(),position:t.find("#me-position").val(),fontFamily:t.find("#me-fontFamily").val(),fontSize:t.find("#me-fontSize").val(),fontWeight:t.find("#me-fontWeight").val(),letterSpacing:t.find("#me-letterSpacing").val(),iconSize:t.find("#me-iconSize").val()},o={slug:t.find("#me-slug").val().trim(),name:t.find("#me-name").val().trim(),description:t.find("#me-description").val().trim(),project:t.find("#me-project").val()},c=["fontFamily","fontSize","fontWeight","letterSpacing","iconSize"],u=Object.fromEntries(Object.entries(s).filter(([r,y])=>c.includes(r)&&y)),m={slug:o.slug,name:o.name,description:o.description,...s.variant&&{variant:s.variant},...s.position&&{position:s.position},...Object.keys(u).length&&{style:u},items:X(i),meta:{...e.meta||{},project:o.project||null}};try{if(n)await k.menus.create(m);else if(m.slug!==e.slug){await k.menus.create(m);const r=await k.menuLocations.get();let y=!1;for(const[w,b]of Object.entries(r))b===e.slug&&(r[w]=m.slug,y=!0);y&&await k.menuLocations.save(r),await k.menus.remove(e.slug)}else await k.menus.update(e.slug,m);E.toast("Saved.",{type:"success"}),location.hash="#/menus/edit/"+encodeURIComponent(m.slug)}catch(r){E.toast(`Save failed: ${r.message||r}`,{type:"error"})}})}};
|
|
28
|
+
`),n(r.id)}}n(null),Domma.icons.scan(t.get(0)),t.get(0).querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}function I(t,l){t.find(".menu-tree-row").each(function(){const a=$(this).data("id"),n=l.find(F=>F.id===a);if(!n||n.type==="separator")return;const e=$(this).find(".me-text");e.length&&(n.text=e.val());const r=$(this).find(".me-url");r.length&&(n.url=r.val());const f=$(this).find(".me-icon");f.length&&(n.icon=f.val());const S=$(this).find(".me-vis");S.length&&(n.visibility=S.val());const y=$(this).find(".me-perm");y.length&&(n.permission=y.val())})}function it(t,l){const a={};for(const n of l){const e=t.querySelector(`[name="${n}"]`);e&&(a[n]=e.type==="checkbox"?e.checked:e.value)}return a}function h(t,l={},...a){const n=document.createElement(t);Object.assign(n,l);for(const e of a)e!=null&&n.append(e);return n}function k(t,l,a){const n=h("label",{className:"form-field"},h("span",{className:"form-label",textContent:t}),l);return a&&n.append(h("small",{className:"form-hint",textContent:a,style:"display:block;font-size:.78em;opacity:.7;margin-top:.25rem;line-height:1.35"})),n}function Q(t,l){const a=[["","\u2014 Everyone \u2014"],["public","Public \u2014 anyone, incl. logged-out"],...(l||[]).map(e=>[e.name,`${e.label||e.name} and above`]),["private","Private \u2014 super-admin only"]];t&&!a.some(([e])=>e===t)&&a.push([t,`${t} (custom)`]);const n=P(a,t||"");return n.name="visibility",n}function P(t,l){const a=h("select",{className:"form-select"});return t.forEach(([n,e])=>a.add(new Option(e,n))),a.value=l??"",a}function B(t,l){const a=typeof l=="string"&&l.startsWith("#"),n=P([["","\u2014 none \u2014"],["primary","primary"],["success","success"],["danger","danger"],["warning","warning"],["info","info"],["neutral","neutral"],["__custom__","Custom\u2026"]],a?"__custom__":l||""),e=h("input",{type:"color",className:"form-input me-colour-hex",value:a?l:"#000000"});e.style.display=a?"":"none",n.addEventListener("change",()=>{e.style.display=n.value==="__custom__"?"":"none"});const r=k(t,n);return r.append(e),r._read=()=>n.value==="__custom__"?e.value:n.value,r}function Y(t,l){const a=h("input",{className:"form-input me-icon",name:"icon",value:l||""}),n=h("span",{className:"me-icon-preview"});l&&n.setAttribute("data-icon",l);const e=h("button",{type:"button",className:"btn btn-sm btn-ghost",textContent:"Browse\u2026"}),r=f=>{f?n.setAttribute("data-icon",f):n.removeAttribute("data-icon"),Domma.icons.scan(n.parentNode||n)};return e.addEventListener("click",()=>V(f=>{a.value=f,r(f)})),a.addEventListener("input",()=>r(a.value.trim())),k(t,h("div",{className:"me-icon-field"},a,e,n))}function W(...t){return h("div",{className:"me-field-grid"},...t)}async function Z(t,l,a,n,e){const r=g=>h("h4",{className:"me-form-section",textContent:g}),f=k("Label",h("input",{className:"form-input",name:"text",value:t.text||""}),"The text shown for this item."),S=k("URL",h("input",{className:"form-input",name:"url",value:t.url||""}),"Where it links. Must start with /, #, https://, or mailto: . Leave blank for a parent that only groups sub-items."),y=Y("Icon",t.icon||""),F=k("Visibility",Q(t.visibility||"",n),"Who can see this item, by role seniority. Picking a role shows it to that role and anyone more senior. Blank = everyone."),L=P([["","\u2014 no permission gate \u2014"],...(l||[]).map(g=>[g.key,g.label||g.key])],t.permission||"");L.name="permission";const z=k("Permission",L,"Hide unless the viewer holds this specific permission. Independent of Visibility \u2014 both must pass."),v=k("Badge text",h("input",{className:"form-input",name:"badgeText",value:t.badge?.text||""})),i=P([["","\u2014 none \u2014"],...(a||[]).map(g=>[g.slug,g.title||g.slug])],t.badge?.countFrom||"");i.name="badgeCount";const m=k("Badge count from",i,"Show a live count of this collection\u2019s entries as the badge."),A=B("Badge colour",t.badge?.variant||""),_=P([["link","Link"],["pill","Pill"]],t.pill?"pill":"link");_.name="renderAs";const T=k("Render as",_,"Pill draws the item as a rounded chip. Pill styling applies to top-level navbar items."),R=k("Pill style",P([["filled","Filled"],["outline","Outline"]],t.pill?.style||"filled"));R.querySelector("select").name="pillStyle";const N=B("Pill colour",t.pill?.variant||""),o=h("div",{className:"me-pill-wrap"},W(R,N));o.style.display=t.pill?"":"none",_.addEventListener("change",()=>{o.style.display=_.value==="pill"?"":"none"});const s=B("Text colour",t.colour||""),d=h("button",{className:"btn btn-primary",textContent:"Apply"}),p=h("div",{className:"me-item-form"},r("Link"),f,S,y,r("Access"),W(F,z),r("Badge"),W(v,A),m,r("Appearance"),T,o,s,d),c=E.slideover({title:"Edit menu item",size:"lg",position:"right"});c.element.appendChild(p),c.open();const u=c.element.closest(".dm-slideover")||c.element.querySelector(".dm-slideover")||c.element;u.style.setProperty("width",K+"px","important"),u.style.setProperty("max-width","95vw","important"),Domma.icons.scan(p);const b=g=>p.querySelector(`[name="${g}"]`)?.value||"";d.addEventListener("click",()=>{t.text=b("text"),t.url=b("url"),t.icon=b("icon"),t.visibility=b("visibility"),t.permission=b("permission");const g=b("badgeText"),j=b("badgeCount"),M=A._read();if(t.badge=g||j?{...g&&{text:g},...j&&{countFrom:j},...M&&{variant:M}}:null,b("renderAs")==="pill"){const D=N._read();t.pill={style:b("pillStyle")||"filled",...D&&{variant:D}}}else t.pill=null;t.colour=s._read(),c.close(),e()})}export const menuEditorView={templateUrl:"/admin/js/templates/menu-editor.html",async onMount(t){const l=location.hash.match(/^#\/menus\/edit\/(.+)$/),a=l?decodeURIComponent(l[1]):null,n=!a;let e;if(n)e={slug:"",name:"",description:"",items:[]};else try{e=await x.menus.get(a)}catch(o){E.toast(`Failed to load menu: ${o.message||o}`,{type:"error"}),location.hash="#/menus";return}const r=await x.projects.list().catch(()=>[]),f=await H.get("/api/auth/permissions-registry").catch(()=>({resources:[]})),S=await x.collections.list().catch(()=>[]),y=await x.get("/collections/roles/entries?limit=100").catch(()=>null),L=(Array.isArray(y)?y:y?.entries||[]).map(o=>({name:o.data?.name,label:o.data?.label,level:o.data?.level??99})).filter(o=>o.name).sort((o,s)=>s.level-o.level),z='<option value="">\u2014 no permission gate \u2014</option>',v=o=>z+(f.resources||[]).map(s=>{const d=(o||"")===s.key?" selected":"";return`<option value="${s.key}"${d}>${s.label||s.key}</option>`}).join("");t.find("#me-title").text(n?"New menu":`Edit "${e.slug}"`);let i=q(e.items);const m=t.find("#me-items-list");w(m,i,v),t.find("#me-add-item").on("click",()=>{I(m,i),i.push({id:C(),parentId:null,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1}),w(m,i,v)}),t.find("#me-add-separator").on("click",()=>{I(m,i),i.push({id:C(),parentId:null,type:"separator"}),w(m,i,v)});function A(o){return function(){I(m,i);const s=$(this).closest(".menu-tree-row").data("id"),d=i.findIndex(u=>u.id===s);if(d===-1)return;const p=i[d],c=o==="separator"?{id:C(),parentId:p.parentId,type:"separator"}:{id:C(),parentId:p.parentId,text:"",url:"/",icon:"",visibility:"",permission:"",hidden:!1,bundled:!1};i.splice(d+1,0,c),w(m,i,v)}}t.off("click",".me-ins-item").on("click",".me-ins-item",A("item")),t.off("click",".me-ins-sep").on("click",".me-ins-sep",A("separator")),t.off("click",".me-remove").on("click",".me-remove",function(){const o=$(this).closest(".menu-tree-row").data("id");i=i.filter(s=>s.id!==o&&s.parentId!==o),w(m,i,v)}),t.off("click",".me-indent").on("click",".me-indent",function(){I(m,i);const o=$(this).closest(".menu-tree-row").data("id"),s=i.find(c=>c.id===o);if(!s)return;const d=i.filter(c=>c.parentId===s.parentId),p=d.findIndex(c=>c.id===o);p<=0||d[p-1].type!=="separator"&&(s.parentId=d[p-1].id,w(m,i,v))}),t.off("click",".me-outdent").on("click",".me-outdent",function(){I(m,i);const o=$(this).closest(".menu-tree-row").data("id"),s=i.find(p=>p.id===o);if(!s||s.parentId==null)return;const d=i.find(p=>p.id===s.parentId);s.parentId=d?d.parentId:null,w(m,i,v)}),t.off("click",".me-up").on("click",".me-up",function(){I(m,i);const o=$(this).closest(".menu-tree-row").data("id"),s=i.filter(u=>u.parentId===i.find(b=>b.id===o)?.parentId),d=s.findIndex(u=>u.id===o);if(d<=0)return;const p=i.indexOf(s[d]),c=i.indexOf(s[d-1]);[i[p],i[c]]=[i[c],i[p]],w(m,i,v)}),t.off("click",".me-down").on("click",".me-down",function(){I(m,i);const o=$(this).closest(".menu-tree-row").data("id"),s=i.filter(u=>u.parentId===i.find(b=>b.id===o)?.parentId),d=s.findIndex(u=>u.id===o);if(d===-1||d>=s.length-1)return;const p=i.indexOf(s[d]),c=i.indexOf(s[d+1]);[i[p],i[c]]=[i[c],i[p]],w(m,i,v)}),t.off("click",".me-hidden").on("click",".me-hidden",function(){I(m,i);const o=$(this).closest(".menu-tree-row").data("id"),s=i.find(d=>d.id===o);s&&(s.hidden=!s.hidden),w(m,i,v)}),t.off("click",".me-edit").on("click",".me-edit",function(){const o=$(this).closest(".menu-tree-row").data("id"),s=i.find(d=>d.id===o);!s||s.type==="separator"||Z(s,f.resources||[],S,L,()=>w(m,i,v))}),E.tabs(t.find("#me-tabs").get(0)),t.find("#me-variant").val(e.variant||""),t.find("#me-position").val(e.position||"");const _=t.find("#me-appearOnHover").get(0);_&&(_.checked=e.appearOnHover===!0),t.find("#me-fontFamily").val(e.style?.fontFamily||""),t.find("#me-fontSize").val(e.style?.fontSize||""),t.find("#me-fontWeight").val(e.style?.fontWeight||""),t.find("#me-letterSpacing").val(e.style?.letterSpacing||""),t.find("#me-iconSize").val(e.style?.iconSize||"");const{getProjectFromHash:T}=await import("../lib/project-context.js"),R=n?T():null,N=t.find("#me-project");N.html('<option value="">\u2014 none \u2014</option>'+r.map(o=>`<option value="${O(o.slug)}">${O(o.name||o.slug)}</option>`).join("")),t.find("#me-slug").val(e.slug||""),t.find("#me-name").val(e.name||""),t.find("#me-description").val(e.description||""),N.val(e.meta?.project||R||""),Domma.icons.scan(t.get(0)),t.find("#me-save").on("click",async()=>{I(m,i);const o={variant:t.find("#me-variant").val(),position:t.find("#me-position").val(),appearOnHover:!!(t.find("#me-appearOnHover").get(0)||{}).checked,fontFamily:t.find("#me-fontFamily").val(),fontSize:t.find("#me-fontSize").val(),fontWeight:t.find("#me-fontWeight").val(),letterSpacing:t.find("#me-letterSpacing").val(),iconSize:t.find("#me-iconSize").val()},s={slug:t.find("#me-slug").val().trim(),name:t.find("#me-name").val().trim(),description:t.find("#me-description").val().trim(),project:t.find("#me-project").val()},d=["fontFamily","fontSize","fontWeight","letterSpacing","iconSize"],p=Object.fromEntries(Object.entries(o).filter(([u,b])=>d.includes(u)&&b)),c={slug:s.slug,name:s.name,description:s.description,...o.variant&&{variant:o.variant},...o.position&&{position:o.position},...o.appearOnHover&&{appearOnHover:!0},...Object.keys(p).length&&{style:p},items:X(i),meta:{...e.meta||{},project:s.project||null}};try{if(n)await x.menus.create(c);else if(c.slug!==e.slug){await x.menus.create(c);const u=await x.menuLocations.get();let b=!1;for(const[g,j]of Object.entries(u))j===e.slug&&(u[g]=c.slug,b=!0);b&&await x.menuLocations.save(u),await x.menus.remove(e.slug)}else await x.menus.update(e.slug,c);E.toast("Saved.",{type:"success"}),location.hash="#/menus/edit/"+encodeURIComponent(c.slug)}catch(u){E.toast(`Save failed: ${u.message||u}`,{type:"error"})}})}};
|
package/package.json
CHANGED
|
@@ -98,6 +98,88 @@ function buildTopPages(daily, days = 7) {
|
|
|
98
98
|
.slice(0, 5);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// ── Journeys / spikes — ported verbatim from plugins/analytics so the core
|
|
102
|
+
// dashboard can compute them off disk without the plugin's encapsulated
|
|
103
|
+
// `fastify.analytics` decorator (which it cannot see). Keep in sync with
|
|
104
|
+
// plugins/analytics/plugin.js.
|
|
105
|
+
const REALTIME_WINDOW_MS = 5 * 60 * 1000;
|
|
106
|
+
|
|
107
|
+
function eventsInRange(journeys, start, end) {
|
|
108
|
+
const startMs = start.getTime(), endMs = end.getTime();
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const [day, events] of Object.entries(journeys)) {
|
|
111
|
+
const dayMs = new Date(day + 'T00:00:00Z').getTime();
|
|
112
|
+
if (dayMs < startMs || dayMs > endMs) continue;
|
|
113
|
+
if (Array.isArray(events)) for (const ev of events) out.push(ev);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function groupBySession(events) {
|
|
119
|
+
const sessions = new Map();
|
|
120
|
+
for (const ev of events) {
|
|
121
|
+
if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
|
|
122
|
+
sessions.get(ev.sid).push(ev);
|
|
123
|
+
}
|
|
124
|
+
for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
|
|
125
|
+
return sessions;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function aggregateJourneys(events) {
|
|
129
|
+
const sessions = groupBySession(events);
|
|
130
|
+
const entry = new Map(), exit = new Map(), paths = new Map();
|
|
131
|
+
let bouncedSessions = 0;
|
|
132
|
+
for (const arr of sessions.values()) {
|
|
133
|
+
if (arr.length === 0) continue;
|
|
134
|
+
entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
|
|
135
|
+
exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
|
|
136
|
+
if (arr.length === 1) bouncedSessions += 1;
|
|
137
|
+
for (let i = 0; i < arr.length - 1; i += 1) {
|
|
138
|
+
const from = arr[i].url, to = arr[i + 1].url;
|
|
139
|
+
const key = from + '\x00' + to;
|
|
140
|
+
const existing = paths.get(key);
|
|
141
|
+
if (existing) existing.count += 1;
|
|
142
|
+
else paths.set(key, { from, to, count: 1 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const toSorted = (m) => Array.from(m.entries())
|
|
146
|
+
.map(([url, count]) => ({ url, count }))
|
|
147
|
+
.sort((a, b) => b.count - a.count).slice(0, 10);
|
|
148
|
+
const totalSessions = sessions.size;
|
|
149
|
+
return {
|
|
150
|
+
entry: toSorted(entry),
|
|
151
|
+
exit: toSorted(exit),
|
|
152
|
+
paths: Array.from(paths.values()).sort((a, b) => b.count - a.count).slice(0, 10),
|
|
153
|
+
bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
|
|
154
|
+
totalSessions
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function detectSpikes(daily, now = new Date()) {
|
|
159
|
+
const todayStr = todayKey(now);
|
|
160
|
+
const currentHour = now.getUTCHours();
|
|
161
|
+
const todayUrls = daily[todayStr] || {};
|
|
162
|
+
const elapsedHours = Math.max(1, currentHour + 1);
|
|
163
|
+
const flagged = [];
|
|
164
|
+
for (const [url, totalToday] of Object.entries(todayUrls)) {
|
|
165
|
+
const currentHits = totalToday / elapsedHours;
|
|
166
|
+
const samples = [];
|
|
167
|
+
for (let i = 1; i <= 7; i += 1) {
|
|
168
|
+
const prev = new Date(now);
|
|
169
|
+
prev.setUTCDate(prev.getUTCDate() - i);
|
|
170
|
+
const key = todayKey(prev);
|
|
171
|
+
samples.push((daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0);
|
|
172
|
+
}
|
|
173
|
+
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
174
|
+
const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
|
|
175
|
+
const stddev = Math.sqrt(variance);
|
|
176
|
+
if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
|
|
177
|
+
flagged.push({ url, hits: Math.round(currentHits), baseline: Math.round(mean), ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
|
|
181
|
+
}
|
|
182
|
+
|
|
101
183
|
/**
|
|
102
184
|
* Recent activity feed — combines page version events with recent collection
|
|
103
185
|
* entries (form submissions). Returns at most 10 items, newest first.
|
|
@@ -193,7 +275,18 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
193
275
|
};
|
|
194
276
|
|
|
195
277
|
const analytics = fastify.analytics;
|
|
196
|
-
|
|
278
|
+
// The analytics plugin decorates `analytics` inside its own prefixed,
|
|
279
|
+
// encapsulated scope, so `fastify.analytics` is undefined here in the
|
|
280
|
+
// core dashboard route — traffic/topPages always read 0 even though hits
|
|
281
|
+
// are recorded. Read the plugin's daily counters straight off disk (same
|
|
282
|
+
// pattern as buildActivity reading versions/collections below).
|
|
283
|
+
const daily = (await safe('analytics.daily', async () => {
|
|
284
|
+
for (const rel of ['plugins/analytics/data/daily.json', 'plugins/analytics/daily.json']) {
|
|
285
|
+
try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
|
|
286
|
+
catch { /* try next location */ }
|
|
287
|
+
}
|
|
288
|
+
return {};
|
|
289
|
+
})) || {};
|
|
197
290
|
|
|
198
291
|
const today = todayKey();
|
|
199
292
|
const yesterday = isoDaysAgo(1);
|
|
@@ -210,12 +303,22 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
210
303
|
previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
|
|
211
304
|
};
|
|
212
305
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
306
|
+
// Session events read straight off disk too (same reason as daily above).
|
|
307
|
+
const journeysData = (await safe('analytics.journeysData', async () => {
|
|
308
|
+
for (const rel of ['plugins/analytics/data/journeys.json', 'plugins/analytics/journeys.json']) {
|
|
309
|
+
try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
|
|
310
|
+
catch { /* try next location */ }
|
|
311
|
+
}
|
|
312
|
+
return {};
|
|
313
|
+
})) || {};
|
|
314
|
+
const realtime = (await safe('analytics.realtime', async () => {
|
|
315
|
+
const evs = journeysData[today] || [];
|
|
316
|
+
const cutoff = Date.now() - REALTIME_WINDOW_MS;
|
|
317
|
+
const sids = new Set();
|
|
318
|
+
for (const ev of evs) if (ev.t >= cutoff) sids.add(ev.sid);
|
|
319
|
+
return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
|
|
320
|
+
})) || { activeSessions: 0 };
|
|
321
|
+
const spikes = (await safe('analytics.spikes', () => detectSpikes(daily))) || [];
|
|
219
322
|
const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
|
|
220
323
|
|
|
221
324
|
if (lite) {
|
|
@@ -229,9 +332,11 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
229
332
|
}
|
|
230
333
|
|
|
231
334
|
const topPages = buildTopPages(daily);
|
|
232
|
-
const journeys = analytics
|
|
233
|
-
|
|
234
|
-
|
|
335
|
+
const journeys = (await safe('analytics.journeys', () => {
|
|
336
|
+
const end = new Date(); end.setUTCHours(0, 0, 0, 0);
|
|
337
|
+
const start = new Date(end); start.setUTCDate(start.getUTCDate() - 6);
|
|
338
|
+
return aggregateJourneys(eventsInRange(journeysData, start, end));
|
|
339
|
+
})) || null;
|
|
235
340
|
const activity = (await safe('activity', () => buildActivity())) || [];
|
|
236
341
|
|
|
237
342
|
return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
|
package/server/services/menus.js
CHANGED
|
@@ -199,6 +199,7 @@ export async function createMenu(input) {
|
|
|
199
199
|
description: input.description || '',
|
|
200
200
|
...(input.variant != null && {variant: input.variant}),
|
|
201
201
|
...(input.position != null && {position: input.position}),
|
|
202
|
+
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
202
203
|
...(input.style != null && {style: input.style}),
|
|
203
204
|
items: Array.isArray(input.items) ? input.items : [],
|
|
204
205
|
meta: {
|
|
@@ -236,6 +237,7 @@ export async function updateMenu(slug, input) {
|
|
|
236
237
|
description: input.description || '',
|
|
237
238
|
...(input.variant != null && {variant: input.variant}),
|
|
238
239
|
...(input.position != null && {position: input.position}),
|
|
240
|
+
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
239
241
|
...(input.style != null && {style: input.style}),
|
|
240
242
|
items: Array.isArray(input.items) ? input.items : [],
|
|
241
243
|
meta: {
|