domma-cms 1.6.6 → 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/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +12 -0
- package/admin/js/templates/analytics.html +5 -0
- package/admin/js/templates/contacts.html +4 -0
- package/admin/js/templates/docs/usage-tools.html +12 -0
- package/admin/js/templates/notes.html +4 -0
- package/admin/js/templates/todo.html +4 -0
- package/admin/js/views/analytics.js +7 -7
- package/admin/js/views/contacts.js +22 -22
- package/admin/js/views/notes.js +8 -8
- package/admin/js/views/todo.js +9 -9
- package/package.json +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -1
- package/public/js/effects.js +1 -1
- package/public/js/site.js +1 -1
- package/public/js/utf8-base64.mjs +1 -0
- package/server/routes/api/contacts.js +12 -14
- package/server/routes/api/notes.js +7 -6
- package/server/routes/api/todo.js +7 -5
- package/server/services/collections.js +11 -2
- package/server/services/markdown.js +10 -10
- package/server/services/renderer.js +2 -2
- package/server/services/tool-trash.js +39 -0
- package/server/templates/page.html +4 -4
package/admin/js/lib/tool-kit.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function onceClosed(t,e=()=>{}){const
|
|
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 > 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
|
|
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>`,
|
|
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>`,
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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,
|
|
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
|
|
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>`,
|
|
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>`,
|
|
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-${
|
|
23
|
-
<input id="ct-f-${
|
|
24
|
-
</div>`,
|
|
25
|
-
<div class="ct-repeater" data-repeater="${
|
|
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>${
|
|
28
|
-
<button type="button" class="btn btn-sm btn-ghost" data-add="${
|
|
29
|
-
<span data-icon="plus" data-icon-size="13"></span> ${
|
|
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">${
|
|
33
|
-
</div>`,
|
|
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
|
-
${
|
|
101
|
+
${r("addresses")}
|
|
102
102
|
</div>
|
|
103
103
|
</details>
|
|
104
104
|
|
|
105
105
|
<details class="ct-section">
|
|
106
106
|
<summary>More emails & phones <span class="ct-section-sum" data-bind-text="extraSummary.value"></span></summary>
|
|
107
107
|
<div class="ct-section-body">
|
|
108
|
-
${
|
|
109
|
-
${
|
|
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=
|
|
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">${
|
|
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>`,
|
|
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
|
|
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:
|
|
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="${
|
|
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>`),
|
|
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()}};
|
package/admin/js/views/notes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{protectToolEditor as se,mountToolTrash as ne}from"../lib/tool-recovery.js";import{apiRequest as le}from"../api.js";import{addBannerAction as ie}from"../lib/plugin-chrome.js";import{helpPopovers as R}from"../lib/help-popover.js";import{mountRelated as re,relatedMenuItems as ce,relatedProviders as de}from"../lib/related.js";import{onceClosed as K,openMenuAt as ue,settingsStore as pe,sidebarCount as ve,viewLifetime as me}from"../lib/tool-kit.js";import{arrangeNotes as be,collectCategories as fe,DEFAULT_SETTINGS as ye,normaliseSettings as j,parseCategories as he,preview as ge,PREVIEW_MAX as we,PREVIEW_MIN as ke}from"../lib/notes-arrange.js";const z=pe("notes.settings",j),N=(m,r="GET",g)=>le(m,{method:r,...g!==void 0?{body:JSON.stringify(g)}:{}});function W(m){const r=document.createElement("template");return r.innerHTML=m.trim(),r.content.firstElementChild}const Ee=`
|
|
2
2
|
<article class="note-card" data-id="{{id}}" tabindex="0">
|
|
3
3
|
<h3 class="note-title">{{title}}</h3>
|
|
4
4
|
<p class="note-preview">{{preview}}</p>
|
|
@@ -7,9 +7,9 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
7
7
|
<time class="note-time" title="{{updatedFull}}">{{updatedRel}}</time>
|
|
8
8
|
</footer>
|
|
9
9
|
<button type="button" class="note-kebab" aria-label="Note actions" title="Actions">\u22EE</button>
|
|
10
|
-
</article>`,
|
|
10
|
+
</article>`,c=me(/^#\/notes\b/);export const notesView={templateUrl:"/admin/js/templates/notes.html",async onMount(m){c.reset();const r=m.get(0);let g=[];de("note").then(e=>{g=e});const d=M.observableArray([]),S=M.observable(""),p=M.observable(""),b=M.observable(!0),C=M.observable(""),$=M.observable(!1),s=M.observable(z.read()),x=M.computed(()=>fe(d.value));c.add(M.effect(()=>{d.value,b.peek()||ve("#/notes")}));const _=M.computed(()=>be(d.value,{query:S.value,category:p.value,sort:s.value.sort})),T=M.observable([]);c.add(M.effect(()=>{const e=s.value.previewLength;T.value=_.value.map(t=>({id:t.id,title:t.title||"Untitled",preview:ge(t.content,e),cats:(t.categories||[]).map(a=>({name:a})),updatedRel:t.updatedAt?D(t.updatedAt).fromNow():"",updatedFull:t.updatedAt?"Updated "+D(t.updatedAt).format("DD MMM YYYY HH:mm"):""}))}));const F=M.computed(()=>!!(S.value.trim()||p.value)),G={query:S,category:p,loading:b,loadError:C,searchFocus:$,categories:x,visible:T,isCards:M.computed(()=>s.value.layout==="cards"),listClass:M.computed(()=>(s.value.layout==="cards"?"is-cards":"is-list")+(s.value.showCategories?"":" hide-cats")),isEmpty:M.computed(()=>!b.value&&!C.value&&T.value.length===0),emptyText:M.computed(()=>F.value?"No notes match. Clear the search or pick another category.":"No notes yet. Click New to write your first."),countText:M.computed(()=>{if(b.value)return"";const e=d.value.length,t=T.value.length;return F.value?`${t} of ${e}`:`${e} note${e===1?"":"s"}`})};c.add(M.effect(()=>{const e=p.value.toLowerCase();e&&!x.value.some(t=>t.toLowerCase()===e)&&(p.value="")})),c.add(M.effect(()=>z.write(s.value)));const L=(e,t)=>{s.value=j({...s.peek(),[e]:t})};r.querySelector(".notes-items").innerHTML=Ee;const J=M.applyBindings(G,r,{methods:{create:()=>w(null),retry:A,useCards:()=>L("layout","cards"),useList:()=>L("layout","list")}});c.add(()=>J.dispose()),ne({root:r,toolbar:r.querySelector(".notes-bar-end"),tool:"notes",endpoint:"/notes",itemPath:"/items",label:"Notes",titleOf:e=>e.title||e.content,reload:A,lifetime:c}),c.add(R(r,{position:"bottom"}));const f=e=>d.peek().find(t=>t.id===e);function X(e){const t=d.peek(),a=t.findIndex(o=>o.id===e.id);d.value=a===-1?[e,...t]:t.map(o=>o.id===e.id?e:o)}async function A(){b.value=!0;try{const e=await N("/notes/items");d.value=Array.isArray(e)?e:e.data??[],C.value=""}catch{C.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load notes",{type:"error"})}finally{b.value=!1}}async function U(e,t){const a=e?await N("/notes/items/"+e,"PUT",t):await N("/notes/items","POST",t);return X(a),a}async function Y(e){if(s.peek().confirmDelete&&!await E.confirm("Move this note to Trash? You can restore it later."))return!1;try{return await N("/notes/items/"+e,"DELETE"),d.value=d.peek().filter(t=>t.id!==e),E.toast("Note moved to Trash",{type:"success"}),!0}catch{return E.toast("Failed to delete note",{type:"error"}),!1}}async function Z(e){const t=f(e);if(t)try{await U(null,{title:`${t.title||"Untitled"} (copy)`,content:t.content||"",categories:t.categories||[]}),E.toast("Note duplicated",{type:"success"})}catch{E.toast("Failed to duplicate note",{type:"error"})}}async function Q(e){const t=f(e);if(t)try{await navigator.clipboard.writeText([t.title,t.content].filter(Boolean).join(`
|
|
11
11
|
|
|
12
|
-
`)),E.toast("Copied to clipboard",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}let
|
|
12
|
+
`)),E.toast("Copied to clipboard",{type:"success"})}catch{E.toast("The browser would not allow copying",{type:"error"})}}let y=null;async function w(e){if(y&&!await y.close())return;const t=!e,a=E.slideover({title:t?"New note":"Edit note",size:"md",position:"right"});y=a;const o=W(`
|
|
13
13
|
<div class="notes-panel">
|
|
14
14
|
<div>
|
|
15
15
|
<label class="form-label" for="note-title-input">Title</label>
|
|
@@ -26,7 +26,7 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
26
26
|
<div class="notes-field-grow">
|
|
27
27
|
<label class="form-label" for="note-content-input">Content
|
|
28
28
|
<span data-help-title="Writing"
|
|
29
|
-
data-help="Plain text. Line breaks are kept. Ctrl+Enter (Cmd+Enter on a Mac) saves; Escape
|
|
29
|
+
data-help="Plain text. Line breaks are kept. Ctrl+Enter (Cmd+Enter on a Mac) saves; Escape offers to keep unsaved changes as a browser draft."></span>
|
|
30
30
|
</label>
|
|
31
31
|
<textarea id="note-content-input" class="form-input" placeholder="Write your note\u2026"
|
|
32
32
|
data-model="content.value"></textarea>
|
|
@@ -47,7 +47,7 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
47
47
|
<span data-icon="save" data-icon-size="13"></span> Save
|
|
48
48
|
</button>
|
|
49
49
|
</div>
|
|
50
|
-
</div>`),
|
|
50
|
+
</div>`),l={title:e?.title||"",content:e?.content||"",cats:t?s.peek().defaultCategories:e.categories||[]},v=M.observable(l.title),u=M.observable(l.content),i=M.observable([...l.cats]),h=M.observable(!1),k={title:v,content:u,saving:h,canDelete:!t,stats:M.computed(()=>{const n=u.value.trim(),B=n?n.split(/\s+/).length:0;return`${B} word${B===1?"":"s"} \xB7 ${u.value.length} characters`}),dirty:M.computed(()=>v.value!==l.title||u.value!==l.content||i.value.join("\0")!==l.cats.join("\0"))};async function O(){if(!h.peek()){h.value=!0;try{await U(e?.id,{title:v.peek().trim(),content:u.peek(),categories:i.peek()}),E.toast(t?"Note created":"Note saved",{type:"success"}),V.saved(),a.close()}catch{E.toast("Failed to save note",{type:"error"})}finally{h.value=!1}}}a.element.appendChild(o),t||re(o.querySelector(".related-box"),"note",e.id,{onOpen:()=>a.close()});const ae=M.applyBindings(k,o,{methods:{submit:O,cancel:()=>a.close(),del:async()=>{await Y(e.id)&&(V.discard(),a.close())}}}),P=E.pillbox(o.querySelector(".notes-cats-input"),{data:x.peek().map(n=>({value:n,label:n})),value:i.peek(),creatable:!0,placeholder:"Add a category\u2026",onChange:()=>{i.value=he(P.getValue())}}),oe=R(o);o.addEventListener("keydown",n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),O())}),K(a,()=>{ae.dispose(),oe(),P.destroy?.(),y===a&&(y=null)});const V=se({so:a,panel:o,tool:"notes",id:e?.id,lifetime:c,read:()=>({title:v.peek(),content:u.peek(),cats:[...i.peek()]}),write:n=>{v.value=n.title??"",u.value=n.content??"",i.value=Array.isArray(n.cats)?n.cats:[],P.setValue(i.peek())}});a.open(),setTimeout(()=>o.querySelector(t?"#note-title-input":"#note-content-input")?.focus(),60)}function ee(){const e=E.slideover({title:"Notes settings",size:"sm",position:"right"}),t=W(`
|
|
51
51
|
<div class="notes-panel">
|
|
52
52
|
<div class="notes-setting">
|
|
53
53
|
<label class="form-label" for="notes-set-layout">Layout
|
|
@@ -71,7 +71,7 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
71
71
|
<span data-help="How much of each note's content shows on its card. 0 shows titles only."></span>
|
|
72
72
|
</label>
|
|
73
73
|
<div class="notes-range">
|
|
74
|
-
<input id="notes-set-preview" type="range" min="${
|
|
74
|
+
<input id="notes-set-preview" type="range" min="${ke}" max="${we}" step="20"
|
|
75
75
|
data-model="previewLength.value">
|
|
76
76
|
<output data-bind-text="previewText.value"></output>
|
|
77
77
|
</div>
|
|
@@ -85,7 +85,7 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
85
85
|
<div class="notes-setting">
|
|
86
86
|
<div class="notes-setting-row">
|
|
87
87
|
<label class="form-label" for="notes-set-confirm" style="margin:0">Confirm before deleting
|
|
88
|
-
<span data-help="Off
|
|
88
|
+
<span data-help="Off moves items straight to Trash. You can restore them."></span>
|
|
89
89
|
</label>
|
|
90
90
|
<input id="notes-set-confirm" type="checkbox" class="form-switch" data-model="confirmDelete.value">
|
|
91
91
|
</div>
|
|
@@ -102,4 +102,4 @@ import{apiRequest as ae}from"../api.js";import{addBannerAction as se}from"../lib
|
|
|
102
102
|
<span class="spacer"></span>
|
|
103
103
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
|
|
104
104
|
</div>
|
|
105
|
-
</div>`),a=(
|
|
105
|
+
</div>`),a=(i,h=k=>k)=>M.computed({read:()=>s.value[i],write:k=>L(i,h(k))}),o={layout:a("layout"),sort:a("sort"),previewLength:a("previewLength",Number),showCategories:a("showCategories"),confirmDelete:a("confirmDelete"),previewText:M.computed(()=>s.value.previewLength?`${s.value.previewLength} ch`:"Off")};e.element.appendChild(t);let l;const v=M.applyBindings(o,t,{methods:{done:()=>e.close(),reset:()=>{s.value={...ye},l?.setValue([])}}});l=E.pillbox(t.querySelector(".notes-set-defaults"),{data:x.peek().map(i=>({value:i,label:i})),value:s.peek().defaultCategories,creatable:!0,placeholder:"None",onChange:()=>L("defaultCategories",l.getValue())});const u=R(t);K(e,()=>{v.dispose(),u(),l.destroy?.()}),e.open()}ie(m,{id:"notes-settings",icon:"settings",title:"Notes settings",onClick:ee});const q=r.querySelector(".notes-list"),te=E.contextMenu(q,{cursor:"glyph",match:".note-card",inherit:!1,density:"compact",items:e=>{const t=e.dataset.id,o=f(t)?.categories||[];return[{label:"Edit",icon:"edit",action:()=>w(f(t))},...ce(g,t),{label:"Duplicate",icon:"copy",action:()=>Z(t)},{label:"Copy text",icon:"clipboard",action:()=>Q(t)},{label:"Show only\u2026",icon:"filter",disabled:o.length===0,submenu:o.map(l=>({label:l,icon:"tag",action:()=>{p.value=l}}))},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>Y(t)}]}});c.add(()=>te?.destroy?.()),q.addEventListener("click",e=>{const t=e.target.closest(".note-card");if(!t)return;const a=e.target.closest(".note-cat");if(a){p.value=a.dataset.cat;return}const o=e.target.closest(".note-kebab");if(o){ue(o);return}w(f(t.dataset.id))}),q.addEventListener("keydown",e=>{const t=e.target.closest?.(".note-card");t&&e.key==="Enter"&&e.target===t&&w(f(t.dataset.id))});const H=e=>{e.ctrlKey||e.metaKey||e.altKey||e.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(e.key==="/"?(e.preventDefault(),$.value=!0):(e.key==="n"||e.key==="N")&&(e.preventDefault(),w(null)))};document.addEventListener("keydown",H),c.add(()=>document.removeEventListener("keydown",H)),c.add(()=>y?.close()),I.scan(r),await A()}};
|