domma-cms 0.92.1 → 0.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -1,10 +1,10 @@
1
- import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}from"../lib/plugin-chrome.js";import{helpPopovers as Ne}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Ie,sidebarCount as z,viewLifetime as it}from"../lib/tool-kit.js";import{MAX_COLUMNS as dt,PAGE_SIZE as se,SHOW_STEP as W,arrangeEntries as ut,canClear as pt,cellText as He,countText as mt,draftable as ft,filterChoices as ht,filterText as yt,hasForm as vt,snapshot as Oe,typedValue as bt,valueText as Re}from"../lib/collection-entries-arrange.js";const Ue=/^#\/collections\/[^/?#]+\/entries\/?(\?.*)?$/,j=m=>`collection-entries.draft.${m}`,N=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function oe(m){const h=document.createElement("template");return h.innerHTML=m.trim(),h.content.firstElementChild}const gt='<span class="cex-chip">{{text}}<button type="button" data-idx="{{idx}}" title="Remove this filter" aria-label="Remove filter {{text}}">\xD7</button></span>';function Et(m){return`
1
+ import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}from"../lib/plugin-chrome.js";import{helpPopovers as Oe}from"../lib/help-popover.js";import{onceClosed as rt,openMenuAt as Re,sidebarCount as j,viewLifetime as dt}from"../lib/tool-kit.js";import{MAX_COLUMNS as ut,PAGE_SIZE as se,SHOW_STEP as X,arrangeEntries as pt,canClear as ft,cellText as Ue,countText as mt,draftable as ht,filterChoices as yt,filterText as vt,hasForm as bt,shownValue as he,snapshot as ze,typedValue as gt,valueText as je}from"../lib/collection-entries-arrange.js";import{fmtDate as Et,fmtDateTime as B}from"../lib/dates.js";const Be=/^#\/collections\/[^/?#]+\/entries\/?(\?.*)?$/,P=m=>`collection-entries.draft.${m}`,D=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function oe(m){const h=document.createElement("template");return h.innerHTML=m.trim(),h.content.firstElementChild}const kt='<span class="cex-chip">{{text}}<button type="button" data-idx="{{idx}}" title="Remove this filter" aria-label="Remove filter {{text}}">\xD7</button></span>';function xt(m){return`
2
2
  <tr class="cex-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
3
  <td class="cex-check"><input type="checkbox" class="cex-sel" aria-label="Select this entry" data-bind-checked="sel"></td>
4
4
  ${Array.from({length:m},(v,a)=>`<td data-col="${a}" title="{{t${a}}}">{{c${a}}}</td>`).join("")}
5
5
  <td class="cex-when" title="{{whenFull}}">{{when}}</td>
6
6
  <td class="cex-tools"><button type="button" class="cex-run" data-bind-hidden="!runs" title="Run one of this collection's actions">\u26A1 {{runs}}</button><button type="button" class="cex-kebab" aria-label="Actions for this entry" title="Actions">\u22EE</button></td>
