domma-cms 1.4.0 → 1.5.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.
@@ -12,11 +12,13 @@
12
12
  "Needs attention" list gathered from every screen's sidebar badge. Every site has it, and it looks the same
13
13
  for everyone.</p>
14
14
 
15
- <h3>Dashboard Pro: a Dashboard you build yourself</h3>
16
- <p>With the <strong>Dashboard Pro</strong> Tool (from the Marketplace), the Dashboard becomes a board of
17
- <strong>widgets</strong> that each person arranges for themselves. Where another Tool owns the Dashboard - the
18
- fleet manager's, say - the board is at <strong>My Dashboard</strong> in the sidebar instead. Switch Dashboard
19
- Pro off and the Dashboard goes back to the fixed one at once; everyone's boards are kept for when it comes back.</p>
15
+ <h3>Dashboard Pro: Dashboards you build yourself</h3>
16
+ <p>With the <strong>Dashboard Pro</strong> Tool (from the Marketplace), the sidebar entry becomes
17
+ <strong>Dashboards</strong>: one place, as tabs. The first tab, <strong>Overview</strong>, is the Dashboard
18
+ above - always there, and it cannot be moved or removed. After it come your own <strong>boards</strong> of
19
+ <strong>widgets</strong>, which each person arranges for themselves. Where another Tool owns the Dashboard - the
20
+ fleet manager's, say - that is the Overview tab instead. Switch Dashboard Pro off and the entry goes back to
21
+ Dashboard at once; everyone's boards are kept for when it comes back.</p>
20
22
 
21
23
  <h3>Adding widgets</h3>
22
24
  <p>Choose <strong>Add widget</strong> in the banner, or right-click anywhere on the board. The gallery lists every
@@ -44,28 +46,30 @@
44
46
  its own, and at once when its Tool's count changes.</p>
45
47
 
46
48
  <h3>Several boards</h3>
47
- <p>Keep up to twelve boards - Sales, Content, Support - as tabs above the Dashboard. <strong>+</strong> makes a
48
- new one and opens the gallery for it. Right-click a tab (or double-click it to rename) to rename, duplicate,
49
- move or delete it. Each board has its own widgets and arrangement, and the board you were last on is the one
50
- that opens.</p>
49
+ <p>Keep up to twelve boards - Sales, Content, Support - as tabs after the Overview. <strong>+</strong> (on any
50
+ Dashboards tab) makes a new one and opens the gallery for it. Right-click a board's tab (or double-click it to
51
+ rename) to rename, duplicate, move or delete it. Each board has its own widgets and arrangement, and its own
52
+ address, so you can bookmark it. The board you were last on is the one that opens from the boards.</p>
51
53
 
52
54
  <h3>Settings (the cog)</h3>
53
55
  <p>The cog in the banner holds your own settings:</p>
54
56
  <ul>
55
- <li><strong>Name</strong> - what your Dashboard is called, in its banner, the browser tab and the sidebar.</li>
57
+ <li><strong>Name</strong> - what your Dashboards are called, in the banner, the browser tab and the sidebar
58
+ entry. Each board's own name is its tab.</li>
56
59
  <li><strong>Greeting</strong> - good morning, your name and today's date.</li>
57
60
  <li><strong>Columns</strong> - 3, 4 or 6 on a wide screen. A narrow window always uses fewer.</li>
58
61
  <li><strong>Cards</strong> - normal or compact.</li>
59
62
  <li><strong>Updates</strong> - Live, Slower (three times as long between refreshes), or Paused (only when you
60
63
  press Refresh).</li>
61
- <li><strong>Open the admin on this board</strong> - signing in, or pressing Dashboard, brings you here.</li>
64
+ <li><strong>Open on my boards</strong> - signing in, or pressing Dashboards in the sidebar, opens the board you
65
+ used last instead of the Overview. The Overview tab is always one click away.</li>
62
66
  </ul>
63
67
 
64
68
  <h3>For administrators</h3>
65
69
  <ul>
66
70
  <li><strong>The default</strong>: <em>Make this the default for everyone</em> in the banner makes the board you
67
71
  are on the starting point for everyone who has not arranged their own. <em>Reset</em> takes anyone back to it.</li>
68
- <li><strong>Board name</strong> (in the cog, under For everyone) - what the Dashboard is called for everyone who
72
+ <li><strong>Dashboards name</strong> (in the cog, under For everyone) - what Dashboards is called for everyone who
69
73
  has not named their own.</li>
70
74
  <li><strong>Lock the layout</strong> - everyone but administrators sees the site's default board and can fold
71
75
  and refresh widgets but not add, move, resize or remove them. Turning the lock off gives people their own
