domma-cms 1.6.56 → 1.6.57

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.
Files changed (111) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. package/server/server.js +1 -1
@@ -1,4 +1,4 @@
1
- import{protectToolEditor as ze,mountToolTrash as Ke,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,promptWith as et,settingsStore as tt,viewLifetime as at}from"../lib/tool-kit.js";import{arrangeContacts as ot,countContacts as st,DEFAULT_SETTINGS as we,duplicateBody as nt,initials as ct,normaliseSettings as Ee,primaryEmail as Se,primaryPhone as Me,withGroup as $e}from"../lib/contacts-arrange.js";const Ce=tt("contacts.settings",Ee),v=(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}]}},lt=`
1
+ import{protectToolEditor as bt,mountToolTrash as yt,mountGroupBatch as gt,orphanToolDrafts as kt}from"../lib/tool-recovery.js";import{SHAPES as wt,whileLoading as Et}from"../lib/loading.js";import{apiRequest as St}from"../api.js";import{addBannerAction as Mt}from"../lib/plugin-chrome.js";import{mountRelated as $t,relatedMenuItems as Ct,relatedProviders as Tt}from"../lib/related.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Q,promptWith as xt,settingsStore as At,sidebarCount as Nt,viewLifetime as Lt}from"../lib/tool-kit.js";import{arrangeContacts as Ot,countContacts as Bt,DEFAULT_SETTINGS as Ue,duplicateBody as Gt,initials as Pt,normaliseSettings as He,primaryEmail as Ke,primaryPhone as Ye,withGroup as We}from"../lib/contacts-arrange.js";const ze=At("contacts.settings",He),w=(G,d="GET",q)=>St(G,{method:d,...q!==void 0?{body:JSON.stringify(q)}:{}}),ge={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"},Dt=["accountName","bankName","sortCode","accountNumber","iban","bic"],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}]}},It=`
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,34 +11,34 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
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>`,rt='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',it='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',dt=`
15
- <div class="ct-group-row" data-group="{{name}}">
14
+ </div>`,Rt='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',jt='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',qt=`
15
+ <div class="ct-group-row" data-group="{{name}}" tabindex="0">
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>`,pt="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}`,h=at(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(N){h.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(()=>st(m.value,y.value)),Te=M.computed(()=>ot(m.value,{query:ce.value,filter:H.value,sort:x.value.sort,favouritesFirst:x.value.favouritesFirst})),A=M.observable([]);h.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:ct(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([]);h.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":""}))]})),h.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)))};h.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([v("/contacts/contacts"),v("/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 v("/contacts/groups")}catch{}}async function J(t,e,o){try{return Ne(await v(`/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 v(`/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 v("/contacts/contacts","POST",nt(e));m.value=[...m.peek(),o],E.toast("Copied - the copy is open to edit.",{type:"success"}),W(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 v("/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 he.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 v("/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 he.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 v("/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 v("/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 W(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
- <div${C?' class="ct-wide"':""}>
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}">
20
+ </div>`,Ft="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 ke(G){const d=document.createElement("template");return d.innerHTML=G.trim(),d.content.firstElementChild}const B=(G,d,q=`${d}s`)=>`${G} ${G===1?d:q}`,h=Lt(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(G){h.reset();const d=G.get(0);let q=!1;h.add(()=>{q=!0});const u=()=>!q,_e=d.querySelector(".ct-loading"),X={editor:null,settings:null,groups:null};let we=[];Tt("contact").then(e=>{u()&&(we=e)});const L=M.observable(ze.read()),p=M.observable([]),g=M.observable([]),ne=M.observable(!0),F=M.observable(!1),se=M.observable(""),Z=M.observable([]),ce=M.observable(null),Ee=M.observable(""),x=M.observable({kind:"all"}),m=M.observable(new Set),Se=M.observable(!1),Me=M.computed(()=>Bt(p.value,g.value)),Je=M.computed(()=>Ot(p.value,{query:Ee.value,filter:x.value,sort:L.value.sort,favouritesFirst:L.value.favouritesFirst})),O=M.observable([]);h.add(M.effect(()=>{const e=m.value;O.value=Je.value.map(t=>({id:t.id,name:t.name||"Unnamed",sub:[t.jobTitle,t.company&&!L.value.showCompany?t.company:""].filter(Boolean).join(" \xB7 "),initials:Pt(t.name),company:t.company||"",email:Ke(t),phone:Ye(t),groupsText:(t.groups||[]).join(", "),favClass:t.favourite===!0?" is-on":"",favTitle:t.favourite===!0?"Starred - click to unstar":"Star this contact",selected:e.has(t.id),selClass:e.has(t.id)?" is-selected":""}))}));const $e=M.observable([]);h.add(M.effect(()=>{const e=Me.value,t=x.value;$e.value=[{key:"all",label:"All",count:e.all,title:"Everyone",cls:t.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:e.fav,title:"Only starred contacts",cls:t.kind==="fav"?" is-active":""},...g.value.map(a=>({key:`group:${a}`,label:a,count:e.groups[a]??0,title:`Only the ${a} group`,cls:t.kind==="group"&&t.value===a?" is-active":""}))]})),h.add(M.effect(()=>{const e=x.value;e.kind==="group"&&!g.value.includes(e.value)&&(x.value={kind:"all"})}));const Ve={loadError:se,rows:O,chips:$e,loading:ne,query:Ee,searchFocus:Se,firstLoad:M.computed(()=>ne.value&&!F.value),hasOrphans:M.computed(()=>Z.value.length>0),orphanText:M.computed(()=>{const e=Z.value;return e.length?e.length===1?`You left unsaved changes to "${e[0].name}", which has since been deleted.`:`You left unsaved changes to ${B(e.length,"contact")} that have since been deleted.`:""}),hasRows:M.computed(()=>O.value.length>0),isEmpty:M.computed(()=>F.value&&!se.value&&O.value.length===0),emptyText:M.computed(()=>p.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(!F.value)return"";const e=O.value.length,t=p.value.length;return e===t?B(t,"contact"):`${e} of ${t}`}),hasSelection:M.computed(()=>m.value.size>0),selectionText:M.computed(()=>`${B(m.value.size,"contact")} selected`),allSelected:M.computed(()=>O.value.length>0&&O.value.every(e=>m.value.has(e.id)))};h.add(M.effect(()=>ze.write(L.value)));const Qe=(e,t)=>{L.value=He({...L.peek(),[e]:t})},U=e=>p.peek().find(t=>t.id===e),Xe=e=>{p.value=p.peek().map(t=>t.id===e.id?e:t)};let le=0;async function W(){const e=++le;ne.value=!0;const t=Promise.all([w("/contacts/contacts"),w("/contacts/groups"),et()]);de();try{const[a,n,c]=F.peek()?await t:await Et(_e,t,wt.iconRows);if(!u()||e!==le)return;p.value=Array.isArray(a)?a:a?.data??[],g.value=Array.isArray(n)?n:[],ee=c,se.value="",F.value=!0,Te()}catch{if(!u()||e!==le)return;se.value="Could not load this list. Your items have not been removed.",E.toast("Failed to load contacts.",{type:"error"})}finally{u()&&e===le&&(ne.value=!1)}}async function Ze(){try{const e=await w("/contacts/groups");u()&&(g.value=Array.isArray(e)?e:g.peek())}catch{}}async function de(){try{const e=await w("/contacts/groups/access");u()&&(ce.value=new Map((e?.groups||[]).map(t=>[t.name,t.canManage===!0])))}catch{u()&&(ce.value=new Map)}}const ue=e=>ce.peek()?.get(e)===!0;let ee=null;async function et(){try{const e=await w("/contacts/trash");return new Set((Array.isArray(e)?e:[]).map(t=>t.id))}catch{return null}}const Ce=e=>{if(ee)for(const t of e)ee.add(t)};function Te(){if(F.peek()){if(!ee){Z.value=[];return}Z.value=kt("contacts",[...p.peek().map(e=>e.id),...ee]).map(({id:e,draft:t})=>({id:e,name:String(t.values?.fields?.name||"").trim()||"Unnamed"}))}}h.add(M.effect(()=>{p.value,g.value,F.peek()&&Nt("#/contacts")}));async function te(e,t,a){try{const n=await w(`/contacts/contacts/${e}`,"PUT",t);return u()&&Xe(n),a&&E.toast(a,{type:"success"}),n||null}catch(n){return E.toast(n?.message||"Failed to update the contact.",{type:"error"}),null}}async function xe(e){const t=U(e);if(L.peek().confirmDelete&&!await E.confirm("Move this contact to Trash? You can restore it later."))return!1;try{if(await w(`/contacts/contacts/${e}`,"DELETE"),Ce([e]),u()){p.value=p.peek().filter(n=>n.id!==e);const a=new Set(m.peek());a.delete(e)&&(m.value=a)}return E.toast(`${t?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function tt(e){const t=U(e);if(t)try{const a=await w("/contacts/contacts","POST",Gt(t));if(!u())return;if(p.value=[...p.peek(),a],X.editor){E.toast("Copied.",{type:"success"});return}E.toast("Copied - the copy is open to edit.",{type:"success"}),H(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function ae(e){if(g.peek().includes(e))return!0;try{return await w("/contacts/groups","POST",{name:e}),await Ze(),!0}catch(t){return E.toast(t?.message||"Could not create the group.",{type:"error"}),!1}}async function at(e,t,a){const n=U(e);n&&(a&&!await ae(t)||await te(e,{groups:We(n.groups,t,a)},a?`Added to ${t}.`:`Removed from ${t}.`))}async function Ae(e){const t=String(await E.prompt("New group name:")??"").trim();t&&await Ie.run(e,t)}async function Ne(e,t){try{await navigator.clipboard.writeText(e),E.toast(`${t} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function ot(){const e=[...m.peek()];if(e.length&&await E.confirm(`Delete ${B(e.length,"contact")}? You can restore it from Trash.`))try{const t=await w("/contacts/contacts/bulk","DELETE",{ids:e});if(!u())return;const a=t?.deletedIds??[];Ce(a),p.value=p.peek().filter(n=>!a.includes(n.id)),m.value=new Set(t?.failedIds??[]),E.toast(`${B(t?.deleted??e.length,"contact")} moved to Trash${t?.failedIds?.length?` \xB7 ${t.failedIds.length} failed (still selected)`:""}.`,{type:t?.failedIds?.length?"warning":"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function nt(e){await Ie.run([...m.peek()],e)}const Le=e=>{const t=new Set(m.peek());t.delete(e)||t.add(e),m.value=t};async function st(){try{const e=await w("/contacts/export"),t=new Blob([JSON.stringify(e,null,2)],{type:"application/json"}),a=URL.createObjectURL(t),n=document.createElement("a");n.href=a,n.download="contacts.json",document.body.appendChild(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const re=d.querySelector(".ct-import-file");re.addEventListener("change",async()=>{const e=re.files?.[0];if(re.value="",!!e)try{const t=JSON.parse(await e.text()),a=Array.isArray(t)?t:t.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const n=await w("/contacts/import","POST",{contacts:a});E.toast(`Imported ${B(n?.imported??0,"contact")}.`,{type:"success"}),u()&&await W()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function H(e,{draftId:t=null}={}){const a=e?U(e):null;e&&!a||pe("editor",n=>ct(a,t,n))}function pe(e,t){if(X[e]||!u())return;const a={};X[e]=a;const n=()=>{X[e]===a&&(X[e]=null)};try{t(n)}catch(c){n(),E.toast("Could not open that panel.",{type:"error"}),console.error(c)}}function ct(e,t,a){const n=E.slideover({title:e?"Edit contact":t?"New contact (from unsaved changes)":"New contact",size:"lg",position:"right"}),c=(o,l,{type:y="text",mono:f=!1,placeholder:$="",wide:A=!1}={})=>`
21
+ <div${A?' class="ct-wide"':""}>
22
+ <label class="form-label" for="ct-f-${o}">${l}</label>
23
+ <input id="ct-f-${o}" type="${y}" class="form-input${f?" font-mono":""}" placeholder="${$}" data-model="${o}.value">
24
+ </div>`,k=o=>`
25
+ <div class="ct-repeater" data-repeater="${o}">
26
26
  <div class="ct-subhead">
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>
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>
30
30
  </div>
31
31
  <div class="ct-repeater-list"></div>
32
- <p class="text-muted text-sm contacts-empty-row">${T[a].empty}</p>
33
- </div>`,l=ne(`
32
+ <p class="text-muted text-sm contacts-empty-row">${N[o].empty}</p>
33
+ </div>`,i=ke(`
34
34
  <div class="ct-panel">
35
35
  <div class="ct-grid">
36
36
  <div class="ct-wide">
37
37
  <label class="form-label" for="ct-f-name">Name <span class="text-danger">*</span></label>
38
38
  <input id="ct-f-name" type="text" class="form-input" placeholder="Full name" data-model="name.value">
39
39
  </div>
40
- ${s("email","Email",{type:"email",placeholder:"email@example.com"})}
41
- ${s("phone","Phone",{placeholder:"+44 \u2026"})}
40
+ ${c("email","Email",{type:"email",placeholder:"email@example.com"})}
41
+ ${c("phone","Phone",{placeholder:"+44 \u2026"})}
42
42
  <div class="ct-wide">
43
43
  <label class="form-label" for="ct-f-notes">Notes</label>
44
44
  <textarea id="ct-f-notes" class="form-input" rows="3" placeholder="Optional notes\u2026" data-model="notes.value"></textarea>
@@ -65,13 +65,13 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
65
65
  <div class="ct-section-body">
66
66
  <p class="ct-panel-meta" style="margin:0">Everything an invoice needs. The Invoice plugin reads these when you bill this contact.</p>
67
67
  <div class="ct-grid">
68
- ${s("company","Company",{placeholder:"Acme Ltd"})}
69
- ${s("jobTitle","Job title",{placeholder:"Finance Manager"})}
70
- ${s("website","Website",{placeholder:"acme.co.uk"})}
71
- ${s("billingEmail","Billing email",{type:"email",placeholder:"accounts@acme.co.uk"})}
72
- ${s("vatNumber","VAT number",{mono:!0,placeholder:"GB123456789"})}
73
- ${s("companyNumber","Company number",{mono:!0,placeholder:"01234567"})}
74
- ${s("reference","Their reference",{placeholder:"PO-4471"})}
68
+ ${c("company","Company",{placeholder:"Acme Ltd"})}
69
+ ${c("jobTitle","Job title",{placeholder:"Finance Manager"})}
70
+ ${c("website","Website",{placeholder:"acme.co.uk"})}
71
+ ${c("billingEmail","Billing email",{type:"email",placeholder:"accounts@acme.co.uk"})}
72
+ ${c("vatNumber","VAT number",{mono:!0,placeholder:"GB123456789"})}
73
+ ${c("companyNumber","Company number",{mono:!0,placeholder:"01234567"})}
74
+ ${c("reference","Their reference",{placeholder:"PO-4471"})}
75
75
  <div>
76
76
  <label class="form-label" for="ct-f-paymentTerms">Payment terms (days)
77
77
  <span data-help="Days they have to pay. Empty uses the invoice default."></span></label>
@@ -90,23 +90,23 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
90
90
  <div>
91
91
  <div class="ct-subhead"><strong>Bank details <span data-help="Printed on an invoice so it can be paid."></span></strong></div>
92
92
  <div class="ct-grid">
93
- ${s("accountName","Account name",{placeholder:"Acme Ltd"})}
94
- ${s("bankName","Bank",{placeholder:"Barclays"})}
95
- ${s("sortCode","Sort code",{mono:!0,placeholder:"20-00-00"})}
96
- ${s("accountNumber","Account number",{mono:!0,placeholder:"12345678"})}
97
- ${s("iban","IBAN",{mono:!0,placeholder:"GB29 NWBK 6016 1331 9268 19"})}
98
- ${s("bic","BIC / SWIFT",{mono:!0,placeholder:"NWBKGB2L"})}
93
+ ${c("accountName","Account name",{placeholder:"Acme Ltd"})}
94
+ ${c("bankName","Bank",{placeholder:"Barclays"})}
95
+ ${c("sortCode","Sort code",{mono:!0,placeholder:"20-00-00"})}
96
+ ${c("accountNumber","Account number",{mono:!0,placeholder:"12345678"})}
97
+ ${c("iban","IBAN",{mono:!0,placeholder:"GB29 NWBK 6016 1331 9268 19"})}
98
+ ${c("bic","BIC / SWIFT",{mono:!0,placeholder:"NWBKGB2L"})}
99
99
  </div>
100
100
  </div>
101
- ${r("addresses")}
101
+ ${k("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
- ${r("emails")}
109
- ${r("phones")}
108
+ ${k("emails")}
109
+ ${k("phones")}
110
110
  </div>
111
111
  </details>
112
112
 
@@ -121,11 +121,11 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
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=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 K=document.createElement("input");K.type="text",K.className="form-input"+(w.wide?" contacts-row-wide":""),K.dataset.key=w.key,K.placeholder=w.placeholder??"",K.value=C[w.key]??"",P.appendChild(K)}const z=document.createElement("button");z.type="button",z.className="btn btn-sm btn-ghost contacts-row-remove",z.title="Remove",z.textContent="\xD7",z.addEventListener("click",()=>{const w=u(a,{keepBlank:!0});w.splice(O,1),g(a,w)}),P.appendChild(z),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 v("/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=it;const We=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?.(),We.dispose(),Ye()});const ye=["name","email","phone","notes","favourite","chosen",...Object.keys(se)],ge=ze({so:o,panel:l,tool:"contacts",id:e?.id,lifetime:h,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)=>`
124
+ </div>`),S=o=>o==null?"":String(o),s={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(ge))s[o]=M.observable(S(e?.[o]));const C=Object.fromEntries(Object.keys(N).map(o=>[o,M.observable(0)]));s.pick=M.observable([]);const z=M.effect(()=>{const o=s.chosen.value;s.pick.value=[...new Set([...g.value,...o])].map(l=>({name:l,on:String(o.includes(l)),cls:o.includes(l)?" is-active":""}))});s.noGroups=M.computed(()=>s.pick.value.length===0),s.cannotSave=M.computed(()=>!s.name.value.trim()),s.billingSummary=M.computed(()=>{const o=[];s.company.value.trim()&&o.push(s.company.value.trim()),s.vatNumber.value.trim()&&o.push("VAT"),(s.sortCode.value.trim()||s.iban.value.trim())&&o.push("bank details");const l=C.addresses.value;return l&&o.push(B(l,"address","addresses")),o.join(" \xB7 ")}),s.extraSummary=M.computed(()=>{const o=C.emails.value+C.phones.value;return o?B(o,"more"):""});function _(o,l){const y=N[o],f=i.querySelector(`[data-repeater="${o}"]`),$=f.querySelector(".ct-repeater-list");$.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=l.length?"none":"",l.forEach((A,P)=>{const R=document.createElement("div");R.className="contacts-row";for(const T of y.columns){if(T.check){const ve=document.createElement("label");ve.className="form-check contacts-row-check";const j=document.createElement("input");j.type="checkbox",j.className="form-check-input",j.dataset.key=T.key,j.checked=A[T.key]===!0,j.addEventListener("change",()=>{j.checked&&$.querySelectorAll(`input[data-key="${T.key}"]`).forEach(Fe=>{Fe!==j&&(Fe.checked=!1)})});const be=document.createElement("span");be.className="form-check-label",be.textContent=T.label,ve.append(j,be),R.appendChild(ve);continue}const V=document.createElement("input");V.type="text",V.className="form-input"+(T.wide?" contacts-row-wide":""),V.dataset.key=T.key,V.placeholder=T.placeholder??"",V.value=A[T.key]??"",R.appendChild(V)}const J=document.createElement("button");J.type="button",J.className="btn btn-sm btn-ghost contacts-row-remove",J.title="Remove",J.textContent="\xD7",J.addEventListener("click",()=>{const T=r(o,{keepBlank:!0});T.splice(P,1),_(o,T)}),R.appendChild(J),$.appendChild(R)}),C[o].value=l.length}function r(o,{keepBlank:l=!1}={}){const y=N[o],f=[...i.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map($=>{const A={};for(const P of y.columns){const R=$.querySelector(`[data-key="${P.key}"]`);A[P.key]=P.check?R?.checked===!0:(R?.value??"").trim()}return A});return l?f:f.filter($=>Object.entries($).some(([A,P])=>A!=="label"&&P!==""&&P!==!1))}i.addEventListener("click",o=>{const l=o.target.closest("[data-add]");if(l){const f=l.dataset.add;_(f,[...r(f,{keepBlank:!0}),{}]),i.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const y=o.target.closest(".ct-pick [data-group]");if(y){const f=y.dataset.group,$=s.chosen.peek();s.chosen.value=$.includes(f)?$.filter(A=>A!==f):[...$,f]}});const v=()=>{const o={name:s.name.peek().trim(),email:s.email.peek().trim(),phone:s.phone.peek().trim(),notes:s.notes.peek().trim(),favourite:s.favourite.peek()===!0,groups:[...s.chosen.peek()]};for(const l of Object.keys(ge))o[l]=String(s[l].peek()??"").trim();for(const l of Object.keys(N))o[l]=r(l);return o};let Y=!1,b=e?.id??null;const oe=o=>{i.inert=o,i.classList.toggle("is-saving",o)},je=async()=>{if(Y||s.cannotSave.peek())return;Y=!0,oe(!0);let o=!1;try{const l=v();for(const f of l.groups)g.peek().includes(f)||await ae(f);let y;if(b){if(y=await te(b,l,"Contact saved."),!y)return}else y=await w("/contacts/contacts","POST",l),b=y.id,u()&&(p.value=[...p.peek(),y]),E.toast("Contact added.",{type:"success"});he.saved({id:y.id??b,was:String(y.updatedAt??"")}),o=await n.close()!==!1}catch(l){E.toast(l?.message||"Failed to save the contact.",{type:"error"})}finally{Y=!1,o||oe(!1)}};n.element.appendChild(i),e&&$t(i.querySelector(".related-box"),"contact",e.id,{onOpen:()=>n.close(),name:e.name});const ht=ie(i);for(const o of Object.keys(N))_(o,Array.isArray(e?.[o])?e[o]:[]);i.querySelector(".ct-pick").innerHTML=jt;const vt=M.applyBindings(s,i,{methods:{save:je,cancel:()=>n.close(),del:async()=>{e&&await xe(e.id)&&(he?.discard(),n.close())},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await ae(o)||s.chosen.peek().includes(o)||(s.chosen.value=[...s.chosen.peek(),o])}}});s.billingSummary.peek()&&(i.querySelector(".ct-section").open=!0),i.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),je())}),I.scan(i),ye(n,()=>{z?.(),vt.dispose(),ht(),a(),u()&&setTimeout(()=>{u()&&Te()},0)});const qe=["name","email","phone","notes","favourite","chosen",...Object.keys(ge)],he=bt({so:n,panel:i,tool:"contacts",id:e?.id??t,lifetime:h,was:e?String(e.updatedAt??""):void 0,gone:!!t,noun:"contact",persist:o=>{for(const l of Dt)delete o.fields?.[l];return o},read:()=>({fields:Object.fromEntries(qe.map(o=>[o,s[o].peek()])),lists:Object.fromEntries(Object.keys(N).map(o=>[o,r(o,{keepBlank:!0})]))}),write:o=>{for(const l of qe)l in(o.fields||{})&&(s[l].value=o.fields[l]);for(const l of Object.keys(N))_(l,Array.isArray(o.lists?.[l])?o.lists[l]:[]);s.billingSummary.peek()&&(i.querySelector(".ct-section").open=!0)}});t&&he.asked.then(o=>{o==="thrown"&&n.close()}),n.open(),setTimeout(()=>i.querySelector("#ct-f-name")?.focus(),60)}function lt(){pe("settings",rt)}function rt(e){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),a=(s,C,z)=>`
125
125
  <div class="ct-setting-row">
126
- <label class="form-label" for="ct-set-${n}" style="margin:0">${d}${L?` <span data-help="${L}"></span>`:""}</label>
127
- <input id="ct-set-${n}" type="checkbox" class="form-switch" data-model="${n}.value">
128
- </div>`,o=ne(`
126
+ <label class="form-label" for="ct-set-${s}" style="margin:0">${C}${z?` <span data-help="${z}"></span>`:""}</label>
127
+ <input id="ct-set-${s}" type="checkbox" class="form-switch" data-model="${s}.value">
128
+ </div>`,n=ke(`
129
129
  <div class="ct-panel">
130
130
  <div>
131
131
  <label class="form-label" for="ct-set-sort">Sort by</label>
@@ -136,7 +136,7 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
136
136
  <option value="updated">Recently changed first</option>
137
137
  </select>
138
138
  </div>
139
- ${e("favouritesFirst","Starred first","Starred contacts sit at the top, whatever the sort.")}
139
+ ${a("favouritesFirst","Starred first","Starred contacts sit at the top, whatever the sort.")}
140
140
  <div>
141
141
  <label class="form-label" for="ct-set-density">Rows
142
142
  <span data-help="Compact fits more on the screen: smaller avatars and no job title line."></span></label>
@@ -148,30 +148,30 @@ import{protectToolEditor as ze,mountToolTrash as Ke,mountGroupBatch as _e}from".
148
148
  <div>
149
149
  <div class="form-label">Columns
150
150
  <span data-help="Name is always shown. On a narrow screen only the name shows; the rest is a click away."></span></div>
151
- ${e("showCompany","Company")}
152
- ${e("showEmail","Email")}
153
- ${e("showPhone","Phone")}
154
- ${e("showGroups","Groups")}
151
+ ${a("showCompany","Company")}
152
+ ${a("showEmail","Email")}
153
+ ${a("showPhone","Phone")}
154
+ ${a("showGroups","Groups")}
155
155
  </div>
156
- ${e("confirmDelete","Confirm before deleting","Off moves a single contact straight to Trash. Deleting several always asks.")}
156
+ ${a("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: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(`
163
+ </div>`),c=s=>M.computed({read:()=>L.value[s],write:C=>Qe(s,C)}),k=Object.fromEntries(Object.keys(Ue).map(s=>[s,c(s)]));t.element.appendChild(n);const i=ie(n),S=M.applyBindings(k,n,{methods:{done:()=>t.close(),reset:()=>{L.value={...Ue}}}});ye(t,()=>{S.dispose(),i(),e()}),t.open()}async function Oe(e){if(!ue(e))return;const t=String(await xt("Rename the group to:",{},e)??"").trim();if(!(!t||t===e||!u()))try{await w(`/contacts/groups/${encodeURIComponent(e)}`,"PUT",{newName:t}),u()&&await W(),E.toast("Group renamed.",{type:"success"})}catch(a){E.toast(a?.message||"Failed to rename the group.",{type:"error"})}}async function Be(e){if(ue(e)&&!(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it.",{title:"Delete group",confirmText:"Delete group"})||!u()))try{await w(`/contacts/groups/${encodeURIComponent(e)}`,"DELETE"),u()&&await W(),E.toast("Group deleted.",{type:"success"})}catch(t){E.toast(t?.message||"Failed to delete the group.",{type:"error"})}}function Ge(e,{panel:t=null}={}){const a=!ue(e),n=a?" (not yours to change)":"",c=p.peek().filter(k=>(k.groups||[]).includes(e)).map(k=>k.id);return[{label:"Show only this group",icon:"filter",action:()=>{x.value={kind:"group",value:e},t?.close()}},{label:`Select its ${B(c.length,"contact")}`,icon:"check-square",disabled:!c.length,action:()=>{x.value={kind:"group",value:e},m.value=new Set(c),t?.close()}},{type:"divider"},{label:`Rename\u2026${n}`,icon:"edit",disabled:a,action:()=>Oe(e)},{label:`Delete\u2026${n}`,icon:"trash",danger:!0,disabled:a,action:()=>Be(e)},...t?[]:[{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:me}]]}function me(){pe("groups",it)}function it(e){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),a=ke(`
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">
167
167
  <button type="submit" class="btn btn-sm btn-primary" data-bind-disabled="cannotAdd.value">Add</button>
168
168
  </form>
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
- <span data-help="Contacts themselves are never deleted from here."></span></p>
170
+ <span data-help="Contacts themselves are never deleted from here. Right-click a group (or Shift+F10 on it) for the same actions."></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="${pt}"></span></p>
172
+ <span data-help-title="Why is Rename greyed out?" data-help="${Ft}"></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
- <p class="text-muted" data-if="noGroups.value">No groups yet.</p>
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 v("/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 et("Rename the group to:",{},$)??"").trim();if(!b||b===$)return;try{await v(`/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 v(`/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=dt;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=lt,i.querySelector(".ct-chips").innerHTML=rt;const U=i.querySelector(".ct-list"),be=i.querySelector(".ct-bar"),De=i.querySelector(".ct-chips-row"),fe=i.querySelector(".ct-bulk");h.add(Z(i,{position:"bottom"}));const qe=M.applyBindings(xe,i,{methods:{create:()=>W(null),retry:Y,bulkDelete:Pe,clearSelection:()=>{p.value=new Set}}});h.add(()=>qe.dispose()),Ke({root:i,toolbar:i.querySelector(".ct-bar"),tool:"contacts",endpoint:"/contacts",itemPath:"/contacts",label:"Contacts",titleOf:t=>t.name,reload:Y,lifetime:h});const he=_e({root:i,ensureGroup:V,byId:q,patch:J,withGroup:$e,lifetime:h});h.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:()=>W(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)}])}});h.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}}]});h.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()])}]});h.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}W(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&&W(e.dataset.id)});const ve=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(),W(null)))};document.addEventListener("keydown",ve),h.add(()=>document.removeEventListener("keydown",ve)),I.scan(i),await Y()}};
176
+ <p class="text-muted" data-if="noGroups.value">No groups yet. Add one above.</p>
177
+ </div>`),n=M.observable(""),c={draft:n,list:M.observable([]),someLocked:M.observable(!1)};let k=!1;de();const i=M.effect(()=>{const r=Me.value.groups,v=ce.value,Y=g.value.map(b=>{const oe=!(v&&v.get(b)===!0);return{name:b,countText:B(r[b]??0,"contact"),locked:oe,lockTitle:oe?"Only its creator, or someone allowed to manage every group, can change this group":""}});c.list.value=Y,c.someLocked.value=v!==null&&Y.some(b=>b.locked)});c.hasGroups=M.computed(()=>c.list.value.length>0),c.noGroups=M.computed(()=>c.list.value.length===0),c.cannotAdd=M.computed(()=>!n.value.trim());let S=!1;async function s(){const r=n.peek().trim();if(!(!r||S)){S=!0;try{await ae(r)&&(await de(),!k&&n.peek().trim()===r&&(n.value=""),E.toast("Group added.",{type:"success"}))}finally{S=!1}}}a.addEventListener("click",r=>{const v=r.target.closest("[data-act]"),b=r.target.closest(".ct-group-row")?.dataset.group;if(b){if(!v){x.value={kind:"group",value:b},t.close();return}v.disabled||(v.dataset.act==="show"?(x.value={kind:"group",value:b},t.close()):v.dataset.act==="rename"?Oe(b):v.dataset.act==="delete"&&Be(b))}}),a.addEventListener("keydown",r=>{const v=r.target.closest?.(".ct-group-row");!v||r.target!==v||(r.key==="F10"&&r.shiftKey?(r.preventDefault(),Q(v)):r.key==="Enter"&&(r.preventDefault(),x.value={kind:"group",value:v.dataset.group},t.close()))});const C=E.contextMenu(a,{cursor:"glyph",match:".ct-group-row",inherit:!1,density:"compact",items:r=>r?.dataset.group?Ge(r.dataset.group,{panel:t}):[]});t.element.appendChild(a);const z=ie(a);a.querySelector(".ct-groups-rows").innerHTML=qt;const _=M.applyBindings(c,a,{methods:{add:()=>(s(),!1)}});ye(t,()=>{k=!0,i?.(),_.dispose(),z(),C?.destroy?.(),e()}),t.open()}Mt(G,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:lt}),d.querySelector(".ct-rows").innerHTML=It,d.querySelector(".ct-chips").innerHTML=Rt;const K=d.querySelector(".ct-list"),Pe=d.querySelector(".ct-bar"),fe=d.querySelector(".ct-chips-row"),De=d.querySelector(".ct-bulk");h.add(ie(d,{position:"bottom"}));const dt=M.applyBindings(Ve,d,{methods:{create:()=>H(null),retry:W,reviewOrphan:()=>{const e=Z.peek()[0];e&&H(null,{draftId:e.id})},bulkDelete:ot,clearSelection:()=>{m.value=new Set}}});h.add(()=>dt.dispose()),yt({root:d,toolbar:d.querySelector(".ct-bar"),tool:"contacts",endpoint:"/contacts",itemPath:"/contacts",label:"Contacts",titleOf:e=>e.name,reload:W,lifetime:h});const Ie=gt({root:d,ensureGroup:ae,byId:U,patch:te,withGroup:We,lifetime:h});h.add(M.effect(()=>{const e=L.value;K.classList.toggle("is-compact",e.density==="compact");const t={company:!e.showCompany,email:!e.showEmail,phone:!e.showPhone,groups:!e.showGroups};for(const[n,c]of Object.entries(t))K.classList.toggle(`hide-${n}`,c);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];t.company||a.push("minmax(0, 1fr)"),t.email||a.push("minmax(0, 1.2fr)"),t.phone||a.push("minmax(0, .9fr)"),t.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),K.style.setProperty("--ct-cols",a.join(" "))}));const ut=E.contextMenu(K,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.id,a=U(t);if(!a)return[];const n=Ke(a),c=Ye(a),k=a.groups||[],i=s=>s.filter(C=>C.visible!==!1),S=Ct(we,t,s=>s.label.replace("{name}",a.name||"them"));return i([{label:"Edit\u2026",icon:"edit",action:()=>H(t)},...S,{type:"divider"},{label:`Email ${n}`,icon:"mail",visible:!!n,action:()=>{location.href=`mailto:${encodeURIComponent(n).replace(/%40/g,"@")}`}},{label:`Call ${c}`,icon:"phone",visible:!!c,action:()=>{location.href=`tel:${c.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!n,action:()=>Ne(n,"Email")},{label:"Copy phone",icon:"copy",visible:!!c,action:()=>Ne(c,"Phone")},{type:"divider",visible:!!(n||c)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>te(t,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...g.peek().map(s=>({label:`${k.includes(s)?"\u2713 ":""}${s}`,action:()=>at(t,s,!k.includes(s))})),...g.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Ae([t])}]},{label:"Duplicate",icon:"copy",action:()=>tt(t)},{label:m.peek().has(t)?"Deselect":"Select",icon:"check-square",action:()=>Le(t)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>xe(t)}])}});h.add(()=>ut?.destroy?.());const pt=E.contextMenu(fe,{cursor:"glyph",match:".ct-chip",inherit:!1,density:"compact",items:e=>{const t=e?.dataset.chip||"";return t.startsWith("group:")?Ge(t.slice(6)):[{label:t==="fav"?"Show only starred":"Show everyone",icon:"filter",action:()=>{x.value=t==="fav"?{kind:"fav"}:{kind:"all"}}},{type:"divider"},{label:"Manage groups\u2026",icon:"folder",action:me}]}});h.add(()=>pt?.destroy?.()),fe.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-chip");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t))});const mt=E.contextMenu(Pe,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:me},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:st},{label:"Import from file\u2026",icon:"upload",action:()=>re.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!O.peek().length,action:()=>{m.value=new Set(O.peek().map(e=>e.id))}},{label:"Clear selection",icon:"x",disabled:!m.peek().size,action:()=>{m.value=new Set}}]});h.add(()=>mt?.destroy?.());const ft=E.contextMenu(De,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...g.peek().map(e=>({label:e,action:()=>nt(e)})),...g.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>Ae([...m.peek()])}]});h.add(()=>ft?.destroy?.()),Pe.addEventListener("click",e=>{const t=e.target.closest(".ct-more");t&&Q(t)}),De.addEventListener("click",e=>{const t=e.target.closest(".ct-bulk-group");t&&Q(t)}),fe.addEventListener("click",e=>{const t=e.target.closest(".ct-chip");if(!t)return;const a=t.dataset.chip;x.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),K.addEventListener("click",e=>{if(e.target.closest(".ct-select-all"))return;const t=e.target.closest(".ct-row");if(!t)return;const a=t.dataset.id;if(e.target.closest(".ct-kebab"))return Q(e.target.closest(".ct-kebab"));if(!e.target.closest(".ct-select")){if(e.target.closest(".ct-fav")){const n=U(a);n&&te(a,{favourite:n.favourite!==!0});return}H(a)}}),K.addEventListener("change",e=>{if(e.target.matches(".ct-select")&&Le(e.target.closest(".ct-row").dataset.id),e.target.matches(".ct-select-all")){const t=O.peek().map(a=>a.id);m.value=e.target.checked?new Set(t):new Set}}),K.addEventListener("keydown",e=>{const t=e.target.closest?.(".ct-row");!t||e.target!==t||(e.key==="Enter"?(e.preventDefault(),H(t.dataset.id)):e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Q(t.querySelector(".ct-kebab")||t)))});const Re=e=>{e.ctrlKey||e.metaKey||e.altKey||e.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(e.key==="/"?(e.preventDefault(),Se.value=!0):(e.key==="n"||e.key==="N")&&(e.preventDefault(),H(null)))};document.addEventListener("keydown",Re),h.add(()=>document.removeEventListener("keydown",Re)),I.scan(d),await W()}};