7
- </tr>`}function kt(m,h){const v={};return m.forEach(a=>{const u={};a.fullWidth?u.span=h:a.span>1&&(u.span=a.span),a.placeholder&&(u.placeholder=a.placeholder),a.helper&&(u.hint=a.helper);const i=a.type==="checkbox"?"boolean":a.type==="date"?"string":a.type;v[a.name]={type:i,label:a.label,required:a.required,options:a.options,...Object.keys(u).length?{formConfig:u}:{}}}),v}function ce(m,h){const v={};for(const a of h){const u=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!u.length)continue;const i=u[0];i.type==="radio"?v[a.name]=u.find(p=>p.checked)?.value??"":i.type==="checkbox"&&u.length>1?v[a.name]=u.filter(p=>p.checked).map(p=>p.value):i.type==="checkbox"?v[a.name]=i.checked:i.tagName==="SELECT"&&i.multiple?v[a.name]=[...i.selectedOptions].map(p=>p.value):v[a.name]=bt(a,i.value)}return v}function ze(m,h,v){for(const a of h){if(!(a.name in v))continue;const u=v[a.name],i=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!i.length)continue;const p=i[0];p.type==="radio"?i.forEach(c=>{c.checked=c.value===String(u)}):p.type==="checkbox"&&i.length>1?i.forEach(c=>{c.checked=Array.isArray(u)&&u.includes(c.value)}):p.type==="checkbox"?p.checked=u===!0:p.tagName==="SELECT"&&p.multiple?[...p.options].forEach(c=>{c.selected=Array.isArray(u)&&u.includes(c.value)}):p.value=u??"",i.forEach(c=>c.dispatchEvent(new Event("input",{bubbles:!0})))}}const je=it(Ue);export const collectionEntriesView={templateUrl:"/admin/js/templates/collection-entries.html",async onMount(m){je.reset();const h=m.get(0).querySelector(".cex")||m.get(0),v=location.hash.match(/^#\/collections\/([^/?#]+)\/entries/),a=v?decodeURIComponent(v[1]):null,u=()=>Ue.test(location.hash)&&h.isConnected&&location.hash.includes(`/collections/${encodeURIComponent(a)}/entries`),i=e=>(je.add(e),e);if(!a){E.toast("No collection selected.",{type:"error"});return}const p=M.observable(null),c=M.observable([]),x=M.observable(0),X=M.observable(!0),B=M.observable(""),Fe=M.computed(()=>p.value!==null),le=M.observable(""),w=M.observable([]),Z=M.observable({key:"created",dir:"desc"}),y=M.observable([]),q=M.observable(W),P=M.observable([]),Q=M.observable({}),A=M.observable(!1);let ee=1;const re=()=>p.peek()?.fields||[],ie=M.computed(()=>(p.value?.fields||[]).slice(0,dt)),_=e=>c.peek().find(t=>t.id===e),he=e=>P.peek().filter(t=>{const n=Q.peek()[t.slug];return n==null||n.has(e)}),T=M.observable([]),ye=M.observable([]);i(M.effect(()=>{const e=ut(c.value,{filters:w.value,query:le.value,sort:Z.value});T.value=e})),i(M.effect(()=>{const e=ie.value,t=new Set(y.value);Q.value,P.value,ye.value=T.value.slice(0,q.value).map(n=>{const s={id:n.id,sel:t.has(n.id),rowCls:t.has(n.id)?"is-selected":""};e.forEach((r,d)=>{s[`c${d}`]=He(n.data?.[r.name]),s[`t${d}`]=Re(n.data?.[r.name])});const o=n.meta?.createdAt;s.when=o?D(o).format("D MMM YYYY"):"",s.whenFull=o?`Created ${D(o).format("D MMM YYYY HH:mm")}${n.meta?.updatedAt?` \xB7 changed ${D(n.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}`:"";const l=he(n.id).length;return s.runs=l?String(l):"",s})}));const Be=M.computed(()=>w.value.map((e,t)=>({key:`${t}:${e.field}:${e.op}:${e.value}`,idx:t,text:yt(e)}))),Pe=M.computed(()=>y.value.length),_e={schema:p,loaded:c,loading:X,loadError:B,ready:Fe,query:le,rows:ye,chips:Be,busy:A,selectedCount:Pe,countLine:M.computed(()=>mt(T.value.length,c.value.length,x.value)),selectedText:M.computed(()=>`${y.value.length} selected`),canClear:M.computed(()=>pt(a)),introText:M.computed(()=>{const e=p.value;return e?`${e.description?`${e.description} - `:""}${(e.fields||[]).length} field${(e.fields||[]).length===1?"":"s"}${e.storage?.adapter==="mongodb"?", on MongoDB":""}.`:""}),emptyText:M.computed(()=>c.value.length?"No entries match the search or filters.":"No entries yet - Add entry in the banner makes one, or Import brings many."),moreText:M.computed(()=>{const e=T.value.length-q.value;if(e>0)return`Show ${Math.min(W,e)} more (${e} not shown)`;const t=x.value-c.value.length;return t>0?`Load the next ${Math.min(se,t)} from the server`:""})};async function J({reset:e=!0}={}){X.value=!0;try{const t=e?Math.max(se,c.peek().length):se;e&&(ee=1);const n=await g.collections.listEntries(a,{limit:t,page:ee});if(!u())return;const s=n?.entries||(Array.isArray(n)?n:[]);c.value=e?s:[...c.peek(),...s.filter(l=>!_(l.id))],x.value=Number(n?.total)||c.peek().length,e&&(ee=Math.max(1,Math.ceil(c.peek().length/se)));const o=new Set(c.peek().map(l=>l.id));y.value=y.peek().filter(l=>o.has(l)),B.value="",await Ve()}catch(t){B.value=`Could not load the entries: ${t.message||t}`}finally{X.value=!1}}async function Je(){ee+=1,await J({reset:!1})}async function Ke(){try{if((await lt("/actions/_count",{method:"GET"}).catch(()=>null))?.available===!1)return;const t=await g.actions.forCollection(a);P.value=Array.isArray(t)?t:[]}catch{P.value=[]}}async function Ve(){const e=P.peek().filter(s=>s.access?.rowLevel);if(!e.length||!c.peek().length)return;const t=c.peek().map(s=>s.id),n={...Q.peek()};await Promise.all(e.map(async s=>{try{n[s.slug]=new Set((await g.actions.checkAccess(s.slug,t)).allowed)}catch{n[s.slug]=null}})),Q.value=n}const K=e=>{const t=re()[0];return(t?He(e?.data?.[t.name],40):"")||`Entry ${String(e?.id||"").slice(0,8)}`};async function ve(e){if(await E.confirm(`Delete \u201C${N(K(e))}\u201D? This cannot be undone.`))try{await g.collections.deleteEntry(a,e.id),c.value=c.peek().filter(t=>t.id!==e.id),x.value=Math.max(0,x.peek()-1),y.value=y.peek().filter(t=>t!==e.id),z("#/collections"),E.toast("Entry deleted.",{type:"success"})}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function Ge(){const e=[...y.peek()];if(!e.length||!await E.confirm(`Delete ${e.length} entr${e.length===1?"y":"ies"}? This cannot be undone.`))return;A.value=!0;let t=0;const n=[];for(const s of e)try{await g.collections.deleteEntry(a,s),t++}catch(o){n.push(o.message||String(o))}A.value=!1,y.value=[],z("#/collections"),E.toast(n.length?`Deleted ${t}; ${n.length} refused (${n[0]}).`:`Deleted ${t} entr${t===1?"y":"ies"}.`,{type:n.length?"warning":"success",duration:n.length?7e3:4e3}),u()&&await J()}async function We(){const e=p.peek();if(await E.confirm(`Delete ALL ${x.peek()} entries in \u201C${N(e?.title||a)}\u201D? This cannot be undone.`)){A.value=!0;try{await g.collections.clearEntries(a),c.value=[],x.value=0,y.value=[],z("#/collections"),E.toast("All entries deleted.",{type:"success"})}catch(t){E.toast(`Not cleared: ${t.message||t}`,{type:"error"})}finally{A.value=!1}}}async function Xe(e){try{const t=await g.collections.createEntry(a,{...e.data||{}});c.value=[t,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Duplicated - the copy is at the top.",{type:"success"})}catch(t){E.toast(`Not duplicated: ${t.message||t}`,{type:"error"})}}async function Ze(e,t){const n=e.trigger?.confirmMessage;if(!(n&&!await E.confirm(N(n)))){A.value=!0;try{const s=await g.actions.execute(e.slug,t.id);if(s?.success){const o=s.stepsCompleted??0;E.toast(`${e.trigger?.label||e.title||"Action"} done (${o} step${o===1?"":"s"}).`,{type:"success"}),u()&&await J()}else E.toast(s?.results?.find(o=>!o.success)?.error||"The action failed.",{type:"error"})}catch(s){E.toast(s.message||"The action failed.",{type:"error"})}finally{A.value=!1}}}async function be(e){try{await navigator.clipboard.writeText(e),E.toast("Copied.",{type:"success",duration:1500})}catch{E.toast("Copy failed - your browser refused the clipboard.",{type:"error"})}}function de(e){const t=y.peek();y.value=t.includes(e)?t.filter(n=>n!==e):[...t,e]}function ge(e,t,n){w.value=[...w.peek(),{field:e.name,label:e.label||e.name,op:t,value:n}],q.value=W}const ue=new Set;let Y=null,Ee=!1;function ke(e,t,n){const s=e.close.bind(e);let o=!1,l=!1;e.close=async r=>{if(!o&&r!==!0&&!Ee&&t()){if(l)return;l=!0;const d=await E.confirm(n);if(l=!1,!d)return}return o=!0,s()}}function te(e,t=()=>{}){ue.add(e),rt(e,()=>{ue.delete(e),t()})}function H(e,t=null){const n=p.peek(),s=re(),o=!!e,l=E.slideover({title:o?`Edit ${N(K(e))}`:"New entry",size:"md",position:"right"}),r=oe(`
7
+ </tr>`}function wt(m,h){const v={};return m.forEach(a=>{const u={};a.fullWidth?u.span=h:a.span>1&&(u.span=a.span),a.placeholder&&(u.placeholder=a.placeholder),a.helper&&(u.hint=a.helper);const r=a.type==="checkbox"?"boolean":a.type==="date"?"string":a.type;v[a.name]={type:r,label:a.label,required:a.required,options:a.options,...Object.keys(u).length?{formConfig:u}:{}}}),v}function ce(m,h){const v={};for(const a of h){const u=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!u.length)continue;const r=u[0];r.type==="radio"?v[a.name]=u.find(p=>p.checked)?.value??"":r.type==="checkbox"&&u.length>1?v[a.name]=u.filter(p=>p.checked).map(p=>p.value):r.type==="checkbox"?v[a.name]=r.checked:r.tagName==="SELECT"&&r.multiple?v[a.name]=[...r.selectedOptions].map(p=>p.value):v[a.name]=gt(a,r.value)}return v}function Fe(m,h,v){for(const a of h){if(!(a.name in v))continue;const u=v[a.name],r=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!r.length)continue;const p=r[0];p.type==="radio"?r.forEach(c=>{c.checked=c.value===String(u)}):p.type==="checkbox"&&r.length>1?r.forEach(c=>{c.checked=Array.isArray(u)&&u.includes(c.value)}):p.type==="checkbox"?p.checked=u===!0:p.tagName==="SELECT"&&p.multiple?[...p.options].forEach(c=>{c.selected=Array.isArray(u)&&u.includes(c.value)}):p.value=u??"",r.forEach(c=>c.dispatchEvent(new Event("input",{bubbles:!0})))}}const Pe=dt(Be);export const collectionEntriesView={templateUrl:"/admin/js/templates/collection-entries.html",async onMount(m){Pe.reset();const h=m.get(0).querySelector(".cex")||m.get(0),v=location.hash.match(/^#\/collections\/([^/?#]+)\/entries/),a=v?decodeURIComponent(v[1]):null,u=()=>Be.test(location.hash)&&h.isConnected&&location.hash.includes(`/collections/${encodeURIComponent(a)}/entries`),r=e=>(Pe.add(e),e);if(!a){E.toast("No collection selected.",{type:"error"});return}const p=M.observable(null),c=M.observable([]),x=M.observable(0),Y=M.observable(!0),_=M.observable(""),_e=M.computed(()=>p.value!==null),le=M.observable(""),w=M.observable([]),Z=M.observable({key:"created",dir:"desc"}),y=M.observable([]),q=M.observable(X),J=M.observable([]),Q=M.observable({}),A=M.observable(!1);let ee=1;const ie=()=>p.peek()?.fields||[],re=M.computed(()=>(p.value?.fields||[]).slice(0,ut)),V=e=>c.peek().find(t=>t.id===e),ye=e=>J.peek().filter(t=>{const n=Q.peek()[t.slug];return n==null||n.has(e)}),T=M.observable([]),ve=M.observable([]);r(M.effect(()=>{const e=pt(c.value,{filters:w.value,query:le.value,sort:Z.value});T.value=e})),r(M.effect(()=>{const e=re.value,t=new Set(y.value);Q.value,J.value,ve.value=T.value.slice(0,q.value).map(n=>{const s={id:n.id,sel:t.has(n.id),rowCls:t.has(n.id)?"is-selected":""};e.forEach((i,d)=>{const f=he(i,n.data?.[i.name]);s[`c${d}`]=Ue(f),s[`t${d}`]=je(f)});const o=n.meta?.createdAt;s.when=o?Et(o):"",s.whenFull=o?`Created ${B(o)}${n.meta?.updatedAt?` \xB7 changed ${B(n.meta.updatedAt)}`:""}`:"";const l=ye(n.id).length;return s.runs=l?String(l):"",s})}));const Je=M.computed(()=>w.value.map((e,t)=>({key:`${t}:${e.field}:${e.op}:${e.value}`,idx:t,text:vt(e)}))),Ve=M.computed(()=>y.value.length),He={schema:p,loaded:c,loading:Y,loadError:_,ready:_e,query:le,rows:ve,chips:Je,busy:A,selectedCount:Ve,countLine:M.computed(()=>mt(T.value.length,c.value.length,x.value)),selectedText:M.computed(()=>`${y.value.length} selected`),canClear:M.computed(()=>ft(a)),introText:M.computed(()=>{const e=p.value;return e?`${e.description?`${e.description} - `:""}${(e.fields||[]).length} field${(e.fields||[]).length===1?"":"s"}${e.storage?.adapter==="mongodb"?", on MongoDB":""}.`:""}),emptyText:M.computed(()=>c.value.length?"No entries match the search or filters.":"No entries yet - Add entry in the banner makes one, or Import brings many."),moreText:M.computed(()=>{const e=T.value.length-q.value;if(e>0)return`Show ${Math.min(X,e)} more (${e} not shown)`;const t=x.value-c.value.length;return t>0?`Load the next ${Math.min(se,t)} from the server`:""})};async function H({reset:e=!0}={}){Y.value=!0;try{const t=e?Math.max(se,c.peek().length):se;e&&(ee=1);const n=await g.collections.listEntries(a,{limit:t,page:ee});if(!u())return;const s=n?.entries||(Array.isArray(n)?n:[]);c.value=e?s:[...c.peek(),...s.filter(l=>!V(l.id))],x.value=Number(n?.total)||c.peek().length,e&&(ee=Math.max(1,Math.ceil(c.peek().length/se)));const o=new Set(c.peek().map(l=>l.id));y.value=y.peek().filter(l=>o.has(l)),_.value="",await We()}catch(t){_.value=`Could not load the entries: ${t.message||t}`}finally{Y.value=!1}}async function Ke(){ee+=1,await H({reset:!1})}async function Ge(){try{if((await it("/actions/_count",{method:"GET"}).catch(()=>null))?.available===!1)return;const t=await g.actions.forCollection(a);J.value=Array.isArray(t)?t:[]}catch{J.value=[]}}async function We(){const e=J.peek().filter(s=>s.access?.rowLevel);if(!e.length||!c.peek().length)return;const t=c.peek().map(s=>s.id),n={...Q.peek()};await Promise.all(e.map(async s=>{try{n[s.slug]=new Set((await g.actions.checkAccess(s.slug,t)).allowed)}catch{n[s.slug]=null}})),Q.value=n}const K=e=>{const t=ie()[0];return(t?Ue(he(t,e?.data?.[t.name]),40):"")||`Entry ${String(e?.id||"").slice(0,8)}`};async function be(e){if(await E.confirm(`Delete \u201C${D(K(e))}\u201D? This cannot be undone.`))try{await g.collections.deleteEntry(a,e.id),c.value=c.peek().filter(t=>t.id!==e.id),x.value=Math.max(0,x.peek()-1),y.value=y.peek().filter(t=>t!==e.id),j("#/collections"),E.toast("Entry deleted.",{type:"success"})}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function Xe(){const e=[...y.peek()];if(!e.length||!await E.confirm(`Delete ${e.length} entr${e.length===1?"y":"ies"}? This cannot be undone.`))return;A.value=!0;let t=0;const n=[];for(const s of e)try{await g.collections.deleteEntry(a,s),t++}catch(o){n.push(o.message||String(o))}A.value=!1,y.value=[],j("#/collections"),E.toast(n.length?`Deleted ${t}; ${n.length} refused (${n[0]}).`:`Deleted ${t} entr${t===1?"y":"ies"}.`,{type:n.length?"warning":"success",duration:n.length?7e3:4e3}),u()&&await H()}async function Ye(){const e=p.peek();if(await E.confirm(`Delete ALL ${x.peek()} entries in \u201C${D(e?.title||a)}\u201D? This cannot be undone.`)){A.value=!0;try{await g.collections.clearEntries(a),c.value=[],x.value=0,y.value=[],j("#/collections"),E.toast("All entries deleted.",{type:"success"})}catch(t){E.toast(`Not cleared: ${t.message||t}`,{type:"error"})}finally{A.value=!1}}}async function Ze(e){try{const t=await g.collections.createEntry(a,{...e.data||{}});c.value=[t,...c.peek()],x.value=x.peek()+1,j("#/collections"),E.toast("Duplicated - the copy is at the top.",{type:"success"})}catch(t){E.toast(`Not duplicated: ${t.message||t}`,{type:"error"})}}async function Qe(e,t){const n=e.trigger?.confirmMessage;if(!(n&&!await E.confirm(D(n)))){A.value=!0;try{const s=await g.actions.execute(e.slug,t.id);if(s?.success){const o=s.stepsCompleted??0;E.toast(`${e.trigger?.label||e.title||"Action"} done (${o} step${o===1?"":"s"}).`,{type:"success"}),u()&&await H()}else E.toast(s?.results?.find(o=>!o.success)?.error||"The action failed.",{type:"error"})}catch(s){E.toast(s.message||"The action failed.",{type:"error"})}finally{A.value=!1}}}async function ge(e){try{await navigator.clipboard.writeText(e),E.toast("Copied.",{type:"success",duration:1500})}catch{E.toast("Copy failed - your browser refused the clipboard.",{type:"error"})}}function de(e){const t=y.peek();y.value=t.includes(e)?t.filter(n=>n!==e):[...t,e]}function Ee(e,t,n){w.value=[...w.peek(),{field:e.name,label:e.label||e.name,op:t,value:n}],q.value=X}const ue=new Set;let N=null,ke=!1;function xe(e,t,n){const s=e.close.bind(e);let o=!1,l=!1;e.close=async i=>{if(!o&&i!==!0&&!ke&&t()){if(l)return;l=!0;const d=await E.confirm(n);if(l=!1,!d)return}return o=!0,s()}}function te(e,t=()=>{}){ue.add(e),rt(e,()=>{ue.delete(e),t()})}function O(e,t=null){const n=p.peek(),s=ie(),o=!!e,l=E.slideover({title:o?`Edit ${D(K(e))}`:"New entry",size:"md",position:"right"}),i=oe(`
8
8
  <div class="cex-form">
9
9
  <div class="cex-fields"></div>
10
10
  <small class="cex-hint cex-when-line"></small>
@@ -15,7 +15,7 @@ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}fro
15
15
  <button type="button" class="btn btn-sm btn-ghost" data-act="cancel">Cancel</button>
16
16
  <button type="button" class="btn btn-sm btn-primary" data-act="save"><span data-icon="check" data-icon-size="13"></span> <span class="cex-save-text"></span></button>
17
17
  </div>
18
- </div>`);r.querySelector(".cex-save-text").textContent=o?"Save":"Add entry";const d=r.querySelector(".cex-fields");F.create(kt(s,n?.columns||2),e?.data||{},{layout:n?.layout||"stacked",columns:n?.columns||2,showSubmitButton:!1}).renderTo(d),s.forEach(k=>{if(k.type!=="date"||!k.name)return;const O=d.querySelector(`[name="${CSS.escape(k.name)}"]`);O&&O.type!=="date"&&(O.type="date")}),e?.data&&ze(d,s,e.data),o&&e.meta?.createdAt&&(r.querySelector(".cex-when-line").textContent=`Created ${D(e.meta.createdAt).format("D MMM YYYY HH:mm")}${e.meta.updatedAt?`, last changed ${D(e.meta.updatedAt).format("D MMM YYYY HH:mm")}`:""}${e.meta.source?` \xB7 via ${e.meta.source}`:""}.`),l.element.appendChild(r);const f=Oe(ce(d,s));t&&ze(d,s,t);const b=()=>Oe(ce(d,s))!==f,ct=r.querySelector(".cex-dirty"),me=()=>{ct.hidden=!b()};d.addEventListener("input",me),d.addEventListener("change",me),me();let fe=!1;async function De(){if(fe)return;document.activeElement?.dispatchEvent?.(new Event("change",{bubbles:!0}));const k=ce(d,s);fe=!0;const O=r.querySelector('[data-act="save"]');O.disabled=!0;try{if(o){const G=await g.collections.updateEntry(a,e.id,k);c.value=c.peek().map(Ye=>Ye.id===e.id?G:Ye),E.toast("Entry saved.",{type:"success"})}else{const G=await g.collections.createEntry(a,k);c.value=[G,...c.peek()],x.value=x.peek()+1,z("#/collections"),E.toast("Entry added.",{type:"success"})}try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}l.close(!0)}catch(G){E.toast(G.message||"Not saved.",{type:"error"})}finally{fe=!1,O.disabled=!1}}r.querySelector('[data-act="save"]').addEventListener("click",De),r.querySelector('[data-act="cancel"]').addEventListener("click",()=>l.close()),r.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&(k.key==="Enter"||k.key==="s")&&(k.preventDefault(),De())});const Te={id:e?.id??null,dirty:b,values:()=>ft(ce(d,s),s)};Y=Te,te(l,()=>{Y===Te&&(Y=null)}),ke(l,b,"Discard the changes you have not saved?"),I.scan(r),l.open(),d.querySelector("input, select, textarea")?.focus()}function Qe(e){const t=E.slideover({title:N(K(e)),size:"md",position:"right"}),n=oe(`
18
+ </div>`);i.querySelector(".cex-save-text").textContent=o?"Save":"Add entry";const d=i.querySelector(".cex-fields");F.create(wt(s,n?.columns||2),e?.data||{},{layout:n?.layout||"stacked",columns:n?.columns||2,showSubmitButton:!1}).renderTo(d),s.forEach(k=>{if(k.type!=="date"||!k.name)return;const U=d.querySelector(`[name="${CSS.escape(k.name)}"]`);U&&U.type!=="date"&&(U.type="date")}),e?.data&&Fe(d,s,e.data),o&&e.meta?.createdAt&&(i.querySelector(".cex-when-line").textContent=`Created ${B(e.meta.createdAt)}${e.meta.updatedAt?`, last changed ${B(e.meta.updatedAt)}`:""}${e.meta.source?` \xB7 via ${e.meta.source}`:""}.`),l.element.appendChild(i);const f=ze(ce(d,s));t&&Fe(d,s,t);const b=()=>ze(ce(d,s))!==f,lt=i.querySelector(".cex-dirty"),fe=()=>{lt.hidden=!b()};d.addEventListener("input",fe),d.addEventListener("change",fe),fe();let me=!1;async function Ne(){if(me)return;document.activeElement?.dispatchEvent?.(new Event("change",{bubbles:!0}));const k=ce(d,s);me=!0;const U=i.querySelector('[data-act="save"]');U.disabled=!0;try{if(o){const W=await g.collections.updateEntry(a,e.id,k);c.value=c.peek().map(De=>De.id===e.id?W:De),E.toast("Entry saved.",{type:"success"})}else{const W=await g.collections.createEntry(a,k);c.value=[W,...c.peek()],x.value=x.peek()+1,j("#/collections"),E.toast("Entry added.",{type:"success"})}try{S.remove?S.remove(P(a)):S.set(P(a),null)}catch{}l.close(!0)}catch(W){E.toast(W.message||"Not saved.",{type:"error"})}finally{me=!1,U.disabled=!1}}i.querySelector('[data-act="save"]').addEventListener("click",Ne),i.querySelector('[data-act="cancel"]').addEventListener("click",()=>l.close()),i.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&(k.key==="Enter"||k.key==="s")&&(k.preventDefault(),Ne())});const Ie={id:e?.id??null,dirty:b,values:()=>ht(ce(d,s),s)};N=Ie,te(l,()=>{N===Ie&&(N=null)}),xe(l,b,"Discard the changes you have not saved?"),I.scan(i),l.open(),d.querySelector("input, select, textarea")?.focus()}function et(e){const t=E.slideover({title:D(K(e)),size:"md",position:"right"}),n=oe(`
19
19
  <div class="cex-form">
20
20
  <dl class="cex-detail"></dl>
21
21
  <div class="cex-meta"></div>
@@ -24,10 +24,10 @@ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}fro
24
24
  <span class="cex-spacer"></span>
25
25
  <button type="button" class="btn btn-sm btn-primary" data-act="edit"><span data-icon="edit" data-icon-size="13"></span> Edit</button>
26
26
  </div>
27
- </div>`),s=n.querySelector(".cex-detail");for(const r of re()){const d=Re(e.data?.[r.name]),f=document.createElement("dt");f.textContent=r.label||r.name;const b=document.createElement("dd");b.textContent=d===""?"-":d,s.append(f,b)}const o=n.querySelector(".cex-meta"),l=r=>{const d=document.createElement("span");d.textContent=r,o.appendChild(d)};l(`Id: ${e.id}`),e.meta?.createdAt&&l(`Created: ${D(e.meta.createdAt).format("D MMM YYYY HH:mm")}`),e.meta?.updatedAt&&l(`Changed: ${D(e.meta.updatedAt).format("D MMM YYYY HH:mm")}`),e.meta?.source&&l(`Source: ${e.meta.source}`),n.querySelector('[data-act="copy"]').addEventListener("click",()=>be(JSON.stringify(e,null,2))),n.querySelector('[data-act="edit"]').addEventListener("click",()=>{t.close(!0),H(e)}),t.element.appendChild(n),te(t),I.scan(n),t.open()}function et(){const e=E.slideover({title:"Import entries",size:"md",position:"right"}),t=oe(`
27
+ </div>`),s=n.querySelector(".cex-detail");for(const i of ie()){const d=je(he(i,e.data?.[i.name])),f=document.createElement("dt");f.textContent=i.label||i.name;const b=document.createElement("dd");b.textContent=d===""?"-":d,s.append(f,b)}const o=n.querySelector(".cex-meta"),l=i=>{const d=document.createElement("span");d.textContent=i,o.appendChild(d)};l(`Id: ${e.id}`),e.meta?.createdAt&&l(`Created: ${B(e.meta.createdAt)}`),e.meta?.updatedAt&&l(`Changed: ${B(e.meta.updatedAt)}`),e.meta?.source&&l(`Source: ${e.meta.source}`),n.querySelector('[data-act="copy"]').addEventListener("click",()=>ge(JSON.stringify(e,null,2))),n.querySelector('[data-act="edit"]').addEventListener("click",()=>{t.close(!0),O(e)}),t.element.appendChild(n),te(t),I.scan(n),t.open()}function tt(){const e=E.slideover({title:"Import entries",size:"md",position:"right"}),t=oe(`
28
28
  <div class="cex-form">
29
29
  <p class="cex-hint">A JSON array of entries, each with a "data" object of field values - what Export gives you.
30
- <span data-help="Entries keep the ids and dates they had only when you export and import with Data Transfer; here each one is added as new. Fields the collection does not have are ignored."></span></p>
30
+ <span data-help="Each entry is added as new, with a new id and today's date (the Data Transfer plugin from the Marketplace keeps ids and dates). Entries are checked like any other save - one that fails, or points a reference at an entry that does not exist, is skipped and reported. Values are stored as given, including fields the collection does not have."></span></p>
31
31
  <div class="cex-actions" style="border:0;padding:0">
32
32
  <button type="button" class="btn btn-sm btn-ghost" data-act="file"><span data-icon="upload" data-icon-size="13"></span> Choose a file\u2026</button>
33
33
  <input type="file" accept=".json,application/json" hidden>
@@ -40,11 +40,11 @@ import{api as g,apiRequest as lt}from"../api.js";import{addBannerAction as U}fro
40
40
  <button type="button" class="btn btn-sm btn-ghost" data-act="cancel">Cancel</button>
41
41
  <button type="button" class="btn btn-sm btn-primary" data-act="import"><span data-icon="upload" data-icon-size="13"></span> Import</button>
42
42
  </div>
43
- </div>`),n=t.querySelector("textarea"),s=t.querySelector(".cex-err"),o=t.querySelector(".cex-ok"),l=t.querySelector('input[type="file"]');t.querySelector('[data-act="file"]').addEventListener("click",()=>l.click()),l.addEventListener("change",async()=>{const d=l.files?.[0];l.value="",d&&(n.value=await d.text())}),t.querySelector('[data-act="cancel"]').addEventListener("click",()=>e.close());let r=!1;t.querySelector('[data-act="import"]').addEventListener("click",async()=>{s.hidden=!0,o.hidden=!0;let d;try{if(d=JSON.parse(n.value),!Array.isArray(d))throw new Error("it must be a JSON array")}catch(b){s.textContent=`Not valid: ${b.message}`,s.hidden=!1;return}const f=t.querySelector('[data-act="import"]');f.disabled=!0;try{const b=await g.collections.import(a,d);o.textContent=`Imported ${b.imported}; skipped ${b.skipped}.`,o.hidden=!1,r=!0,z("#/collections"),u()&&await J(),setTimeout(()=>e.close(!0),1200)}catch(b){s.textContent=b.message||"Import failed.",s.hidden=!1}finally{f.disabled=!1}}),e.element.appendChild(t),te(e),ke(e,()=>!r&&n.value.trim()!=="","Discard the entries you pasted?"),Ne(t),I.scan(t),e.open(),n.focus()}function tt(){const e=E.slideover({title:"Export entries",size:"sm",position:"right"}),t=oe(`
43
+ </div>`),n=t.querySelector("textarea"),s=t.querySelector(".cex-err"),o=t.querySelector(".cex-ok"),l=t.querySelector('input[type="file"]');t.querySelector('[data-act="file"]').addEventListener("click",()=>l.click()),l.addEventListener("change",async()=>{const d=l.files?.[0];l.value="",d&&(n.value=await d.text())}),t.querySelector('[data-act="cancel"]').addEventListener("click",()=>e.close());let i=!1;t.querySelector('[data-act="import"]').addEventListener("click",async()=>{s.hidden=!0,o.hidden=!0;let d;try{if(d=JSON.parse(n.value),!Array.isArray(d))throw new Error("it must be a JSON array")}catch(b){s.textContent=`Not valid: ${b.message}`,s.hidden=!1;return}const f=t.querySelector('[data-act="import"]');f.disabled=!0;try{const b=await g.collections.import(a,d);o.textContent=`Imported ${b.imported}; skipped ${b.skipped}.`,o.hidden=!1,i=!0,j("#/collections"),u()&&await H(),setTimeout(()=>e.close(!0),1200)}catch(b){s.textContent=b.message||"Import failed.",s.hidden=!1}finally{f.disabled=!1}}),e.element.appendChild(t),te(e),xe(e,()=>!i&&n.value.trim()!=="","Discard the entries you pasted?"),Oe(t),I.scan(t),e.open(),n.focus()}function nt(){const e=E.slideover({title:"Export entries",size:"sm",position:"right"}),t=oe(`
44
44
  <div class="cex-form">
45
45
  <p class="cex-hint">Every entry in the collection - not only the ones shown.</p>
46
46
  <div class="cex-export">
47
47
  <button type="button" class="btn btn-ghost" data-fmt="json"><span data-icon="download" data-icon-size="14"></span> Export as JSON</button>
48
48
  <button type="button" class="btn btn-ghost" data-fmt="csv"><span data-icon="download" data-icon-size="14"></span> Export as CSV</button>
49
49
  </div>
50
- </div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const l=await o.blob(),r=document.createElement("a");r.href=URL.createObjectURL(l),r.download=`${a}-entries.${s}`,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(r.href),1e3),e.close(!0)}catch(o){E.toast(`Export failed (${o.message}).`,{type:"error"})}})),e.element.appendChild(t),te(e),I.scan(t),e.open()}function nt(e,t){const n=_(e.dataset.id);if(!n)return[];const s=t?.event?.target?.closest?.("td[data-col]"),o=s?ie.peek()[Number(s.dataset.col)]:null,l=he(n.id),r=y.peek().includes(n.id),d=o?ht(o,n.data?.[o.name]):[];return[{header:K(n)},{label:"Edit\u2026",icon:"edit",action:()=>H(n)},{label:"See all of it\u2026",icon:"eye",action:()=>Qe(n)},{label:"Duplicate",icon:"copy",action:()=>Xe(n)},{label:"Copy id",icon:"hash",action:()=>be(n.id)},{label:r?"Deselect":"Select",icon:"check",action:()=>de(n.id)},...l.length?[{type:"divider"},...l.map(f=>({label:`${f.trigger?.label||f.title||f.slug}\u2026`,icon:f.trigger?.icon||"zap",disabled:A.peek(),action:()=>Ze(f,n)}))]:[],...o?[{type:"divider"},{label:`Filter by ${o.label||o.name}`,icon:"filter",submenu:[...d.map(f=>({label:f.label,action:()=>ge(o,f.op,f.value)})),{label:`${o.label||o.name} contains\u2026`,action:async()=>{const f=await E.prompt(`Show entries whose ${N(o.label||o.name)} contains`,{inputPlaceholder:"text"});f&&String(f).trim()&&ge(o,"contains",String(f).trim())}}]}]:[],...w.peek().length?[{label:`Clear filters (${w.peek().length})`,icon:"x",action:()=>{w.value=[]}}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ve(n)}]}const L=h.querySelector(".cex-rows"),V=h.querySelector(".cex-head"),pe=h.querySelector(".cex-chips-list");pe.innerHTML=gt;const at=E.loader(h,{type:"dots"});try{p.value=await g.collections.get(a)}catch(e){B.value=`Could not load the collection: ${e.message||e}`}finally{at.destroy()}if(!u())return;if(!p.peek()&&!B.peek()){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const xe=ie.peek();L.innerHTML=Et(xe.length),V.replaceChildren();const ne=(e,t)=>{const n=document.createElement("th");return e&&(n.className=e),t&&n.append(t),V.appendChild(n),n},$=document.createElement("input");$.type="checkbox",$.title="Select every entry shown",$.setAttribute("aria-label","Select every entry shown"),ne("cex-check",$);const we=(e,t)=>{const n=document.createElement("button");return n.type="button",n.dataset.sort=e,n.append(document.createTextNode(t),Object.assign(document.createElement("span"),{className:"cex-sort"})),n};xe.forEach(e=>ne("",we(e.name,e.label||e.name))),ne("cex-when",we("created","Created")),ne("cex-tools"),i(M.effect(()=>{const e=Z.value;V.querySelectorAll("[data-sort]").forEach(t=>{t.querySelector(".cex-sort").textContent=t.dataset.sort===e.key?e.dir==="asc"?"\u25B2":"\u25BC":"",t.parentElement.setAttribute("aria-sort",t.dataset.sort===e.key?e.dir==="asc"?"ascending":"descending":"none")})})),i(M.effect(()=>{const e=T.value.map(n=>n.id),t=new Set(y.value);$.checked=e.length>0&&e.every(n=>t.has(n)),$.indeterminate=!$.checked&&e.some(n=>t.has(n))}));const Se=e=>{const t=e.target.closest("[data-sort]");if(!t)return;const n=Z.peek();Z.value={key:t.dataset.sort,dir:n.key===t.dataset.sort&&n.dir==="asc"?"desc":"asc"}};V.addEventListener("click",Se),$.addEventListener("change",()=>{const e=T.peek().map(t=>t.id);y.value=$.checked?[...new Set([...y.peek(),...e])]:y.peek().filter(t=>!e.includes(t))}),i(()=>V.removeEventListener("click",Se));const st=M.applyBindings(_e,h,{methods:{clearSelection:()=>{y.value=[]},deleteSelected:()=>Ge(),clearAll:()=>We(),clearFilters:()=>{w.value=[]},showMore:()=>{T.peek().length>q.peek()?q.value=q.peek()+W:Je()}}});i(()=>st.dispose()),i(Ne(h,{position:"bottom"})),i(M.effect(()=>{le.value,w.value,q.value=W}));const Me=e=>{const t=e.target.closest("button[data-idx]");if(!t)return;const n=Number(t.dataset.idx);w.value=w.peek().filter((s,o)=>o!==n)};pe.addEventListener("click",Me),i(()=>pe.removeEventListener("click",Me));const ot=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:(e,t)=>nt(e,t)});i(()=>ot?.destroy?.());const Ce=e=>{const t=e.target.closest(".cex-row");if(!t)return;if(e.target.closest(".cex-kebab, .cex-run")){Ie(e.target.closest(".cex-kebab, .cex-run"));return}if(e.target.closest(".cex-check"))return;const n=_(t.dataset.id);n&&H(n)},$e=e=>{if(!e.target.matches(".cex-sel"))return;const t=e.target.closest(".cex-row");t&&de(t.dataset.id)},Ae=e=>{const t=e.target.closest(".cex-row");if(!t||e.target!==t)return;const n=_(t.dataset.id);n&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ie(t)):e.key==="Enter"?(e.preventDefault(),H(n)):e.key===" "?(e.preventDefault(),de(n.id)):e.key==="Delete"&&(e.preventDefault(),ve(n)))};L.addEventListener("click",Ce),L.addEventListener("change",$e),L.addEventListener("keydown",Ae),i(()=>{L.removeEventListener("click",Ce),L.removeEventListener("change",$e),L.removeEventListener("keydown",Ae)});const Le=p.peek(),ae=m.get(0).querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(ae&&Le?.title){for(;ae.nextSibling;)ae.nextSibling.remove();ae.after(document.createTextNode(Le.title))}U(m,{id:"entries-back",icon:"arrow-left",title:"All collections",onClick:()=>{location.hash="#/collections"}}),U(m,{id:"entries-fields",icon:"edit-3",title:"Edit its fields and settings",onClick:()=>{location.hash=`#/collections/edit/${encodeURIComponent(a)}`}}),vt(a)&&U(m,{id:"entries-form",icon:"layout",title:"Open its form",onClick:()=>{location.hash=`#/forms/edit/${encodeURIComponent(a)}`}}),U(m,{id:"entries-export",icon:"download",title:"Export",onClick:()=>tt()}),U(m,{id:"entries-import",icon:"upload",title:"Import",onClick:()=>et()}),U(m,{id:"entries-add",icon:"plus",title:"Add entry",onClick:()=>H(null)});const qe=e=>{Y?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",qe),i(()=>window.removeEventListener("beforeunload",qe)),i(()=>{if(Y?.dirty())try{S.set(j(a),{id:Y.id,values:Y.values(),at:new Date().toISOString()})}catch{}Ee=!0,[...ue].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(h),!p.peek()){X.value=!1;return}if(await Ke(),!u()||(await J(),!u()))return;let C=null;try{C=S.get(j(a))}catch{}if(C?.values){try{S.remove?S.remove(j(a)):S.set(j(a),null)}catch{}let e=C.id?_(C.id):null;if(C.id&&!e&&(e=await g.collections.getEntry(a,C.id).catch(()=>null)),!u()||C.id&&!e)return;const t=e?`your changes to \u201C${N(K(e))}\u201D`:"a new entry",n=e?.meta?.updatedAt&&C.at&&e.meta.updatedAt>C.at?" It has been changed since - check before saving.":"";await E.confirm(`You left ${t} unsaved.${n} Pick up where you left off?`)&&u()&&H(e,C.values)}}};
50
+ </div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const l=await o.blob(),i=document.createElement("a");i.href=URL.createObjectURL(l),i.download=`${a}-entries.${s}`,document.body.appendChild(i),i.click(),i.remove(),setTimeout(()=>URL.revokeObjectURL(i.href),1e3),e.close(!0)}catch(o){E.toast(`Export failed (${o.message}).`,{type:"error"})}})),e.element.appendChild(t),te(e),I.scan(t),e.open()}function at(e,t){const n=V(e.dataset.id);if(!n)return[];const s=t?.event?.target?.closest?.("td[data-col]"),o=s?re.peek()[Number(s.dataset.col)]:null,l=ye(n.id),i=y.peek().includes(n.id),d=o?yt(o,n.data?.[o.name]):[];return[{header:K(n)},{label:"Edit\u2026",icon:"edit",action:()=>O(n)},{label:"See all of it\u2026",icon:"eye",action:()=>et(n)},{label:"Duplicate",icon:"copy",action:()=>Ze(n)},{label:"Copy id",icon:"hash",action:()=>ge(n.id)},{label:i?"Deselect":"Select",icon:"check",action:()=>de(n.id)},...l.length?[{type:"divider"},...l.map(f=>({label:`${f.trigger?.label||f.title||f.slug}\u2026`,icon:f.trigger?.icon||"zap",disabled:A.peek(),action:()=>Qe(f,n)}))]:[],...o?[{type:"divider"},{label:`Filter by ${o.label||o.name}`,icon:"filter",submenu:[...d.map(f=>({label:f.label,action:()=>Ee(o,f.op,f.value)})),{label:`${o.label||o.name} contains\u2026`,action:async()=>{const f=await E.prompt(`Show entries whose ${D(o.label||o.name)} contains`,{inputPlaceholder:"text"});f&&String(f).trim()&&Ee(o,"contains",String(f).trim())}}]}]:[],...w.peek().length?[{label:`Clear filters (${w.peek().length})`,icon:"x",action:()=>{w.value=[]}}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>be(n)}]}const L=h.querySelector(".cex-rows"),G=h.querySelector(".cex-head"),pe=h.querySelector(".cex-chips-list");pe.innerHTML=kt;const st=E.loader(h,{type:"dots"});try{p.value=await g.collections.get(a)}catch(e){_.value=`Could not load the collection: ${e.message||e}`}finally{st.destroy()}if(!u())return;if(!p.peek()&&!_.peek()){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const we=re.peek();L.innerHTML=xt(we.length),G.replaceChildren();const ne=(e,t)=>{const n=document.createElement("th");return e&&(n.className=e),t&&n.append(t),G.appendChild(n),n},$=document.createElement("input");$.type="checkbox",$.title="Select every entry shown",$.setAttribute("aria-label","Select every entry shown"),ne("cex-check",$);const Se=(e,t)=>{const n=document.createElement("button");return n.type="button",n.dataset.sort=e,n.append(document.createTextNode(t),Object.assign(document.createElement("span"),{className:"cex-sort"})),n};we.forEach(e=>ne("",Se(e.name,e.label||e.name))),ne("cex-when",Se("created","Created")),ne("cex-tools"),r(M.effect(()=>{const e=Z.value;G.querySelectorAll("[data-sort]").forEach(t=>{t.querySelector(".cex-sort").textContent=t.dataset.sort===e.key?e.dir==="asc"?"\u25B2":"\u25BC":"",t.parentElement.setAttribute("aria-sort",t.dataset.sort===e.key?e.dir==="asc"?"ascending":"descending":"none")})})),r(M.effect(()=>{const e=T.value.map(n=>n.id),t=new Set(y.value);$.checked=e.length>0&&e.every(n=>t.has(n)),$.indeterminate=!$.checked&&e.some(n=>t.has(n))}));const Ce=e=>{const t=e.target.closest("[data-sort]");if(!t)return;const n=Z.peek();Z.value={key:t.dataset.sort,dir:n.key===t.dataset.sort&&n.dir==="asc"?"desc":"asc"}};G.addEventListener("click",Ce),$.addEventListener("change",()=>{const e=T.peek().map(t=>t.id);y.value=$.checked?[...new Set([...y.peek(),...e])]:y.peek().filter(t=>!e.includes(t))}),r(()=>G.removeEventListener("click",Ce));const ot=M.applyBindings(He,h,{methods:{clearSelection:()=>{y.value=[]},deleteSelected:()=>Xe(),clearAll:()=>Ye(),clearFilters:()=>{w.value=[]},showMore:()=>{T.peek().length>q.peek()?q.value=q.peek()+X:Ke()}}});r(()=>ot.dispose()),r(Oe(h,{position:"bottom"})),r(M.effect(()=>{le.value,w.value,q.value=X}));const $e=e=>{const t=e.target.closest("button[data-idx]");if(!t)return;const n=Number(t.dataset.idx);w.value=w.peek().filter((s,o)=>o!==n)};pe.addEventListener("click",$e),r(()=>pe.removeEventListener("click",$e));const ct=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:(e,t)=>at(e,t)});r(()=>ct?.destroy?.());const Ae=e=>{const t=e.target.closest(".cex-row");if(!t)return;if(e.target.closest(".cex-kebab, .cex-run")){Re(e.target.closest(".cex-kebab, .cex-run"));return}if(e.target.closest(".cex-check"))return;const n=V(t.dataset.id);n&&O(n)},Me=e=>{if(!e.target.matches(".cex-sel"))return;const t=e.target.closest(".cex-row");t&&de(t.dataset.id)},Le=e=>{const t=e.target.closest(".cex-row");if(!t||e.target!==t)return;const n=V(t.dataset.id);n&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Re(t)):e.key==="Enter"?(e.preventDefault(),O(n)):e.key===" "?(e.preventDefault(),de(n.id)):e.key==="Delete"&&(e.preventDefault(),be(n)))};L.addEventListener("click",Ae),L.addEventListener("change",Me),L.addEventListener("keydown",Le),r(()=>{L.removeEventListener("click",Ae),L.removeEventListener("change",Me),L.removeEventListener("keydown",Le)});const qe=p.peek(),ae=m.get(0).querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(ae&&qe?.title){for(;ae.nextSibling;)ae.nextSibling.remove();ae.after(document.createTextNode(qe.title))}z(m,{id:"entries-back",icon:"arrow-left",title:"All collections",onClick:()=>{location.hash="#/collections"}}),z(m,{id:"entries-fields",icon:"edit-3",title:"Edit its fields and settings",onClick:()=>{location.hash=`#/collections/edit/${encodeURIComponent(a)}`}}),bt(a)&&z(m,{id:"entries-form",icon:"layout",title:"Open its form",onClick:()=>{location.hash=`#/forms/edit/${encodeURIComponent(a)}`}}),z(m,{id:"entries-export",icon:"download",title:"Export",onClick:()=>nt()}),z(m,{id:"entries-import",icon:"upload",title:"Import",onClick:()=>tt()}),z(m,{id:"entries-add",icon:"plus",title:"Add entry",onClick:()=>O(null)});const Te=e=>{N?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Te),r(()=>window.removeEventListener("beforeunload",Te)),r(()=>{if(N?.dirty())try{S.set(P(a),{id:N.id,values:N.values(),at:new Date().toISOString()})}catch{}ke=!0,[...ue].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(h),!p.peek()){Y.value=!1;return}if(await Ge(),!u()||(await H(),!u()))return;let C=null;try{C=S.get(P(a))}catch{}if(C?.values){try{S.remove?S.remove(P(a)):S.set(P(a),null)}catch{}let e=C.id?V(C.id):null;if(C.id&&!e&&(e=await g.collections.getEntry(a,C.id).catch(()=>null)),!u()||C.id&&!e)return;const t=e?`your changes to \u201C${D(K(e))}\u201D`:"a new entry",n=e?.meta?.updatedAt&&C.at&&e.meta.updatedAt>C.at?" It has been changed since - check before saving.":"";await E.confirm(`You left ${t} unsaved.${n} Pick up where you left off?`)&&u()&&O(e,C.values)}}};
@@ -1,2 +1,2 @@
1
- import{api as P}from"../api.js";import{createSimpleEditor as Ie}from"../lib/simple-editor.js";import{addBannerAction as Q}from"../lib/plugin-chrome.js";import{helpPopovers as Ue}from"../lib/help-popover.js";import{openMenuAt as xe,sidebarCount as Ke}from"../lib/tool-kit.js";import{PANES as J,STARTER_SOURCE as Be,assembleSource as Fe,attributeFor as Je,draftKey as Y,draftStatus as Ye,nameProblem as He,previewRows as Me,readValue as We,snapshot as ce,splitSource as _e,tagFor as ze,writeValue as $e}from"../lib/component-editor-arrange.js";const Ge=/^\/components\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,X=n=>String(n||"").split("?")[0],Ne=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),H={get(n){try{return S.get(n)}catch{return null}},set(n,i){try{S.set(n,i)}catch{}},drop(n){try{S.remove?S.remove(n):S.set(n,null)}catch{}}};let h=null,je=!1;function Qe(){je||!window.R?.use||(je=!0,R.use((n,i,c)=>{const p=i?.path||"";return!(Ge.test(p)&&X(p)!==X(n?.path))||!h?.dirty()?c():E.confirm(h.question()).then(W=>{W?c():history.replaceState(null,"",`#${p}`)})}),M.subscribe("router:afterChange",({to:n}={})=>{if(h&&X(n?.path)!==h.path){const i=h;h=null,i.dispose()}}))}function Xe(n,i){const c=n.querySelector(".dm-plugin-banner-name");if(!c)return;const p=c.querySelector(".dm-plugin-banner-sep");if(p){for(;p.nextSibling;)p.nextSibling.remove();p.after(document.createTextNode(i))}else c.textContent=`Components \u203A ${i}`}export const componentEditorView={templateUrl:"/admin/js/templates/component-editor.html",async onMount(n){if(Qe(),h){const e=h;h=null,e.dispose()}const i=n.get(0),c=i.querySelector(".cex")||i,p=[],v=e=>(p.push(e),e);let W=!1;const x=()=>!W&&c.isConnected,le=location.hash.match(/^#\/components\/edit\/([^/?#]+)/);let a=le?decodeURIComponent(le[1]):null;const Te=X(location.hash.slice(1)),C=M.observable(!a),u=M.observable(!1),m=M.observable(a||""),d=M.observable(""),$=M.observable(!1),O=M.observable(0),j=M.observable(""),A=M.observable(!1),b=M.observable("compiling"),V=M.observable(""),Z=M.observable("template"),ie=M.observable([]),ee={value:{}},g={},I=()=>Fe(Object.fromEntries(J.map(e=>[e,g[e]?.getValue()??""]))),te=()=>({name:m.peek(),project:d.peek(),bundled:$.peek(),source:I()}),U=M.computed(()=>(O.value,u.value?!1:ce({name:m.value,project:d.value,bundled:$.value,source:I()})!==j.value)),oe=M.computed(()=>u.value?"":He(m.value,{isNew:C.value,taken:ie.value}));let K=null;v(()=>clearTimeout(K));const Le=()=>{O.value=O.peek()+1,clearTimeout(K),K=setTimeout(F,300)};for(const e of J){const t=c.querySelector(`#component-src-${e}`);t&&(g[e]=Ie(t,{initialValue:"",onChange:Le}))}v(()=>{for(const e of J)try{g[e]?.destroy?.()}catch{}});const _=e=>{const t=_e(e);for(const o of J)g[o]?.setValue(t[o]);O.value=O.peek()+1},B=c.querySelector("#component-project"),pe=E.loader(i,{type:"dots"});let T;try{const[e,t,o]=await Promise.all([a?P.components.get(a):Promise.resolve(null),P.projects.list().catch(()=>[]),P.components.list().catch(()=>[])]);T={comp:e,projects:t,list:o}}catch(e){pe.destroy(),E.toast(e.message||"Component not found.",{type:"error"}),x()&&(location.hash="#/components");return}if(pe.destroy(),!c.isConnected)return;for(const e of T.projects||[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,B?.appendChild(t)}ie.value=(T.list||[]).map(e=>e.name);const N=T.comp;if(N)u.value=N.readOnly===!0||N.origin==="plugin",ee.value=N.meta||{},d.value=N.meta?.project||"",$.value=!!N.bundled,_(N.source);else{const e=new URLSearchParams(location.hash.split("?")[1]||"").get("project");e&&(T.projects||[]).some(t=>t.slug===e)&&(d.value=e),_(Be)}if(j.value=ce(te()),u.peek())for(const e of J)g[e]?.textarea&&(g[e].textarea.readOnly=!0);Xe(i,u.peek()?`View source: ${a}`:a||"New component");const f=c.querySelector("#component-preview-iframe");let ne=null,y={},z=null,ae=0;const G=e=>{try{f?.contentWindow?.postMessage(e,"*")}catch{}};function Re(e){if(!f)return;if(!f.dataset.dmMounted){f.dataset.dmMounted="true",G({type:"mount",payload:e});return}z=e;const t=()=>{f.removeEventListener("load",t),z&&x()&&G({type:"mount",payload:z}),z=null};f.addEventListener("load",t),f.src=f.src}async function F(){const e=++ae,t=m.peek().trim()||"preview";b.value="compiling";let o;try{o=await P.components.compile(t,I())}catch(r){if(e!==ae||!x())return;b.value="error",V.value=r.message||"Compile request failed.";return}if(!(e!==ae||!x())){if(o.errors?.length){b.value="error",V.value=o.errors.map(r=>`[${r.type}] ${r.message}`).join(`
2
- `),ne=null;return}ne=o.js,b.value="ok",V.value="",de(),Re({compiledJs:ne,tagName:`dm-${t}`,props:y})}}const w=c.querySelector("#component-preview-props-panel");let ue=[];function me(e){w.replaceChildren();const t=document.createElement("p");t.className="cex-note",t.textContent=e,w.appendChild(t)}function de(){const{rows:e,error:t}=Me(g.props?.getValue(),y);if(ue=e,t){me("The <props> block is not valid JSON - fix it to see the props here.");return}if(!e.length){y={},me("No props declared. Add them in the <props> tab.");return}y=Object.fromEntries(e.map(o=>[o.name,o.value])),w.replaceChildren();for(const o of e){const r=document.createElement("div");r.className="cex-prop",r.tabIndex=0,r.dataset.prop=o.name;const l=document.createElement("label");l.className="cex-prop-label",l.textContent=o.label,l.title=`${o.name} (${o.type})`;const s=o.type==="array"||o.type==="object"?document.createElement("textarea"):document.createElement("input");o.type==="boolean"?(s.type="checkbox",s.className="form-check",s.checked=$e(o.type,o.value)):(s.tagName==="INPUT"?s.type=o.type==="number"?"number":"text":s.rows=2,s.className="form-input form-input--sm cex-prop-input",s.value=$e(o.type,o.value)),s.setAttribute("aria-label",o.label);const Ce=()=>{y[o.name]=We(o.type,o.type==="boolean"?s.checked:s.value),G({type:"update",payload:{props:y}})};s.addEventListener("input",Ce),s.addEventListener("change",Ce);const q=document.createElement("button");q.type="button",q.className="cex-kebab",q.setAttribute("aria-label",`Actions for ${o.label}`),q.title="Actions",q.textContent="\u22EE",r.append(l,s,q),w.appendChild(r)}}const re=async(e,t)=>{try{await navigator.clipboard.writeText(e),E.toast(`${t} copied.`,{type:"success"})}catch{E.toast(e,{type:"info",duration:8e3})}};function qe(e){const t=ue.find(l=>l.name===e);if(!t)return[];const o=y[t.name],r=Je(t.name,t.type,o);return[{header:t.label},{label:"Reset to its default",icon:"rotate-ccw",action:()=>{delete y[t.name];const l=Me(g.props?.getValue(),{}).rows.find(s=>s.name===t.name);y[t.name]=l?l.value:null,de(),G({type:"update",payload:{props:y}})}},{label:"Copy as an attribute",icon:"copy",disabled:!r,action:()=>re(r,"Attribute")},{label:"Copy the tag with this value",icon:"code",action:()=>re(`<dm-${m.peek().trim()||"component"}${r?` ${r}`:""}></dm-${m.peek().trim()||"component"}>`,"Tag")},{label:"Copy the prop name",icon:"copy",action:()=>re(t.name,"Name")},{type:"divider"},{label:"Edit in <props>",icon:"edit",action:()=>{Z.value="props";const l=g.props?.textarea;if(!l)return;const s=l.value.indexOf(`"${t.name}"`);l.focus(),s>=0&&l.setSelectionRange(s,s+t.name.length+2)}}]}const Pe=E.contextMenu(w,{cursor:"glyph",match:".cex-prop",inherit:!1,density:"compact",items:e=>qe(e.dataset.prop)});v(()=>Pe?.destroy?.());const ve=e=>{const t=e.target.closest(".cex-kebab");t&&xe(t)},fe=e=>{const t=e.target.closest(".cex-prop");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),xe(t))};w.addEventListener("click",ve),w.addEventListener("keydown",fe),v(()=>{w.removeEventListener("click",ve),w.removeEventListener("keydown",fe)});async function se(){if(u.peek()||A.peek())return;const e=oe.peek();if(e){E.toast(e,{type:"warning"});return}if(b.peek()==="error"){E.toast("Fix the compile errors first - a component that does not compile is not saved.",{type:"warning"});return}const t=m.peek().trim(),o={...ee.value||{}};delete o.bundled,d.peek()?o.project=d.peek():delete o.project,A.value=!0;try{if(await P.components.put(t,{source:I(),bundled:$.peek(),meta:o}),!x())return;ee.value=o,j.value=ce(te()),H.drop(Y(a)),E.toast(C.peek()?`Created the component "${t}".`:"Saved.",{type:"success"}),C.peek()&&(Ke("#/components"),a=t,location.hash=`#/components/edit/${encodeURIComponent(t)}`)}catch(r){E.toast(`Not saved: ${r.message||r}`,{type:"error"})}finally{A.value=!1}}async function Oe(){if(!U.peek()||!await E.confirm("Throw away the changes you have not saved?"))return;const e=JSON.parse(j.peek());C.peek()&&(m.value=e[0]),d.value=e[1],$.value=e[2],_(e[3]),clearTimeout(K),F()}function ye(){H.set(Y(null),{name:`${a}-copy`,project:"",bundled:!1,source:I(),base:null,seed:!0,at:new Date().toISOString()}),location.hash="#/components/new"}const Ae={name:m,project:d,bundled:$,dirty:U,saving:A,readOnly:u,isNew:C,nameErr:oe,compileErrors:V,activeTab:Z,notNew:M.computed(()=>!C.value),canEdit:M.computed(()=>!u.value),tagText:M.computed(()=>ze(m.value)),statusText:M.computed(()=>({compiling:"Compiling\u2026",ok:"Compiles",error:"Does not compile - see below"})[b.value]),statusCls:M.computed(()=>`is-${b.value}`),hasErrors:M.computed(()=>b.value==="error"&&!!V.value),saveBlocked:M.computed(()=>A.value||!!oe.value||b.value==="error"),nameLocked:M.computed(()=>!C.value||u.value)},De=M.applyBindings(Ae,c,{methods:{save:()=>se(),discard:()=>Oe(),copyToNew:()=>ye(),showTab:e=>{Z.value=e}}});v(()=>De.dispose()),v(Ue(c,{position:"bottom"})),B&&(B.value=d.peek());const L=c.querySelector("#component-quick-switch");if(L&&a){for(const t of[...T.list||[]].sort((o,r)=>o.name.localeCompare(r.name))){const o=document.createElement("option");o.value=t.name,o.textContent=t.origin==="plugin"?`${t.name} (plugin)`:t.name,t.name===a&&(o.selected=!0),L.appendChild(o)}L.hidden=!1;const e=()=>{const t=L.value;t&&t!==a&&(location.hash=`#/components/edit/${encodeURIComponent(t)}`)};L.addEventListener("change",e),v(()=>L.removeEventListener("change",e))}Q(n,{id:"component-editor-back",icon:"arrow-left",title:"All components",onClick:()=>{location.hash="#/components"}}),a&&Q(n,{id:"component-editor-export",icon:"download",title:"Export .dmcomponent.json",onClick:async()=>{try{await P.components.exportBundle(a)}catch(e){E.toast(e.message||"Export failed.",{type:"error"})}}}),u.peek()?Q(n,{id:"component-editor-copy",icon:"copy",title:"Copy to a new component",onClick:ye}):Q(n,{id:"component-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>se()});const he=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),se())};document.addEventListener("keydown",he,!0),v(()=>document.removeEventListener("keydown",he,!0));const be=()=>{U.peek()&&H.set(Y(a),{...te(),base:j.peek(),at:new Date().toISOString()})},ge=e=>{U.peek()&&(be(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ge),v(()=>window.removeEventListener("beforeunload",ge)),h={path:Te,dirty:()=>U.peek(),question:()=>`You have unsaved changes to ${a?`<dm-${Ne(a)}>`:"this new component"}. Leave anyway? They are kept as a draft for your next visit.`,dispose(){W=!0,be(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},Domma.icons.scan(c),f?.contentDocument?.readyState==="complete"?F():f?.addEventListener("load",()=>{x()&&F()},{once:!0}),u.peek())return;const k=H.get(Y(a)),we=Ye(k,j.peek());if(!we.ok)return;H.drop(Y(a));const ke=()=>{C.peek()&&typeof k.name=="string"&&(m.value=k.name),d.value=String(k.project??""),B&&(B.value=d.peek()),$.value=k.bundled===!0,_(k.source),clearTimeout(K),F()};if(k.seed){ke();return}const Ee=k.at?` (${D(k.at).format("D MMM HH:mm")})`:"",Se=a?`<dm-${Ne(a)}>`:"a new component",Ve=we.stale?`You left unsaved changes to ${Se}${Ee}, but the saved version has changed since. Restore your version anyway?`:`You left unsaved changes to ${Se}${Ee}. Restore them?`;await E.confirm(Ve)&&x()&&ke()}};
1
+ import{api as P}from"../api.js";import{createSimpleEditor as De}from"../lib/simple-editor.js";import{addBannerAction as H}from"../lib/plugin-chrome.js";import{helpPopovers as Ie}from"../lib/help-popover.js";import{openMenuAt as Ce,sidebarCount as Ue}from"../lib/tool-kit.js";import{PANES as F,STARTER_SOURCE as Ke,assembleSource as Be,attributeFor as Fe,draftKey as J,draftStatus as Je,nameProblem as Ye,previewRows as xe,readValue as We,snapshot as se,splitSource as _e,tagFor as ze,writeValue as Me}from"../lib/component-editor-arrange.js";import{fmtDateTime as Ge}from"../lib/dates.js";const He=/^\/components\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,Q=n=>String(n||"").split("?")[0],$e=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Y={get(n){try{return S.get(n)}catch{return null}},set(n,i){try{S.set(n,i)}catch{}},drop(n){try{S.remove?S.remove(n):S.set(n,null)}catch{}}};let h=null,Ne=!1;function Qe(){Ne||!window.R?.use||(Ne=!0,R.use((n,i,c)=>{const p=i?.path||"";return!(He.test(p)&&Q(p)!==Q(n?.path))||!h?.dirty()?c():E.confirm(h.question()).then(W=>{W?c():history.replaceState(null,"",`#${p}`)})}),M.subscribe("router:afterChange",({to:n}={})=>{if(h&&Q(n?.path)!==h.path){const i=h;h=null,i.dispose()}}))}function Xe(n,i){const c=n.querySelector(".dm-plugin-banner-name");if(!c)return;const p=c.querySelector(".dm-plugin-banner-sep");if(p){for(;p.nextSibling;)p.nextSibling.remove();p.after(document.createTextNode(i))}else c.textContent=`Components \u203A ${i}`}export const componentEditorView={templateUrl:"/admin/js/templates/component-editor.html",async onMount(n){if(Qe(),h){const e=h;h=null,e.dispose()}const i=n.get(0),c=i.querySelector(".cex")||i,p=[],v=e=>(p.push(e),e);let W=!1;const x=()=>!W&&c.isConnected,ce=location.hash.match(/^#\/components\/edit\/([^/?#]+)/);let a=ce?decodeURIComponent(ce[1]):null;const je=Q(location.hash.slice(1)),C=M.observable(!a),u=M.observable(!1),m=M.observable(a||""),d=M.observable(""),$=M.observable(!1),O=M.observable(0),j=M.observable(""),A=M.observable(!1),b=M.observable("compiling"),V=M.observable(""),X=M.observable("template"),le=M.observable([]),Z={value:{}},g={},D=()=>Be(Object.fromEntries(F.map(e=>[e,g[e]?.getValue()??""]))),ee=()=>({name:m.peek(),project:d.peek(),bundled:$.peek(),source:D()}),I=M.computed(()=>(O.value,u.value?!1:se({name:m.value,project:d.value,bundled:$.value,source:D()})!==j.value)),te=M.computed(()=>u.value?"":Ye(m.value,{isNew:C.value,taken:le.value}));let U=null;v(()=>clearTimeout(U));const Te=()=>{O.value=O.peek()+1,clearTimeout(U),U=setTimeout(B,300)};for(const e of F){const t=c.querySelector(`#component-src-${e}`);t&&(g[e]=De(t,{initialValue:"",onChange:Te}))}v(()=>{for(const e of F)try{g[e]?.destroy?.()}catch{}});const _=e=>{const t=_e(e);for(const o of F)g[o]?.setValue(t[o]);O.value=O.peek()+1},K=c.querySelector("#component-project"),ie=E.loader(i,{type:"dots"});let T;try{const[e,t,o]=await Promise.all([a?P.components.get(a):Promise.resolve(null),P.projects.list().catch(()=>[]),P.components.list().catch(()=>[])]);T={comp:e,projects:t,list:o}}catch(e){ie.destroy(),E.toast(e.message||"Component not found.",{type:"error"}),x()&&(location.hash="#/components");return}if(ie.destroy(),!c.isConnected)return;for(const e of T.projects||[]){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,K?.appendChild(t)}le.value=(T.list||[]).map(e=>e.name);const N=T.comp;if(N)u.value=N.readOnly===!0||N.origin==="plugin",Z.value=N.meta||{},d.value=N.meta?.project||"",$.value=!!N.bundled,_(N.source);else{const e=new URLSearchParams(location.hash.split("?")[1]||"").get("project");e&&(T.projects||[]).some(t=>t.slug===e)&&(d.value=e),_(Ke)}if(j.value=se(ee()),u.peek())for(const e of F)g[e]?.textarea&&(g[e].textarea.readOnly=!0);Xe(i,u.peek()?`View source: ${a}`:a||"New component");const f=c.querySelector("#component-preview-iframe");let oe=null,y={},z=null,ne=0;const G=e=>{try{f?.contentWindow?.postMessage(e,"*")}catch{}};function Le(e){if(!f)return;if(!f.dataset.dmMounted){f.dataset.dmMounted="true",G({type:"mount",payload:e});return}z=e;const t=()=>{f.removeEventListener("load",t),z&&x()&&G({type:"mount",payload:z}),z=null};f.addEventListener("load",t),f.src=f.src}async function B(){const e=++ne,t=m.peek().trim()||"preview";b.value="compiling";let o;try{o=await P.components.compile(t,D())}catch(r){if(e!==ne||!x())return;b.value="error",V.value=r.message||"Compile request failed.";return}if(!(e!==ne||!x())){if(o.errors?.length){b.value="error",V.value=o.errors.map(r=>`[${r.type}] ${r.message}`).join(`
2
+ `),oe=null;return}oe=o.js,b.value="ok",V.value="",me(),Le({compiledJs:oe,tagName:`dm-${t}`,props:y})}}const w=c.querySelector("#component-preview-props-panel");let pe=[];function ue(e){w.replaceChildren();const t=document.createElement("p");t.className="cex-note",t.textContent=e,w.appendChild(t)}function me(){const{rows:e,error:t}=xe(g.props?.getValue(),y);if(pe=e,t){ue("The <props> block is not valid JSON - fix it to see the props here.");return}if(!e.length){y={},ue("No props declared. Add them in the <props> tab.");return}y=Object.fromEntries(e.map(o=>[o.name,o.value])),w.replaceChildren();for(const o of e){const r=document.createElement("div");r.className="cex-prop",r.tabIndex=0,r.dataset.prop=o.name;const l=document.createElement("label");l.className="cex-prop-label",l.textContent=o.label,l.title=`${o.name} (${o.type})`;const s=o.type==="array"||o.type==="object"?document.createElement("textarea"):document.createElement("input");o.type==="boolean"?(s.type="checkbox",s.className="form-check",s.checked=Me(o.type,o.value)):(s.tagName==="INPUT"?s.type=o.type==="number"?"number":"text":s.rows=2,s.className="form-input form-input--sm cex-prop-input",s.value=Me(o.type,o.value)),s.setAttribute("aria-label",o.label);const Se=()=>{y[o.name]=We(o.type,o.type==="boolean"?s.checked:s.value),G({type:"update",payload:{props:y}})};s.addEventListener("input",Se),s.addEventListener("change",Se);const q=document.createElement("button");q.type="button",q.className="cex-kebab",q.setAttribute("aria-label",`Actions for ${o.label}`),q.title="Actions",q.textContent="\u22EE",r.append(l,s,q),w.appendChild(r)}}const ae=async(e,t)=>{try{await navigator.clipboard.writeText(e),E.toast(`${t} copied.`,{type:"success"})}catch{E.toast(e,{type:"info",duration:8e3})}};function Re(e){const t=pe.find(l=>l.name===e);if(!t)return[];const o=y[t.name],r=Fe(t.name,t.type,o);return[{header:t.label},{label:"Reset to its default",icon:"rotate-ccw",action:()=>{delete y[t.name];const l=xe(g.props?.getValue(),{}).rows.find(s=>s.name===t.name);y[t.name]=l?l.value:null,me(),G({type:"update",payload:{props:y}})}},{label:"Copy as an attribute",icon:"copy",disabled:!r,action:()=>ae(r,"Attribute")},{label:"Copy the tag with this value",icon:"code",action:()=>ae(`<dm-${m.peek().trim()||"component"}${r?` ${r}`:""}></dm-${m.peek().trim()||"component"}>`,"Tag")},{label:"Copy the prop name",icon:"copy",action:()=>ae(t.name,"Name")},{type:"divider"},{label:"Edit in <props>",icon:"edit",action:()=>{X.value="props";const l=g.props?.textarea;if(!l)return;const s=l.value.indexOf(`"${t.name}"`);l.focus(),s>=0&&l.setSelectionRange(s,s+t.name.length+2)}}]}const qe=E.contextMenu(w,{cursor:"glyph",match:".cex-prop",inherit:!1,density:"compact",items:e=>Re(e.dataset.prop)});v(()=>qe?.destroy?.());const de=e=>{const t=e.target.closest(".cex-kebab");t&&Ce(t)},ve=e=>{const t=e.target.closest(".cex-prop");t&&e.key==="F10"&&e.shiftKey&&(e.preventDefault(),Ce(t))};w.addEventListener("click",de),w.addEventListener("keydown",ve),v(()=>{w.removeEventListener("click",de),w.removeEventListener("keydown",ve)});async function re(){if(u.peek()||A.peek())return;const e=te.peek();if(e){E.toast(e,{type:"warning"});return}if(b.peek()==="error"){E.toast("Fix the compile errors first - a component that does not compile is not saved.",{type:"warning"});return}const t=m.peek().trim(),o={...Z.value||{}};delete o.bundled,d.peek()?o.project=d.peek():delete o.project,A.value=!0;try{if(await P.components.put(t,{source:D(),bundled:$.peek(),meta:o}),!x())return;Z.value=o,j.value=se(ee()),Y.drop(J(a)),E.toast(C.peek()?`Created the component "${t}".`:"Saved.",{type:"success"}),C.peek()&&(Ue("#/components"),a=t,location.hash=`#/components/edit/${encodeURIComponent(t)}`)}catch(r){E.toast(`Not saved: ${r.message||r}`,{type:"error"})}finally{A.value=!1}}async function Pe(){if(!I.peek()||!await E.confirm("Throw away the changes you have not saved?"))return;const e=JSON.parse(j.peek());C.peek()&&(m.value=e[0]),d.value=e[1],$.value=e[2],_(e[3]),clearTimeout(U),B()}function fe(){Y.set(J(null),{name:`${a}-copy`,project:"",bundled:!1,source:D(),base:null,seed:!0,at:new Date().toISOString()}),location.hash="#/components/new"}const Oe={name:m,project:d,bundled:$,dirty:I,saving:A,readOnly:u,isNew:C,nameErr:te,compileErrors:V,activeTab:X,notNew:M.computed(()=>!C.value),canEdit:M.computed(()=>!u.value),tagText:M.computed(()=>ze(m.value)),statusText:M.computed(()=>({compiling:"Compiling\u2026",ok:"Compiles",error:"Does not compile - see below"})[b.value]),statusCls:M.computed(()=>`is-${b.value}`),hasErrors:M.computed(()=>b.value==="error"&&!!V.value),saveBlocked:M.computed(()=>A.value||!!te.value||b.value==="error"),nameLocked:M.computed(()=>!C.value||u.value)},Ae=M.applyBindings(Oe,c,{methods:{save:()=>re(),discard:()=>Pe(),copyToNew:()=>fe(),showTab:e=>{X.value=e}}});v(()=>Ae.dispose()),v(Ie(c,{position:"bottom"})),K&&(K.value=d.peek());const L=c.querySelector("#component-quick-switch");if(L&&a){for(const t of[...T.list||[]].sort((o,r)=>o.name.localeCompare(r.name))){const o=document.createElement("option");o.value=t.name,o.textContent=t.origin==="plugin"?`${t.name} (plugin)`:t.name,t.name===a&&(o.selected=!0),L.appendChild(o)}L.hidden=!1;const e=()=>{const t=L.value;t&&t!==a&&(location.hash=`#/components/edit/${encodeURIComponent(t)}`)};L.addEventListener("change",e),v(()=>L.removeEventListener("change",e))}H(n,{id:"component-editor-back",icon:"arrow-left",title:"All components",onClick:()=>{location.hash="#/components"}}),a&&H(n,{id:"component-editor-export",icon:"download",title:"Export .dmcomponent.json",onClick:async()=>{try{await P.components.exportBundle(a)}catch(e){E.toast(e.message||"Export failed.",{type:"error"})}}}),u.peek()?H(n,{id:"component-editor-copy",icon:"copy",title:"Copy to a new component",onClick:fe}):H(n,{id:"component-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>re()});const ye=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),re())};document.addEventListener("keydown",ye,!0),v(()=>document.removeEventListener("keydown",ye,!0));const he=()=>{I.peek()&&Y.set(J(a),{...ee(),base:j.peek(),at:new Date().toISOString()})},be=e=>{I.peek()&&(he(),e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",be),v(()=>window.removeEventListener("beforeunload",be)),h={path:je,dirty:()=>I.peek(),question:()=>`You have unsaved changes to ${a?`<dm-${$e(a)}>`:"this new component"}. Leave anyway? They are kept as a draft for your next visit.`,dispose(){W=!0,he(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},Domma.icons.scan(c),f?.contentDocument?.readyState==="complete"?B():f?.addEventListener("load",()=>{x()&&B()},{once:!0}),u.peek())return;const k=Y.get(J(a)),ge=Je(k,j.peek());if(!ge.ok)return;Y.drop(J(a));const we=()=>{C.peek()&&typeof k.name=="string"&&(m.value=k.name),d.value=String(k.project??""),K&&(K.value=d.peek()),$.value=k.bundled===!0,_(k.source),clearTimeout(U),B()};if(k.seed){we();return}const ke=k.at?` (${Ge(k.at)})`:"",Ee=a?`<dm-${$e(a)}>`:"a new component",Ve=ge.stale?`You left unsaved changes to ${Ee}${ke}, but the saved version has changed since. Restore your version anyway?`:`You left unsaved changes to ${Ee}${ke}. Restore them?`;await E.confirm(Ve)&&x()&&we()}};
@@ -1,4 +1,4 @@
1
- import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib/plugin-chrome.js";import{mountRelated as ze,relatedMenuItems as Ke,relatedProviders as We}from"../lib/related.js";import{helpPopovers as _}from"../lib/help-popover.js";import{onceClosed as ee,openMenuAt as te,settingsStore as Ye,viewLifetime as Je}from"../lib/tool-kit.js";import{arrangeContacts as _e,countContacts as Ve,DEFAULT_SETTINGS as he,duplicateBody as Qe,initials as Xe,normaliseSettings as ye,primaryEmail as ge,primaryPhone as ke,withGroup as ae}from"../lib/contacts-arrange.js";const we=Ye("contacts.settings",ye),h=(x,i="GET",O)=>Fe(x,{method:i,...O!==void 0?{body:JSON.stringify(O)}:{}}),Ee={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},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}]}},Ze=`
1
+ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib/plugin-chrome.js";import{mountRelated as ze,relatedMenuItems as Ke,relatedProviders as We}from"../lib/related.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as te,openMenuAt as ae,settingsStore as Ye,viewLifetime as _e}from"../lib/tool-kit.js";import{arrangeContacts as Je,countContacts as Ve,DEFAULT_SETTINGS as ve,duplicateBody as Qe,initials as Xe,normaliseSettings as ye,primaryEmail as ge,primaryPhone as ke,withGroup as oe}from"../lib/contacts-arrange.js";const we=Ye("contacts.settings",ye),h=(A,d="GET",q)=>Fe(A,{method:d,...q!==void 0?{body:JSON.stringify(q)}:{}}),Ee={company:"Company",jobTitle:"Job title",website:"Website",billingEmail:"Billing email",vatNumber:"VAT number",companyNumber:"Company number",reference:"Their reference",paymentTerms:"Payment terms",currency:"Preferred currency",accountName:"Account name",bankName:"Bank",sortCode:"Sort code",accountNumber:"Account number",iban:"IBAN",bic:"BIC / SWIFT"},N={addresses:{title:"Addresses",add:"Add address",empty:"No address yet.",help:'Tick "Use for invoices" on the one an invoice should be addressed to.',columns:[{key:"label",placeholder:"Label (Office, Home\u2026)"},{key:"line1",placeholder:"Address line 1",wide:!0},{key:"line2",placeholder:"Address line 2",wide:!0},{key:"city",placeholder:"City"},{key:"region",placeholder:"County / Region"},{key:"postcode",placeholder:"Postcode"},{key:"country",placeholder:"Country"},{key:"isBilling",label:"Use for invoices",check:!0}]},emails:{title:"More email addresses",add:"Add email",empty:"None.",help:"Besides the main email above. Search finds a contact by these too.",columns:[{key:"label",placeholder:"Label (Accounts, Personal\u2026)"},{key:"address",placeholder:"name@example.com",wide:!0}]},phones:{title:"More phone numbers",add:"Add phone",empty:"None.",help:"Besides the main phone above. Call in the menu uses the main one, or the first of these.",columns:[{key:"label",placeholder:"Label (Mobile, Office\u2026)"},{key:"number",placeholder:"+44 \u2026",wide:!0}]}},Ze=`
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>
@@ -15,22 +15,22 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
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
- <button type="button" class="btn btn-sm btn-ghost" data-act="rename">Rename</button>
19
- <button type="button" class="btn btn-sm btn-ghost text-danger" data-act="delete">Delete</button>
20
- </div>`;function oe(x){const i=document.createElement("template");return i.innerHTML=x.trim(),i.content.firstElementChild}const A=(x,i,O=`${i}s`)=>`${x} ${x===1?i:O}`,g=Je(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(x){g.reset();const i=x.get(0);let O=[];We("contact").then(t=>{O=t});const S=M.observable(we.read()),p=M.observable([]),b=M.observable([]),W=M.observable(!0),se=M.observable(""),q=M.observable({kind:"all"}),d=M.observable(new Set),ne=M.observable(!1),ce=M.computed(()=>Ve(p.value,b.value)),Se=M.computed(()=>_e(p.value,{query:se.value,filter:q.value,sort:S.value.sort,favouritesFirst:S.value.favouritesFirst})),$=M.observable([]);g.add(M.effect(()=>{const t=d.value;$.value=Se.value.map(e=>({id:e.id,name:e.name||"Unnamed",sub:[e.jobTitle,e.company&&!S.value.showCompany?e.company:""].filter(Boolean).join(" \xB7 "),initials:Xe(e.name),company:e.company||"",email:ge(e),phone:ke(e),groupsText:(e.groups||[]).join(", "),favClass:e.favourite===!0?" is-on":"",favTitle:e.favourite===!0?"Starred - click to unstar":"Star this contact",selected:t.has(e.id),selClass:t.has(e.id)?" is-selected":""}))}));const le=M.observable([]);g.add(M.effect(()=>{const t=ce.value,e=q.value;le.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":""},...b.value.map(a=>({key:`group:${a}`,label:a,count:t.groups[a]??0,title:`Only the ${a} group`,cls:e.kind==="group"&&e.value===a?" is-active":""}))]})),g.add(M.effect(()=>{const t=q.value;t.kind==="group"&&!b.value.includes(t.value)&&(q.value={kind:"all"})}));const $e={rows:$,chips:le,loading:W,query:se,searchFocus:ne,hasRows:M.computed(()=>$.value.length>0),isEmpty:M.computed(()=>!W.value&&$.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(W.value)return"";const t=$.value.length,e=p.value.length;return t===e?A(e,"contact"):`${t} of ${e}`}),hasSelection:M.computed(()=>d.value.size>0),selectionText:M.computed(()=>`${A(d.value.size,"contact")} selected`),allSelected:M.computed(()=>$.value.length>0&&$.value.every(t=>d.value.has(t.id)))};g.add(M.effect(()=>we.write(S.value)));const Me=(t,e)=>{S.value=ye({...S.peek(),[t]:e})},N=t=>p.peek().find(e=>e.id===t),Ce=t=>{p.value=p.peek().map(e=>e.id===t.id?t:e)};async function Y(){W.value=!0;try{const[t,e]=await Promise.all([h("/contacts/contacts"),h("/contacts/groups")]);p.value=Array.isArray(t)?t:t?.data??[],b.value=Array.isArray(e)?e:[]}catch{E.toast("Failed to load contacts.",{type:"error"})}finally{W.value=!1}}async function xe(){try{b.value=await h("/contacts/groups")}catch{}}async function U(t,e,a){try{return Ce(await h(`/contacts/contacts/${t}`,"PUT",e)),a&&E.toast(a,{type:"success"}),!0}catch(s){return E.toast(s?.message||"Failed to update the contact.",{type:"error"}),!1}}async function re(t){const e=N(t);if(S.peek().confirmDelete&&!await E.confirm("Delete this contact? This cannot be undone."))return!1;try{await h(`/contacts/contacts/${t}`,"DELETE"),p.value=p.peek().filter(s=>s.id!==t);const a=new Set(d.peek());return a.delete(t)&&(d.value=a),E.toast(`${e?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function Te(t){const e=N(t);if(e)try{const a=await h("/contacts/contacts","POST",Qe(e));p.value=[...p.peek(),a],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 F(t){if(b.peek().includes(t))return!0;try{return await h("/contacts/groups","POST",{name:t}),await xe(),!0}catch(e){return E.toast(e?.message||"Could not create the group.",{type:"error"}),!1}}async function Ae(t,e,a){const s=N(t);s&&(a&&!await F(e)||await U(t,{groups:ae(s.groups,e,a)},a?`Added to ${e}.`:`Removed from ${e}.`))}async function ie(t){const e=String(await E.prompt("New group name:")??"").trim();if(!(!e||!await F(e))){for(const a of t){const s=N(a);s&&!(s.groups||[]).includes(e)&&await U(a,{groups:ae(s.groups,e,!0)})}E.toast(`Filed in ${e}.`,{type:"success"})}}async function de(t,e){try{await navigator.clipboard.writeText(t),E.toast(`${e} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function Ne(){const t=[...d.peek()];if(t.length&&await E.confirm(`Delete ${A(t.length,"contact")}? This cannot be undone.`))try{const e=await h("/contacts/contacts/bulk","DELETE",{ids:t});p.value=p.peek().filter(a=>!t.includes(a.id)),d.value=new Set,E.toast(`${A(e?.deleted??t.length,"contact")} deleted.`,{type:"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function Le(t){const e=[...d.peek()];if(!(!e.length||!await F(t))){for(const a of e){const s=N(a);s&&!(s.groups||[]).includes(t)&&await U(a,{groups:ae(s.groups,t,!0)})}E.toast(`${A(e.length,"contact")} filed in ${t}.`,{type:"success"})}}const pe=t=>{const e=new Set(d.peek());e.delete(t)||e.add(t),d.value=e};async function Be(){try{const t=await h("/contacts/export"),e=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),a=URL.createObjectURL(e),s=document.createElement("a");s.href=a,s.download="contacts.json",document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const J=i.querySelector(".ct-import-file");J.addEventListener("change",async()=>{const t=J.files?.[0];if(J.value="",!!t)try{const e=JSON.parse(await t.text()),a=Array.isArray(e)?e:e.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const s=await h("/contacts/import","POST",{contacts:a});E.toast(`Imported ${A(s?.imported??0,"contact")}.`,{type:"success"}),await Y()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function H(t){const e=t?N(t):null;if(t&&!e)return;const a=E.slideover({title:e?"Edit contact":"New contact",size:"lg",position:"right"}),s=(o,c,{type:L="text",mono:f=!1,placeholder:v="",wide:w=!1}={})=>`
21
- <div${w?' class="ct-wide"':""}>
18
+ <button type="button" class="btn btn-sm btn-ghost" data-act="rename" data-bind-disabled="locked" title="{{lockTitle}}">Rename</button>
19
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-act="delete" data-bind-disabled="locked" title="{{lockTitle}}">Delete</button>
20
+ </div>`,ot="Groups are shared by everyone who uses Contacts, so renaming or deleting one changes their contacts too. Only the person who created a group can do that, or someone whose role has Manage groups in Contacts (admins do, unless it was taken away). Groups made before creators were recorded belong to the Manage groups holders. You can still file your own contacts in any group.";function se(A){const d=document.createElement("template");return d.innerHTML=A.trim(),d.content.firstElementChild}const L=(A,d,q=`${d}s`)=>`${A} ${A===1?d:q}`,w=_e(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(A){w.reset();const d=A.get(0);let q=[];We("contact").then(t=>{q=t});const x=M.observable(we.read()),m=M.observable([]),v=M.observable([]),J=M.observable(!0),ne=M.observable(""),H=M.observable({kind:"all"}),p=M.observable(new Set),ce=M.observable(!1),le=M.computed(()=>Ve(m.value,v.value)),Se=M.computed(()=>Je(m.value,{query:ne.value,filter:H.value,sort:x.value.sort,favouritesFirst:x.value.favouritesFirst})),T=M.observable([]);w.add(M.effect(()=>{const t=p.value;T.value=Se.value.map(e=>({id:e.id,name:e.name||"Unnamed",sub:[e.jobTitle,e.company&&!x.value.showCompany?e.company:""].filter(Boolean).join(" \xB7 "),initials:Xe(e.name),company:e.company||"",email:ge(e),phone:ke(e),groupsText:(e.groups||[]).join(", "),favClass:e.favourite===!0?" is-on":"",favTitle:e.favourite===!0?"Starred - click to unstar":"Star this contact",selected:t.has(e.id),selClass:t.has(e.id)?" is-selected":""}))}));const re=M.observable([]);w.add(M.effect(()=>{const t=le.value,e=H.value;re.value=[{key:"all",label:"All",count:t.all,title:"Everyone",cls:e.kind==="all"?" is-active":""},{key:"fav",label:"\u2605 Starred",count:t.fav,title:"Only starred contacts",cls:e.kind==="fav"?" is-active":""},...v.value.map(a=>({key:`group:${a}`,label:a,count:t.groups[a]??0,title:`Only the ${a} group`,cls:e.kind==="group"&&e.value===a?" is-active":""}))]})),w.add(M.effect(()=>{const t=H.value;t.kind==="group"&&!v.value.includes(t.value)&&(H.value={kind:"all"})}));const Me={rows:T,chips:re,loading:J,query:ne,searchFocus:ce,hasRows:M.computed(()=>T.value.length>0),isEmpty:M.computed(()=>!J.value&&T.value.length===0),emptyText:M.computed(()=>m.value.length?"No contacts match. Clear the search or pick All.":"No contacts yet. New contact adds one, or \u22EE > Import brings a file in."),countText:M.computed(()=>{if(J.value)return"";const t=T.value.length,e=m.value.length;return t===e?L(e,"contact"):`${t} of ${e}`}),hasSelection:M.computed(()=>p.value.size>0),selectionText:M.computed(()=>`${L(p.value.size,"contact")} selected`),allSelected:M.computed(()=>T.value.length>0&&T.value.every(t=>p.value.has(t.id)))};w.add(M.effect(()=>we.write(x.value)));const $e=(t,e)=>{x.value=ye({...x.peek(),[t]:e})},B=t=>m.peek().find(e=>e.id===t),Ce=t=>{m.value=m.peek().map(e=>e.id===t.id?t:e)};async function V(){J.value=!0;try{const[t,e]=await Promise.all([h("/contacts/contacts"),h("/contacts/groups")]);m.value=Array.isArray(t)?t:t?.data??[],v.value=Array.isArray(e)?e:[]}catch{E.toast("Failed to load contacts.",{type:"error"})}finally{J.value=!1}}async function xe(){try{v.value=await h("/contacts/groups")}catch{}}async function z(t,e,a){try{return Ce(await h(`/contacts/contacts/${t}`,"PUT",e)),a&&E.toast(a,{type:"success"}),!0}catch(s){return E.toast(s?.message||"Failed to update the contact.",{type:"error"}),!1}}async function ie(t){const e=B(t);if(x.peek().confirmDelete&&!await E.confirm("Delete this contact? This cannot be undone."))return!1;try{await h(`/contacts/contacts/${t}`,"DELETE"),m.value=m.peek().filter(s=>s.id!==t);const a=new Set(p.peek());return a.delete(t)&&(p.value=a),E.toast(`${e?.name?"Contact":"It"} deleted.`,{type:"success"}),!0}catch{return E.toast("Failed to delete the contact.",{type:"error"}),!1}}async function Te(t){const e=B(t);if(e)try{const a=await h("/contacts/contacts","POST",Qe(e));m.value=[...m.peek(),a],E.toast("Copied - the copy is open to edit.",{type:"success"}),W(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function K(t){if(v.peek().includes(t))return!0;try{return await h("/contacts/groups","POST",{name:t}),await xe(),!0}catch(e){return E.toast(e?.message||"Could not create the group.",{type:"error"}),!1}}async function Ae(t,e,a){const s=B(t);s&&(a&&!await K(e)||await z(t,{groups:oe(s.groups,e,a)},a?`Added to ${e}.`:`Removed from ${e}.`))}async function de(t){const e=String(await E.prompt("New group name:")??"").trim();if(!(!e||!await K(e))){for(const a of t){const s=B(a);s&&!(s.groups||[]).includes(e)&&await z(a,{groups:oe(s.groups,e,!0)})}E.toast(`Filed in ${e}.`,{type:"success"})}}async function pe(t,e){try{await navigator.clipboard.writeText(t),E.toast(`${e} copied.`,{type:"success"})}catch{E.toast("The browser would not allow copying.",{type:"error"})}}async function Ne(){const t=[...p.peek()];if(t.length&&await E.confirm(`Delete ${L(t.length,"contact")}? This cannot be undone.`))try{const e=await h("/contacts/contacts/bulk","DELETE",{ids:t});m.value=m.peek().filter(a=>!t.includes(a.id)),p.value=new Set,E.toast(`${L(e?.deleted??t.length,"contact")} deleted.`,{type:"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function Le(t){const e=[...p.peek()];if(!(!e.length||!await K(t))){for(const a of e){const s=B(a);s&&!(s.groups||[]).includes(t)&&await z(a,{groups:oe(s.groups,t,!0)})}E.toast(`${L(e.length,"contact")} filed in ${t}.`,{type:"success"})}}const ue=t=>{const e=new Set(p.peek());e.delete(t)||e.add(t),p.value=e};async function Be(){try{const t=await h("/contacts/export"),e=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),a=URL.createObjectURL(e),s=document.createElement("a");s.href=a,s.download="contacts.json",document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(a),1e3),E.toast("Contacts exported.",{type:"success"})}catch{E.toast("Export failed.",{type:"error"})}}const Q=d.querySelector(".ct-import-file");Q.addEventListener("change",async()=>{const t=Q.files?.[0];if(Q.value="",!!t)try{const e=JSON.parse(await t.text()),a=Array.isArray(e)?e:e.contacts;if(!Array.isArray(a))throw new Error("Invalid format");const s=await h("/contacts/import","POST",{contacts:a});E.toast(`Imported ${L(s?.imported??0,"contact")}.`,{type:"success"}),await V()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function W(t){const e=t?B(t):null;if(t&&!e)return;const a=E.slideover({title:e?"Edit contact":"New contact",size:"lg",position:"right"}),s=(o,c,{type:P="text",mono:f=!1,placeholder:y="",wide:C=!1}={})=>`
21
+ <div${C?' class="ct-wide"':""}>
22
22
  <label class="form-label" for="ct-f-${o}">${c}</label>
23
- <input id="ct-f-${o}" type="${L}" class="form-input${f?" font-mono":""}" placeholder="${v}" data-model="${o}.value">
24
- </div>`,u=o=>`
23
+ <input id="ct-f-${o}" type="${P}" class="form-input${f?" font-mono":""}" placeholder="${y}" data-model="${o}.value">
24
+ </div>`,l=o=>`
25
25
  <div class="ct-repeater" data-repeater="${o}">
26
26
  <div class="ct-subhead">
27
- <strong>${T[o].title} <span data-help="${T[o].help}"></span></strong>
27
+ <strong>${N[o].title} <span data-help="${N[o].help}"></span></strong>
28
28
  <button type="button" class="btn btn-sm btn-ghost" data-add="${o}">
29
- <span data-icon="plus" data-icon-size="13"></span> ${T[o].add}</button>
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[o].empty}</p>
33
- </div>`,r=oe(`
32
+ <p class="text-muted text-sm contacts-empty-row">${N[o].empty}</p>
33
+ </div>`,r=se(`
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
- ${u("addresses")}
101
+ ${l("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
- ${u("emails")}
109
- ${u("phones")}
108
+ ${l("emails")}
109
+ ${l("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>`),k=o=>o==null?"":String(o),n={name:M.observable(k(e?.name)),email:M.observable(k(e?.email)),phone:M.observable(k(e?.phone)),notes:M.observable(k(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const o of Object.keys(Ee))n[o]=M.observable(k(e?.[o]));const l=Object.fromEntries(Object.keys(T).map(o=>[o,M.observable(0)]));n.pick=M.observable([]);const m=M.effect(()=>{const o=n.chosen.value;n.pick.value=[...new Set([...b.value,...o])].map(c=>({name:c,on:String(o.includes(c)),cls:o.includes(c)?" is-active":""}))});n.noGroups=M.computed(()=>n.pick.value.length===0),n.cannotSave=M.computed(()=>!n.name.value.trim()),n.billingSummary=M.computed(()=>{const o=[];n.company.value.trim()&&o.push(n.company.value.trim()),n.vatNumber.value.trim()&&o.push("VAT"),(n.sortCode.value.trim()||n.iban.value.trim())&&o.push("bank details");const c=l.addresses.value;return c&&o.push(A(c,"address","addresses")),o.join(" \xB7 ")}),n.extraSummary=M.computed(()=>{const o=l.emails.value+l.phones.value;return o?A(o,"more"):""});function C(o,c){const L=T[o],f=r.querySelector(`[data-repeater="${o}"]`),v=f.querySelector(".ct-repeater-list");v.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=c.length?"none":"",c.forEach((w,B)=>{const P=document.createElement("div");P.className="contacts-row";for(const y of L.columns){if(y.check){const X=document.createElement("label");X.className="form-check contacts-row-check";const G=document.createElement("input");G.type="checkbox",G.className="form-check-input",G.dataset.key=y.key,G.checked=w[y.key]===!0,G.addEventListener("change",()=>{G.checked&&v.querySelectorAll(`input[data-key="${y.key}"]`).forEach(ve=>{ve!==G&&(ve.checked=!1)})});const Z=document.createElement("span");Z.className="form-check-label",Z.textContent=y.label,X.append(G,Z),P.appendChild(X);continue}const K=document.createElement("input");K.type="text",K.className="form-input"+(y.wide?" contacts-row-wide":""),K.dataset.key=y.key,K.placeholder=y.placeholder??"",K.value=w[y.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 y=j(o,{keepBlank:!0});y.splice(B,1),C(o,y)}),P.appendChild(z),v.appendChild(P)}),l[o].value=c.length}function j(o,{keepBlank:c=!1}={}){const L=T[o],f=[...r.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map(v=>{const w={};for(const B of L.columns){const P=v.querySelector(`[data-key="${B.key}"]`);w[B.key]=B.check?P?.checked===!0:(P?.value??"").trim()}return w});return c?f:f.filter(v=>Object.entries(v).some(([w,B])=>w!=="label"&&B!==""&&B!==!1))}r.addEventListener("click",o=>{const c=o.target.closest("[data-add]");if(c){const f=c.dataset.add;C(f,[...j(f,{keepBlank:!0}),{}]),r.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const L=o.target.closest(".ct-pick [data-group]");if(L){const f=L.dataset.group,v=n.chosen.peek();n.chosen.value=v.includes(f)?v.filter(w=>w!==f):[...v,f]}});const V=()=>{const o={name:n.name.peek().trim(),email:n.email.peek().trim(),phone:n.phone.peek().trim(),notes:n.notes.peek().trim(),favourite:n.favourite.peek()===!0,groups:[...n.chosen.peek()]};for(const c of Object.keys(Ee))o[c]=String(n[c].peek()??"").trim();for(const c of Object.keys(T))o[c]=j(c);return o};let Q=!1;const be=async()=>{if(!(Q||n.cannotSave.peek())){Q=!0;try{const o=V();for(const c of o.groups)b.peek().includes(c)||await F(c);if(e){if(!await U(e.id,o,"Contact saved."))return}else{const c=await h("/contacts/contacts","POST",o);p.value=[...p.peek(),c],E.toast("Contact added.",{type:"success"})}a.close()}catch(o){E.toast(o?.message||"Failed to save the contact.",{type:"error"})}finally{Q=!1}}};a.element.appendChild(r),e&&ze(r.querySelector(".related-box"),"contact",e.id,{onOpen:()=>a.close(),name:e.name});const qe=_(r);for(const o of Object.keys(T))C(o,Array.isArray(e?.[o])?e[o]:[]);r.querySelector(".ct-pick").innerHTML=tt;const Ue=M.applyBindings(n,r,{methods:{save:be,cancel:()=>a.close(),del:async()=>{e&&await re(e.id)&&a.close()},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await F(o)||n.chosen.peek().includes(o)||(n.chosen.value=[...n.chosen.peek(),o])}}});n.billingSummary.peek()&&(r.querySelector(".ct-section").open=!0),r.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),be())}),I.scan(r),ee(a,()=>{m?.(),Ue.dispose(),qe()}),a.open(),setTimeout(()=>r.querySelector("#ct-f-name")?.focus(),60)}function Pe(){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),e=(n,l,m)=>`
124
+ </div>`),S=o=>o==null?"":String(o),n={name:M.observable(S(e?.name)),email:M.observable(S(e?.email)),phone:M.observable(S(e?.phone)),notes:M.observable(S(e?.notes)),favourite:M.observable(e?.favourite===!0),chosen:M.observable(Array.isArray(e?.groups)?[...e.groups]:[]),isEdit:!!e,meta:e?`Added ${e.createdAt?D(e.createdAt).format("D MMM YYYY"):"earlier"}${e.updatedAt?`, last changed ${D(e.updatedAt).format("D MMM YYYY HH:mm")}`:""}. Ctrl+Enter saves.`:"Ctrl+Enter saves."};for(const o of Object.keys(Ee))n[o]=M.observable(S(e?.[o]));const i=Object.fromEntries(Object.keys(N).map(o=>[o,M.observable(0)]));n.pick=M.observable([]);const O=M.effect(()=>{const o=n.chosen.value;n.pick.value=[...new Set([...v.value,...o])].map(c=>({name:c,on:String(o.includes(c)),cls:o.includes(c)?" is-active":""}))});n.noGroups=M.computed(()=>n.pick.value.length===0),n.cannotSave=M.computed(()=>!n.name.value.trim()),n.billingSummary=M.computed(()=>{const o=[];n.company.value.trim()&&o.push(n.company.value.trim()),n.vatNumber.value.trim()&&o.push("VAT"),(n.sortCode.value.trim()||n.iban.value.trim())&&o.push("bank details");const c=i.addresses.value;return c&&o.push(L(c,"address","addresses")),o.join(" \xB7 ")}),n.extraSummary=M.computed(()=>{const o=i.emails.value+i.phones.value;return o?L(o,"more"):""});function g(o,c){const P=N[o],f=r.querySelector(`[data-repeater="${o}"]`),y=f.querySelector(".ct-repeater-list");y.replaceChildren(),f.querySelector(".contacts-empty-row").style.display=c.length?"none":"",c.forEach((C,G)=>{const R=document.createElement("div");R.className="contacts-row";for(const k of P.columns){if(k.check){const Z=document.createElement("label");Z.className="form-check contacts-row-check";const j=document.createElement("input");j.type="checkbox",j.className="form-check-input",j.dataset.key=k.key,j.checked=C[k.key]===!0,j.addEventListener("change",()=>{j.checked&&y.querySelectorAll(`input[data-key="${k.key}"]`).forEach(he=>{he!==j&&(he.checked=!1)})});const ee=document.createElement("span");ee.className="form-check-label",ee.textContent=k.label,Z.append(j,ee),R.appendChild(Z);continue}const _=document.createElement("input");_.type="text",_.className="form-input"+(k.wide?" contacts-row-wide":""),_.dataset.key=k.key,_.placeholder=k.placeholder??"",_.value=C[k.key]??"",R.appendChild(_)}const Y=document.createElement("button");Y.type="button",Y.className="btn btn-sm btn-ghost contacts-row-remove",Y.title="Remove",Y.textContent="\xD7",Y.addEventListener("click",()=>{const k=u(o,{keepBlank:!0});k.splice(G,1),g(o,k)}),R.appendChild(Y),y.appendChild(R)}),i[o].value=c.length}function u(o,{keepBlank:c=!1}={}){const P=N[o],f=[...r.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map(y=>{const C={};for(const G of P.columns){const R=y.querySelector(`[data-key="${G.key}"]`);C[G.key]=G.check?R?.checked===!0:(R?.value??"").trim()}return C});return c?f:f.filter(y=>Object.entries(y).some(([C,G])=>C!=="label"&&G!==""&&G!==!1))}r.addEventListener("click",o=>{const c=o.target.closest("[data-add]");if(c){const f=c.dataset.add;g(f,[...u(f,{keepBlank:!0}),{}]),r.querySelector(`[data-repeater="${f}"] .contacts-row:last-child input`)?.focus();return}const P=o.target.closest(".ct-pick [data-group]");if(P){const f=P.dataset.group,y=n.chosen.peek();n.chosen.value=y.includes(f)?y.filter(C=>C!==f):[...y,f]}});const $=()=>{const o={name:n.name.peek().trim(),email:n.email.peek().trim(),phone:n.phone.peek().trim(),notes:n.notes.peek().trim(),favourite:n.favourite.peek()===!0,groups:[...n.chosen.peek()]};for(const c of Object.keys(Ee))o[c]=String(n[c].peek()??"").trim();for(const c of Object.keys(N))o[c]=u(c);return o};let b=!1;const F=async()=>{if(!(b||n.cannotSave.peek())){b=!0;try{const o=$();for(const c of o.groups)v.peek().includes(c)||await K(c);if(e){if(!await z(e.id,o,"Contact saved."))return}else{const c=await h("/contacts/contacts","POST",o);m.value=[...m.peek(),c],E.toast("Contact added.",{type:"success"})}a.close()}catch(o){E.toast(o?.message||"Failed to save the contact.",{type:"error"})}finally{b=!1}}};a.element.appendChild(r),e&&ze(r.querySelector(".related-box"),"contact",e.id,{onOpen:()=>a.close(),name:e.name});const qe=X(r);for(const o of Object.keys(N))g(o,Array.isArray(e?.[o])?e[o]:[]);r.querySelector(".ct-pick").innerHTML=tt;const Ue=M.applyBindings(n,r,{methods:{save:F,cancel:()=>a.close(),del:async()=>{e&&await ie(e.id)&&a.close()},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await K(o)||n.chosen.peek().includes(o)||(n.chosen.value=[...n.chosen.peek(),o])}}});n.billingSummary.peek()&&(r.querySelector(".ct-section").open=!0),r.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),F())}),I.scan(r),te(a,()=>{O?.(),Ue.dispose(),qe()}),a.open(),setTimeout(()=>r.querySelector("#ct-f-name")?.focus(),60)}function Oe(){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),e=(n,i,O)=>`
125
125
  <div class="ct-setting-row">
126
- <label class="form-label" for="ct-set-${n}" style="margin:0">${l}${m?` <span data-help="${m}"></span>`:""}</label>
126
+ <label class="form-label" for="ct-set-${n}" style="margin:0">${i}${O?` <span data-help="${O}"></span>`:""}</label>
127
127
  <input id="ct-set-${n}" type="checkbox" class="form-switch" data-model="${n}.value">
128
- </div>`,a=oe(`
128
+ </div>`,a=se(`
129
129
  <div class="ct-panel">
130
130
  <div>
131
131
  <label class="form-label" for="ct-set-sort">Sort by</label>
@@ -160,16 +160,18 @@ import{apiRequest as Fe}from"../api.js";import{addBannerAction as He}from"../lib
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:()=>S.value[n],write:l=>Me(n,l)}),u=Object.fromEntries(Object.keys(he).map(n=>[n,s(n)]));t.element.appendChild(a);const r=_(a),k=M.applyBindings(u,a,{methods:{done:()=>t.close(),reset:()=>{S.value={...he}}}});ee(t,()=>{k.dispose(),r()}),t.open()}function Ge(){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),e=oe(`
163
+ </div>`),s=n=>M.computed({read:()=>x.value[n],write:i=>$e(n,i)}),l=Object.fromEntries(Object.keys(ve).map(n=>[n,s(n)]));t.element.appendChild(a);const r=X(a),S=M.applyBindings(l,a,{methods:{done:()=>t.close(),reset:()=>{x.value={...ve}}}});te(t,()=>{S.dispose(),r()}),t.open()}function Pe(){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),e=se(`
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
- <p class="ct-panel-meta" style="margin:0">Renaming a group renames it on every contact in it; deleting one takes it off them.
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
+ <p class="ct-panel-meta" style="margin:0" data-bind-hidden="!someLocked.value">Some groups cannot be renamed or deleted by you.
172
+ <span data-help-title="Why is Rename greyed out?" data-help="${ot}"></span></p>
171
173
  <div class="ct-groups-list" data-if="hasGroups.value">
172
174
  <div class="ct-groups-rows" data-each="list.value key=name"></div>
173
175
  </div>
174
176
  <p class="text-muted" data-if="noGroups.value">No groups yet.</p>
175
- </div>`),a=M.observable(""),s={draft:a,list:M.observable([])},u=M.effect(()=>{const l=ce.value.groups;s.list.value=b.value.map(m=>({name:m,countText:A(l[m]??0,"contact")}))});s.hasGroups=M.computed(()=>s.list.value.length>0),s.noGroups=M.computed(()=>s.list.value.length===0),s.cannotAdd=M.computed(()=>!a.value.trim());async function r(){const l=a.peek().trim();l&&await F(l)&&(a.value="",E.toast("Group added.",{type:"success"}))}e.addEventListener("click",async l=>{const m=l.target.closest("[data-act]"),C=m?.closest("[data-group]")?.dataset.group;if(!(!m||!C)){if(m.dataset.act==="show")q.value={kind:"group",value:C},t.close();else if(m.dataset.act==="rename"){const j=String(await E.prompt("Rename the group to:",{inputValue:C})??"").trim();if(!j||j===C)return;try{await h(`/contacts/groups/${encodeURIComponent(C)}`,"PUT",{newName:j}),await Y(),E.toast("Group renamed.",{type:"success"})}catch(V){E.toast(V?.message||"Failed to rename the group.",{type:"error"})}}else if(m.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(C)}`,"DELETE"),await Y(),E.toast("Group deleted.",{type:"success"})}catch{E.toast("Failed to delete the group.",{type:"error"})}}}}),t.element.appendChild(e);const k=_(e);e.querySelector(".ct-groups-rows").innerHTML=at;const n=M.applyBindings(s,e,{methods:{add:()=>(r(),!1)}});ee(t,()=>{u?.(),n.dispose(),k()}),t.open()}He(x,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:Pe}),i.querySelector(".ct-rows").innerHTML=Ze,i.querySelector(".ct-chips").innerHTML=et;const R=i.querySelector(".ct-list"),ue=i.querySelector(".ct-bar"),Oe=i.querySelector(".ct-chips-row"),me=i.querySelector(".ct-bulk");g.add(_(i,{position:"bottom"}));const De=M.applyBindings($e,i,{methods:{create:()=>H(null),bulkDelete:Ne,clearSelection:()=>{d.value=new Set}}});g.add(()=>De.dispose()),g.add(M.effect(()=>{const t=S.value;R.classList.toggle("is-compact",t.density==="compact");const e={company:!t.showCompany,email:!t.showEmail,phone:!t.showPhone,groups:!t.showGroups};for(const[s,u]of Object.entries(e))R.classList.toggle(`hide-${s}`,u);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];e.company||a.push("minmax(0, 1fr)"),e.email||a.push("minmax(0, 1.2fr)"),e.phone||a.push("minmax(0, .9fr)"),e.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),R.style.setProperty("--ct-cols",a.join(" "))}));const Re=E.contextMenu(R,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,a=N(e);if(!a)return[];const s=ge(a),u=ke(a),r=a.groups||[],k=l=>l.filter(m=>m.visible!==!1),n=Ke(O,e,l=>l.label.replace("{name}",a.name||"them"));return k([{label:"Edit\u2026",icon:"edit",action:()=>H(e)},...n,{type:"divider"},{label:`Email ${s}`,icon:"mail",visible:!!s,action:()=>{location.href=`mailto:${encodeURIComponent(s).replace(/%40/g,"@")}`}},{label:`Call ${u}`,icon:"phone",visible:!!u,action:()=>{location.href=`tel:${u.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!s,action:()=>de(s,"Email")},{label:"Copy phone",icon:"copy",visible:!!u,action:()=>de(u,"Phone")},{type:"divider",visible:!!(s||u)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>U(e,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...b.peek().map(l=>({label:`${r.includes(l)?"\u2713 ":""}${l}`,action:()=>Ae(e,l,!r.includes(l))})),...b.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>ie([e])}]},{label:"Duplicate",icon:"copy",action:()=>Te(e)},{label:d.peek().has(e)?"Deselect":"Select",icon:"check-square",action:()=>pe(e)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>re(e)}])}});g.add(()=>Re?.destroy?.());const Ie=E.contextMenu(ue,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:Ge},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:Be},{label:"Import from file\u2026",icon:"upload",action:()=>J.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!$.peek().length,action:()=>{d.value=new Set($.peek().map(t=>t.id))}},{label:"Clear selection",icon:"x",disabled:!d.peek().size,action:()=>{d.value=new Set}}]});g.add(()=>Ie?.destroy?.());const je=E.contextMenu(me,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...b.peek().map(t=>({label:t,action:()=>Le(t)})),...b.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>ie([...d.peek()])}]});g.add(()=>je?.destroy?.()),ue.addEventListener("click",t=>{const e=t.target.closest(".ct-more");e&&te(e)}),me.addEventListener("click",t=>{const e=t.target.closest(".ct-bulk-group");e&&te(e)}),Oe.addEventListener("click",t=>{const e=t.target.closest(".ct-chip");if(!e)return;const a=e.dataset.chip;q.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),R.addEventListener("click",t=>{if(t.target.closest(".ct-select-all"))return;const e=t.target.closest(".ct-row");if(!e)return;const a=e.dataset.id;if(t.target.closest(".ct-kebab"))return te(t.target.closest(".ct-kebab"));if(!t.target.closest(".ct-select")){if(t.target.closest(".ct-fav")){const s=N(a);s&&U(a,{favourite:s.favourite!==!0});return}H(a)}}),R.addEventListener("change",t=>{if(t.target.matches(".ct-select")&&pe(t.target.closest(".ct-row").dataset.id),t.target.matches(".ct-select-all")){const e=$.peek().map(a=>a.id);d.value=t.target.checked?new Set(e):new Set}}),R.addEventListener("keydown",t=>{const e=t.target.closest?.(".ct-row");e&&t.key==="Enter"&&t.target===e&&H(e.dataset.id)});const fe=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),ne.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),H(null)))};document.addEventListener("keydown",fe),g.add(()=>document.removeEventListener("keydown",fe)),I.scan(i),await Y()}};
177
+ </div>`),a=M.observable(""),s=M.observable(null),l={draft:a,list:M.observable([]),someLocked:M.observable(!1)},r=async()=>{try{const g=await h("/contacts/groups/access");s.value=new Map((g?.groups||[]).map(u=>[u.name,u.canManage===!0]))}catch{s.value=new Map}};r();const S=M.effect(()=>{const g=le.value.groups,u=s.value,$=v.value.map(b=>{const F=!(u&&u.get(b)===!0);return{name:b,countText:L(g[b]??0,"contact"),locked:F,lockTitle:F?"Only its creator, or someone allowed to manage every group, can change this group":""}});l.list.value=$,l.someLocked.value=u!==null&&$.some(b=>b.locked)});l.hasGroups=M.computed(()=>l.list.value.length>0),l.noGroups=M.computed(()=>l.list.value.length===0),l.cannotAdd=M.computed(()=>!a.value.trim());async function n(){const g=a.peek().trim();g&&await K(g)&&(await r(),a.value="",E.toast("Group added.",{type:"success"}))}e.addEventListener("click",async g=>{const u=g.target.closest("[data-act]"),$=u?.closest("[data-group]")?.dataset.group;if(!(!u||!$||u.disabled)){if(u.dataset.act==="show")H.value={kind:"group",value:$},t.close();else if(u.dataset.act==="rename"){const b=String(await E.prompt("Rename the group to:",{inputValue:$})??"").trim();if(!b||b===$)return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"PUT",{newName:b}),await V(),await r(),E.toast("Group renamed.",{type:"success"})}catch(F){E.toast(F?.message||"Failed to rename the group.",{type:"error"})}}else if(u.dataset.act==="delete"){if(!await E.confirm("Delete this group? Its contacts stay - they are just no longer in it."))return;try{await h(`/contacts/groups/${encodeURIComponent($)}`,"DELETE"),await V(),await r(),E.toast("Group deleted.",{type:"success"})}catch(b){E.toast(b?.message||"Failed to delete the group.",{type:"error"})}}}}),t.element.appendChild(e);const i=X(e);e.querySelector(".ct-groups-rows").innerHTML=at;const O=M.applyBindings(l,e,{methods:{add:()=>(n(),!1)}});te(t,()=>{S?.(),O.dispose(),i()}),t.open()}He(A,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:Oe}),d.querySelector(".ct-rows").innerHTML=Ze,d.querySelector(".ct-chips").innerHTML=et;const U=d.querySelector(".ct-list"),me=d.querySelector(".ct-bar"),Ge=d.querySelector(".ct-chips-row"),be=d.querySelector(".ct-bulk");w.add(X(d,{position:"bottom"}));const Re=M.applyBindings(Me,d,{methods:{create:()=>W(null),bulkDelete:Ne,clearSelection:()=>{p.value=new Set}}});w.add(()=>Re.dispose()),w.add(M.effect(()=>{const t=x.value;U.classList.toggle("is-compact",t.density==="compact");const e={company:!t.showCompany,email:!t.showEmail,phone:!t.showPhone,groups:!t.showGroups};for(const[s,l]of Object.entries(e))U.classList.toggle(`hide-${s}`,l);const a=["1.1rem","1.5rem","minmax(10rem, 1.4fr)"];e.company||a.push("minmax(0, 1fr)"),e.email||a.push("minmax(0, 1.2fr)"),e.phone||a.push("minmax(0, .9fr)"),e.groups||a.push("minmax(0, 1fr)"),a.push("1.75rem"),U.style.setProperty("--ct-cols",a.join(" "))}));const De=E.contextMenu(U,{cursor:"glyph",match:".ct-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,a=B(e);if(!a)return[];const s=ge(a),l=ke(a),r=a.groups||[],S=i=>i.filter(O=>O.visible!==!1),n=Ke(q,e,i=>i.label.replace("{name}",a.name||"them"));return S([{label:"Edit\u2026",icon:"edit",action:()=>W(e)},...n,{type:"divider"},{label:`Email ${s}`,icon:"mail",visible:!!s,action:()=>{location.href=`mailto:${encodeURIComponent(s).replace(/%40/g,"@")}`}},{label:`Call ${l}`,icon:"phone",visible:!!l,action:()=>{location.href=`tel:${l.replace(/[^\d+]/g,"")}`}},{label:"Copy email",icon:"copy",visible:!!s,action:()=>pe(s,"Email")},{label:"Copy phone",icon:"copy",visible:!!l,action:()=>pe(l,"Phone")},{type:"divider",visible:!!(s||l)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>z(e,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...v.peek().map(i=>({label:`${r.includes(i)?"\u2713 ":""}${i}`,action:()=>Ae(e,i,!r.includes(i))})),...v.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>de([e])}]},{label:"Duplicate",icon:"copy",action:()=>Te(e)},{label:p.peek().has(e)?"Deselect":"Select",icon:"check-square",action:()=>ue(e)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>ie(e)}])}});w.add(()=>De?.destroy?.());const Ie=E.contextMenu(me,{cursor:"glyph",match:".ct-more",inherit:!1,density:"compact",items:()=>[{label:"Manage groups\u2026",icon:"folder",action:Pe},{type:"divider"},{label:"Export all (JSON)",icon:"download",action:Be},{label:"Import from file\u2026",icon:"upload",action:()=>Q.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!T.peek().length,action:()=>{p.value=new Set(T.peek().map(t=>t.id))}},{label:"Clear selection",icon:"x",disabled:!p.peek().size,action:()=>{p.value=new Set}}]});w.add(()=>Ie?.destroy?.());const je=E.contextMenu(be,{cursor:"glyph",match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...v.peek().map(t=>({label:t,action:()=>Le(t)})),...v.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>de([...p.peek()])}]});w.add(()=>je?.destroy?.()),me.addEventListener("click",t=>{const e=t.target.closest(".ct-more");e&&ae(e)}),be.addEventListener("click",t=>{const e=t.target.closest(".ct-bulk-group");e&&ae(e)}),Ge.addEventListener("click",t=>{const e=t.target.closest(".ct-chip");if(!e)return;const a=e.dataset.chip;H.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),U.addEventListener("click",t=>{if(t.target.closest(".ct-select-all"))return;const e=t.target.closest(".ct-row");if(!e)return;const a=e.dataset.id;if(t.target.closest(".ct-kebab"))return ae(t.target.closest(".ct-kebab"));if(!t.target.closest(".ct-select")){if(t.target.closest(".ct-fav")){const s=B(a);s&&z(a,{favourite:s.favourite!==!0});return}W(a)}}),U.addEventListener("change",t=>{if(t.target.matches(".ct-select")&&ue(t.target.closest(".ct-row").dataset.id),t.target.matches(".ct-select-all")){const e=T.peek().map(a=>a.id);p.value=t.target.checked?new Set(e):new Set}}),U.addEventListener("keydown",t=>{const e=t.target.closest?.(".ct-row");e&&t.key==="Enter"&&t.target===e&&W(e.dataset.id)});const fe=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),ce.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),W(null)))};document.addEventListener("keydown",fe),w.add(()=>document.removeEventListener("keydown",fe)),I.scan(d),await V()}};