@@ -1,16 +1,16 @@
1
- import{apiRequest as m,getUser as ke}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{openMenuAt as ie}from"../lib/tool-kit.js";import{SIZE_LABELS as Se,SIZES as Ee,addWidget as $e,anyOpen as R,boardCards as Ce,dropWidget as Le,galleryGroups as Ae,removeWidget as de,resizeWidget as De,sameLayout as re,setAllCollapsed as Me,setCollapsed as Te,shiftWidget as z,withToday as xe}from"../lib/dashboard-board-arrange.js";import{destroyWidget as qe,drawError as Ne,drawWidget as Oe}from"../lib/widget-render.js";const Re=400,ze=h=>String(h||"").replace(/^\/api(?=\/)/,""),r=h=>String(h??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export async function mountBoard(h,f,{isAdmin:Y=!1}={}){const G=h.get(0),c=G.querySelector(".dbx")||G;c.classList.add("dbp"),c.replaceChildren(),c.insertAdjacentHTML("beforeend",`
1
+ import{apiRequest as m,getUser as Le}from"../api.js";import{addBannerAction as C}from"../lib/plugin-chrome.js";import{helpPopovers as re}from"../lib/help-popover.js";import{openMenuAt as le}from"../lib/tool-kit.js";import{SIZE_LABELS as De,SIZES as Ce,addWidget as Oe,anyOpen as z,boardCards as Te,dropWidget as Me,galleryGroups as xe,removeWidget as ce,resizeWidget as qe,sameLayout as pe,setAllCollapsed as Ne,setCollapsed as Re,shiftWidget as G,withToday as Ie}from"../lib/dashboard-board-arrange.js";import{destroyWidget as ze,drawError as Ge,drawWidget as Be}from"../lib/widget-render.js";import{MAX_BOARDS as be,OVERVIEW as B,boardFromHash as ue,boardHref as Fe,dashboardsLabel as _,stripTabs as Pe,tabsMarkup as We}from"../lib/dashboards-arrange.js";import{allowOverviewOnce as Ye,setDashboardsBanner as He}from"../lib/dashboards-strip.js";const Ue=400,Je=h=>String(h||"").replace(/^\/api(?=\/)/,""),r=h=>String(h??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");export async function mountBoard(h,f,{isAdmin:j=!1}={}){const F=h.get(0),c=F.querySelector(".dbx")||F;c.classList.add("dbp"),c.replaceChildren(),c.insertAdjacentHTML("beforeend",`
2
+ <div class="dbp-tabs" role="tablist" aria-label="Dashboards" hidden></div>
2
3
  <p class="dbp-greeting" hidden></p>
3
4
  <div class="dbp-head">
4
- <p class="dbp-hint">Your own Dashboard.
5
- <span data-help-title="Dashboard Pro" data-help="Add widgets from the Tools you use - Add widget in the banner, or right-click the board. Each card keeps itself up to date. Keep several boards as tabs: + makes one, right-click a tab to rename, duplicate, move or delete it. Your boards are yours - other people arrange their own. Reset goes back to the site's default; an administrator can make a board the default for everyone who has not arranged their own."></span></p>
5
+ <p class="dbp-hint">Your own boards.
6
+ <span data-help-title="Dashboards" data-help="The first tab, Overview, is the site's Dashboard - always there. Your boards follow it: add widgets from the Tools you use (Add widget in the banner, or right-click the board), and each card keeps itself up to date. + makes a board; right-click a tab to rename, duplicate, move or delete it. Your boards are yours - other people arrange their own. Reset goes back to the site's default; an administrator can make a board the default for everyone who has not arranged their own."></span></p>
6
7
  <span class="dbp-source" aria-live="polite"></span>
7
8
  </div>
8
- <div class="dbp-tabs" role="tablist" aria-label="Your boards" hidden></div>
9
9
  <div class="dbp-board" role="list" aria-label="Dashboard widgets"></div>
10
10
  <div class="dbp-empty-board" hidden>
11
11
  <p>Nothing on your Dashboard yet.</p>
12
12
  <button type="button" class="btn btn-primary dbp-add-first"><span data-icon="plus" data-icon-size="14"></span> Add a widget</button>
13
- </div>`);const b=c.querySelector(".dbp-board"),le=c.querySelector(".dbp-source"),U=c.querySelector(".dbp-greeting"),ce=c.querySelector(".dbp-hint"),w=c.querySelector(".dbp-tabs"),H=()=>c.isConnected;let y=[],i={version:1,widgets:[]},o={version:2,active:"main",boards:[{id:"main",name:"Main",widgets:[]}]},q="",D="built-in",d={mine:{name:"",greeting:!0,columns:4,density:"normal",updates:"live",home:!1},site:{name:"My Dashboard",locked:!1},name:"My Dashboard",isAdmin:Y,locked:!1};const M=new Map;f.add(()=>{for(const e of M.values())clearInterval(e);M.clear()});async function pe(){const[e,t,a]=await Promise.all([m("/widgets",{method:"GET"}).catch(()=>[]),m("/dashboard/boards",{method:"GET"}).catch(()=>null),m("/dashboard/settings",{method:"GET"}).catch(()=>null)]);a&&(d={...d,...a}),y=Array.isArray(e)?e:[],t?.boards?.length&&(o={version:2,active:t.active,boards:t.boards}),i={version:1,widgets:T().widgets},q=JSON.stringify(o),D=t?.source||"built-in"}const T=()=>o.boards.find(e=>e.id===o.active)||o.boards[0],N=()=>{T().widgets=i.widgets};let P=null;function S(){clearTimeout(P),!d.locked&&(P=setTimeout(async()=>{N();const e=JSON.stringify(o);if(e!==q)try{const t=await m("/dashboard/boards",{method:"PUT",body:e});q=e,D=t?.source||"mine",B()}catch(t){E.toast(`Your layout was not saved: ${t.message||t}`,{type:"error"})}},Re))}f.add(()=>clearTimeout(P));function B(){le.textContent={mine:"Your layout",site:"The site's default layout","built-in":"The starting layout",locked:"Set by your administrator"}[D]||""}function g(e){re(e,i)||(i=e,k(),S())}function F(e,t){i=Te(i,e,t);const a=b.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`);a&&j(a,t),S()}function j(e,t){const a=e.classList.contains("is-collapsed");e.classList.toggle("is-collapsed",t);const s=e.querySelector(".dbp-body");s&&(s.hidden=t);const n=e.querySelector(".dbp-fold"),l=e.getAttribute("aria-label")||"";n&&(n.setAttribute("aria-expanded",t?"false":"true"),n.title=t?"Open":"Fold",n.setAttribute("aria-label",`${t?"Open":"Fold"} ${l}`)),a&&!t&&v(e)}function J(e){i=Me(i,e),b.querySelectorAll(".dbp-card").forEach(t=>j(t,e)),S()}const be=4,ue=16;function K(e){const t=e.getBoundingClientRect().height;t&&(e.style.gridRowEnd=`span ${Math.max(1,Math.ceil((t+ue)/be))}`)}const W=typeof ResizeObserver=="function"?new ResizeObserver(e=>{for(const t of e)K(t.target)}):null;f.add(()=>W?.disconnect());async function v(e){const t=y.find(s=>s.id===e.dataset.id),a=e.querySelector(".dbp-body");if(!(!t||!a)){e.classList.add("is-loading");try{const s=t.kind==="count"?xe(t.dataUrl):t.dataUrl,n=await m(ze(s),{method:"GET"});if(!H()||!e.isConnected)return;await Oe(a,t.kind,n)}catch(s){e.isConnected&&Ne(a,s.status===403?"You cannot see this any more.":"Could not load.")}finally{e.classList.remove("is-loading")}}}function he(e){const t=e.href?`<a class="dbp-title" href="${r(e.href)}" title="Open ${r(e.title)}">${r(e.title)}</a>`:`<span class="dbp-title">${r(e.title)}</span>`;return`<section class="dbp-card dbp-size-${r(e.size)}${e.collapsed?" is-collapsed":""}" data-id="${r(e.id)}" role="listitem" tabindex="0" aria-label="${r(e.title)}">
13
+ </div>`);const b=c.querySelector(".dbp-board"),he=c.querySelector(".dbp-source"),V=c.querySelector(".dbp-greeting"),fe=c.querySelector(".dbp-hint"),$=c.querySelector(".dbp-tabs"),K=()=>c.isConnected;let y=[],i={version:1,widgets:[]},o={version:2,active:"main",boards:[{id:"main",name:"Main",widgets:[]}]},q="",O="built-in",d={mine:{name:"",greeting:!0,columns:4,density:"normal",updates:"live",home:!1},site:{name:"Dashboards",locked:!1},name:"Dashboards",isAdmin:j,locked:!1};const T=new Map;f.add(()=>{for(const e of T.values())clearInterval(e);T.clear()});async function me(){const[e,t,a]=await Promise.all([m("/widgets",{method:"GET"}).catch(()=>[]),m("/dashboard/boards",{method:"GET"}).catch(()=>null),m("/dashboard/settings",{method:"GET"}).catch(()=>null)]);a&&(d={...d,...a}),y=Array.isArray(e)?e:[],t?.boards?.length&&(o={version:2,active:t.active,boards:t.boards});const s=ue(location.hash).board;s&&o.boards.some(n=>n.id===s)&&(o.active=s),i={version:1,widgets:w().widgets},q=JSON.stringify(o),O=t?.source||"built-in"}const w=()=>o.boards.find(e=>e.id===o.active)||o.boards[0],N=()=>{w().widgets=i.widgets};let P=null;function A(){clearTimeout(P),!d.locked&&(P=setTimeout(async()=>{N();const e=JSON.stringify(o);if(e!==q)try{const t=await m("/dashboard/boards",{method:"PUT",body:e});q=e,O=t?.source||"mine",W()}catch(t){E.toast(`Your layout was not saved: ${t.message||t}`,{type:"error"})}},Ue))}f.add(()=>clearTimeout(P));function W(){he.textContent={mine:"Your layout",site:"The site's default layout","built-in":"The starting layout",locked:"Set by your administrator"}[O]||""}function g(e){pe(e,i)||(i=e,S(),A())}function Y(e,t){i=Re(i,e,t);const a=b.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`);a&&X(a,t),A()}function X(e,t){const a=e.classList.contains("is-collapsed");e.classList.toggle("is-collapsed",t);const s=e.querySelector(".dbp-body");s&&(s.hidden=t);const n=e.querySelector(".dbp-fold"),l=e.getAttribute("aria-label")||"";n&&(n.setAttribute("aria-expanded",t?"false":"true"),n.title=t?"Open":"Fold",n.setAttribute("aria-label",`${t?"Open":"Fold"} ${l}`)),a&&!t&&v(e)}function Z(e){i=Ne(i,e),b.querySelectorAll(".dbp-card").forEach(t=>X(t,e)),A()}const ge=4,ye=16;function Q(e){const t=e.getBoundingClientRect().height;t&&(e.style.gridRowEnd=`span ${Math.max(1,Math.ceil((t+ye)/ge))}`)}const H=typeof ResizeObserver=="function"?new ResizeObserver(e=>{for(const t of e)Q(t.target)}):null;f.add(()=>H?.disconnect());async function v(e){const t=y.find(s=>s.id===e.dataset.id),a=e.querySelector(".dbp-body");if(!(!t||!a)){e.classList.add("is-loading");try{const s=t.kind==="count"?Ie(t.dataUrl):t.dataUrl,n=await m(Je(s),{method:"GET"});if(!K()||!e.isConnected)return;await Be(a,t.kind,n)}catch(s){e.isConnected&&Ge(a,s.status===403?"You cannot see this any more.":"Could not load.")}finally{e.classList.remove("is-loading")}}}function ve(e){const t=e.href?`<a class="dbp-title" href="${r(e.href)}" title="Open ${r(e.title)}">${r(e.title)}</a>`:`<span class="dbp-title">${r(e.title)}</span>`;return`<section class="dbp-card dbp-size-${r(e.size)}${e.collapsed?" is-collapsed":""}" data-id="${r(e.id)}" role="listitem" tabindex="0" aria-label="${r(e.title)}">
14
14
  <header class="dbp-card-head">
15
15
  <span class="dbp-grip" title="Drag to move" aria-hidden="true"><span data-icon="menu" data-icon-size="14"></span></span>
16
16
  <span class="dbp-icon" aria-hidden="true"><span data-icon="${r(e.icon||"grid")}" data-icon-size="16"></span></span>
@@ -20,44 +20,43 @@ import{apiRequest as m,getUser as ke}from"../api.js";import{addBannerAction as A
20
20
  <button type="button" class="dbp-kebab" aria-label="Actions for ${r(e.title)}" title="Actions">\u22EE</button>
21
21
  </header>
22
22
  <div class="dbp-body"${e.collapsed?" hidden":""}><div class="dbp-skeleton"></div></div>
23
- </section>`}function $(){if(w.hidden=d.locked===!0,d.locked){w.replaceChildren();return}w.innerHTML=o.boards.map(e=>`<button type="button" class="dbp-tab${e.id===o.active?" is-active":""}" role="tab"
24
- aria-selected="${e.id===o.active}" data-board="${r(e.id)}" title="${r(e.name)} - right-click to rename, duplicate or delete">${r(e.name)}</button>`).join("")+(o.boards.length<12?'<button type="button" class="dbp-tab dbp-tab-new" data-new-board title="New board" aria-label="New board"><span data-icon="plus" data-icon-size="14"></span></button>':""),I.scan(w)}function _(e){e===o.active||!o.boards.some(t=>t.id===e)||(N(),o.active=e,i={version:1,widgets:T().widgets},$(),k(),S())}const V=()=>`b-${Date.now().toString(36)}${Math.random().toString(36).slice(2,5)}`;async function X(e,t=""){const a=await E.prompt(e,{inputValue:r(t),inputPlaceholder:"Sales, Content, Support\u2026"});return a==null?null:String(a).replace(/\s+/g," ").trim().slice(0,40)||null}async function Z(){if(o.boards.length>=12)return;const e=await X("Name the new board");if(!e)return;N();const t={id:V(),name:e,widgets:[]};o.boards.push(t),o.active=t.id,i={version:1,widgets:[]},$(),k(),S(),x()}async function C(e,t){const a=o.boards.findIndex(n=>n.id===e);if(a<0)return;N();const s=o.boards[a];if(t==="rename"){const n=await X("Rename the board",s.name);if(!n)return;s.name=n}else if(t==="duplicate"){if(o.boards.length>=12)return;const n={id:V(),name:`${s.name} copy`.slice(0,40),widgets:s.widgets.map(l=>({...l}))};o.boards.splice(a+1,0,n),o.active=n.id}else if(t==="delete"){if(o.boards.length<2||!await E.confirm(`Delete the board "${r(s.name)}"? Its widgets go with it (they stay available to add again).`))return;o.boards.splice(a,1),o.active===e&&(o.active=o.boards[Math.max(0,a-1)].id)}else t==="left"&&a>0?[o.boards[a-1],o.boards[a]]=[o.boards[a],o.boards[a-1]]:t==="right"&&a<o.boards.length-1&&([o.boards[a+1],o.boards[a]]=[o.boards[a],o.boards[a+1]]);i={version:1,widgets:T().widgets},$(),k(),S()}w.addEventListener("click",e=>{if(e.target.closest("[data-new-board]")){Z();return}const t=e.target.closest("[data-board]");t&&_(t.dataset.board)}),w.addEventListener("dblclick",e=>{const t=e.target.closest("[data-board]");t&&C(t.dataset.board,"rename")});const fe=E.contextMenu(w,{cursor:"glyph",match:"[data-board]",inherit:!1,density:"compact",items:e=>{const t=e.dataset.board,a=o.boards.findIndex(s=>s.id===t);return[{header:o.boards[a]?.name||"Board"},{label:"Open",icon:"arrow-right",disabled:t===o.active,action:()=>_(t)},{label:"Rename\u2026",icon:"edit",action:()=>C(t,"rename")},{label:"Duplicate",icon:"copy",disabled:o.boards.length>=12,action:()=>C(t,"duplicate")},{type:"divider"},{label:"Move left",icon:"arrow-left",disabled:a<=0,action:()=>C(t,"left")},{label:"Move right",icon:"arrow-right",disabled:a>=o.boards.length-1,action:()=>C(t,"right")},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,disabled:o.boards.length<2,action:()=>C(t,"delete")}]}});f.add(()=>fe?.destroy?.());function k(){for(const t of M.values())clearInterval(t);M.clear(),b.querySelectorAll(".dbp-body").forEach(qe);const e=Ce(i,y);b.innerHTML=e.map(he).join(""),c.querySelector(".dbp-empty-board").hidden=e.length>0,W?.disconnect();for(const t of b.querySelectorAll(".dbp-card"))K(t),W?.observe(t);I.scan(b);for(const t of b.querySelectorAll(".dbp-card")){t.classList.contains("is-collapsed")||v(t);const a=y.find(n=>n.id===t.dataset.id);if(d.mine.updates==="paused")continue;const s=(a?.refresh||60)*1e3*(d.mine.updates==="slower"?3:1);M.set(t.dataset.id,setInterval(()=>{!document.hidden&&t.isConnected&&!t.classList.contains("is-collapsed")&&v(t)},s))}B()}const Q=e=>{const t=e.detail?.url;if(t)for(const a of b.querySelectorAll(".dbp-card"))y.find(n=>n.id===a.dataset.id)?.href===t&&v(a)};window.addEventListener("dm:sidebar-count",Q),f.add(()=>window.removeEventListener("dm:sidebar-count",Q));function me(e){const t=e.dataset.id,a=y.find(p=>p.id===t),s=i.widgets.find(p=>p.id===t);if(!a||!s)return[];const n=i.widgets.findIndex(p=>p.id===t),l=[{header:a.title},...a.href?[{label:`Open ${a.title}`,icon:"arrow-right",action:()=>{location.hash=a.href}}]:[],{label:s.collapsed?"Open":"Fold",icon:s.collapsed?"chevron-down":"chevron-up",action:()=>F(t,!s.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:s.collapsed===!0,action:()=>v(e)}];return d.locked?l:[{header:a.title},{label:"Add widget\u2026",icon:"plus",action:()=>x()},{type:"divider"},...a.href?[{label:`Open ${a.title}`,icon:"arrow-right",action:()=>{location.hash=a.href}}]:[],{label:s.collapsed?"Open":"Fold",icon:s.collapsed?"chevron-down":"chevron-up",action:()=>F(t,!s.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:s.collapsed===!0,action:()=>v(e)},{type:"divider"},...Ee.map(p=>({label:Se[p],icon:s.size===p?"check":"maximize-2",disabled:s.size===p,action:()=>g(De(i,t,p))})),{type:"divider"},{label:"Move earlier",icon:"arrow-left",disabled:n<=0,action:()=>g(z(i,t,-1))},{label:"Move later",icon:"arrow-right",disabled:n>=i.widgets.length-1,action:()=>g(z(i,t,1))},{type:"divider"},{label:"Remove from the Dashboard",icon:"x",action:()=>g(de(i,t))}]}const ge=E.contextMenu(b,{cursor:"glyph",match:".dbp-card",inherit:!1,density:"compact",items:e=>me(e)});f.add(()=>ge?.destroy?.());const ee=e=>{const t=e.target.closest(".dbp-kebab");if(t){ie(t);return}const a=e.target.closest(".dbp-fold");if(a){const s=a.closest(".dbp-card");s&&F(s.dataset.id,!s.classList.contains("is-collapsed"))}},te=e=>{const t=e.target.closest(".dbp-card");if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),ie(t);return}e.target===t&&(e.altKey&&(e.key==="ArrowLeft"||e.key==="ArrowUp")?(e.preventDefault(),g(z(i,t.dataset.id,-1)),ae(t.dataset.id)):e.altKey&&(e.key==="ArrowRight"||e.key==="ArrowDown")?(e.preventDefault(),g(z(i,t.dataset.id,1)),ae(t.dataset.id)):e.key==="Delete"&&(e.preventDefault(),g(de(i,t.dataset.id))))}},ae=e=>requestAnimationFrame(()=>b.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`)?.focus());if(b.addEventListener("click",ee),b.addEventListener("keydown",te),f.add(()=>{b.removeEventListener("click",ee),b.removeEventListener("keydown",te)}),typeof E.sortable=="function"){const e=E.sortable(b,{items:".dbp-card",handle:".dbp-grip",live:!1,axis:"x",onDrop({key:t,targetKey:a,zone:s}){if(d.locked||s!=="before"&&s!=="after")return!1;const n=Le(i,t,a,s);return re(n,i)?!1:(g(n),!0)}});f.add(()=>e?.destroy?.())}function x(){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">
23
+ </section>`}function k(){$.hidden=!1;const e=d.locked===!0,t=Pe(o,e?"main":o.active,{locked:e,lockedName:_(d.name)});$.innerHTML=We(t,{canAdd:!e&&o.boards.length<be}),I.scan($);const a=_(d.name),s=t.find(n=>n.active)?.name||w().name;He(F,a,s),document.title=`${s} - ${a} - Domma CMS`}function M(){const e=Fe(o.active);if(location.hash!==e)try{history.replaceState(null,"",e)}catch{}}function U(e){if(e===B){Ye(),location.hash="#/";return}e===o.active||!o.boards.some(t=>t.id===e)||(N(),o.active=e,i={version:1,widgets:w().widgets},k(),M(),S(),A())}const ee=()=>`b-${Date.now().toString(36)}${Math.random().toString(36).slice(2,5)}`;async function te(e,t=""){const a=await E.prompt(e,{inputValue:r(t),inputPlaceholder:"Sales, Content, Support\u2026"});return a==null?null:String(a).replace(/\s+/g," ").trim().slice(0,40)||null}async function J(){if(o.boards.length>=12)return;const e=await te("Name the new board");if(!e)return;N();const t={id:ee(),name:e,widgets:[]};o.boards.push(t),o.active=t.id,i={version:1,widgets:[]},k(),M(),S(),A(),x()}async function L(e,t){const a=o.boards.findIndex(n=>n.id===e);if(a<0)return;N();const s=o.boards[a];if(t==="rename"){const n=await te("Rename the board",s.name);if(!n)return;s.name=n}else if(t==="duplicate"){if(o.boards.length>=12)return;const n={id:ee(),name:`${s.name} copy`.slice(0,40),widgets:s.widgets.map(l=>({...l}))};o.boards.splice(a+1,0,n),o.active=n.id,M()}else if(t==="delete"){if(o.boards.length<2||!await E.confirm(`Delete the board "${r(s.name)}"? Its widgets go with it (they stay available to add again).`))return;o.boards.splice(a,1),o.active===e&&(o.active=o.boards[Math.max(0,a-1)].id),M()}else t==="left"&&a>0?[o.boards[a-1],o.boards[a]]=[o.boards[a],o.boards[a-1]]:t==="right"&&a<o.boards.length-1&&([o.boards[a+1],o.boards[a]]=[o.boards[a],o.boards[a+1]]);i={version:1,widgets:w().widgets},k(),S(),A()}$.addEventListener("click",e=>{if(e.target.closest("[data-new-board]")){J();return}const t=e.target.closest("[data-board]");t&&U(t.dataset.board)}),$.addEventListener("dblclick",e=>{const t=e.target.closest("[data-board]");t&&t.dataset.board!==B&&!d.locked&&L(t.dataset.board,"rename")});const we=E.contextMenu($,{cursor:"glyph",match:"[data-board]",inherit:!1,density:"compact",items:e=>{const t=e.dataset.board,a=o.boards.findIndex(s=>s.id===t);return t===B||d.locked?[{header:e.textContent.trim()||"Overview"},{label:"Open",icon:"arrow-right",disabled:t!==B,action:()=>U(t)}]:[{header:o.boards[a]?.name||"Board"},{label:"Open",icon:"arrow-right",disabled:t===o.active,action:()=>U(t)},{label:"Rename\u2026",icon:"edit",action:()=>L(t,"rename")},{label:"Duplicate",icon:"copy",disabled:o.boards.length>=12,action:()=>L(t,"duplicate")},{type:"divider"},{label:"Move left",icon:"arrow-left",disabled:a<=0,action:()=>L(t,"left")},{label:"Move right",icon:"arrow-right",disabled:a>=o.boards.length-1,action:()=>L(t,"right")},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,disabled:o.boards.length<2,action:()=>L(t,"delete")}]}});f.add(()=>we?.destroy?.());function S(){for(const t of T.values())clearInterval(t);T.clear(),b.querySelectorAll(".dbp-body").forEach(ze);const e=Te(i,y);b.innerHTML=e.map(ve).join(""),c.querySelector(".dbp-empty-board").hidden=e.length>0,H?.disconnect();for(const t of b.querySelectorAll(".dbp-card"))Q(t),H?.observe(t);I.scan(b);for(const t of b.querySelectorAll(".dbp-card")){t.classList.contains("is-collapsed")||v(t);const a=y.find(n=>n.id===t.dataset.id);if(d.mine.updates==="paused")continue;const s=(a?.refresh||60)*1e3*(d.mine.updates==="slower"?3:1);T.set(t.dataset.id,setInterval(()=>{!document.hidden&&t.isConnected&&!t.classList.contains("is-collapsed")&&v(t)},s))}W()}const ae=e=>{const t=e.detail?.url;if(t)for(const a of b.querySelectorAll(".dbp-card"))y.find(n=>n.id===a.dataset.id)?.href===t&&v(a)};window.addEventListener("dm:sidebar-count",ae),f.add(()=>window.removeEventListener("dm:sidebar-count",ae));function ke(e){const t=e.dataset.id,a=y.find(p=>p.id===t),s=i.widgets.find(p=>p.id===t);if(!a||!s)return[];const n=i.widgets.findIndex(p=>p.id===t),l=[{header:a.title},...a.href?[{label:`Open ${a.title}`,icon:"arrow-right",action:()=>{location.hash=a.href}}]:[],{label:s.collapsed?"Open":"Fold",icon:s.collapsed?"chevron-down":"chevron-up",action:()=>Y(t,!s.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:s.collapsed===!0,action:()=>v(e)}];return d.locked?l:[{header:a.title},{label:"Add widget\u2026",icon:"plus",action:()=>x()},{type:"divider"},...a.href?[{label:`Open ${a.title}`,icon:"arrow-right",action:()=>{location.hash=a.href}}]:[],{label:s.collapsed?"Open":"Fold",icon:s.collapsed?"chevron-down":"chevron-up",action:()=>Y(t,!s.collapsed)},{label:"Refresh",icon:"refresh-cw",disabled:s.collapsed===!0,action:()=>v(e)},{type:"divider"},...Ce.map(p=>({label:De[p],icon:s.size===p?"check":"maximize-2",disabled:s.size===p,action:()=>g(qe(i,t,p))})),{type:"divider"},{label:"Move earlier",icon:"arrow-left",disabled:n<=0,action:()=>g(G(i,t,-1))},{label:"Move later",icon:"arrow-right",disabled:n>=i.widgets.length-1,action:()=>g(G(i,t,1))},{type:"divider"},{label:"Remove from the Dashboard",icon:"x",action:()=>g(ce(i,t))}]}const Ee=E.contextMenu(b,{cursor:"glyph",match:".dbp-card",inherit:!1,density:"compact",items:e=>ke(e)});f.add(()=>Ee?.destroy?.());const se=e=>{const t=e.target.closest(".dbp-kebab");if(t){le(t);return}const a=e.target.closest(".dbp-fold");if(a){const s=a.closest(".dbp-card");s&&Y(s.dataset.id,!s.classList.contains("is-collapsed"))}},oe=e=>{const t=e.target.closest(".dbp-card");if(t){if(e.key==="F10"&&e.shiftKey){e.preventDefault(),le(t);return}e.target===t&&(e.altKey&&(e.key==="ArrowLeft"||e.key==="ArrowUp")?(e.preventDefault(),g(G(i,t.dataset.id,-1)),ne(t.dataset.id)):e.altKey&&(e.key==="ArrowRight"||e.key==="ArrowDown")?(e.preventDefault(),g(G(i,t.dataset.id,1)),ne(t.dataset.id)):e.key==="Delete"&&(e.preventDefault(),g(ce(i,t.dataset.id))))}},ne=e=>requestAnimationFrame(()=>b.querySelector(`.dbp-card[data-id="${CSS.escape(e)}"]`)?.focus());if(b.addEventListener("click",se),b.addEventListener("keydown",oe),f.add(()=>{b.removeEventListener("click",se),b.removeEventListener("keydown",oe)}),typeof E.sortable=="function"){const e=E.sortable(b,{items:".dbp-card",handle:".dbp-grip",live:!1,axis:"x",onDrop({key:t,targetKey:a,zone:s}){if(d.locked||s!=="before"&&s!=="after")return!1;const n=Me(i,t,a,s);return pe(n,i)?!1:(g(n),!0)}});f.add(()=>e?.destroy?.())}function x(){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">
25
24
  <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>
26
- <div class="dbp-gallery-list"></div>`,e.element.appendChild(t);const a=t.querySelector(".dbp-gallery-list"),s=t.querySelector(".dbp-gallery-search");function n(){const l=Ae(y,i,s.value);a.innerHTML=l.length?l.map(p=>`
25
+ <div class="dbp-gallery-list"></div>`,e.element.appendChild(t);const a=t.querySelector(".dbp-gallery-list"),s=t.querySelector(".dbp-gallery-search");function n(){const l=xe(y,i,s.value);a.innerHTML=l.length?l.map(p=>`
27
26
  <h4 class="dbp-gallery-group">${r(p.source)}</h4>
28
27
  ${p.widgets.map(u=>`
29
28
  <div class="dbp-gallery-item">
30
29
  <span class="dbp-icon"><span data-icon="${r(u.icon||"grid")}" data-icon-size="16"></span></span>
31
30
  <span class="dbp-gallery-main"><strong>${r(u.title)}</strong><small>${r(u.description||"")}</small></span>
32
31
  ${u.added?'<span class="dbp-gallery-added">On the board</span>':`<button type="button" class="btn btn-sm btn-primary" data-add="${r(u.id)}">Add</button>`}
33
- </div>`).join("")}`).join(""):'<p class="dbp-empty">No widgets match.</p>',I.scan(a)}a.addEventListener("click",l=>{const p=l.target.closest("[data-add]")?.dataset.add,u=p&&y.find(O=>O.id===p);u&&(g($e(i,u)),E.toast(`${u.title} added.`,{type:"success",duration:2e3}),n())}),s.addEventListener("input",n),e.open(),n(),setTimeout(()=>s.focus(),50)}c.querySelector(".dbp-add-first").addEventListener("click",x);function ye(e){const t=G.querySelector(".dm-plugin-banner-name");if(!t)return;const a=t.querySelector(".dm-plugin-banner-sep");if(a){for(;a.nextSibling;)a.nextSibling.remove();t.appendChild(document.createTextNode(e))}else t.textContent=e}function ve(){const e=new Date().getHours(),t=e<12?"Good morning":e<18?"Good afternoon":"Good evening",a=String(ke()?.name||"").trim().split(/\s+/)[0],s=new Date().toLocaleDateString("en-GB",{weekday:"long",day:"numeric",month:"long"});return`${t}${a?`, ${a}`:""}. ${s}.`}function se(){const e=d.mine;ye(d.name||"My Dashboard"),document.title=`${d.name||"My Dashboard"} - Domma CMS`,U.hidden=!e.greeting,U.textContent=e.greeting?ve():"",c.classList.remove("dbp-cols-3","dbp-cols-4","dbp-cols-6"),c.classList.add(`dbp-cols-${e.columns||4}`),c.classList.toggle("is-compact",e.density==="compact"),c.classList.toggle("is-locked",d.locked===!0),ce.firstChild.textContent=d.locked?"Your administrator has set this layout. You can fold and refresh widgets; the cog has your own settings. ":"Your own Dashboard. Right-click the board to add widgets; drag a card by its grip; the chevron folds it; a card's \u22EE resizes or removes it. Tabs hold your boards. ",d.locked&&(D="locked"),B()}const L=(e,t,a)=>`<option value="${r(e)}"${String(a)===String(e)?" selected":""}>${r(t)}</option>`;function oe(){const e=d.mine,t=E.slideover({title:"Dashboard settings",size:"md",position:"right"}),a=document.createElement("form");a.className="dbp-settings",a.noValidate=!0,a.innerHTML=`
32
+ </div>`).join("")}`).join(""):'<p class="dbp-empty">No widgets match.</p>',I.scan(a)}a.addEventListener("click",l=>{const p=l.target.closest("[data-add]")?.dataset.add,u=p&&y.find(R=>R.id===p);u&&(g(Oe(i,u)),E.toast(`${u.title} added.`,{type:"success",duration:2e3}),n())}),s.addEventListener("input",n),e.open(),n(),setTimeout(()=>s.focus(),50)}c.querySelector(".dbp-add-first").addEventListener("click",x);function Se(){const e=new Date().getHours(),t=e<12?"Good morning":e<18?"Good afternoon":"Good evening",a=String(Le()?.name||"").trim().split(/\s+/)[0],s=new Date().toLocaleDateString("en-GB",{weekday:"long",day:"numeric",month:"long"});return`${t}${a?`, ${a}`:""}. ${s}.`}function ie(){const e=d.mine;k(),V.hidden=!e.greeting,V.textContent=e.greeting?Se():"",c.classList.remove("dbp-cols-3","dbp-cols-4","dbp-cols-6"),c.classList.add(`dbp-cols-${e.columns||4}`),c.classList.toggle("is-compact",e.density==="compact"),c.classList.toggle("is-locked",d.locked===!0),fe.firstChild.textContent=d.locked?"Your administrator has set this layout. You can fold and refresh widgets; the cog has your own settings. ":"Your own boards. Right-click the board to add widgets; drag a card by its grip; the chevron folds it; a card's \u22EE resizes or removes it. Overview, the first tab, is the site's Dashboard. ",d.locked&&(O="locked"),W()}const D=(e,t,a)=>`<option value="${r(e)}"${String(a)===String(e)?" selected":""}>${r(t)}</option>`;function de(){const e=d.mine,t=E.slideover({title:"Dashboard settings",size:"md",position:"right"}),a=document.createElement("form");a.className="dbp-settings",a.noValidate=!0,a.innerHTML=`
34
33
  <h4 class="dbp-settings-section">Yours</h4>
35
34
  <label class="dbp-field"><span class="form-label">Name
36
- <span data-help="What your board is called - in its banner and in the sidebar. Leave it empty to use the site's name for it."></span></span>
37
- <input class="form-input" name="name" maxlength="40" placeholder="${r(d.site.name||"My Dashboard")}" value="${r(e.name)}" autocomplete="off"></label>
35
+ <span data-help="What your Dashboards are called - in the banner and the sidebar entry. Leave it empty to use the site's name. Each board's own name is its tab: right-click a tab to rename it."></span></span>
36
+ <input class="form-input" name="name" maxlength="40" placeholder="${r(_(d.site.name))}" value="${r(e.name)}" autocomplete="off"></label>
38
37
  <label class="dbp-field dbp-switch"><input type="checkbox" class="form-switch" name="greeting"${e.greeting?" checked":""}>
39
38
  <span>Greeting <span data-help="A line at the top: good morning, your name and today's date."></span></span></label>
40
39
  <div class="dbp-field-row">
41
40
  <label class="dbp-field"><span class="form-label">Columns
42
41
  <span data-help="How many columns the board has on a wide screen. More columns fit more small widgets side by side; a narrow window always uses fewer."></span></span>
43
- <select class="form-select" name="columns">${[3,4,6].map(n=>L(n,`${n} columns`,e.columns)).join("")}</select></label>
42
+ <select class="form-select" name="columns">${[3,4,6].map(n=>D(n,`${n} columns`,e.columns)).join("")}</select></label>
44
43
  <label class="dbp-field"><span class="form-label">Cards
45
44
  <span data-help="Compact cards use less padding and smaller figures, so more fits on the screen."></span></span>
46
- <select class="form-select" name="density">${L("normal","Normal",e.density)}${L("compact","Compact",e.density)}</select></label>
45
+ <select class="form-select" name="density">${D("normal","Normal",e.density)}${D("compact","Compact",e.density)}</select></label>
47
46
  </div>
48
47
  <label class="dbp-field"><span class="form-label">Updates
49
48
  <span data-help="Live: each widget refreshes on its own interval. Slower: three times as long between refreshes - kinder to a slow connection. Paused: only when you press Refresh. A Tool saying its count changed still updates its widget."></span></span>
50
- <select class="form-select" name="updates">${L("live","Live",e.updates)}${L("slower","Slower",e.updates)}${L("paused","Paused - refresh by hand",e.updates)}</select></label>
49
+ <select class="form-select" name="updates">${D("live","Live",e.updates)}${D("slower","Slower",e.updates)}${D("paused","Paused - refresh by hand",e.updates)}</select></label>
51
50
  <label class="dbp-field dbp-switch"><input type="checkbox" class="form-switch" name="home"${e.home?" checked":""}>
52
- <span>Open the admin on this board <span data-help="Signing in, or pressing Dashboard, brings you here instead of the usual Dashboard. Takes effect the next time the admin loads."></span></span></label>
51
+ <span>Open on my boards <span data-help="Signing in, or pressing Dashboards in the sidebar, opens the board you used last instead of the Overview. The Overview tab is always one click away. Takes effect the next time the admin loads."></span></span></label>
53
52
  ${d.isAdmin?`
54
53
  <h4 class="dbp-settings-section">For everyone</h4>
55
- <label class="dbp-field"><span class="form-label">Board name
56
- <span data-help="What the board is called for everyone who has not named their own."></span></span>
57
- <input class="form-input" name="siteName" maxlength="40" placeholder="My Dashboard" value="${r(d.site.name)}" autocomplete="off"></label>
54
+ <label class="dbp-field"><span class="form-label">Dashboards name
55
+ <span data-help="What Dashboards is called - banner and sidebar - for everyone who has not named their own. Empty: Dashboards."></span></span>
56
+ <input class="form-input" name="siteName" maxlength="40" placeholder="Dashboards" value="${r(d.site.name)}" autocomplete="off"></label>
58
57
  <label class="dbp-field dbp-switch"><input type="checkbox" class="form-switch" name="locked"${d.site.locked?" checked":""}>
59
58
  <span>Lock the layout <span data-help="Everyone but administrators sees the site's default layout and cannot add, move, resize or remove widgets - they can still fold and refresh them. Set the default with Make this the default for everyone. Turning the lock off gives people their own layouts back."></span></span></label>`:""}
60
59
  <div class="dbp-settings-actions">
61
60
  <button type="button" class="btn btn-ghost" data-act="cancel">Cancel</button>
62
61
  <button type="submit" class="btn btn-primary"><span data-icon="check" data-icon-size="14"></span> Save</button>
63
- </div>`,t.element.appendChild(a);const s=ne(a,{position:"left"});I.scan(a),a.querySelector('[data-act="cancel"]').addEventListener("click",()=>t.close()),a.addEventListener("submit",async n=>{n.preventDefault();const l=a.elements,p={name:l.name.value.trim(),greeting:l.greeting.checked,columns:Number(l.columns.value),density:l.density.value,updates:l.updates.value,home:l.home.checked};try{const u=await m("/dashboard/settings",{method:"PUT",body:JSON.stringify(p)});if(d={...d,mine:u.mine,site:u.site,name:u.name},d.isAdmin&&l.siteName){const O=await m("/dashboard/settings/site",{method:"PUT",body:JSON.stringify({name:l.siteName.value.trim(),locked:l.locked.checked})});d.site=O.site,d.name=u.mine.name||O.site.name}se(),$(),k();try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}E.toast("Dashboard settings saved.",{type:"success"}),s?.(),t.close()}catch(u){E.toast(`Not saved: ${u.message||u}`,{type:"error"})}}),t.open()}if(await pe(),!H())return;d.locked||A(h,{id:"dashboard-add-widget",icon:"plus",title:"Add widget",onClick:x}),A(h,{id:"dashboard-fold-all",icon:"chevrons-up",title:"Fold or open every widget",onClick:()=>J(R(i))}),A(h,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh every widget",onClick:()=>b.querySelectorAll(".dbp-card").forEach(v)}),d.locked||A(h,{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? All your boards are replaced by the one default board."))try{await m("/dashboard/layout",{method:"DELETE"});const e=await m("/dashboard/boards",{method:"GET"});o={version:2,active:e.active,boards:e.boards},i={version:1,widgets:T().widgets},q=JSON.stringify(o),D=e?.source||"built-in",$(),k(),E.toast("Back to the default layout.",{type:"success"})}catch(e){E.toast(`Could not reset: ${e.message||e}`,{type:"error"})}}}),Y&&A(h,{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 m("/dashboard/layout/default",{method:"PUT",body:JSON.stringify(i)}),E.toast("This is now the default layout.",{type:"success"})}catch(e){E.toast(`Could not set the default: ${e.message||e}`,{type:"error"})}}}),A(h,{id:"dashboard-settings",icon:"settings",title:"Dashboard settings",onClick:oe});const we=E.contextMenu(c,{cursor:!1,inherit:!1,density:"compact",items:()=>[{header:d.name||"My Dashboard"},...d.locked?[]:[{label:"Add widget\u2026",icon:"plus",action:()=>x()},{label:"New board\u2026",icon:"layout",disabled:o.boards.length>=12,action:()=>Z()},{type:"divider"}],{label:R(i)?"Fold every widget":"Open every widget",icon:R(i)?"chevrons-up":"chevrons-down",action:()=>J(R(i))},{label:"Refresh every widget",icon:"refresh-cw",action:()=>b.querySelectorAll(".dbp-card").forEach(v)},{type:"divider"},{label:"Dashboard settings\u2026",icon:"settings",action:()=>oe()}]});f.add(()=>we?.destroy?.()),f.add(ne(c,{position:"bottom"})),I.scan(c),se(),$(),k()}
62
+ </div>`,t.element.appendChild(a);const s=re(a,{position:"left"});I.scan(a),a.querySelector('[data-act="cancel"]').addEventListener("click",()=>t.close()),a.addEventListener("submit",async n=>{n.preventDefault();const l=a.elements,p={name:l.name.value.trim(),greeting:l.greeting.checked,columns:Number(l.columns.value),density:l.density.value,updates:l.updates.value,home:l.home.checked};try{const u=await m("/dashboard/settings",{method:"PUT",body:JSON.stringify(p)});if(d={...d,mine:u.mine,site:u.site,name:u.name},d.isAdmin&&l.siteName){const R=await m("/dashboard/settings/site",{method:"PUT",body:JSON.stringify({name:l.siteName.value.trim(),locked:l.locked.checked})});d.site=R.site,d.name=u.mine.name||R.site.name}ie(),k(),S();try{document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild"))}catch{}E.toast("Dashboard settings saved.",{type:"success"}),s?.(),t.close()}catch(u){E.toast(`Not saved: ${u.message||u}`,{type:"error"})}}),t.open()}if(await me(),!K())return;d.locked||C(h,{id:"dashboard-add-widget",icon:"plus",title:"Add widget",onClick:x}),C(h,{id:"dashboard-fold-all",icon:"chevrons-up",title:"Fold or open every widget",onClick:()=>Z(z(i))}),C(h,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh every widget",onClick:()=>b.querySelectorAll(".dbp-card").forEach(v)}),d.locked||C(h,{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? All your boards are replaced by the one default board."))try{await m("/dashboard/layout",{method:"DELETE"});const e=await m("/dashboard/boards",{method:"GET"});o={version:2,active:e.active,boards:e.boards},i={version:1,widgets:w().widgets},q=JSON.stringify(o),O=e?.source||"built-in",k(),S(),E.toast("Back to the default layout.",{type:"success"})}catch(e){E.toast(`Could not reset: ${e.message||e}`,{type:"error"})}}}),j&&C(h,{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 m("/dashboard/layout/default",{method:"PUT",body:JSON.stringify(i)}),E.toast("This is now the default layout.",{type:"success"})}catch(e){E.toast(`Could not set the default: ${e.message||e}`,{type:"error"})}}}),C(h,{id:"dashboard-settings",icon:"settings",title:"Dashboard settings",onClick:de});const $e=E.contextMenu(c,{cursor:!1,inherit:!1,density:"compact",items:()=>[{header:w().name},...d.locked?[]:[{label:"Add widget\u2026",icon:"plus",action:()=>x()},{label:"New board\u2026",icon:"layout",disabled:o.boards.length>=12,action:()=>J()},{type:"divider"}],{label:z(i)?"Fold every widget":"Open every widget",icon:z(i)?"chevrons-up":"chevrons-down",action:()=>Z(z(i))},{label:"Refresh every widget",icon:"refresh-cw",action:()=>b.querySelectorAll(".dbp-card").forEach(v)},{type:"divider"},{label:"Dashboard settings\u2026",icon:"settings",action:()=>de()}]});f.add(()=>$e?.destroy?.()),f.add(re(c,{position:"bottom"})),I.scan(c),ie(),k(),S();const Ae=ue(location.hash).isNew&&!d.locked&&o.boards.length<be;M(),Ae&&J()}
@@ -1,7 +1,7 @@
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=`
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=`
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 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)}};
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)}};
@@ -0,0 +1 @@
1
+ import{apiRequest as i}from"../api.js";import{viewLifetime as n}from"../lib/tool-kit.js";const o=/^#\/dashboards\/?(\?.*)?$/,a=n(o);export const dashboardsView={templateUrl:"/admin/js/templates/dashboards.html",async onMount(t){a.reset();const e=await i("/dashboard/pro",{method:"GET"}).catch(()=>null);if(!o.test(location.hash))return;const s=t.get(0).querySelector(".dbx")||t.get(0);if(!e?.enabled){s.innerHTML='<p class="dbp-empty">Your own boards come with Dashboard Pro, which is not switched on for this site. Find it in the Marketplace. <a href="#/">Open the Dashboard</a></p>';return}const{mountBoard:r}=await import("./dashboard-board.js");await r(t,a,{isAdmin:e.isAdmin===!0})}};
@@ -1 +1 @@
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};
1
+ import{dashboardView as o}from"./dashboard.js";import{dashboardsView as i}from"./dashboards.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 d}from"./documentation.js";import{tutorialsView as l}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 L}from"./form-submissions.js";import{viewsListView as x}from"./views-list.js";import{viewEditorView as M}from"./view-editor.js";import{viewPreviewView as D}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,dashboards:i,pages:r,pageEditor:t,settings:e,theme:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:d,tutorials:l,apiReference:b,collections:h,collectionEditor:v,collectionEntries:y,forms:j,formEditor:k,formSubmissions:L,viewsList:x,viewEditor:M,viewPreview:D,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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -69,7 +69,8 @@ function formatSize(bytes) {
69
69
  }
