domma-cms 1.6.5 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -264,7 +264,7 @@ Permissions per resource are defined in `config/auth.json`.
264
264
 
265
265
  The sidebar groups content by role:
266
266
 
267
- - **Overview** - Dashboard
267
+ - **Dashboards** - Dashboard
268
268
  - **Structure** - Navigation, Layouts *(admin and manager only)*
269
269
  - **Content** - Pages, Media
270
270
  - **Configuration** - Users, Site Settings *(admin and manager only)*
@@ -1 +1 @@
1
- export function onceClosed(t,e=()=>{}){const n=t.close.bind(t),o=t.close;let c=!1;t.close=(...r)=>{const s=n(...r);return c||(c=!0,e(),setTimeout(()=>{t.close=o;try{t.destroy?.()}catch{}t.element?.remove()},400)),s}}export function openMenuAt(t){const e=t.getBoundingClientRect();t.dispatchEvent(new MouseEvent("contextmenu",{bubbles:!0,cancelable:!0,clientX:e.left,clientY:e.bottom,view:window}))}export function settingsStore(t,e){return{read(){try{return e(S.get(t))}catch{return e(null)}},write(n){try{S.set(t,n)}catch{}}}}export function viewLifetime(t){let e=[];const n=()=>{const c=e;e=[],c.forEach(r=>{try{r?.()}catch{}})},o=()=>{t.test(location.hash)||n()};return{add(c){e.push(c)},reset(){n(),window.addEventListener("hashchange",o),e.push(()=>window.removeEventListener("hashchange",o))}}}export function sidebarCount(t,e={}){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:t,...e}}))}catch{}}
1
+ export function onceClosed(t,e=()=>{}){const c=t.close.bind(t),r=t.close;let n=!1;t.close=(...o)=>{const i=c(...o);return n||(n=!0,e(),setTimeout(()=>{t.close=r;try{t.destroy?.()}catch{}t.element?.remove()},400)),i}}export function openMenuAt(t){const e=t.getBoundingClientRect();t.dispatchEvent(new MouseEvent("contextmenu",{bubbles:!0,cancelable:!0,clientX:e.left,clientY:e.bottom,view:window}))}export function settingsStore(t,e){return{read(){try{return e(S.get(t))}catch{return e(null)}},write(c){try{S.set(t,c)}catch{}}}}export function viewLifetime(t){let e=[];const c=()=>{const n=e;e=[],n.forEach(o=>{try{o?.()}catch{}})},r=()=>{t.test(location.hash)||c()};return{add(n){return e.push(n),()=>{const o=e.indexOf(n);o!==-1&&e.splice(o,1)}},reset(){c(),window.addEventListener("hashchange",r),e.push(()=>window.removeEventListener("hashchange",r))}}}export function sidebarCount(t,e={}){try{window.dispatchEvent(new CustomEvent("dm:sidebar-count",{detail:{url:t,...e}}))}catch{}}
@@ -0,0 +1,12 @@
1
+ import{apiRequest as N}from"../api.js";import{onceClosed as O,openMenuAt as J}from"./tool-kit.js";const $=(s,t="GET")=>N(s,{method:t}),L=s=>{const t=document.createElement("template");return t.innerHTML=s,t.content.firstElementChild};export function toolDraftKey(s,t){let g;try{g=S.get("auth_user")?.id}catch{}return g?`tools.draft.${g}.${s}.${t||"new"}`:null}export function protectToolEditor({so:s,panel:t,tool:g,id:k,read:m,write:x,lifetime:w}){const c=toolDraftKey(g,k);let o=JSON.stringify(m()),e=!1,n=!1,T;const p=s.close.bind(s),h=()=>{try{return c?S.get(c):null}catch{return null}},l=()=>{try{c&&S.remove(c)}catch{}},u=()=>JSON.stringify(m())!==o,a=()=>{if(!(e||!c))try{u()?S.set(c,{values:m(),base:o}):l()}catch{}},r=()=>queueMicrotask(a);t.addEventListener("input",r),t.addEventListener("change",r),t.addEventListener("click",r);const f=d=>{a(),!e&&u()&&(d.preventDefault(),d.returnValue="")};window.addEventListener("beforeunload",f);const b=()=>{e=!0,T?.(),t.removeEventListener("input",r),t.removeEventListener("change",r),t.removeEventListener("click",r),window.removeEventListener("beforeunload",f),p()};s.close=async()=>{if(e||n)return!1;if(u()){n=!0;const d=await E.confirm("Close this editor? Your changes will be kept as a browser draft for the next time you open it.",{title:"Unsaved changes",confirmText:"Keep draft and close",cancelText:"Keep editing"});if(n=!1,!d||e)return!1}return a(),b(),!0},T=w.add(()=>{e||(a(),b())});const i=h();return i?.values&&JSON.stringify(i.values)!==o&&Promise.resolve(E.confirm("There are unsaved changes from your last visit. Restore the browser draft?"+(i.base!==o?" The saved item has changed since; review the draft before saving.":""),{title:"Recover draft",confirmText:"Restore draft",cancelText:"Discard draft"})).then(d=>{e||(d?(x(i.values),a()):l())}),{saved(){o=JSON.stringify(m()),l()},discard(){o=JSON.stringify(m()),l()}}}export function mountToolTrash({root:s,toolbar:t,tool:g,endpoint:k,itemPath:m,label:x,titleOf:w,reload:c,lifetime:o}){const e=L('<button type="button" class="btn btn-sm btn-ghost tool-trash-button">Trash</button>');e.setAttribute("aria-label",`${x} Trash`),t.appendChild(e);let n;const T=()=>{if(n)return;const p=E.slideover({title:`${x} Trash`,size:"md",position:"right"});n=p;const h=L(`<div class="tool-trash-panel" style="display:flex;flex-direction:column;gap:.75rem">
2
+ <p>Items stay here until you restore them or delete them permanently.</p>
3
+ <div role="alert" data-if="error.value"><span data-bind-text="error.value"></span>
4
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="retry" data-bind-disabled="loading.value">Retry</button></div>
5
+ <p data-if="loading.value">Loading Trash\u2026</p>
6
+ <p data-if="empty.value">Trash is empty.</p>
7
+ <div class="tool-trash-rows" data-each="rows.value key=id"></div>
8
+ </div>`);h.querySelector(".tool-trash-rows").innerHTML=`<div class="tool-trash-row" data-id="{{id}}" tabindex="0" style="display:flex;align-items:center;gap:.5rem;padding:.5rem;border-bottom:1px solid var(--dm-border)">
9
+ <span style="flex:1;overflow-wrap:anywhere">{{title}}</span><small>{{when}}</small>
10
+ <button type="button" class="btn btn-sm btn-ghost tool-trash-more" aria-label="Trash item actions">\u22EE</button></div>`;const l=M.observable([]),u=M.observable(""),a=M.observable(!1),r=M.observable(!1);let f=!1;const b=async()=>{if(!a.peek()){a.value=!0;try{const v=await $(`${k}/trash`);if(f)return;l.value=v.map(y=>({id:y.id,title:String(w(y)||"Untitled").slice(0,160),when:D(y.trashedAt).fromNow()})),u.value=""}catch{f||(u.value="Could not load Trash.")}finally{f||(a.value=!1)}}},i=async(v,y)=>{if(!r.peek()){r.value=!0;try{if(y&&!await E.confirm("Delete this item permanently? This cannot be undone.",{confirmText:"Delete permanently"}))return;await $(y?`${k}/trash/${v}`:`${k}${m}/${v}/restore`,y?"DELETE":"POST"),f||(l.value=l.peek().filter(R=>R.id!==v)),await c(),E.toast(y?"Permanently deleted.":"Restored.",{type:"success"})}catch{E.toast(y?"Could not delete the item.":"Could not restore the item.",{type:"error"})}finally{r.value=!1}}};p.element.appendChild(h);const d=M.applyBindings({rows:l,error:u,loading:a,empty:M.computed(()=>!a.value&&!u.value&&!l.value.length)},h,{methods:{retry:b}}),C=E.contextMenu(h,{match:".tool-trash-row",inherit:!1,density:"compact",items:v=>[{label:"Restore",disabled:r.peek(),action:()=>i(v.dataset.id,!1)},{label:"Delete permanently\u2026",disabled:r.peek(),action:()=>i(v.dataset.id,!0)}]});h.addEventListener("click",v=>{v.target.closest(".tool-trash-more")&&J(v.target.closest(".tool-trash-row"))}),O(p,()=>{f=!0,n=null,d.dispose(),C?.destroy?.()}),p.open(),b()};e.addEventListener("click",T),o.add(()=>{e.removeEventListener("click",T),n?.close(),e.remove()})}export function mountGroupBatch({root:s,ensureGroup:t,byId:g,patch:k,withGroup:m,lifetime:x}){const w=L(`<div class="tool-bulk-result" role="status" data-if="message.value" style="margin:.5rem 0">
11
+ <span data-bind-text="message.value"></span>
12
+ <button type="button" class="btn btn-sm btn-ghost" data-if="hasFailed.value" data-on-click="retry" data-bind-disabled="busy.value">Retry failed</button></div>`),c=s.querySelector(".ct-bar");c?c.after(w):s.appendChild(w);const o=M.observable(""),e=M.observable(!1),n=M.observable([]);let T="",p=!1;const h=async(u,a)=>{if(!e.peek()){e.value=!0,T=a;try{if(!await t(a)){n.value=[...u],o.value="Could not create the group. Retry when it is available.";return}let r=0,f=0;const b=[];for(const i of[...new Set(u)]){if(p)return;const d=g(i);if(!d){b.push(i);continue}if((d.groups||[]).includes(a)){f++;continue}await k(i,{groups:m(d.groups,a,!0)})?r++:b.push(i)}if(p)return;n.value=b,o.value=`${r} added to ${a} \xB7 ${f} already in group \xB7 ${b.length} failed`}finally{e.value=!1}}},l=M.applyBindings({message:o,busy:e,hasFailed:M.computed(()=>n.value.length>0)},w,{methods:{retry:()=>h(n.peek(),T)}});return x.add(()=>{p=!0,l.dispose(),w.remove()}),{run:h}}
@@ -24,6 +24,11 @@
24
24
  </div>
25
25
  </div>
26
26
 
27
+ <p class="an-note" role="status" data-bind-text="freshness.value"></p>
28
+ <div class="tool-load-error" role="alert" data-if="loadError.value || liveError.value">
29
+ <span data-bind-text="loadError.value || liveError.value"></span>
30
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="refresh">Retry</button>
31
+ </div>
27
32
  <div class="an-kpis">
