domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,174 @@
1
+ import{apiRequest as Ue}from"../api.js";import{addBannerAction as Fe}from"../lib/plugin-chrome.js";import{helpPopovers as J}from"../lib/help-popover.js";import{onceClosed as Z,openMenuAt as ee,settingsStore as He,viewLifetime as ze}from"../lib/tool-kit.js";import{arrangeContacts as Ke,countContacts as We,DEFAULT_SETTINGS as ve,duplicateBody as Ye,initials as Je,normaliseSettings as ye,primaryEmail as he,primaryPhone as ge,withGroup as te}from"../lib/contacts-arrange.js";const ke=He("contacts.settings",ye),h=(x,r="GET",m)=>Ue(x,{method:r,...m!==void 0?{body:JSON.stringify(m)}:{}}),we={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}]}},_e=`
2
+ <div class="ct-row{{selClass}}" data-id="{{id}}" tabindex="0">
3
+ <input type="checkbox" class="ct-select" aria-label="Select {{name}}" data-bind-checked="selected">
4
+ <button type="button" class="ct-fav{{favClass}}" title="{{favTitle}}" aria-label="{{favTitle}}">\u2605</button>
5
+ <span class="ct-name">
6
+ <span class="ct-avatar" aria-hidden="true">{{initials}}</span>
7
+ <span class="ct-name-text"><strong>{{name}}</strong><small>{{sub}}</small></span>
8
+ </span>
9
+ <span class="ct-col ct-company" title="{{company}}">{{company}}</span>
10
+ <span class="ct-col ct-email" title="{{email}}">{{email}}</span>
11
+ <span class="ct-col ct-phone">{{phone}}</span>
12
+ <span class="ct-col ct-groups"><span class="ct-groups-text" title="{{groupsText}}">{{groupsText}}</span></span>
13
+ <button type="button" class="ct-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
14
+ </div>`,Ve='<button type="button" class="ct-chip{{cls}}" data-chip="{{key}}" title="{{title}}">{{label}} <b>{{count}}</b></button>',Qe='<button type="button" class="ct-chip{{cls}}" data-group="{{name}}" aria-pressed="{{on}}">{{name}}</button>',Xe=`
15
+ <div class="ct-group-row" data-group="{{name}}">
16
+ <span>{{name}}</span><small>{{countText}}</small>
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 ae(x){const r=document.createElement("template");return r.innerHTML=x.trim(),r.content.firstElementChild}const A=(x,r,m=`${r}s`)=>`${x} ${x===1?r:m}`,k=ze(/^#\/contacts\b/);export const contactsView={templateUrl:"/admin/js/templates/contacts.html",async onMount(x){k.reset();const r=x.get(0),m=M.observable(ke.read()),p=M.observable([]),b=M.observable([]),K=M.observable(!0),oe=M.observable(""),j=M.observable({kind:"all"}),i=M.observable(new Set),se=M.observable(!1),ne=M.computed(()=>We(p.value,b.value)),Ee=M.computed(()=>Ke(p.value,{query:oe.value,filter:j.value,sort:m.value.sort,favouritesFirst:m.value.favouritesFirst})),S=M.observable([]);k.add(M.effect(()=>{const t=i.value;S.value=Ee.value.map(e=>({id:e.id,name:e.name||"Unnamed",sub:[e.jobTitle,e.company&&!m.value.showCompany?e.company:""].filter(Boolean).join(" \xB7 "),initials:Je(e.name),company:e.company||"",email:he(e),phone:ge(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 ce=M.observable([]);k.add(M.effect(()=>{const t=ne.value,e=j.value;ce.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":""}))]})),k.add(M.effect(()=>{const t=j.value;t.kind==="group"&&!b.value.includes(t.value)&&(j.value={kind:"all"})}));const Se={rows:S,chips:ce,loading:K,query:oe,searchFocus:se,hasRows:M.computed(()=>S.value.length>0),isEmpty:M.computed(()=>!K.value&&S.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(K.value)return"";const t=S.value.length,e=p.value.length;return t===e?A(e,"contact"):`${t} of ${e}`}),hasSelection:M.computed(()=>i.value.size>0),selectionText:M.computed(()=>`${A(i.value.size,"contact")} selected`),allSelected:M.computed(()=>S.value.length>0&&S.value.every(t=>i.value.has(t.id)))};k.add(M.effect(()=>ke.write(m.value)));const $e=(t,e)=>{m.value=ye({...m.peek(),[t]:e})},N=t=>p.peek().find(e=>e.id===t),Me=t=>{p.value=p.peek().map(e=>e.id===t.id?t:e)};async function W(){K.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{K.value=!1}}async function Ce(){try{b.value=await h("/contacts/groups")}catch{}}async function q(t,e,a){try{return Me(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 le(t){const e=N(t);if(m.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(i.peek());return a.delete(t)&&(i.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 xe(t){const e=N(t);if(e)try{const a=await h("/contacts/contacts","POST",Ye(e));p.value=[...p.peek(),a],E.toast("Copied - the copy is open to edit.",{type:"success"}),F(a.id)}catch{E.toast("Failed to duplicate the contact.",{type:"error"})}}async function U(t){if(b.peek().includes(t))return!0;try{return await h("/contacts/groups","POST",{name:t}),await Ce(),!0}catch(e){return E.toast(e?.message||"Could not create the group.",{type:"error"}),!1}}async function Te(t,e,a){const s=N(t);s&&(a&&!await U(e)||await q(t,{groups:te(s.groups,e,a)},a?`Added to ${e}.`:`Removed from ${e}.`))}async function re(t){const e=String(await E.prompt("New group name:")??"").trim();if(!(!e||!await U(e))){for(const a of t){const s=N(a);s&&!(s.groups||[]).includes(e)&&await q(a,{groups:te(s.groups,e,!0)})}E.toast(`Filed in ${e}.`,{type:"success"})}}async function ie(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 Ae(){const t=[...i.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)),i.value=new Set,E.toast(`${A(e?.deleted??t.length,"contact")} deleted.`,{type:"success"})}catch{E.toast("Bulk delete failed.",{type:"error"})}}async function Ne(t){const e=[...i.peek()];if(!(!e.length||!await U(t))){for(const a of e){const s=N(a);s&&!(s.groups||[]).includes(t)&&await q(a,{groups:te(s.groups,t,!0)})}E.toast(`${A(e.length,"contact")} filed in ${t}.`,{type:"success"})}}const de=t=>{const e=new Set(i.peek());e.delete(t)||e.add(t),i.value=e};async function Le(){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 Y=r.querySelector(".ct-import-file");Y.addEventListener("change",async()=>{const t=Y.files?.[0];if(Y.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 W()}catch{E.toast("Import failed. The file must be a contacts JSON export.",{type:"error"})}});function F(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:y="",wide:w=!1}={})=>`
21
+ <div${w?' class="ct-wide"':""}>
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="${y}" data-model="${o}.value">
24
+ </div>`,u=o=>`
25
+ <div class="ct-repeater" data-repeater="${o}">
26
+ <div class="ct-subhead">
27
+ <strong>${T[o].title} <span data-help="${T[o].help}"></span></strong>
28
+ <button type="button" class="btn btn-sm btn-ghost" data-add="${o}">
29
+ <span data-icon="plus" data-icon-size="13"></span> ${T[o].add}</button>
30
+ </div>
31
+ <div class="ct-repeater-list"></div>
32
+ <p class="text-muted text-sm contacts-empty-row">${T[o].empty}</p>
33
+ </div>`,l=ae(`
34
+ <div class="ct-panel">
35
+ <div class="ct-grid">
36
+ <div class="ct-wide">
37
+ <label class="form-label" for="ct-f-name">Name <span class="text-danger">*</span></label>
38
+ <input id="ct-f-name" type="text" class="form-input" placeholder="Full name" data-model="name.value">
39
+ </div>
40
+ ${s("email","Email",{type:"email",placeholder:"email@example.com"})}
41
+ ${s("phone","Phone",{placeholder:"+44 \u2026"})}
42
+ <div class="ct-wide">
43
+ <label class="form-label" for="ct-f-notes">Notes</label>
44
+ <textarea id="ct-f-notes" class="form-input" rows="3" placeholder="Optional notes\u2026" data-model="notes.value"></textarea>
45
+ </div>
46
+ </div>
47
+ <div class="ct-setting-row">
48
+ <label class="form-label" for="ct-f-fav" style="margin:0">Starred
49
+ <span data-help="Starred contacts sit at the top of the list (unless switched off in settings) and have their own chip."></span>
50
+ </label>
51
+ <input id="ct-f-fav" type="checkbox" class="form-switch" data-model="favourite.value">
52
+ </div>
53
+ <div>
54
+ <div class="ct-subhead">
55
+ <strong>Groups <span data-help="Click a group to file this contact in it, again to take it out. A contact can be in several."></span></strong>
56
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="newGroup">
57
+ <span data-icon="folder-plus" data-icon-size="13"></span> New group</button>
58
+ </div>
59
+ <div class="ct-pick" data-each="pick.value key=name"></div>
60
+ <p class="text-muted text-sm" data-if="noGroups.value" style="margin:0">No groups yet - New group makes one.</p>
61
+ </div>
62
+
63
+ <details class="ct-section">
64
+ <summary>Company, bank &amp; invoicing <span class="ct-section-sum" data-bind-text="billingSummary.value"></span></summary>
65
+ <div class="ct-section-body">
66
+ <p class="ct-panel-meta" style="margin:0">Everything an invoice needs. The Invoice plugin reads these when you bill this contact.</p>
67
+ <div class="ct-grid">
68
+ ${s("company","Company",{placeholder:"Acme Ltd"})}
69
+ ${s("jobTitle","Job title",{placeholder:"Finance Manager"})}
70
+ ${s("website","Website",{placeholder:"acme.co.uk"})}
71
+ ${s("billingEmail","Billing email",{type:"email",placeholder:"accounts@acme.co.uk"})}
72
+ ${s("vatNumber","VAT number",{mono:!0,placeholder:"GB123456789"})}
73
+ ${s("companyNumber","Company number",{mono:!0,placeholder:"01234567"})}
74
+ ${s("reference","Their reference",{placeholder:"PO-4471"})}
75
+ <div>
76
+ <label class="form-label" for="ct-f-paymentTerms">Payment terms (days)
77
+ <span data-help="Days they have to pay. Empty uses the invoice default."></span></label>
78
+ <input id="ct-f-paymentTerms" type="number" min="0" max="365" class="form-input" placeholder="30" data-model="paymentTerms.value">
79
+ </div>
80
+ <div>
81
+ <label class="form-label" for="ct-f-currency">Preferred currency</label>
82
+ <select id="ct-f-currency" class="form-input" data-model="currency.value">
83
+ <option value="">Use the invoice default</option>
84
+ <option value="GBP">GBP - British Pound</option>
85
+ <option value="EUR">EUR - Euro</option>
86
+ <option value="USD">USD - US Dollar</option>
87
+ </select>
88
+ </div>
89
+ </div>
90
+ <div>
91
+ <div class="ct-subhead"><strong>Bank details <span data-help="Printed on an invoice so it can be paid."></span></strong></div>
92
+ <div class="ct-grid">
93
+ ${s("accountName","Account name",{placeholder:"Acme Ltd"})}
94
+ ${s("bankName","Bank",{placeholder:"Barclays"})}
95
+ ${s("sortCode","Sort code",{mono:!0,placeholder:"20-00-00"})}
96
+ ${s("accountNumber","Account number",{mono:!0,placeholder:"12345678"})}
97
+ ${s("iban","IBAN",{mono:!0,placeholder:"GB29 NWBK 6016 1331 9268 19"})}
98
+ ${s("bic","BIC / SWIFT",{mono:!0,placeholder:"NWBKGB2L"})}
99
+ </div>
100
+ </div>
101
+ ${u("addresses")}
102
+ </div>
103
+ </details>
104
+
105
+ <details class="ct-section">
106
+ <summary>More emails &amp; phones <span class="ct-section-sum" data-bind-text="extraSummary.value"></span></summary>
107
+ <div class="ct-section-body">
108
+ ${u("emails")}
109
+ ${u("phones")}
110
+ </div>
111
+ </details>
112
+
113
+ <small class="ct-panel-meta" data-bind-text="meta"></small>
114
+ <div class="ct-panel-actions">
115
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="del" data-if="isEdit">
116
+ <span data-icon="trash" data-icon-size="13"></span> Delete
117
+ </button>
118
+ <span class="spacer"></span>
119
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
120
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannotSave.value">
121
+ <span data-icon="save" data-icon-size="13"></span> Save</button>
122
+ </div>
123
+ </div>`),$=o=>o==null?"":String(o),n={name:M.observable($(e?.name)),email:M.observable($(e?.email)),phone:M.observable($(e?.phone)),notes:M.observable($(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(we))n[o]=M.observable($(e?.[o]));const d=Object.fromEntries(Object.keys(T).map(o=>[o,M.observable(0)]));n.pick=M.observable([]);const v=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=d.addresses.value;return c&&o.push(A(c,"address","addresses")),o.join(" \xB7 ")}),n.extraSummary=M.computed(()=>{const o=d.emails.value+d.phones.value;return o?A(o,"more"):""});function C(o,c){const L=T[o],f=l.querySelector(`[data-repeater="${o}"]`),y=f.querySelector(".ct-repeater-list");y.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 g of L.columns){if(g.check){const Q=document.createElement("label");Q.className="form-check contacts-row-check";const G=document.createElement("input");G.type="checkbox",G.className="form-check-input",G.dataset.key=g.key,G.checked=w[g.key]===!0,G.addEventListener("change",()=>{G.checked&&y.querySelectorAll(`input[data-key="${g.key}"]`).forEach(be=>{be!==G&&(be.checked=!1)})});const X=document.createElement("span");X.className="form-check-label",X.textContent=g.label,Q.append(G,X),P.appendChild(Q);continue}const z=document.createElement("input");z.type="text",z.className="form-input"+(g.wide?" contacts-row-wide":""),z.dataset.key=g.key,z.placeholder=g.placeholder??"",z.value=w[g.key]??"",P.appendChild(z)}const H=document.createElement("button");H.type="button",H.className="btn btn-sm btn-ghost contacts-row-remove",H.title="Remove",H.textContent="\xD7",H.addEventListener("click",()=>{const g=R(o,{keepBlank:!0});g.splice(B,1),C(o,g)}),P.appendChild(H),y.appendChild(P)}),d[o].value=c.length}function R(o,{keepBlank:c=!1}={}){const L=T[o],f=[...l.querySelectorAll(`[data-repeater="${o}"] .contacts-row`)].map(y=>{const w={};for(const B of L.columns){const P=y.querySelector(`[data-key="${B.key}"]`);w[B.key]=B.check?P?.checked===!0:(P?.value??"").trim()}return w});return c?f:f.filter(y=>Object.entries(y).some(([w,B])=>w!=="label"&&B!==""&&B!==!1))}l.addEventListener("click",o=>{const c=o.target.closest("[data-add]");if(c){const f=c.dataset.add;C(f,[...R(f,{keepBlank:!0}),{}]),l.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,y=n.chosen.peek();n.chosen.value=y.includes(f)?y.filter(w=>w!==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(we))o[c]=String(n[c].peek()??"").trim();for(const c of Object.keys(T))o[c]=R(c);return o};let V=!1;const fe=async()=>{if(!(V||n.cannotSave.peek())){V=!0;try{const o=_();for(const c of o.groups)b.peek().includes(c)||await U(c);if(e){if(!await q(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{V=!1}}};a.element.appendChild(l);const je=J(l);for(const o of Object.keys(T))C(o,Array.isArray(e?.[o])?e[o]:[]);l.querySelector(".ct-pick").innerHTML=Qe;const qe=M.applyBindings(n,l,{methods:{save:fe,cancel:()=>a.close(),del:async()=>{e&&await le(e.id)&&a.close()},async newGroup(){const o=String(await E.prompt("New group name:")??"").trim();!o||!await U(o)||n.chosen.peek().includes(o)||(n.chosen.value=[...n.chosen.peek(),o])}}});n.billingSummary.peek()&&(l.querySelector(".ct-section").open=!0),l.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),fe())}),I.scan(l),Z(a,()=>{v?.(),qe.dispose(),je()}),a.open(),setTimeout(()=>l.querySelector("#ct-f-name")?.focus(),60)}function Be(){const t=E.slideover({title:"Contacts settings",size:"sm",position:"right"}),e=(n,d,v)=>`
124
+ <div class="ct-setting-row">
125
+ <label class="form-label" for="ct-set-${n}" style="margin:0">${d}${v?` <span data-help="${v}"></span>`:""}</label>
126
+ <input id="ct-set-${n}" type="checkbox" class="form-switch" data-model="${n}.value">
127
+ </div>`,a=ae(`
128
+ <div class="ct-panel">
129
+ <div>
130
+ <label class="form-label" for="ct-set-sort">Sort by</label>
131
+ <select id="ct-set-sort" class="form-input" data-model="sort.value">
132
+ <option value="name">Name</option>
133
+ <option value="company">Company</option>
134
+ <option value="added">Newest added first</option>
135
+ <option value="updated">Recently changed first</option>
136
+ </select>
137
+ </div>
138
+ ${e("favouritesFirst","Starred first","Starred contacts sit at the top, whatever the sort.")}
139
+ <div>
140
+ <label class="form-label" for="ct-set-density">Rows
141
+ <span data-help="Compact fits more on the screen: smaller avatars and no job title line."></span></label>
142
+ <select id="ct-set-density" class="form-input" data-model="density.value">
143
+ <option value="comfortable">Comfortable</option>
144
+ <option value="compact">Compact</option>
145
+ </select>
146
+ </div>
147
+ <div>
148
+ <div class="form-label">Columns
149
+ <span data-help="Name is always shown. On a narrow screen only the name shows; the rest is a click away."></span></div>
150
+ ${e("showCompany","Company")}
151
+ ${e("showEmail","Email")}
152
+ ${e("showPhone","Phone")}
153
+ ${e("showGroups","Groups")}
154
+ </div>
155
+ ${e("confirmDelete","Confirm before deleting","Off deletes a single contact straight away - there is no undo. Deleting several always asks.")}
156
+ <small class="ct-panel-meta">Saved in this browser as you change them.</small>
157
+ <div class="ct-panel-actions">
158
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="reset">Reset to defaults</button>
159
+ <span class="spacer"></span>
160
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
161
+ </div>
162
+ </div>`),s=n=>M.computed({read:()=>m.value[n],write:d=>$e(n,d)}),u=Object.fromEntries(Object.keys(ve).map(n=>[n,s(n)]));t.element.appendChild(a);const l=J(a),$=M.applyBindings(u,a,{methods:{done:()=>t.close(),reset:()=>{m.value={...ve}}}});Z(t,()=>{$.dispose(),l()}),t.open()}function Pe(){const t=E.slideover({title:"Groups",size:"sm",position:"right"}),e=ae(`
163
+ <div class="ct-panel">
164
+ <form class="ct-add-group" data-on-submit="add">
165
+ <input type="text" class="form-input" placeholder="New group name\u2026" aria-label="New group name" data-model="draft.value">
166
+ <button type="submit" class="btn btn-sm btn-primary" data-bind-disabled="cannotAdd.value">Add</button>
167
+ </form>
168
+ <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
+ <span data-help="Contacts themselves are never deleted from here."></span></p>
170
+ <div class="ct-groups-list" data-if="hasGroups.value">
171
+ <div class="ct-groups-rows" data-each="list.value key=name"></div>
172
+ </div>
173
+ <p class="text-muted" data-if="noGroups.value">No groups yet.</p>
174
+ </div>`),a=M.observable(""),s={draft:a,list:M.observable([])},u=M.effect(()=>{const d=ne.value.groups;s.list.value=b.value.map(v=>({name:v,countText:A(d[v]??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 l(){const d=a.peek().trim();d&&await U(d)&&(a.value="",E.toast("Group added.",{type:"success"}))}e.addEventListener("click",async d=>{const v=d.target.closest("[data-act]"),C=v?.closest("[data-group]")?.dataset.group;if(!(!v||!C)){if(v.dataset.act==="show")j.value={kind:"group",value:C},t.close();else if(v.dataset.act==="rename"){const R=String(await E.prompt("Rename the group to:",{inputValue:C})??"").trim();if(!R||R===C)return;try{await h(`/contacts/groups/${encodeURIComponent(C)}`,"PUT",{newName:R}),await W(),E.toast("Group renamed.",{type:"success"})}catch(_){E.toast(_?.message||"Failed to rename the group.",{type:"error"})}}else if(v.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 W(),E.toast("Group deleted.",{type:"success"})}catch{E.toast("Failed to delete the group.",{type:"error"})}}}}),t.element.appendChild(e);const $=J(e);e.querySelector(".ct-groups-rows").innerHTML=Xe;const n=M.applyBindings(s,e,{methods:{add:()=>(l(),!1)}});Z(t,()=>{u?.(),n.dispose(),$()}),t.open()}Fe(x,{id:"contacts-settings",icon:"settings",title:"Contacts settings",onClick:Be}),r.querySelector(".ct-rows").innerHTML=_e,r.querySelector(".ct-chips").innerHTML=Ve;const O=r.querySelector(".ct-list"),pe=r.querySelector(".ct-bar"),Ge=r.querySelector(".ct-chips-row"),ue=r.querySelector(".ct-bulk");k.add(J(r,{position:"bottom"}));const Oe=M.applyBindings(Se,r,{methods:{create:()=>F(null),bulkDelete:Ae,clearSelection:()=>{i.value=new Set}}});k.add(()=>Oe.dispose()),k.add(M.effect(()=>{const t=m.value;O.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))O.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"),O.style.setProperty("--ct-cols",a.join(" "))}));const De=E.contextMenu(O,{match:".ct-row",inherit:!1,density:"compact",items:t=>{const e=t.dataset.id,a=N(e);if(!a)return[];const s=he(a),u=ge(a),l=a.groups||[];return(n=>n.filter(d=>d.visible!==!1))([{label:"Edit\u2026",icon:"edit",action:()=>F(e)},{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:()=>ie(s,"Email")},{label:"Copy phone",icon:"copy",visible:!!u,action:()=>ie(u,"Phone")},{type:"divider",visible:!!(s||u)},{label:a.favourite===!0?"Unstar":"Star",icon:"star",action:()=>q(e,{favourite:a.favourite!==!0})},{label:"Groups",icon:"folder",submenu:[...b.peek().map(n=>({label:`${l.includes(n)?"\u2713 ":""}${n}`,action:()=>Te(e,n,!l.includes(n))})),...b.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>re([e])}]},{label:"Duplicate",icon:"copy",action:()=>xe(e)},{label:i.peek().has(e)?"Deselect":"Select",icon:"check-square",action:()=>de(e)},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>le(e)}])}});k.add(()=>De?.destroy?.());const Re=E.contextMenu(pe,{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:Le},{label:"Import from file\u2026",icon:"upload",action:()=>Y.click()},{type:"divider"},{label:"Select all shown",icon:"check-square",disabled:!S.peek().length,action:()=>{i.value=new Set(S.peek().map(t=>t.id))}},{label:"Clear selection",icon:"x",disabled:!i.peek().size,action:()=>{i.value=new Set}}]});k.add(()=>Re?.destroy?.());const Ie=E.contextMenu(ue,{match:".ct-bulk-group",inherit:!1,density:"compact",items:()=>[...b.peek().map(t=>({label:t,action:()=>Ne(t)})),...b.peek().length?[{type:"divider"}]:[],{label:"New group\u2026",action:()=>re([...i.peek()])}]});k.add(()=>Ie?.destroy?.()),pe.addEventListener("click",t=>{const e=t.target.closest(".ct-more");e&&ee(e)}),ue.addEventListener("click",t=>{const e=t.target.closest(".ct-bulk-group");e&&ee(e)}),Ge.addEventListener("click",t=>{const e=t.target.closest(".ct-chip");if(!e)return;const a=e.dataset.chip;j.value=a==="fav"?{kind:"fav"}:a.startsWith("group:")?{kind:"group",value:a.slice(6)}:{kind:"all"}}),O.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 ee(t.target.closest(".ct-kebab"));if(!t.target.closest(".ct-select")){if(t.target.closest(".ct-fav")){const s=N(a);s&&q(a,{favourite:s.favourite!==!0});return}F(a)}}),O.addEventListener("change",t=>{if(t.target.matches(".ct-select")&&de(t.target.closest(".ct-row").dataset.id),t.target.matches(".ct-select-all")){const e=S.peek().map(a=>a.id);i.value=t.target.checked?new Set(e):new Set}}),O.addEventListener("keydown",t=>{const e=t.target.closest?.(".ct-row");e&&t.key==="Enter"&&t.target===e&&F(e.dataset.id)});const me=t=>{t.ctrlKey||t.metaKey||t.altKey||t.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(t.key==="/"?(t.preventDefault(),se.value=!0):(t.key==="n"||t.key==="N")&&(t.preventDefault(),F(null)))};document.addEventListener("keydown",me),k.add(()=>document.removeEventListener("keydown",me)),I.scan(r),await W()}};
@@ -0,0 +1,55 @@
1
+ import{api as v}from"../api.js";import{makeIconInput as B}from"/admin/js/lib/shortcode-modal.js";import{makePageInput as L}from"/admin/js/lib/page-picker.js";const C=[{label:"This page",actions:{navigate:"Go to a URL",reload:"Reload the page",back:"Back",forward:"Forward",print:"Print","copy-link":"Copy link to this view","site-search":"Open site search"}},{label:"Clipboard and selection",actions:{copy:"Copy the selection",cut:"Cut",paste:"Paste","select-all":"Select all"}},{label:"Collection displays",actions:{filter:"Filter the display",search:"Search this display",sort:"Sort the display",export:"Export the display","mark-as-spam":"Mark as spam"}},{label:"This site",actions:{"open-modal":"Open a block in a modal","run-action":"Run an Action"}}],q=Object.assign({},...C.map(c=>c.actions)),I={back:"greys out when there is no history behind the page",forward:"greys out unless you have gone back",copy:"greys out with nothing selected",cut:"greys out outside an editable field, or with nothing selected",paste:"greys out outside an editable field","site-search":"greys out on a site where search is unavailable",search:"greys out unless the click lands in a collection display",filter:"greys out unless the click lands in a collection display",sort:"greys out unless the click lands in a collection display",export:"greys out unless the click lands in a collection display"},M={"mark-as-spam":"needs a right-click on an entry inside a collection display"},O={navigate:{key:"url",label:"URL",placeholder:"/shop"},"run-action":{key:"slug",label:"Action slug",placeholder:"archive-entry"},"open-modal":{key:"block",label:"Block name",placeholder:"contact-card"},export:{key:"format",label:"Format",placeholder:"csv"},sort:{key:"field",label:"Field",placeholder:"price"},filter:{key:"field",label:"Field",placeholder:"category"},"mark-as-spam":{key:"field",label:"Flag field (optional)",placeholder:"spam"}};function d(c){return String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function R(c){return String(c||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64)}let g=null;function b(){if(g){try{g.destroy()}catch{}g=null}const c=document.getElementById("ctx-preview");if(c&&E.contextMenu&&E.contextMenu.registry)for(const m of E.contextMenu.registry(c))try{m.destroy()}catch{}}function j(c){return String(c||"").split(",").map(m=>m.trim()).filter(Boolean)}export const contextMenuEditorView={templateUrl:"/admin/js/templates/context-menu-editor.html",async onMount(c){E.tabs&&E.tabs(c.find("#ctx-tabs").get(0)),b();const m=location.hash.match(/^#\/context-menus\/edit\/([^?#]+)/),y=m?decodeURIComponent(m[1]):null,x=!y;let a=[];if(!x)try{const t=await v.contextMenus.get(y);document.getElementById("ctx-title").textContent=`Edit: ${t.name||t.slug}`,$("#ctx-name").val(t.name||""),$("#ctx-slug").val(t.slug).prop("disabled",!0),$("#ctx-selector").val(t.target?.selector||""),$("#ctx-match").val(t.target?.match||""),$("#ctx-exclude").val(t.target?.exclude||""),$("#ctx-inherit").val(String(t.inherit===!1?"false":t.inherit||"append")),$("#ctx-enabled").prop("checked",t.enabled!==!1),$("#ctx-accent").val(t.theme?.accent||""),$("#ctx-transition").val(t.theme?.transition||""),$("#ctx-density").val(t.theme?.density||""),$("#ctx-radius").val(t.theme?.radius||""),$("#ctx-shadow").val(t.theme?.shadow||""),$("#ctx-opacity").val(t.theme?.opacity??""),$("#ctx-pages").val((t.binding?.pages||[]).join(", ")),$("#ctx-projects").val((t.binding?.projects||[]).join(", ")),a=Array.isArray(t.items)?t.items:[]}catch(t){E.toast(`Could not load "${y}": ${t.message||t}`,{type:"error"});return}let f=!x;$("#ctx-slug").on("input",()=>{f=!0}),$("#ctx-name").on("input",function(){f||$("#ctx-slug").val(R(this.value))});function u(){const t=document.getElementById("ctx-items"),s=document.getElementById("ctx-items-empty");s.style.display=a.length?"none":"",t.replaceChildren(),a.forEach((e,i)=>{const n=e.type||"item",o=O[e.action?.type],r=document.createElement("div");r.className="card mb-2",r.innerHTML=`
2
+ <div class="card-body">
3
+ <div class="row">
4
+ <div class="col-4">
5
+ <label class="form-label">Type</label>
6
+ <select class="form-select ctx-item-type" data-i="${i}">
7
+ <option value="item"${n==="item"?" selected":""}>Item</option>
8
+ <option value="divider"${n==="divider"?" selected":""}>Divider</option>
9
+ <option value="header"${n==="header"?" selected":""}>Heading</option>
10
+ </select>
11
+ </div>
12
+ ${n==="divider"?"":`
13
+ <div class="col-8">
14
+ <label class="form-label">Text</label>
15
+ <input class="form-input ctx-item-text" data-i="${i}" type="text" value="${d(e.text)}">
16
+ </div>`}
17
+ </div>
18
+ ${n==="item"?`
19
+ <div class="row mt-2">
20
+ <div class="col-4">
21
+ <label class="form-label">Icon</label>
22
+ <div class="ctx-icon-slot" data-i="${i}"></div>
23
+ </div>
24
+ <div class="col-4">
25
+ <label class="form-label">Does</label>
26
+ <select class="form-select ctx-item-action" data-i="${i}">
27
+ ${C.map(l=>`
28
+ <optgroup label="${d(l.label)}">
29
+ ${Object.entries(l.actions).map(([S,T])=>`<option value="${S}"${e.action?.type===S?" selected":""}>${d(T)}</option>`).join("")}
30
+ </optgroup>`).join("")}
31
+ </select>
32
+ ${I[e.action?.type]?`<small class="text-muted">${d(I[e.action.type])}</small>`:""}
33
+ ${M[e.action?.type]?`<small class="text-muted">${d(M[e.action.type])}</small>`:""}
34
+ </div>
35
+ ${o?`
36
+ <div class="col-4">
37
+ <label class="form-label">${d(o.label)}</label>
38
+ ${o.key==="url"?`<div class="ctx-url-slot" data-i="${i}"></div>`:`<input class="form-input ctx-item-arg" data-i="${i}" data-key="${o.key}"
39
+ type="text" value="${d(e.action?.[o.key])}" placeholder="${d(o.placeholder)}">`}
40
+ </div>`:""}
41
+ </div>
42
+ <div class="row mt-2">
43
+ <div class="col-6">
44
+ <label class="form-check">
45
+ <input type="checkbox" class="ctx-item-danger" data-i="${i}"${e.danger?" checked":""}>
46
+ Destructive (red)
47
+ </label>
48
+ </div>
49
+ </div>`:""}
50
+ <div class="mt-2 text-right">
51
+ <button class="btn btn-sm btn-ghost ctx-item-up" data-i="${i}" ${i===0?"disabled":""}>Up</button>
52
+ <button class="btn btn-sm btn-ghost ctx-item-down" data-i="${i}" ${i===a.length-1?"disabled":""}>Down</button>
53
+ <button class="btn btn-sm btn-ghost ctx-item-remove" data-i="${i}">Remove</button>
54
+ </div>
55
+ </div>`;const w=r.querySelector(".ctx-icon-slot");if(w){const l=B("edit",e.icon||"");l.input.className+=" ctx-item-icon",l.input.dataset.i=String(i),w.appendChild(l.el)}const N=r.querySelector(".ctx-url-slot");if(N){const l=L(o.placeholder,e.action?.url||"");l.input.className+=" ctx-item-arg",l.input.dataset.i=String(i),l.input.dataset.key="url",N.appendChild(l.el)}t.appendChild(r)}),Domma.icons.scan("#ctx-items"),p()}c.on("change",".ctx-item-type",function(){const t=Number(this.dataset.i);a[t].type=this.value,this.value!=="item"?delete a[t].action:a[t].action||(a[t].action={type:"navigate"}),u()}),c.on("input",".ctx-item-text",function(){a[Number(this.dataset.i)].text=this.value,p()}),c.on("input",".ctx-item-icon",function(){a[Number(this.dataset.i)].icon=this.value,p()}),c.on("change",".ctx-item-action",function(){const t=Number(this.dataset.i);a[t].action={type:this.value},u()}),c.on("input",".ctx-item-arg",function(){const t=Number(this.dataset.i);a[t].action=a[t].action||{},a[t].action[this.dataset.key]=this.value,p()}),c.on("change",".ctx-item-danger",function(){a[Number(this.dataset.i)].danger=this.checked,p()}),c.on("click",".ctx-item-remove",function(){a.splice(Number(this.dataset.i),1),u()}),c.on("click",".ctx-item-up",function(){const t=Number(this.dataset.i);[a[t-1],a[t]]=[a[t],a[t-1]],u()}),c.on("click",".ctx-item-down",function(){const t=Number(this.dataset.i);[a[t+1],a[t]]=[a[t],a[t+1]],u()}),$("#ctx-add-item").on("click",()=>{a.push({type:"item",text:"New item",action:{type:"navigate",url:""}}),u()});function k(){const t={},s=$("#ctx-accent").val(),e=$("#ctx-transition").val(),i=$("#ctx-density").val(),n=$("#ctx-radius").val(),o=$("#ctx-shadow").val(),r=$("#ctx-opacity").val();return s&&(t.accent=s),e&&(t.transition=e),i&&(t.density=i),n&&(t.radius=n),o&&(t.shadow=o),r!==""&&r!=null&&(t.opacity=Number(r)),t}function A(){const t=$("#ctx-inherit").val(),s=j($("#ctx-pages").val()),e=j($("#ctx-projects").val()),i={slug:$("#ctx-slug").val().trim(),name:$("#ctx-name").val().trim(),enabled:$("#ctx-enabled").is(":checked"),target:{selector:$("#ctx-selector").val().trim(),match:$("#ctx-match").val().trim(),exclude:$("#ctx-exclude").val().trim()},inherit:t==="false"?!1:t,items:a},n=k();return Object.keys(n).length&&(i.theme=n),(s.length||e.length)&&(i.binding={},s.length&&(i.binding.pages=s),e.length&&(i.binding.projects=e)),i}E.tooltip&&E.tooltip(".ctx-help",{trigger:"click",position:"right"});function p(){b();const t=a.map(e=>{const i=e.type||"item";return i==="divider"?{type:"divider"}:i==="header"?{type:"header",header:e.text||""}:{label:e.text||"(no text)",icon:e.icon||void 0,danger:!!e.danger}});if(!t.some(e=>e.label))return;const s=k();g=E.contextMenu("#ctx-preview",{items:t,inherit:!1,accent:s.accent,transition:s.transition,density:s.density,radius:s.radius,shadow:s.shadow,opacity:s.opacity})}c.on("change","#ctx-accent, #ctx-transition, #ctx-density, #ctx-radius, #ctx-shadow",p),c.on("input","#ctx-opacity",p);function h(t){const s=document.getElementById("ctx-errors");if(!t){s.style.display="none";return}s.style.display="",s.innerHTML=String(t).split("; ").map(e=>`<div>${d(e)}</div>`).join(""),s.scrollIntoView({behavior:"smooth",block:"nearest"})}$("#ctx-save").on("click",async function(){h(null);const t=A();if(!t.slug)return h("Slug is required");if(!t.name)return h("Name is required");if(!t.target.selector)return h("Container selector is required");this.disabled=!0;try{x?(await v.contextMenus.create(t),E.toast("Context menu created.",{type:"success"}),location.hash="#/context-menus"):(await v.contextMenus.update(y,t),E.toast("Context menu saved.",{type:"success"}))}catch(s){h(s.message||String(s))}finally{this.disabled=!1}}),u(),Domma.icons.scan(c.get(0))},onUnmount(){E.contextMenu&&E.contextMenu.closeAll&&E.contextMenu.closeAll(),b()}};
@@ -0,0 +1,5 @@
1
+ import{api as l}from"../api.js";import{filterByProject as u,getProjectFromHash as g}from"../lib/project-context.js";import{keepTableIconsAlive as m}from"../lib/table-icons.js";function o(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const contextMenusView={templateUrl:"/admin/js/templates/context-menus.html",async onMount(s){const r=E.loader(s.get(0),{type:"dots"});let c;try{c=await l.contextMenus.list().catch(()=>[])}finally{r.destroy()}const i=g(),d=i?u(c,i):c;T.create("#context-menus-table",{pageSize:25,data:d,emptyMessage:'No context menus yet. Click "New context menu" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:e=>`<code>${o(e)}</code>`},{key:"name",title:"Name",sortable:!0,render:(e,t)=>`<a href="#/context-menus/edit/${encodeURIComponent(t.slug)}" style="font-weight:600;">${o(e||t.slug)}</a>`},{key:"selector",title:"Binds to",render:(e,t)=>{const n=t.selector?`<code>${o(t.selector)}</code>`:'<span class="text-muted">-</span>',a=t.match?` &rarr; <code>${o(t.match)}</code>`:"";return n+a}},{key:"itemsCount",title:"Items",render:(e,t)=>String(t.itemsCount??0)},{key:"binding",title:"Scope",render:(e,t)=>{const n=t.binding;if(!n)return'<span class="badge badge-secondary">site-wide</span>';const a=[...(n.projects||[]).map(p=>`project:${p}`),...n.pages||[]];return a.length?`<span class="badge badge-info" data-tooltip="${o(a.join(", "))}">${a.length} rule${a.length===1?"":"s"}</span>`:'<span class="badge badge-warning" data-tooltip="Binding matches nothing, so this menu never appears">never matches</span>'}},{key:"enabled",title:"Enabled",render:(e,t)=>t.enabled===!1?'<span class="badge badge-secondary">off</span>':'<span class="badge badge-success">on</span>'},{key:"_actions",title:"Actions",render:(e,t)=>{const n=o(t.slug);return`
2
+ <a class="btn btn-sm btn-ghost" href="#/context-menus/edit/${encodeURIComponent(t.slug)}" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
+ <button class="btn btn-sm btn-ghost btn-duplicate" data-slug="${n}" data-tooltip="Duplicate"><span data-icon="copy" data-icon-size="14"></span></button>
4
+ <button class="btn btn-sm btn-ghost btn-delete" data-slug="${n}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
5
+ `}}]}),Domma.icons.scan("#context-menus-table"),m(s.get(0)),document.querySelectorAll("#context-menus-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})}),s.off("click",".btn-duplicate").on("click",".btn-duplicate",async function(){const e=$(this).data("slug");try{const t=await l.contextMenus.duplicate(e);E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),location.reload()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}),s.off("click",".btn-delete").on("click",".btn-delete",async function(){const e=$(this).data("slug");if(await E.confirm(`Delete context menu "${e}"? This cannot be undone.`))try{await l.contextMenus.remove(e),E.toast("Context menu deleted.",{type:"success"}),location.reload()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}})}};