70
70
 
71
71
  import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identity.js';
72
- import {describeWhen, quickTimes} from './scheduling.js';
72
+ import {describeWhen, quickTimes, sendQuestion} from './scheduling.js';
73
+ import {isSendShortcut} from './keyboard.js';
73
74
  import {fmtDateTime} from '../ui/dates.js';
74
75
  import {applyTemplate, expandTemplate} from './templates.js';
75
76
  import {makeResizable} from './resizable.js';
@@ -300,7 +301,7 @@ const FORM = `
300
301
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="later" title="Send this at a time you choose">Later…</button>
301
302
  <button type="button" class="btn btn-sm btn-primary" data-on-click="send"
302
303
  data-bind-disabled="cannotSend.value" data-bind-text="sendLabel.value" data-bind-title="sendTitle.value"></button>
303
- <span data-help-title="Sending" data-help="Later… sends at a time you choose, and the button then says when. With an undo delay set in Mail Settings, Send holds the message for those seconds so you can take it back; it has not gone until they pass."></span>
304
+ <span data-help-title="Sending" data-help="Ctrl+Enter (Cmd+Enter on a Mac) sends too, after asking first. Later… sends at a time you choose, and the button then says when. With an undo delay set in Mail Settings, Send holds the message for those seconds so you can take it back; it has not gone until they pass."></span>
304
305
  </div>
305
306
  </div>`;