28
33
  <div class="an-kpi"><span class="an-kpi-value" data-bind-text="kpi.value.hits">-</span>
29
34
  <span class="an-kpi-label">Page views <span data-help="Every page view in the chosen range. Admin pages, the API, and visitors whose browser asks not to be tracked are never counted."></span></span></div>
@@ -3,6 +3,10 @@
3
3
  inserts the row and chip templates: the router renders this file through _.render, eating any {{…}}.
4
4
  The editor, settings and groups panels are slideovers built in the view. -->
5
5
  <div class="ct-tool">
6
+ <div class="tool-load-error" role="alert" data-if="loadError.value">
7
+ <span data-bind-text="loadError.value"></span>
8
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="retry" data-bind-disabled="loading.value">Retry</button>
9
+ </div>
6
10
 
7
11
  <div class="ct-bar">
8
12
  <label class="ct-search">
@@ -108,6 +108,15 @@
108
108
  Notifications &gt; Sources ("To-dos overdue or due today").</p>
109
109
 
110
110
  <h3>Where Notes, Todo and Contacts are stored</h3>
111
+ <p><strong>Recovering changes.</strong> These editors offer to keep unsaved changes as a browser
112
+ draft when you close them. Opening the same item again lets you restore or discard the draft.
113
+ Drafts belong to your account on this browser; they are not saved to the site until you press
114
+ Save. Todo also remembers an unfinished task in its quick-add bar. If a list cannot load,
115
+ use its Retry button rather than treating it as empty.</p>
116
+ <p><strong>Trash.</strong> Deleting a note, task or contact moves it to that Tool's Trash, including
117
+ completed tasks cleared together and contacts deleted in bulk. Open Trash and use an item's
118
+ menu to Restore it or Delete permanently. Restoration keeps its original ID and fields.
119
+ Items in Trash stay out of normal lists, notifications and contact sharing.</p>
111
120
  <p>On files, unless the site has a working MongoDB connection (Pro): Notes and Todo then use
112
121
  MongoDB, as their collections declare; Contacts stay on files. A site is never switched to
113
122
  an empty store - while the files still hold notes or to-dos they stay on files, and the
@@ -118,6 +127,9 @@
118
127
  <p>Page views and visitors, counted on your own server - no third-party script. Admin pages, the
119
128
  API and browsers that ask not to be tracked are never counted. Analytics Pro, in the
120
129
  Marketplace, adds deeper reports.</p>
130
+ <p>The figures show when they last refreshed. If a refresh fails, previous figures are marked
131
+ stale and Retry fetches them again. The live visitor count also shows when it is unavailable
132
+ or stale; hover over it for its last refresh time.</p>
121
133
 
122
134
  <h3>SEO</h3>
123
135
  <p>How the site looks to search engines: an audit of every page with a score, each page's search
@@ -1,6 +1,10 @@
1
1
  <!-- No heading: the banner above (admin/js/lib/plugin-chrome.js) is the title.
2
2
  Everything that changes is bound (M.applyBindings) - see admin/js/views/notes.js. -->
3
3
  <div class="notes-tool">
4
+ <div class="tool-load-error" role="alert" data-if="loadError.value">
5
+ <span data-bind-text="loadError.value"></span>
6
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="retry" data-bind-disabled="loading.value">Retry</button>
7
+ </div>
4
8
 
5
9
  <!-- One compact bar: find, filter, count, layout, new. -->
6
10
  <div class="notes-bar">
@@ -2,6 +2,10 @@
2
2
  Everything that changes is bound (M.applyBindings) - see admin/js/views/todo.js, which also
3
3
  inserts the row template: the router renders this file through _.render, eating any {{…}}. -->
4
4
  <div class="todo-tool">
5
+ <div class="tool-load-error" role="alert" data-if="loadError.value">
6
+ <span data-bind-text="loadError.value"></span>
7
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="retry" data-bind-disabled="loading.value">Retry</button>
8
+ </div>
5
9
 
6
10
  <!-- Quick add: type and press Enter. -->
7
11
  <form class="todo-add" data-on-submit="add">
@@ -1,4 +1,4 @@
1
- import{apiRequest as v,getToken as K,refreshAccessToken as Z}from"../api.js";import{addBannerAction as Q}from"../lib/plugin-chrome.js";import{helpPopovers as V}from"../lib/help-popover.js";import{onceClosed as X,openMenuAt as ee,settingsStore as te,sidebarCount as ae,viewLifetime as se}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as ne,journeyRows as oe,kpis as le,LIMITS as ie,normaliseSettings as O,RANGE_LABEL as D,RANGES as re,seriesRange as ce,topRows as de}from"../lib/analytics-shape.js";import{fmtDate as pe}from"../lib/dates.js";const ue="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",me=3e4,B=te("analytics.settings",O),ve=`
1
+ import{apiRequest as h,getToken as se,refreshAccessToken as ne}from"../api.js";import{addBannerAction as oe}from"../lib/plugin-chrome.js";import{helpPopovers as J}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as ie,settingsStore as re,sidebarCount as ce,viewLifetime as de}from"../lib/tool-kit.js";import{DEFAULT_SETTINGS as ue,journeyRows as pe,kpis as ve,LIMITS as me,normaliseSettings as z,RANGE_LABEL as U,RANGES as be,seriesRange as he,topRows as fe}from"../lib/analytics-shape.js";import{fmtDate as ye}from"../lib/dates.js";const ge="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js",we=3e4,Y=re("analytics.settings",z),Me=`
2
2
  <div class="an-row" data-url="{{url}}">
3
3
  <span class="an-bar-fill" style="width: {{bar}}"></span>
4
4
  <span class="an-rank">{{rank}}</span>
@@ -6,24 +6,24 @@ import{apiRequest as v,getToken as K,refreshAccessToken as Z}from"../api.js";imp
6
6
  <span class="an-num">{{hitsText}}</span>
7
7
  <span class="an-share">{{share}}</span>
8
8
  <button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
9
- </div>`,A=`
9
+ </div>`,H=`
10
10
  <div class="an-row" data-url="{{url}}">
11
11
  <span class="an-bar-fill" style="width: {{bar}}"></span>
12
12
  <span class="an-url" title="{{label}}">{{label}}</span>
13
13
  <span class="an-num">{{count}}</span>
14
14
  <button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
15
- </div>`,be=`
15
+ </div>`,Re=`
16
16
  <div class="an-row" data-url="{{url}}">
17
17
  <span class="an-url" title="{{url}}">{{url}}</span>
18
18
  <span class="an-num" title="Views per hour today, against the usual {{baseline}}">{{hits}}/h</span>
19
19
  <span class="an-share">\xD7{{ratio}}</span>
20
20
  <button type="button" class="an-kebab" aria-label="Page actions" title="Actions">\u22EE</button>
