domma-cms 0.25.11 → 0.25.13

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.
@@ -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 k}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 O=()=>"i_"+G++,K=760;function q(t,l=null,a=[]){for(const n of t||[]){const e=O();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 d=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},...d.length&&{items:d}}})}return a(null)}function A(t){return String(t||"").replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;")}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 x(t,l,a=()=>""){t.html("");function n(e){for(const d of l.filter(f=>f.parentId===e)){const f=J(d.id,l);if(d.type==="separator"){t.append(`
2
- <div class="menu-tree-row menu-tree-row--separator" data-id="${d.id}" style="margin-left:${f*28}px">
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,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;")}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 F=d.badge&&(d.badge.text||d.badge.countFrom)?`<span class="dm-menu-badge">${A(String(d.badge.text||"#"))}</span>`:"",v=U(d.colour),_=v?` style="color:${A(v)}"`:"";t.append(`
14
- <div class="menu-tree-row menu-tree-row--slim${d.hidden?" menu-tree-row--hidden":""}" data-id="${d.id}" style="margin-left:${f*28}px">
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"${_}>${A(d.text||"(untitled)")}${F}</span>
17
- <span class="me-row-url">${A(d.url||"")}</span>
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 ${d.hidden?"active":""}" data-tooltip="${d.hidden?"Show":"Hide"}"><span data-icon="eye-off"></span></button>
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/bin/cli.js CHANGED
@@ -322,12 +322,8 @@ content/media/
322
322
  *.log
323
323
  .domma-backups/
324
324
 
325
- # Analytics plugin runtime counters written on every page hit. Never track:
326
- # committing them resets live analytics on every checkout/pull/update.
327
- plugins/analytics/stats.json
328
- plugins/analytics/daily.json
329
- plugins/analytics/lifetime.json
330
- plugins/analytics/journeys.json
325
+ # Analytics plugin runtime counters (written on every page hit). Never track.
326
+ plugins/analytics/data/
331
327
  `;
332
328
 
333
329
  step('Writing .gitignore');
package/bin/update.js CHANGED
@@ -475,10 +475,7 @@ export default async function update(_positional, flags) {
475
475
  const gitignorePath = path.join(cwd, '.gitignore');
476
476
  const requiredIgnores = [
477
477
  '.domma-backups/',
478
- 'plugins/analytics/stats.json',
479
- 'plugins/analytics/daily.json',
480
- 'plugins/analytics/lifetime.json',
481
- 'plugins/analytics/journeys.json',
478
+ 'plugins/analytics/data/',
482
479
  ];
483
480
  const current = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
484
481
  const missing = requiredIgnores.filter(entry => !current.split(/\r?\n/).includes(entry));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.25.11",
3
+ "version": "0.25.13",
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",
@@ -20,6 +20,7 @@
20
20
  "config/",
21
21
  "!config/connections.json",
22
22
  "plugins/",
23
+ "!plugins/analytics/data",
23
24
  "!plugins/analytics/stats.json",
24
25
  "!plugins/analytics/daily.json",
25
26
  "!plugins/analytics/lifetime.json",
@@ -19,10 +19,17 @@ import path from 'path';
19
19
  import {fileURLToPath} from 'url';
20
20
 
21
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
- const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
23
- const DAILY_FILE = path.join(__dirname, 'daily.json');
24
- const LEGACY_FILE = path.join(__dirname, 'stats.json');
25
- const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
22
+
23
+ // Runtime counters live under data/ — the carve-out that BOTH update paths
24
+ // preserve (siteCmsUpdater's copyPlugin backs up data/; `domma-cms update`
25
+ // keeps 'data' in its preserve set). Pre-2.1 they sat in the plugin root and
26
+ // were overwritten by the manager's copy on every plugin push. On first boot
27
+ // migrateToDataDir() moves any pre-2.1 root files across.
28
+ const DATA_DIR = path.join(__dirname, 'data');
29
+ const LIFETIME_FILE = path.join(DATA_DIR, 'lifetime.json');
30
+ const DAILY_FILE = path.join(DATA_DIR, 'daily.json');
31
+ const LEGACY_FILE = path.join(DATA_DIR, 'stats.json');
32
+ const JOURNEYS_FILE = path.join(DATA_DIR, 'journeys.json');
26
33
  const JOURNEY_DAILY_CAP = 5000;
27
34
  const REALTIME_WINDOW_MS = 5 * 60 * 1000;
28
35
 
@@ -35,11 +42,29 @@ async function readJson(file, fallback) {
35
42
  }
36
43
 
37
44
  async function writeJson(file, data) {
45
+ await fs.mkdir(path.dirname(file), { recursive: true });
38
46
  const tmp = file + '.tmp';
39
47
  await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
40
48
  await fs.rename(tmp, file);
41
49
  }
42
50
 
51
+ /**
52
+ * One-shot relocation of pre-2.1 counters from the plugin root into data/.
53
+ * Runs before migrateLegacy() so the rest of the plugin only ever sees data/.
54
+ */
55
+ async function migrateToDataDir() {
56
+ await fs.mkdir(DATA_DIR, { recursive: true });
57
+ for (const name of ['lifetime.json', 'daily.json', 'journeys.json', 'stats.json']) {
58
+ const dest = path.join(DATA_DIR, name);
59
+ try { await fs.access(dest); continue; } catch { /* dest absent — maybe move */ }
60
+ const oldPath = path.join(__dirname, name);
61
+ try {
62
+ await fs.access(oldPath);
63
+ await fs.rename(oldPath, dest);
64
+ } catch { /* no pre-2.1 file to move */ }
65
+ }
66
+ }
67
+
43
68
  /**
44
69
  * One-shot migration from the legacy flat stats.json.
45
70
  * Preserves existing totals as lifetime numbers; daily history starts empty.
@@ -253,6 +278,7 @@ export default async function analyticsPlugin(fastify, options) {
253
278
  const { authenticate, requireAdmin } = options.auth;
254
279
  const settings = options.settings || {};
255
280
 
281
+ await migrateToDataDir();
256
282
  await migrateLegacy();
257
283
 
258
284
  fastify.decorate('analytics', {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "analytics",
3
3
  "displayName": "Analytics",
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "description": "Page view analytics with daily trends, time-range filtering, top-pages table and CSV export. Honours Do-Not-Track and dedups per session.",
6
6
  "author": "Darryl Waterhouse",
7
7
  "date": "2026-05-04",
@@ -37,19 +37,19 @@
37
37
  "scaffold": {
38
38
  "reset": [
39
39
  {
40
- "path": "lifetime.json",
40
+ "path": "data/lifetime.json",
41
41
  "content": "{}"
42
42
  },
43
43
  {
44
- "path": "daily.json",
44
+ "path": "data/daily.json",
45
45
  "content": "{}"
46
46
  },
47
47
  {
48
- "path": "journeys.json",
48
+ "path": "data/journeys.json",
49
49
  "content": "{}"
50
50
  },
51
51
  {
52
- "path": "stats.json",
52
+ "path": "data/stats.json",
53
53
  "content": "{}"
54
54
  }
55
55
  ]
@@ -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: {