306
307
 
@@ -848,6 +849,25 @@ export function openCompose({
848
849
  attachList.addEventListener('click', onAttachClick);
849
850
  attachList.addEventListener('keydown', onAttachKey);
850
851
 
852
+ // Ctrl+Enter (Cmd+Enter): send, from anywhere in the window - the editor included - but only
853
+ // after "Are you sure?", because the combination is easy to hit while typing.
854
+ let askingSend = false;
855
+ const onSendKey = async (e) => {
856
+ if (!isSendShortcut(e)) return;
857
+ e.preventDefault();
858
+ e.stopPropagation();
859
+ if (askingSend || closed || cannotSend.peek()) return;
860
+ askingSend = true;
861
+ try {
862
+ const q = sendQuestion({...collect(), sendAt: scheduledAt.peek(), undoSeconds});
863
+ const ok = await E.confirm(q.text, {title: q.title, confirmText: q.confirmText, cancelText: 'Keep writing'});
864
+ if (ok && !closed && !cannotSend.peek()) await send();
865
+ } finally {
866
+ askingSend = false;
867
+ }
868
+ };
869
+ slideover.element.addEventListener('keydown', onSendKey, true);
870
+
851
871
  slideover.element.appendChild(form);
852
872
  const stopResizing = makeResizable(slideover, {
853
873
  storageKey: 'domma-mail.compose-width',
@@ -911,6 +931,7 @@ export function openCompose({
911
931
  attachMenu?.destroy?.();
912
932
  attachList.removeEventListener('click', onAttachClick);
913
933
  attachList.removeEventListener('keydown', onAttachKey);
934
+ slideover.element?.removeEventListener('keydown', onSendKey, true);
914
935
  try { stopHelp?.(); } catch { /* gone */ }
915
936
  closedListeners.splice(0).forEach(fn => { try { fn(); } catch { /* ignore */ } });
916
937
  // Domma hides a closed slideover rather than removing it: every message
@@ -126,6 +126,7 @@ export const SHORTCUTS = [
126
126
  ['r / Shift-R', 'Reply / reply all'],
127
127
  ['f', 'Forward'],
128
128
  ['c', 'New message'],
129
+ ['Ctrl-Enter', 'Send the message you are writing (asks first; Cmd-Enter on a Mac)'],
129
130
  ['/', 'Search'],
130
131
  ['x', 'Select or deselect'],
131
132
  ['Shift-A', 'Select all on this page'],
@@ -134,3 +135,16 @@ export const SHORTCUTS = [
134
135
  ['Esc', 'Clear search or selection'],
135
136
  ['?', 'This list']
136
137
  ];
138
+
139
+ /**
140
+ * Ctrl+Enter (Cmd+Enter on a Mac) - "send" in the compose window, as in most
141
+ * mail clients. The one combination this file claims, and only in compose,
142
+ * where it asks before anything goes (compose-window.js).
143
+ *
144
+ * @param {KeyboardEvent} event
145
+ * @returns {boolean}
146
+ */
147
+ export function isSendShortcut(event) {
148
+ if (!event || event.key !== 'Enter' || event.altKey || event.shiftKey || event.isComposing) return false;
149
+ return Boolean(event.ctrlKey || event.metaKey) && !(event.ctrlKey && event.metaKey);
150
+ }
@@ -203,3 +203,25 @@ export function dueNow(items, now = Date.now()) {
203
203
  return Number.isFinite(when) && when <= at;
204
204
  });
205
205
  }
206
+
207
+ /**
208
+ * The "Are you sure?" for Ctrl+Enter: who it goes to, what, and when - a
209
+ * keypress is easy to make by accident, so the question says what would happen.
210
+ *
211
+ * @param {{to?: Array<{name?: string, address?: string}|string>, subject?: string, sendAt?: string|null, undoSeconds?: number}} msg
212
+ * @param {Date} [now]
213
+ * @returns {{title: string, text: string, confirmText: string}}
214
+ */
215
+ export function sendQuestion({to = [], subject = '', sendAt = null, undoSeconds = 0} = {}, now = new Date()) {
216
+ const names = (Array.isArray(to) ? to : []).map(r => (typeof r === 'string' ? r : (r?.name || r?.address || ''))).filter(Boolean);
217
+ const who = !names.length ? 'nobody yet'
218
+ : names.length <= 3 ? names.join(', ')
219
+ : `${names.slice(0, 2).join(', ')} and ${names.length - 2} more`;
220
+ const what = String(subject || '').trim() ? `"${String(subject).trim().slice(0, 80)}"` : 'this message (no subject)';
221
+ if (sendAt) {
222
+ return {title: 'Schedule this message?', confirmText: 'Schedule',
223
+ text: `Send ${what} to ${who} ${describeWhen(sendAt, now)}?`};
224
+ }
225
+ return {title: 'Send this message?', confirmText: 'Send',
226
+ text: `Send ${what} to ${who} now?${undoSeconds > 0 ? ` You will have ${undoSeconds} seconds to take it back.` : ''}`};
227
+ }
@@ -250,8 +250,8 @@ export async function authRoutes(fastify) {
250
250
  menuItems: async () => (await getMenu('admin-sidebar').catch(() => null))?.items || [],
251
251
  canSee: (visibility) => checkVisibility(request.user, visibility)
252
252
  });
253
- // Dashboard Pro's "Open the admin on my board" - for a user the admin does not confine.
254
- if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/my-dashboard';
253
+ // Dashboard Pro's "Open on my boards" - for a user the admin does not confine.
254
+ if (!adminScope && isDashboardPro() && (await readPrefs(request.user)).home) adminHome = '/dashboards';
255
255
  return {permissions, adminScope, adminHome};
256
256
  });
257
257
 
@@ -26,6 +26,17 @@ export async function sidebarRoutes(fastify, opts = {}) {
26
26
  const authenticate = opts.authenticate || defaultAuthenticate;
27
27
  const signedIn = {preHandler: [authenticate]};
28
28
 
29
+ /** Dashboard Pro: the Dashboard entry (`#/`) is Dashboards, wearing the name this person (or the site) gave it. */
30
+ const dashboardsLabel = async (request) => (isDashboardPro()
31
+ ? boardName(await readPrefs(request.user), await readSite())
32
+ : null);
33
+ /** Relabel the entry for `#/` - only while it still says "Dashboard", so a name an admin gave it stays. */
34
+ const relabel = (items, label) => (items || []).map((it) => {
35
+ if (!it || typeof it !== 'object') return it;
36
+ const next = it.url === '#/' && String(it.text || '').trim() === 'Dashboard' ? {...it, text: label} : it;
37
+ return Array.isArray(next.items) ? {...next, items: relabel(next.items, label)} : next;
38
+ });
39
+
29
40
  fastify.get('/sidebar/menu', signedIn, async (request) => {
30
41
  const menu = await getMenu(ADMIN_SIDEBAR_SLUG);
31
42
  if (!menu) return {items: []};
@@ -33,16 +44,16 @@ export async function sidebarRoutes(fastify, opts = {}) {
33
44
  // renderer needs them to place Tools before it drops them. An item
34
45
  // this user may not see (`visibility`) arrives as a label-less hidden
35
46
  // stub for the same reason - see restrictedStub() in menus.js.
36
- return {...menu, items: filterItemsForUser(menu.items || [], request.user, {keepHidden: true})};
47
+ const items = filterItemsForUser(menu.items || [], request.user, {keepHidden: true});
48
+ const label = await dashboardsLabel(request);
49
+ return {...menu, items: label ? relabel(items, label) : items};
37
50
  });
38
51
 
39
52
  fastify.get('/sidebar/registered-items', signedIn, async (request) => {
40
- // Dashboard Pro: My Dashboard wears the name this person (or the site) gave it.
41
- const boardLabel = isDashboardPro()
42
- ? boardName(await readPrefs(request.user), await readSite())
43
- : null;
53
+ // A Tool that owns `#/` (the fleet manager's Dashboard) registers the entry itself.
54
+ const boardLabel = await dashboardsLabel(request);
44
55
  return getRegisteredSidebarItems()
45
- .map(entry => (boardLabel && entry?.item?.url === '#/my-dashboard'
56
+ .map(entry => (boardLabel && entry?.item?.url === '#/' && String(entry.item.text || '').trim() === 'Dashboard'
46
57
  ? {...entry, item: {...entry.item, text: boardLabel}}
47
58
  : entry))
48
59
  .filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
@@ -24,7 +24,9 @@ export const UPDATES = ['live', 'slower', 'paused'];
24
24
  export const NAME_MAX = 40;
25
25
 
26
26
  export const DEFAULT_PREFS = {name: '', greeting: true, columns: 4, density: 'normal', updates: 'live', home: false};
27
- export const DEFAULT_SITE = {name: 'My Dashboard', locked: false};
27
+ export const DEFAULT_SITE = {name: 'Dashboards', locked: false};
28
+ /** The default before 1.5, when it named the board's own sidebar entry: read as no name at all. */
29
+ const OLD_DEFAULT = 'My Dashboard';
28
30
 
29
31
  const cleanName = (v) => String(v ?? '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
30
32
 
@@ -41,10 +43,11 @@ export function normalisePrefs(input) {
41
43
  };
42
44
  }
43
45
 
44
- /** The site's settings, cleaned. A blank name falls back to "My Dashboard". */
46
+ /** The site's settings, cleaned. A blank name (or the old default) falls back to "Dashboards". */
45
47
  export function normaliseSite(input) {
46
48
  const s = input && typeof input === 'object' ? input : {};
47
- return {name: cleanName(s.name) || DEFAULT_SITE.name, locked: s.locked === true};
49
+ const name = cleanName(s.name);
50
+ return {name: name && name !== OLD_DEFAULT ? name : DEFAULT_SITE.name, locked: s.locked === true};
48
51
  }
49
52
 
50
53
  /** What is wrong with submitted settings, as messages ([] when they are fine). */
@@ -66,7 +69,8 @@ export function siteProblems(input) {
66
69
 
67
70
  /** What the board is called for this person: their own name, else the site's. */
68
71
  export function boardName(prefs, site) {
69
- return cleanName(prefs?.name) || normaliseSite(site).name;
72
+ const mine = cleanName(prefs?.name);
73
+ return mine && mine !== OLD_DEFAULT ? mine : normaliseSite(site).name;
70
74
  }
71
75
 
72
76
  // ---- Files -------------------------------------------------------------------