21
- </div>`;function he(){return window.Chart?Promise.resolve():new Promise((r,s)=>{const t=document.createElement("script");t.src=ue,t.async=!0,t.onload=()=>r(),t.onerror=()=>s(new Error("chart.js load failed")),document.head.appendChild(t)})}function fe(r){const s=document.createElement("template");return s.innerHTML=r.trim(),s.content.firstElementChild}async function ye(r){const s=b=>fetch("/api/analytics/export.csv?range="+encodeURIComponent(r),{headers:b?{Authorization:"Bearer "+b}:{}});let t=await s(K());if(t.status===401&&(t=await s(await Z())),!t.ok)throw new Error("export failed");const c=await t.blob(),d=(t.headers.get("Content-Disposition")||"").match(/filename="?([^"]+)"?/),u=URL.createObjectURL(c),i=document.createElement("a");i.href=u,i.download=d?d[1]:"analytics.csv",document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(u)}const l=se(/^#\/analytics\b/);export const analyticsView={templateUrl:"/admin/js/templates/analytics.html",async onMount(r){l.reset();const s=r.get(0),t=M.observable(B.read()),c=M.observable(t.peek().range),d=M.observable("pages"),u=M.observable(""),i=M.observable(null),b=M.observable([]),y=M.observable(null),S=M.observable([]),g=M.observable(null),x=M.observable([]),j=M.observable("");l.add(M.effect(()=>{const e=i.value,{rows:a,matched:n}=de(e?.top,{filter:u.value,limit:t.value.limit,total:e?.total});x.value=a,j.value=e?e.top.length?n?n>a.length?`Showing ${a.length} of ${n} pages - the cog changes how many.`:"":"No pages match that filter.":"No page views recorded in this range yet.":"Loading\u2026"}));const P=M.observable([]),U=M.observable([]),$=M.observable([]);l.add(M.effect(()=>{const e=oe(y.value);P.value=e.entry,U.value=e.exit,$.value=e.paths}));const q={range:c,section:d,filter:u,spikes:S,pageRows:x,pagesNote:j,entryRows:P,exitRows:U,pathRows:$,kpi:M.computed(()=>le(i.value,y.value)),isAllTime:M.computed(()=>c.value==="all"),sessionsCount:M.computed(()=>y.value?.totalSessions??0),liveText:M.computed(()=>g.value===null?"Active now: -":`${g.value} active now`),liveClass:M.computed(()=>g.value?"is-on":""),liveTitle:M.computed(()=>t.value.live?"Refreshes every 30 seconds":"Live refresh is off - see settings")};l.add(M.effect(()=>B.write(t.value)));const G=(e,a)=>{t.value=O({...t.peek(),[e]:a})};let k=0;async function w(){const e=c.peek(),a=++k;try{const[n,p,m,f]=await Promise.all([v("/analytics/stats?range="+encodeURIComponent(e)),v("/analytics/series?range="+encodeURIComponent(ce(e))),v("/analytics/journeys?range="+encodeURIComponent(e)),v("/analytics/spikes")]);if(a!==k)return;i.value=n,b.value=p?.series??[],y.value=m,S.value=(f?.items??[]).map(o=>({...o,key:o.url}))}catch{a===k&&E.toast("Could not load analytics.",{type:"error"})}}async function C(){try{g.value=(await v("/analytics/realtime"))?.activeSessions??0,ae("#/analytics")}catch{}}let _=!0;l.add(M.effect(()=>{if(c.value,_){_=!1;return}w()}));let L=null;l.add(M.effect(()=>{clearInterval(L),L=t.value.live?setInterval(()=>{document.hidden||C()},me):null})),l.add(()=>clearInterval(L));const H=s.querySelector(".an-chart");let R=null;l.add(()=>{R?.destroy(),R=null}),l.add(M.effect(()=>{const e=b.value,a=t.value.chart;if(!window.Chart||!H.isConnected)return;const n=getComputedStyle(document.documentElement),p=n.getPropertyValue("--dm-primary").trim()||"rgb(99, 102, 241)",m=n.getPropertyValue("--dm-text-muted").trim()||"#888",f=`color-mix(in srgb, ${n.getPropertyValue("--dm-border").trim()||"#888"} 45%, transparent)`;R?.destroy(),R=new window.Chart(H,{type:a,data:{labels:e.map(o=>pe(o.date).slice(0,5)),datasets:[{label:"Page views",data:e.map(o=>o.hits),borderColor:p,backgroundColor:a==="bar"?p:`color-mix(in srgb, ${p} 18%, transparent)`,fill:a==="line",tension:.3,borderWidth:2,pointRadius:e.length>40?0:2,borderRadius:3}]},options:{responsive:!0,maintainAspectRatio:!1,animation:!1,plugins:{legend:{display:!1}},scales:{y:{beginAtZero:!0,ticks:{precision:0,color:m},grid:{color:f}},x:{ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:10,color:m},grid:{display:!1}}}}})}));function W(){const e=E.slideover({title:"Analytics settings",size:"sm",position:"right"}),a=fe(`
21
+ </div>`;function ke(){return window.Chart?Promise.resolve():new Promise((r,s)=>{const a=document.createElement("script");a.src=ge,a.async=!0,a.onload=()=>r(),a.onerror=()=>s(new Error("chart.js load failed")),document.head.appendChild(a)})}function Ee(r){const s=document.createElement("template");return s.innerHTML=r.trim(),s.content.firstElementChild}async function Ce(r){const s=f=>fetch("/api/analytics/export.csv?range="+encodeURIComponent(r),{headers:f?{Authorization:"Bearer "+f}:{}});let a=await s(se());if(a.status===401&&(a=await s(await ne())),!a.ok)throw new Error("export failed");const c=await a.blob(),d=(a.headers.get("Content-Disposition")||"").match(/filename="?([^"]+)"?/),p=URL.createObjectURL(c),i=document.createElement("a");i.href=p,i.download=d?d[1]:"analytics.csv",document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(p)}const l=de(/^#\/analytics\b/);export const analyticsView={templateUrl:"/admin/js/templates/analytics.html",async onMount(r){l.reset();const s=r.get(0),a=M.observable(Y.read()),c=M.observable(a.peek().range),d=M.observable("pages"),p=M.observable(""),i=M.observable(null),f=M.observable([]),R=M.observable(null),O=M.observable([]),v=M.observable(null),y=M.observable(""),m=M.observable(""),L=M.observable(null),S=M.observable(null),T=M.observable(null),_=M.observable([]),N=M.observable("");l.add(M.effect(()=>{const e=i.value,{rows:t,matched:n}=fe(e?.top,{filter:p.value,limit:a.value.limit,total:e?.total});_.value=t,N.value=e?e.top.length?n?n>t.length?`Showing ${t.length} of ${n} pages - the cog changes how many.`:"":"No pages match that filter.":"No page views recorded in this range yet.":"Loading\u2026"}));const V=M.observable([]),q=M.observable([]),B=M.observable([]);l.add(M.effect(()=>{const e=pe(R.value);V.value=e.entry,q.value=e.exit,B.value=e.paths}));const K={range:c,section:d,filter:p,spikes:O,pageRows:_,pagesNote:N,entryRows:V,exitRows:q,pathRows:B,loadError:y,liveError:m,freshness:M.computed(()=>L.value?`Last updated ${D(L.value).format("HH:mm:ss")} \xB7 ${U[T.value]||T.value}${y.value?" \xB7 Figures are stale":""}`:y.value?"Figures unavailable":"Loading figures\u2026"),kpi:M.computed(()=>ve(i.value,R.value)),isAllTime:M.computed(()=>c.value==="all"),sessionsCount:M.computed(()=>R.value?.totalSessions??0),liveText:M.computed(()=>m.value?v.value===null?"Active now unavailable":`${v.value} active \xB7 stale`:v.value===null?"Active now: -":`${v.value} active now`),liveClass:M.computed(()=>!m.value&&v.value?"is-on":""),liveTitle:M.computed(()=>(S.value?`Last updated ${D(S.value).format("HH:mm:ss")}. `:"")+(m.value||(a.value.live?"Refreshes every 30 seconds":"Live refresh is off - see settings")))};l.add(M.effect(()=>Y.write(a.value)));const Z=(e,t)=>{a.value=z({...a.peek(),[e]:t})};let A=0;async function k(){const e=c.peek(),t=++A;try{const[n,u,b,w]=await Promise.all([h("/analytics/stats?range="+encodeURIComponent(e)),h("/analytics/series?range="+encodeURIComponent(he(e))),h("/analytics/journeys?range="+encodeURIComponent(e)),h("/analytics/spikes")]);if(t!==A)return;i.value=n,f.value=u?.series??[],R.value=b,O.value=(w?.items??[]).map(o=>({...o,key:o.url})),T.value=e,L.value=new Date().toISOString(),y.value=""}catch{t===A&&(y.value="Could not refresh analytics. Previous figures, if shown, are retained.")}}let x=0;async function P(){const e=++x;try{const t=await h("/analytics/realtime");if(e!==x)return;v.value=t?.activeSessions??0,S.value=new Date().toISOString(),m.value="",ce("#/analytics")}catch{e===x&&(m.value="Live visitor count is unavailable. Retry to refresh it.")}}let G=!0;l.add(M.effect(()=>{if(c.value,G){G=!1;return}k()}));let $=null;l.add(M.effect(()=>{clearInterval($),$=a.value.live?setInterval(()=>{document.hidden||P()},we):null})),l.add(()=>clearInterval($));const W=s.querySelector(".an-chart");let C=null;l.add(()=>{C?.destroy(),C=null}),l.add(M.effect(()=>{const e=f.value,t=a.value.chart;if(!window.Chart||!W.isConnected)return;const n=getComputedStyle(document.documentElement),u=n.getPropertyValue("--dm-primary").trim()||"rgb(99, 102, 241)",b=n.getPropertyValue("--dm-text-muted").trim()||"#888",w=`color-mix(in srgb, ${n.getPropertyValue("--dm-border").trim()||"#888"} 45%, transparent)`;C?.destroy(),C=new window.Chart(W,{type:t,data:{labels:e.map(o=>ye(o.date).slice(0,5)),datasets:[{label:"Page views",data:e.map(o=>o.hits),borderColor:u,backgroundColor:t==="bar"?u:`color-mix(in srgb, ${u} 18%, transparent)`,fill:t==="line",tension:.3,borderWidth:2,pointRadius:e.length>40?0:2,borderRadius:3}]},options:{responsive:!0,maintainAspectRatio:!1,animation:!1,plugins:{legend:{display:!1}},scales:{y:{beginAtZero:!0,ticks:{precision:0,color:b},grid:{color:w}},x:{ticks:{maxRotation:0,autoSkip:!0,maxTicksLimit:10,color:b},grid:{display:!1}}}}})}));function Q(){const e=E.slideover({title:"Analytics settings",size:"sm",position:"right"}),t=Ee(`
22
22
  <div class="an-panel-form">
23
23
  <div>
24
24
  <label class="form-label" for="an-set-range">Open on</label>
25
25
  <select id="an-set-range" class="form-input" data-model="range.value">
26
- ${re.map(o=>`<option value="${o}">${D[o]}</option>`).join("")}
26
+ ${be.map(o=>`<option value="${o}">${U[o]}</option>`).join("")}
27
27
  </select>
28
28
  </div>
29
29
  <div>
@@ -31,7 +31,7 @@ import{apiRequest as v,getToken as K,refreshAccessToken as Z}from"../api.js";imp
31
31
  <span data-help="How many pages the Top pages list shows. The filter still searches all of them, and the CSV export always has every page."></span>
32
32
  </label>
33
33
  <select id="an-set-limit" class="form-input" data-model="limit.value">
34
- ${ie.map(o=>`<option value="${o}">${o||"All"}</option>`).join("")}
34
+ ${me.map(o=>`<option value="${o}">${o||"All"}</option>`).join("")}
35
35
  </select>
36
36
  </div>
37
37
  <div>
@@ -59,4 +59,4 @@ import{apiRequest as v,getToken as K,refreshAccessToken as Z}from"../api.js";imp
59
59
  <span class="spacer"></span>
60
60
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
61
61
  </div>
62
- </div>`),n=(o,F=T=>T)=>M.computed({read:()=>t.value[o],write:T=>G(o,F(T))}),p={range:n("range"),limit:n("limit",Number),chart:n("chart"),live:n("live"),confirmReset:n("confirmReset")};e.element.appendChild(a);const m=V(a),f=M.applyBindings(p,a,{methods:{done:()=>e.close(),reset:()=>{t.value={...ne}}}});X(e,()=>{f.dispose(),m()}),e.open()}Q(r,{id:"analytics-settings",icon:"settings",title:"Analytics settings",onClick:W});const h=[...s.querySelectorAll(".an-list")];h[0].innerHTML=ve,h[1].innerHTML=A,h[2].innerHTML=A,h[3].innerHTML=A,h[4].innerHTML=be,l.add(V(s,{position:"bottom"}));const J=M.applyBindings(q,s,{methods:{refresh:()=>{w(),C()},showPages:()=>{d.value="pages"},showJourneys:()=>{d.value="journeys"},showSpikes:()=>{d.value="spikes"}}});l.add(()=>J.dispose());const N=e=>new URL(e,location.origin).href,z=E.contextMenu(s,{cursor:"glyph",match:".an-row",inherit:!1,density:"compact",items:e=>{const a=e.dataset.url;return[{label:"Open on the site",icon:"external-link",action:()=>window.open(N(a),"_blank","noopener")},{label:"Copy address",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(N(a)),E.toast("Address copied",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}},{label:"Show only this page",icon:"filter",action:()=>{d.value="pages",u.value=a}}]}});l.add(()=>z?.destroy?.());const Y=E.contextMenu(s.querySelector(".an-bar-end"),{cursor:"glyph",match:".an-more",inherit:!1,density:"compact",items:()=>[{label:`Export ${D[c.peek()].toLowerCase()} as CSV`,icon:"download",action:async()=>{try{await ye(c.peek())}catch{E.toast("Export failed.",{type:"error"})}}},{type:"divider"},{label:"Reset all statistics\u2026",icon:"trash",danger:!0,action:async()=>{if(!(t.peek().confirmReset&&!await E.confirm("Delete every analytics counter for this site? This cannot be undone.")))try{await v("/analytics/stats",{method:"DELETE"}),E.toast("Analytics reset.",{type:"success"}),w()}catch{E.toast("Reset failed.",{type:"error"})}}}]});l.add(()=>Y?.destroy?.()),s.addEventListener("click",e=>{const a=e.target.closest(".an-kebab, .an-more");a&&ee(a)}),I.scan(s);try{await he()}catch{E.toast("The chart library could not be loaded; figures still work.",{type:"warning"})}await Promise.all([w(),C()])}};
62
+ </div>`),n=(o,ae=j=>j)=>M.computed({read:()=>a.value[o],write:j=>Z(o,ae(j))}),u={range:n("range"),limit:n("limit",Number),chart:n("chart"),live:n("live"),confirmReset:n("confirmReset")};e.element.appendChild(t);const b=J(t),w=M.applyBindings(u,t,{methods:{done:()=>e.close(),reset:()=>{a.value={...ue}}}});le(e,()=>{w.dispose(),b()}),e.open()}oe(r,{id:"analytics-settings",icon:"settings",title:"Analytics settings",onClick:Q});const g=[...s.querySelectorAll(".an-list")];g[0].innerHTML=Me,g[1].innerHTML=H,g[2].innerHTML=H,g[3].innerHTML=H,g[4].innerHTML=Re,l.add(J(s,{position:"bottom"}));const X=M.applyBindings(K,s,{methods:{refresh:()=>{k(),P()},showPages:()=>{d.value="pages"},showJourneys:()=>{d.value="journeys"},showSpikes:()=>{d.value="spikes"}}});l.add(()=>X.dispose());const F=e=>new URL(e,location.origin).href,ee=E.contextMenu(s,{cursor:"glyph",match:".an-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.url;return[{label:"Open on the site",icon:"external-link",action:()=>window.open(F(t),"_blank","noopener")},{label:"Copy address",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(F(t)),E.toast("Address copied",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}},{label:"Show only this page",icon:"filter",action:()=>{d.value="pages",p.value=t}}]}});l.add(()=>ee?.destroy?.());const te=E.contextMenu(s.querySelector(".an-bar-end"),{cursor:"glyph",match:".an-more",inherit:!1,density:"compact",items:()=>[{label:`Export ${U[c.peek()].toLowerCase()} as CSV`,icon:"download",action:async()=>{try{await Ce(c.peek())}catch{E.toast("Export failed.",{type:"error"})}}},{type:"divider"},{label:"Reset all statistics\u2026",icon:"trash",danger:!0,action:async()=>{if(!(a.peek().confirmReset&&!await E.confirm("Delete every analytics counter for this site? This cannot be undone.")))try{await h("/analytics/stats",{method:"DELETE"}),E.toast("Analytics reset.",{type:"success"}),k()}catch{E.toast("Reset failed.",{type:"error"})}}}]});l.add(()=>te?.destroy?.()),s.addEventListener("click",e=>{const t=e.target.closest(".an-kebab, .an-more");t&&ie(t)}),I.scan(s);try{await ke()}catch{E.toast("The chart library could not be loaded; figures still work.",{type:"warning"})}await Promise.all([k(),P()])}};
@@ -1,4 +1,4 @@
1
- import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib/plugin-chrome.js";import{mountRelated as ze,relatedMenuItems as Ke,relatedProviders as We}from"../lib/related.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as te,openMenuAt as ae,settingsStore as Ye,viewLifetime as _e}from"../lib/tool-kit.js";import{arrangeContacts as Je,countContacts as Ve,DEFAULT_SETTINGS as ve,duplicateBody as Qe,initials as Xe,normaliseSettings as ye,primaryEmail as ge,primaryPhone as ke,withGroup as oe}from"../lib/contacts-arrange.js";const we=Ye("contacts.settings",ye),h=(A,d="GET",q)=>Fe(A,{method:d,...q!==void 0?{body:JSON.stringify(q)}:{}}),Ee={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},N={addresses:{title:"Addresses",add:"Add address",empty:"No address yet.",help:'Tick "Use for invoices" on the one an invoice should be addressed to.',columns:[{key:"label",placeholder:"Label (Office, Home\u2026)"},{key:"line1",placeholder:"Address line 1",wide:!0},{key:"line2",placeholder:"Address line 2",wide:!0},{key:"city",placeholder:"City"},{key:"region",placeholder:"County / Region"},{key:"postcode",placeholder:"Postcode"},{key:"country",placeholder:"Country"},{key:"isBilling",label:"Use for invoices",check:!0}]},emails:{title:"More email addresses",add:"Add email",empty:"None.",help:"Besides the main email above. Search finds a contact by these too.",columns:[{key:"label",placeholder:"Label (Accounts, Personal\u2026)"},{key:"address",placeholder:"name@example.com",wide:!0}]},phones:{title:"More phone numbers",add:"Add phone",empty:"None.",help:"Besides the main phone above. Call in the menu uses the main one, or the first of these.",columns:[{key:"label",placeholder:"Label (Mobile, Office\u2026)"},{key:"number",placeholder:"+44 \u2026",wide:!0}]}},Ze=`
1
+ import{protectToolEditor as Ke,mountToolTrash as We,mountGroupBatch as _e}from"../lib/tool-recovery.js";import{apiRequest as Je}from"../api.js";import{addBannerAction as Ve}from"../lib/plugin-chrome.js";import{mountRelated as Qe,relatedMenuItems as Xe,relatedProviders as Ze}from"../lib/related.js";import{helpPopovers as Z}from"../lib/help-popover.js";import{onceClosed as ae,openMenuAt as oe,settingsStore as et,viewLifetime as tt}from"../lib/tool-kit.js";import{arrangeContacts as at,countContacts as ot,DEFAULT_SETTINGS as we,duplicateBody as st,initials as nt,normaliseSettings as Ee,primaryEmail as Se,primaryPhone as Me,withGroup as $e}from"../lib/contacts-arrange.js";const Ce=et("contacts.settings",Ee),h=(N,i="GET",j)=>Je(N,{method:i,...j!==void 0?{body:JSON.stringify(j)}:{}}),se={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},T={addresses:{title:"Addresses",add:"Add address",empty:"No address yet.",help:'Tick "Use for invoices" on the one an invoice should be addressed to.',columns:[{key:"label",placeholder:"Label (Office, Home\u2026)"},{key:"line1",placeholder:"Address line 1",wide:!0},{key:"line2",placeholder:"Address line 2",wide:!0},{key:"city",placeholder:"City"},{key:"region",placeholder:"County / Region"},{key:"postcode",placeholder:"Postcode"},{key:"country",placeholder:"Country"},{key:"isBilling",label:"Use for invoices",check:!0}]},emails:{title:"More email addresses",add:"Add email",empty:"None.",help:"Besides the main email above. Search finds a contact by these too.",columns:[{key:"label",placeholder:"Label (Accounts, Personal\u2026)"},{key:"address",placeholder:"name@example.com",wide:!0}]},phones:{title:"More phone numbers",add:"Add phone",empty:"None.",help:"Besides the main phone above. Call in the menu uses the main one, or the first of these.",columns:[{key:"label",placeholder:"Label (Mobile, Office\u2026)"},{key:"number",placeholder:"+44 \u2026",wide:!0}]}},ct=`
2
2
  <div class="ct-row{{selClass}}" data-id="{{id}}" tabindex="0">
3
3
  <input type="checkbox" class="ct-select" aria-label="Select {{name}}" data-bind-checked="selected">
4
4
  <button type="button" class="ct-fav{{favClass}}" title="{{favTitle}}" aria-label="{{favTitle}}">\u2605</button>
@@ -11,26 +11,26 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
11
11
  <span class="ct-col ct-phone">{{phone}}</span>
12
12
  <span class="ct-col ct-groups"><span class="ct-groups-text" title="{{groupsText}}">{{groupsText}}</span></span>
13
13
  <button type="button" class="ct-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
14
- </div>`,et='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',tt='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',at=`
14
+ </div>`,lt='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',rt='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',it=`
15
15
  <div class="ct-group-row" data-group="{{name}}">
16
16
  <span>{{name}}</span><small>{{countText}}</small>
17
17
  <button type="button" class="btn btn-sm btn-ghost" data-act="show">Show</button>
18
18
  <button type="button" class="btn btn-sm btn-ghost" data-act="rename" data-bind-disabled="locked" title="{{lockTitle}}">Rename</button>
19
19
  <button type="button" class="btn btn-sm btn-ghost text-danger" data-act="delete" data-bind-disabled="locked" title="{{lockTitle}}">Delete</button>
20
- </div>`,ot="Groups are shared by everyone who uses Contacts, so renaming or deleting one changes their contacts too. Only the person who created a group can do that, or someone whose role has Manage groups in Contacts (admins do, unless it was taken away). Groups made before creators were recorded belong to the Manage groups holders. You can still file your own contacts in any group.";function se(A){const d=document.createElement("template");return d.innerHTML=A.trim(),d.content.firstElementChild}const L=(A,d,q=`${d}s`)=>`${A} ${A===1?d:q}`,w=_e(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(A){w.reset();const d=A.get(0);let q=[];We("contact").then(t=>{q=t});const x=M.observable(we.read()),m=M.observable([]),v=M.observable([]),J=M.observable(!0),ne=M.observable(""),H=M.observable({kind:"all"}),p=M.observable(new Set),ce=M.observable(!1),le=M.computed(()=>Ve(m.value,v.value)),Se=M.computed(()=>Je(m.value,{query:ne.value,filter:H.value,sort:x.value.sort,favouritesFirst:x.value.favouritesFirst})),T=M.observable([]);w.add(M.effect(()=>{const t=p.value;T.value=Se.value.map(e=>({id:e.id,name:e.name||"Unnamed",sub:[e.jobTitle,e.company&&!x.value.showCompany?e.company:""].filter(Boolean).join(" \xB7 "),initials:Xe(e.name),company:e.company||"",email:ge(e),phone:ke(e),groupsText:(e.groups||[]).join(", "),favClass:e.favourite===!0?" is-on":"",favTitle:e.favourite===!0?"Starred - click to unstar":"Star this contact",selected:t.has(e.id),selClass:t.has(e.id)?" is-selected":""}))}));const re=M.observable([]);w.add(M.effect(()=>{const t=le.value,e=H.value;re.value=[{key:"all",label:"All",count:t.all,title:"Everyone",cls:e.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:t.fav,title:"Only starred contacts",cls:e.kind==="fav"?" is-active":""},...v.value.map(a=>({key:`group:${a}`,label:a,count:t.groups[a]??0,title:`Only the ${a} group`,cls:e.kind==="group"&&e.value===a?" is-active":""}))]})),w.add(M.effect(()=>{const t=H.value;t.kind==="group"&&!v.value.includes(t.value)&&(H.value={kind:"all"})}));const Me={rows:T,chips:re,loading:J,query:ne,searchFocus:ce,hasRows:M.computed(()=>T.value.length>0),isEmpty:M.computed(()=>!J.value&&T.value.length===0),emptyText:M.computed(()=>m.value.length?"No contacts match. Clear the search or pick All.":"No contacts yet. New contact adds one, or \u22EE > Import brings a file in."),countText:M.computed(()=>{if(J.value)return"";const t=T.value.length,e=m.value.length;return t===e?L(e,"contact"):`${t} of ${e}`}),hasSelection:M.computed(()=>p.value.size>0),selectionText:M.computed(()=>`${L(p.value.size,"contact")} selected`),allSelected:M.computed(()=>T.value.length>0&&T.value.every(t=>p.value.has(t.id)))};w.add(M.effect(()=>we.write(x.value)));const $e=(t,e)=>{x.value=ye({...x.peek(),[t]:e})},B=t=>m.peek().find(e=>e.id===t),Ce=t=>{m.value=m.peek().map(e=>e.id===t.id?t:e)};async function V(){J.value=!0;try{const[t,e]=await Promise.all([h("/contacts/contacts"),h("/contacts/groups")]);m.value=Array.isArray(t)?t:t?.data??[],v.value=Array.isArray(e)?e:[]}catch{E.toast("Failed to load contacts.",{type:"error"})}finally{J.value=!1}}async function xe(){try{v.value=await h("/contacts/groups")}catch{}}async function z(t,e,a){try{return Ce(await h(`/contacts/contacts/${t}`,"PUT",e)),a&&E.toast(a,{type:"success"}),!0}catch(s){return E.toast(s?.message||"Failed to update the contact.",{type:"error"}),!1}}async function ie(t){const e=B(t);if(x.peek().confirmDelete&&!await E.confirm("Delete this contact? This cannot be undone."))return!1;try{await h(`/contacts/contacts/${t}`,"DELETE"),m.value=m.peek().filter(s=>s.id!==t);const a=new Set(p.peek());return a.delete(t)&&(p.value=a),E.toast(`${e?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function Te(t){const e=B(t);if(e)try{const a=await h("/contacts/contacts","POST",Qe(e));m.value=[...m.peek(),a],E.toast("Copied - the copy is open to edit.",{type:"success"}),W(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function K(t){if(v.peek().includes(t))return!0;try{return await h("/contacts/groups","POST",{name:t}),await xe(),!0}catch(e){return E.toast(e?.message||"Could not create the group.",{type:"error"}),!1}}async function Ae(t,e,a){const s=B(t);s&&(a&&!await K(e)||await z(t,{groups:oe(s.groups,e,a)},a?`Added to ${e}.`:`Removed from ${e}.`))}async function de(t){const e=String(await E.prompt("New group name:")??"").trim();if(!(!e||!await K(e))){for(const a of t){const s=B(a);s&&!(s.groups||[]).includes(e)&&await z(a,{groups:oe(s.groups,e,!0)})}E.toast(`Filed in ${e}.`,{type:"success"})}}async function pe(t,e){try{await navigator.clipboard.writeText(t),E.toast(`${e} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function Ne(){const t=[...p.peek()];if(t.length&&await E.confirm(`Delete ${L(t.length,"contact")}? This cannot be undone.`))try{const e=await h("/contacts/contacts/bulk","DELETE",{ids:t});m.value=m.peek().filter(a=>!t.includes(a.id)),p.value=new Set,E.toast(`${L(e?.deleted??t.length,"contact")} deleted.`,{type:"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function Le(t){const e=[...p.peek()];if(!(!e.length||!await K(t))){for(const a of e){const s=B(a);s&&!(s.groups||[]).includes(t)&&await z(a,{groups:oe(s.groups,t,!0)})}E.toast(`${L(e.length,"contact")} filed in ${t}.`,{type:"success"})}}const ue=t=>{const e=new Set(p.peek());e.delete(t)||e.add(t),p.value=e};async function Be(){try{const t=await h("/contacts/export"),e=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),a=URL.createObjectURL(e),s=document.createElement("a");s.href=a,s.download="contacts.json",document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const Q=d.querySelector(".ct-import-file");Q.addEventListener("change",async()=>{const t=Q.files?.[0];if(Q.value="",!!t)try{const e=JSON.parse(await t.text()),a=Array.isArray(e)?e:e.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const s=await h("/contacts/import","POST",{contacts:a});E.toast(`Imported ${L(s?.imported??0,"contact")}.`,{type:"success"}),await V()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function W(t){const e=t?B(t):null;if(t&&!e)return;const a=E.slideover({title:e?"Edit contact":"New contact",size:"lg",position:"right"}),s=(o,c,{type:P="text",mono:f=!1,placeholder:y="",wide:C=!1}={})=>`
20
+ </div>`,dt="Groups are shared by everyone who uses Contacts, so renaming or deleting one changes their contacts too. Only the person who created a group can do that, or someone whose role has Manage groups in Contacts (admins do, unless it was taken away). Groups made before creators were recorded belong to the Manage groups holders. You can still file your own contacts in any group.";function ne(N){const i=document.createElement("template");return i.innerHTML=N.trim(),i.content.firstElementChild}const R=(N,i,j=`${i}s`)=>`${N} ${N===1?i:j}`,v=tt(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(N){v.reset();const i=N.get(0);let j=[];Ze("contact").then(t=>{j=t});const x=M.observable(Ce.read()),m=M.observable([]),y=M.observable([]),_=M.observable(!0),Q=M.observable(""),ce=M.observable(""),H=M.observable({kind:"all"}),p=M.observable(new Set),le=M.observable(!1),re=M.computed(()=>ot(m.value,y.value)),Te=M.computed(()=>at(m.value,{query:ce.value,filter:H.value,sort:x.value.sort,favouritesFirst:x.value.favouritesFirst})),A=M.observable([]);v.add(M.effect(()=>{const t=p.value;A.value=Te.value.map(e=>({id:e.id,name:e.name||"Unnamed",sub:[e.jobTitle,e.company&&!x.value.showCompany?e.company:""].filter(Boolean).join(" \xB7 "),initials:nt(e.name),company:e.company||"",email:Se(e),phone:Me(e),groupsText:(e.groups||[]).join(", "),favClass:e.favourite===!0?" is-on":"",favTitle:e.favourite===!0?"Starred - click to unstar":"Star this contact",selected:t.has(e.id),selClass:t.has(e.id)?" is-selected":""}))}));const ie=M.observable([]);v.add(M.effect(()=>{const t=re.value,e=H.value;ie.value=[{key:"all",label:"All",count:t.all,title:"Everyone",cls:e.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:t.fav,title:"Only starred contacts",cls:e.kind==="fav"?" is-active":""},...y.value.map(o=>({key:`group:${o}`,label:o,count:t.groups[o]??0,title:`Only the ${o} group`,cls:e.kind==="group"&&e.value===o?" is-active":""}))]})),v.add(M.effect(()=>{const t=H.value;t.kind==="group"&&!y.value.includes(t.value)&&(H.value={kind:"all"})}));const xe={loadError:Q,rows:A,chips:ie,loading:_,query:ce,searchFocus:le,hasRows:M.computed(()=>A.value.length>0),isEmpty:M.computed(()=>!_.value&&!Q.value&&A.value.length===0),emptyText:M.computed(()=>m.value.length?"No contacts match. Clear the search or pick All.":"No contacts yet. New contact adds one, or \u22EE > Import brings a file in."),countText:M.computed(()=>{if(_.value)return"";const t=A.value.length,e=m.value.length;return t===e?R(e,"contact"):`${t} of ${e}`}),hasSelection:M.computed(()=>p.value.size>0),selectionText:M.computed(()=>`${R(p.value.size,"contact")} selected`),allSelected:M.computed(()=>A.value.length>0&&A.value.every(t=>p.value.has(t.id)))};v.add(M.effect(()=>Ce.write(x.value)));const Ae=(t,e)=>{x.value=Ee({...x.peek(),[t]:e})},q=t=>m.peek().find(e=>e.id===t),Ne=t=>{m.value=m.peek().map(e=>e.id===t.id?t:e)};async function Y(){_.value=!0;try{const[t,e]=await Promise.all([h("/contacts/contacts"),h("/contacts/groups")]);m.value=Array.isArray(t)?t:t?.data??[],y.value=Array.isArray(e)?e:[],Q.value=""}catch{Q.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load contacts.",{type:"error"})}finally{_.value=!1}}async function Le(){try{y.value=await h("/contacts/groups")}catch{}}async function J(t,e,o){try{return Ne(await h(`/contacts/contacts/${t}`,"PUT",e)),o&&E.toast(o,{type:"success"}),!0}catch(s){return E.toast(s?.message||"Failed to update the contact.",{type:"error"}),!1}}async function de(t){const e=q(t);if(x.peek().confirmDelete&&!await E.confirm("Move this contact to Trash? You can restore it later."))return!1;try{await h(`/contacts/contacts/${t}`,"DELETE"),m.value=m.peek().filter(s=>s.id!==t);const o=new Set(p.peek());return o.delete(t)&&(p.value=o),E.toast(`${e?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function Be(t){const e=q(t);if(e)try{const o=await h("/contacts/contacts","POST",st(e));m.value=[...m.peek(),o],E.toast("Copied - the copy is open to edit.",{type:"success"}),z(o.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function V(t){if(y.peek().includes(t))return!0;try{return await h("/contacts/groups","POST",{name:t}),await Le(),!0}catch(e){return E.toast(e?.message||"Could not create the group.",{type:"error"}),!1}}async function Oe(t,e,o){const s=q(t);s&&(o&&!await V(e)||await J(t,{groups:$e(s.groups,e,o)},o?`Added to ${e}.`:`Removed from ${e}.`))}async function pe(t){const e=String(await E.prompt("New group name:")??"").trim();e&&await ve.run(t,e)}async function ue(t,e){try{await navigator.clipboard.writeText(t),E.toast(`${e} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function Pe(){const t=[...p.peek()];if(t.length&&await E.confirm(`Delete ${R(t.length,"contact")}? You can restore it from Trash.`))try{const e=await h("/contacts/contacts/bulk","DELETE",{ids:t}),o=e?.deletedIds??[];m.value=m.peek().filter(s=>!o.includes(s.id)),p.value=new Set(e?.failedIds??[]),E.toast(`${R(e?.deleted??t.length,"contact")} moved to Trash${e?.failedIds?.length?` \xB7 ${e.failedIds.length} failed (still selected)`:""}.`,{type:e?.failedIds?.length?"warning":"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function Ge(t){await ve.run([...p.peek()],t)}const me=t=>{const e=new Set(p.peek());e.delete(t)||e.add(t),p.value=e};async function Ie(){try{const t=await h("/contacts/export"),e=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),o=URL.createObjectURL(e),s=document.createElement("a");s.href=o,s.download="contacts.json",document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const X=i.querySelector(".ct-import-file");X.addEventListener("change",async()=>{const t=X.files?.[0];if(X.value="",!!t)try{const e=JSON.parse(await t.text()),o=Array.isArray(e)?e:e.contacts;if(!Array.isArray(o))throw new Error("Invalid format");const s=await h("/contacts/import","POST",{contacts:o});E.toast(`Imported ${R(s?.imported??0,"contact")}.`,{type:"success"}),await Y()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function z(t){const e=t?q(t):null;if(t&&!e)return;const o=E.slideover({title:e?"Edit contact":"New contact",size:"lg",position:"right"}),s=(a,c,{type:B="text",mono:f=!1,placeholder:k="",wide:C=!1}={})=>`
21
21
  <div${C?' class="ct-wide"':""}>
22
- <label class="form-label" for="ct-f-${o}">${c}</label>
23
- <input id="ct-f-${o}" type="${P}" class="form-input${f?" font-mono":""}" placeholder="${y}" data-model="${o}.value">
24
- </div>`,l=o=>`
25
- <div class="ct-repeater" data-repeater="${o}">
22
+ <label class="form-label" for="ct-f-${a}">${c}</label>
23
+ <input id="ct-f-${a}" type="${B}" class="form-input${f?" font-mono":""}" placeholder="${k}" data-model="${a}.value">
24
+ </div>`,r=a=>`
25
+ <div class="ct-repeater" data-repeater="${a}">
26
26
  <div class="ct-subhead">
27
- <strong>${N[o].title} <span data-help="${N[o].help}"></span></strong>
28
- <button type="button" class="btn btn-sm btn-ghost" data-add="${o}">
29
- <span data-icon="plus" data-icon-size="13"></span> ${N[o].add}</button>
27
+ <strong>${T[a].title} <span data-help="${T[a].help}"></span></strong>
28
+ <button type="button" class="btn btn-sm btn-ghost" data-add="${a}">
29
+ <span data-icon="plus" data-icon-size="13"></span> ${T[a].add}</button>
30
30
  </div>
31
31
  <div class="ct-repeater-list"></div>
32
- <p class="text-muted text-sm contacts-empty-row">${N[o].empty}</p>
33
- </div>`,r=se(`
32
+ <p class="text-muted text-sm contacts-empty-row">${T[a].empty}</p>
33
+ </div>`,l=ne(`
34
34
  <div class="ct-panel">
35
35
  <div class="ct-grid">
36
36
  <div class="ct-wide">
@@ -98,15 +98,15 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
98
98
  ${s("bic","BIC / SWIFT",{mono:!0,placeholder:"NWBKGB2L"})}
99
99
  </div>
100
100
  </div>
101
- ${l("addresses")}
101
+ ${r("addresses")}
102
102
  </div>
103
103
  </details>
104
104
 
105
105
  <details class="ct-section">
106
106
  <summary>More emails &amp; phones <span class="ct-section-sum" data-bind-text="extraSummary.value"></span></summary>
107
107
  <div class="ct-section-body">
108
- ${l("emails")}
109
- ${l("phones")}
108
+ ${r("emails")}
109
+ ${r("phones")}
110
110
  </div>
111
111
  </details>
112
112
 
@@ -121,11 +121,11 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
121
121
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannotSave.value">
122
122
  <span data-icon="save" data-icon-size="13"></span> Save</button>
123
123
  </div>
124
- </div>`),S=o=>o==null?"":String(o),n={name:M.observable(S(e?.name)),email:M.observable(S(e?.email)),phone:M.observable(S(e?.phone)),notes:M.observable(S(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const o of Object.keys(Ee))n[o]=M.observable(S(e?.[o]));const i=Object.fromEntries(Object.keys(N).map(o=>[o,M.observable(0)]));n.pick=M.observable([]);const O=M.effect(()=>{const o=n.chosen.value;n.pick.value=[...new Set([...v.value,...o])].map(c=>({name:c,on:String(o.includes(c)),cls:o.includes(c)?" is-active":""}))});n.noGroups=M.computed(()=>n.pick.value.length===0),n.cannotSave=M.computed(()=>!n.name.value.trim()),n.billingSummary=M.computed(()=>{const o=[];n.company.value.trim()&&o.push(n.company.value.trim()),n.vatNumber.value.trim()&&o.push("VAT"),(n.sortCode.value.trim()||n.iban.value.trim())&&o.push("bank details");const c=i.addresses.value;return c&&o.push(L(c,"address","addresses")),o.join(" \xB7 ")}),n.extraSummary=M.computed(()=>{const o=i.emails.value+i.phones.value;return o?L(o,"more"):""});function g(o,c){const P=N[o],f=r.querySelector(`[data-repeater="${o}"]`),y=f.querySelector(".ct-repeater-list");y.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=c.length?"none":"",c.forEach((C,G)=>{const R=document.createElement("div");R.className="contacts-row";for(const k of P.columns){if(k.check){const Z=document.createElement("label");Z.className="form-check contacts-row-check";const j=document.createElement("input");j.type="checkbox",j.className="form-check-input",j.dataset.key=k.key,j.checked=C[k.key]===!0,j.addEventListener("change",()=>{j.checked&&y.querySelectorAll(`input[data-key="${k.key}"]`).forEach(he=>{he!==j&&(he.checked=!1)})});const ee=document.createElement("span");ee.className="form-check-label",ee.textContent=k.label,Z.append(j,ee),R.appendChild(Z);continue}const _=document.createElement("input");_.type="text",_.className="form-input"+(k.wide?" contacts-row-wide":""),_.dataset.key=k.key,_.placeholder=k.placeholder??"",_.value=C[k.key]??"",R.appendChild(_)}const Y=document.createElement("button");Y.type="button",Y.className="btn btn-sm btn-ghost contacts-row-remove",Y.title="Remove",Y.textContent="\xD7",Y.addEventListener("click",()=>{const k=u(o,{keepBlank:!0});k.splice(G,1),g(o,k)}),R.appendChild(Y),y.appendChild(R)}),i[o].value=c.length}function u(o,{keepBlank:c=!1}={}){const P=N[o],f=[...r.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map(y=>{const C={};for(const G of P.columns){const R=y.querySelector(`[data-key="${G.key}"]`);C[G.key]=G.check?R?.checked===!0:(R?.value??"").trim()}return C});return c?f:f.filter(y=>Object.entries(y).some(([C,G])=>C!=="label"&&G!==""&&G!==!1))}r.addEventListener("click",o=>{const c=o.target.closest("[data-add]");if(c){const f=c.dataset.add;g(f,[...u(f,{keepBlank:!0}),{}]),r.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const P=o.target.closest(".ct-pick [data-group]");if(P){const f=P.dataset.group,y=n.chosen.peek();n.chosen.value=y.includes(f)?y.filter(C=>C!==f):[...y,f]}});const $=()=>{const o={name:n.name.peek().trim(),email:n.email.peek().trim(),phone:n.phone.peek().trim(),notes:n.notes.peek().trim(),favourite:n.favourite.peek()===!0,groups:[...n.chosen.peek()]};for(const c of Object.keys(Ee))o[c]=String(n[c].peek()??"").trim();for(const c of Object.keys(N))o[c]=u(c);return o};let b=!1;const F=async()=>{if(!(b||n.cannotSave.peek())){b=!0;try{const o=$();for(const c of o.groups)v.peek().includes(c)||await K(c);if(e){if(!await z(e.id,o,"Contact saved."))return}else{const c=await h("/contacts/contacts","POST",o);m.value=[...m.peek(),c],E.toast("Contact added.",{type:"success"})}a.close()}catch(o){E.toast(o?.message||"Failed to save the contact.",{type:"error"})}finally{b=!1}}};a.element.appendChild(r),e&&ze(r.querySelector(".related-box"),"contact",e.id,{onOpen:()=>a.close(),name:e.name});const qe=X(r);for(const o of Object.keys(N))g(o,Array.isArray(e?.[o])?e[o]:[]);r.querySelector(".ct-pick").innerHTML=tt;const Ue=M.applyBindings(n,r,{methods:{save:F,cancel:()=>a.close(),del:async()=>{e&&await ie(e.id)&&a.close()},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await K(o)||n.chosen.peek().includes(o)||(n.chosen.value=[...n.chosen.peek(),o])}}});n.billingSummary.peek()&&(r.querySelector(".ct-section").open=!0),r.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),F())}),I.scan(r),te(a,()=>{O?.(),Ue.dispose(),qe()}),a.open(),setTimeout(()=>r.querySelector("#ct-f-name")?.focus(),60)}function Oe(){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),e=(n,i,O)=>`
124
+ </div>`),S=a=>a==null?"":String(a),n={name:M.observable(S(e?.name)),email:M.observable(S(e?.email)),phone:M.observable(S(e?.phone)),notes:M.observable(S(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const a of Object.keys(se))n[a]=M.observable(S(e?.[a]));const d=Object.fromEntries(Object.keys(T).map(a=>[a,M.observable(0)]));n.pick=M.observable([]);const L=M.effect(()=>{const a=n.chosen.value;n.pick.value=[...new Set([...y.value,...a])].map(c=>({name:c,on:String(a.includes(c)),cls:a.includes(c)?" is-active":""}))});n.noGroups=M.computed(()=>n.pick.value.length===0),n.cannotSave=M.computed(()=>!n.name.value.trim()),n.billingSummary=M.computed(()=>{const a=[];n.company.value.trim()&&a.push(n.company.value.trim()),n.vatNumber.value.trim()&&a.push("VAT"),(n.sortCode.value.trim()||n.iban.value.trim())&&a.push("bank details");const c=d.addresses.value;return c&&a.push(R(c,"address","addresses")),a.join(" \xB7 ")}),n.extraSummary=M.computed(()=>{const a=d.emails.value+d.phones.value;return a?R(a,"more"):""});function g(a,c){const B=T[a],f=l.querySelector(`[data-repeater="${a}"]`),k=f.querySelector(".ct-repeater-list");k.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=c.length?"none":"",c.forEach((C,O)=>{const P=document.createElement("div");P.className="contacts-row";for(const w of B.columns){if(w.check){const ee=document.createElement("label");ee.className="form-check contacts-row-check";const G=document.createElement("input");G.type="checkbox",G.className="form-check-input",G.dataset.key=w.key,G.checked=C[w.key]===!0,G.addEventListener("change",()=>{G.checked&&k.querySelectorAll(`input[data-key="${w.key}"]`).forEach(ke=>{ke!==G&&(ke.checked=!1)})});const te=document.createElement("span");te.className="form-check-label",te.textContent=w.label,ee.append(G,te),P.appendChild(ee);continue}const W=document.createElement("input");W.type="text",W.className="form-input"+(w.wide?" contacts-row-wide":""),W.dataset.key=w.key,W.placeholder=w.placeholder??"",W.value=C[w.key]??"",P.appendChild(W)}const K=document.createElement("button");K.type="button",K.className="btn btn-sm btn-ghost contacts-row-remove",K.title="Remove",K.textContent="\xD7",K.addEventListener("click",()=>{const w=u(a,{keepBlank:!0});w.splice(O,1),g(a,w)}),P.appendChild(K),k.appendChild(P)}),d[a].value=c.length}function u(a,{keepBlank:c=!1}={}){const B=T[a],f=[...l.querySelectorAll(`[data-repeater="${a}"] .contacts-row`)].map(k=>{const C={};for(const O of B.columns){const P=k.querySelector(`[data-key="${O.key}"]`);C[O.key]=O.check?P?.checked===!0:(P?.value??"").trim()}return C});return c?f:f.filter(k=>Object.entries(k).some(([C,O])=>C!=="label"&&O!==""&&O!==!1))}l.addEventListener("click",a=>{const c=a.target.closest("[data-add]");if(c){const f=c.dataset.add;g(f,[...u(f,{keepBlank:!0}),{}]),l.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const B=a.target.closest(".ct-pick [data-group]");if(B){const f=B.dataset.group,k=n.chosen.peek();n.chosen.value=k.includes(f)?k.filter(C=>C!==f):[...k,f]}});const $=()=>{const a={name:n.name.peek().trim(),email:n.email.peek().trim(),phone:n.phone.peek().trim(),notes:n.notes.peek().trim(),favourite:n.favourite.peek()===!0,groups:[...n.chosen.peek()]};for(const c of Object.keys(se))a[c]=String(n[c].peek()??"").trim();for(const c of Object.keys(T))a[c]=u(c);return a};let b=!1;const F=async()=>{if(!(b||n.cannotSave.peek())){b=!0;try{const a=$();for(const c of a.groups)y.peek().includes(c)||await V(c);if(e){if(!await J(e.id,a,"Contact saved."))return}else{const c=await h("/contacts/contacts","POST",a);m.value=[...m.peek(),c],E.toast("Contact added.",{type:"success"})}ge.saved(),o.close()}catch(a){E.toast(a?.message||"Failed to save the contact.",{type:"error"})}finally{b=!1}}};o.element.appendChild(l),e&&Qe(l.querySelector(".related-box"),"contact",e.id,{onOpen:()=>o.close(),name:e.name});const Ye=Z(l);for(const a of Object.keys(T))g(a,Array.isArray(e?.[a])?e[a]:[]);l.querySelector(".ct-pick").innerHTML=rt;const ze=M.applyBindings(n,l,{methods:{save:F,cancel:()=>o.close(),del:async()=>{e&&await de(e.id)&&(ge?.discard(),o.close())},async newGroup(){const a=String(await E.prompt("New group name:")??"").trim();!a||!await V(a)||n.chosen.peek().includes(a)||(n.chosen.value=[...n.chosen.peek(),a])}}});n.billingSummary.peek()&&(l.querySelector(".ct-section").open=!0),l.addEventListener("keydown",a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),F())}),I.scan(l),ae(o,()=>{L?.(),ze.dispose(),Ye()});const ye=["name","email","phone","notes","favourite","chosen",...Object.keys(se)],ge=Ke({so:o,panel:l,tool:"contacts",id:e?.id,lifetime:v,read:()=>({fields:Object.fromEntries(ye.map(a=>[a,n[a].peek()])),lists:Object.fromEntries(Object.keys(T).map(a=>[a,u(a,{keepBlank:!0})]))}),write:a=>{for(const c of ye)c in(a.fields||{})&&(n[c].value=a.fields[c]);for(const c of Object.keys(T))g(c,Array.isArray(a.lists?.[c])?a.lists[c]:[])}});o.open(),setTimeout(()=>l.querySelector("#ct-f-name")?.focus(),60)}function Re(){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),e=(n,d,L)=>`
125
125
  <div class="ct-setting-row">
126
- <label class="form-label" for="ct-set-${n}" style="margin:0">${i}${O?` <span data-help="${O}"></span>`:""}</label>
126
+ <label class="form-label" for="ct-set-${n}" style="margin:0">${d}${L?` <span data-help="${L}"></span>`:""}</label>
127
127
  <input id="ct-set-${n}" type="checkbox" class="form-switch" data-model="${n}.value">
128
- </div>`,a=se(`
128
+ </div>`,o=ne(`
129
129
  <div class="ct-panel">
130
130
  <div>
131
131
  <label class="form-label" for="ct-set-sort">Sort by</label>
@@ -153,14 +153,14 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
153
153
  ${e("showPhone","Phone")}
154
154
  ${e("showGroups","Groups")}
155
155
  </div>
156
- ${e("confirmDelete","Confirm before deleting","Off deletes a single contact straight away - there is no undo. Deleting several always asks.")}
156
+ ${e("confirmDelete","Confirm before deleting","Off moves a single contact straight to Trash. Deleting several always asks.")}
157
157
  <small class="ct-panel-meta">Saved in this browser as you change them.</small>
158
158
  <div class="ct-panel-actions">
159
159
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="reset">Reset to defaults</button>
160
160
  <span class="spacer"></span>
161
161
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
162
162
  </div>
163
- </div>`),s=n=>M.computed({read:()=>x.value[n],write:i=>$e(n,i)}),l=Object.fromEntries(Object.keys(ve).map(n=>[n,s(n)]));t.element.appendChild(a);const r=X(a),S=M.applyBindings(l,a,{methods:{done:()=>t.close(),reset:()=>{x.value={...ve}}}});te(t,()=>{S.dispose(),r()}),t.open()}function Pe(){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),e=se(`
163
+ </div>`),s=n=>M.computed({read:()=>x.value[n],write:d=>Ae(n,d)}),r=Object.fromEntries(Object.keys(we).map(n=>[n,s(n)]));t.element.appendChild(o);const l=Z(o),S=M.applyBindings(r,o,{methods:{done:()=>t.close(),reset:()=>{x.value={...we}}}});ae(t,()=>{S.dispose(),l()}),t.open()}function je(){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),e=ne(`
164
164
  <div class="ct-panel">
165
165
  <form class="ct-add-group" data-on-submit="add">
166
166
  <input type="text" class="form-input" placeholder="New group name\u2026" aria-label="New group name" data-model="draft.value">
@@ -169,9 +169,9 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
169
169
  <p class="ct-panel-meta" style="margin:0">Renaming a group renames it on every contact in it - everyone's, since groups are shared; deleting one takes it off them.
170
170
  <span data-help="Contacts themselves are never deleted from here."></span></p>
171
171
  <p class="ct-panel-meta" style="margin:0" data-bind-hidden="!someLocked.value">Some groups cannot be renamed or deleted by you.
172
- <span data-help-title="Why is Rename greyed out?" data-help="${ot}"></span></p>
172
+ <span data-help-title="Why is Rename greyed out?" data-help="${dt}"></span></p>
173
173
  <div class="ct-groups-list" data-if="hasGroups.value">
174
174
  <div class="ct-groups-rows" data-each="list.value key=name"></div>
175
175
  </div>
176
176
  <p class="text-muted" data-if="noGroups.value">No groups yet.</p>
177
- </div>`),a=M.observable(""),s=M.observable(null),l={draft:a,list:M.observable([]),someLocked:M.observable(!1)},r=async()=>{try{const g=await h("/contacts/groups/access");s.value=new Map((g?.groups||[]).map(u=>[u.name,u.canManage===!0]))}catch{s.value=new Map}};r();const S=M.effect(()=>{const g=le.value.groups,u=s.value,$=v.value.map(b=>{const F=!(u&&u.get(b)===!0);return{name:b,countText:L(g[b]??0,"contact"),locked:F,lockTitle:F?"Only its creator, or someone allowed to manage every group, can change this group":""}});l.list.value=$,l.someLocked.value=u!==null&&$.some(b=>b.locked)});l.hasGroups=M.computed(()=>l.list.value.length>0),l.noGroups=M.computed(()=>l.list.value.length===0),l.cannotAdd=M.computed(()=>!a.value.trim());async function n(){const g=a.peek().trim();g&&await K(g)&&(await r(),a.value="",E.toast("Group added.",{type:"success"}))}e.addEventListener("click",async g=>{const u=g.target.closest("[data-act]"),$=u?.closest("[data-group]")?.dataset.group;if(!(!u||!$||u.disabled)){if(u.dataset.act==="show")H.value={kind:"group",value:$},t.close();else if(u.dataset.act==="rename"){const b=String(await E.prompt("Rename the group to:",{inputValue:$})??"").trim();if(!b||b===$)return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"PUT",{newName:b}),await V(),await r(),E.toast("Group renamed.",{type:"success"})}catch(F){E.toast(F?.message||"Failed to rename the group.",{type:"error"})}}else if(u.dataset.act==="delete"){if(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it."))return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"DELETE"),await V(),await r(),E.toast("Group deleted.",{type:"success"})}catch(b){E.toast(b?.message||"Failed to delete the group.",{type:"error"})}}}}),t.element.appendChild(e);const i=X(e);e.querySelector(".ct-groups-rows").innerHTML=at;const O=M.applyBindings(l,e,{methods:{add:()=>(n(),!1)}});te(t,()=>{S?.(),O.dispose(),i()}),t.open()}He(A,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:Oe}),d.querySelector(".ct-rows").innerHTML=Ze,d.querySelector(".ct-chips").innerHTML=et;const U=d.querySelector(".ct-list"),me=d.querySelector(".ct-bar"),Ge=d.querySelector(".ct-chips-row"),be=d.querySelector(".ct-bulk");w.add(X(d,{position:"bottom"}));const Re=M.applyBindings(Me,d,{methods:{create:()=>W(null),bulkDelete:Ne,clearSelection:()=>{p.value=new Set}}});w.add(()=>Re.dispose()),w.add(M.effect(()=>{const t=x.value;U.classList.toggle("is-compact",t.density==="compact");const e={company:!t.showCompany,email:!t.showEmail,phone:!t.showPhone,groups:!t.showGroups};for(const[s,l]of Object.entries(e))U.classList.toggle(`hide-${s}`,l);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];e.company||a.push("minmax(0, 1fr)"),e.email||a.push("minmax(0, 1.2fr)"),e.phone||a.push("minmax(0, .9fr)"),e.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),U.style.setProperty("--ct-cols",a.join(" "))}));const De=E.contextMenu(U,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,a=B(e);if(!a)return[];const s=ge(a),l=ke(a),r=a.groups||[],S=i=>i.filter(O=>O.visible!==!1),n=Ke(q,e,i=>i.label.replace("{name}",a.name||"them"));return S([{label:"Edit\u2026",icon:"edit",action:()=>W(e)},...n,{type:"divider"},{label:`Email ${s}`,icon:"mail",visible:!!s,action:()=>{location.href=`mailto:${encodeURIComponent(s).replace(/%40/g,"@")}`}},{label:`Call ${l}`,icon:"phone",visible:!!l,action:()=>{location.href=`tel:${l.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!s,action:()=>pe(s,"Email")},{label:"Copy phone",icon:"copy",visible:!!l,action:()=>pe(l,"Phone")},{type:"divider",visible:!!(s||l)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>z(e,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...v.peek().map(i=>({label:`${r.includes(i)?"\u2713 ":""}${i}`,action:()=>Ae(e,i,!r.includes(i))})),...v.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>de([e])}]},{label:"Duplicate",icon:"copy",action:()=>Te(e)},{label:p.peek().has(e)?"Deselect":"Select",icon:"check-square",action:()=>ue(e)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>ie(e)}])}});w.add(()=>De?.destroy?.());const Ie=E.contextMenu(me,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:Pe},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:Be},{label:"Import from file\u2026",icon:"upload",action:()=>Q.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!T.peek().length,action:()=>{p.value=new Set(T.peek().map(t=>t.id))}},{label:"Clear selection",icon:"x",disabled:!p.peek().size,action:()=>{p.value=new Set}}]});w.add(()=>Ie?.destroy?.());const je=E.contextMenu(be,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...v.peek().map(t=>({label:t,action:()=>Le(t)})),...v.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>de([...p.peek()])}]});w.add(()=>je?.destroy?.()),me.addEventListener("click",t=>{const e=t.target.closest(".ct-more");e&&ae(e)}),be.addEventListener("click",t=>{const e=t.target.closest(".ct-bulk-group");e&&ae(e)}),Ge.addEventListener("click",t=>{const e=t.target.closest(".ct-chip");if(!e)return;const a=e.dataset.chip;H.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),U.addEventListener("click",t=>{if(t.target.closest(".ct-select-all"))return;const e=t.target.closest(".ct-row");if(!e)return;const a=e.dataset.id;if(t.target.closest(".ct-kebab"))return ae(t.target.closest(".ct-kebab"));if(!t.target.closest(".ct-select")){if(t.target.closest(".ct-fav")){const s=B(a);s&&z(a,{favourite:s.favourite!==!0});return}W(a)}}),U.addEventListener("change",t=>{if(t.target.matches(".ct-select")&&ue(t.target.closest(".ct-row").dataset.id),t.target.matches(".ct-select-all")){const e=T.peek().map(a=>a.id);p.value=t.target.checked?new Set(e):new Set}}),U.addEventListener("keydown",t=>{const e=t.target.closest?.(".ct-row");e&&t.key==="Enter"&&t.target===e&&W(e.dataset.id)});const fe=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),ce.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),W(null)))};document.addEventListener("keydown",fe),w.add(()=>document.removeEventListener("keydown",fe)),I.scan(d),await V()}};
177
+ </div>`),o=M.observable(""),s=M.observable(null),r={draft:o,list:M.observable([]),someLocked:M.observable(!1)},l=async()=>{try{const g=await h("/contacts/groups/access");s.value=new Map((g?.groups||[]).map(u=>[u.name,u.canManage===!0]))}catch{s.value=new Map}};l();const S=M.effect(()=>{const g=re.value.groups,u=s.value,$=y.value.map(b=>{const F=!(u&&u.get(b)===!0);return{name:b,countText:R(g[b]??0,"contact"),locked:F,lockTitle:F?"Only its creator, or someone allowed to manage every group, can change this group":""}});r.list.value=$,r.someLocked.value=u!==null&&$.some(b=>b.locked)});r.hasGroups=M.computed(()=>r.list.value.length>0),r.noGroups=M.computed(()=>r.list.value.length===0),r.cannotAdd=M.computed(()=>!o.value.trim());async function n(){const g=o.peek().trim();g&&await V(g)&&(await l(),o.value="",E.toast("Group added.",{type:"success"}))}e.addEventListener("click",async g=>{const u=g.target.closest("[data-act]"),$=u?.closest("[data-group]")?.dataset.group;if(!(!u||!$||u.disabled)){if(u.dataset.act==="show")H.value={kind:"group",value:$},t.close();else if(u.dataset.act==="rename"){const b=String(await E.prompt("Rename the group to:",{inputValue:$})??"").trim();if(!b||b===$)return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"PUT",{newName:b}),await Y(),await l(),E.toast("Group renamed.",{type:"success"})}catch(F){E.toast(F?.message||"Failed to rename the group.",{type:"error"})}}else if(u.dataset.act==="delete"){if(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it."))return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"DELETE"),await Y(),await l(),E.toast("Group deleted.",{type:"success"})}catch(b){E.toast(b?.message||"Failed to delete the group.",{type:"error"})}}}}),t.element.appendChild(e);const d=Z(e);e.querySelector(".ct-groups-rows").innerHTML=it;const L=M.applyBindings(r,e,{methods:{add:()=>(n(),!1)}});ae(t,()=>{S?.(),L.dispose(),d()}),t.open()}Ve(N,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:Re}),i.querySelector(".ct-rows").innerHTML=ct,i.querySelector(".ct-chips").innerHTML=lt;const U=i.querySelector(".ct-list"),be=i.querySelector(".ct-bar"),De=i.querySelector(".ct-chips-row"),fe=i.querySelector(".ct-bulk");v.add(Z(i,{position:"bottom"}));const qe=M.applyBindings(xe,i,{methods:{create:()=>z(null),retry:Y,bulkDelete:Pe,clearSelection:()=>{p.value=new Set}}});v.add(()=>qe.dispose()),We({root:i,toolbar:i.querySelector(".ct-bar"),tool:"contacts",endpoint:"/contacts",itemPath:"/contacts",label:"Contacts",titleOf:t=>t.name,reload:Y,lifetime:v});const ve=_e({root:i,ensureGroup:V,byId:q,patch:J,withGroup:$e,lifetime:v});v.add(M.effect(()=>{const t=x.value;U.classList.toggle("is-compact",t.density==="compact");const e={company:!t.showCompany,email:!t.showEmail,phone:!t.showPhone,groups:!t.showGroups};for(const[s,r]of Object.entries(e))U.classList.toggle(`hide-${s}`,r);const o=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];e.company||o.push("minmax(0, 1fr)"),e.email||o.push("minmax(0, 1.2fr)"),e.phone||o.push("minmax(0, .9fr)"),e.groups||o.push("minmax(0, 1fr)"),o.push("1.75rem"),U.style.setProperty("--ct-cols",o.join(" "))}));const Ue=E.contextMenu(U,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,o=q(e);if(!o)return[];const s=Se(o),r=Me(o),l=o.groups||[],S=d=>d.filter(L=>L.visible!==!1),n=Xe(j,e,d=>d.label.replace("{name}",o.name||"them"));return S([{label:"Edit\u2026",icon:"edit",action:()=>z(e)},...n,{type:"divider"},{label:`Email ${s}`,icon:"mail",visible:!!s,action:()=>{location.href=`mailto:${encodeURIComponent(s).replace(/%40/g,"@")}`}},{label:`Call ${r}`,icon:"phone",visible:!!r,action:()=>{location.href=`tel:${r.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!s,action:()=>ue(s,"Email")},{label:"Copy phone",icon:"copy",visible:!!r,action:()=>ue(r,"Phone")},{type:"divider",visible:!!(s||r)},{label:o.favourite===!0?"Unstar":"Star",icon:"star",action:()=>J(e,{favourite:o.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...y.peek().map(d=>({label:`${l.includes(d)?"\u2713 ":""}${d}`,action:()=>Oe(e,d,!l.includes(d))})),...y.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>pe([e])}]},{label:"Duplicate",icon:"copy",action:()=>Be(e)},{label:p.peek().has(e)?"Deselect":"Select",icon:"check-square",action:()=>me(e)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>de(e)}])}});v.add(()=>Ue?.destroy?.());const Fe=E.contextMenu(be,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:je},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:Ie},{label:"Import from file\u2026",icon:"upload",action:()=>X.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!A.peek().length,action:()=>{p.value=new Set(A.peek().map(t=>t.id))}},{label:"Clear selection",icon:"x",disabled:!p.peek().size,action:()=>{p.value=new Set}}]});v.add(()=>Fe?.destroy?.());const He=E.contextMenu(fe,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...y.peek().map(t=>({label:t,action:()=>Ge(t)})),...y.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>pe([...p.peek()])}]});v.add(()=>He?.destroy?.()),be.addEventListener("click",t=>{const e=t.target.closest(".ct-more");e&&oe(e)}),fe.addEventListener("click",t=>{const e=t.target.closest(".ct-bulk-group");e&&oe(e)}),De.addEventListener("click",t=>{const e=t.target.closest(".ct-chip");if(!e)return;const o=e.dataset.chip;H.value=o==="fav"?{kind:"fav"}:o.startsWith("group:")?{kind:"group",value:o.slice(6)}:{kind:"all"}}),U.addEventListener("click",t=>{if(t.target.closest(".ct-select-all"))return;const e=t.target.closest(".ct-row");if(!e)return;const o=e.dataset.id;if(t.target.closest(".ct-kebab"))return oe(t.target.closest(".ct-kebab"));if(!t.target.closest(".ct-select")){if(t.target.closest(".ct-fav")){const s=q(o);s&&J(o,{favourite:s.favourite!==!0});return}z(o)}}),U.addEventListener("change",t=>{if(t.target.matches(".ct-select")&&me(t.target.closest(".ct-row").dataset.id),t.target.matches(".ct-select-all")){const e=A.peek().map(o=>o.id);p.value=t.target.checked?new Set(e):new Set}}),U.addEventListener("keydown",t=>{const e=t.target.closest?.(".ct-row");e&&t.key==="Enter"&&t.target===e&&z(e.dataset.id)});const he=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),le.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),z(null)))};document.addEventListener("keydown",he),v.add(()=>document.removeEventListener("keydown",he)),I.scan(i),await Y()}};