domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1 +1 @@
1
- import{api as T}from"../api.js";import{populateThemeSelect as w}from"../lib/themes.js";const se=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}];export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(r){E.tabs(r.find("#theme-tabs").get(0));const F=E.loader(r.get(0),{type:"dots"});let d,R;try{[d,R]=await Promise.all([T.theme.get(),T.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){F.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}F.destroy();const z=R.tokens||[],A=new Map;async function K(e){if(!e)return null;if(A.has(e))return A.get(e);const t=await T.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return A.set(e,n),n}function $(){const e=r.find("#theme-auto-enabled").prop("checked");return d.baseTheme||(e?S.val():f.val())||d.theme}const V=["--dm-card-bg","--dm-surface"],_=[...V,"--dm-text","--dm-primary","--dm-background"];function H(e){const t=document.createElement("span");return t.style.cssText="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;border:1px solid var(--dm-border);vertical-align:middle;margin-right:.25rem;",/^#[0-9a-f]{3,8}$/i.test(e)?t.style.background=e:t.style.background="transparent",t}async function I(e){const t=e.querySelector(".ov-theme-diff");if(!t)return;for(;t.firstChild;)t.removeChild(t.firstChild);const n=e.querySelector(".ov-theme")?.value;if(!n)return;const o=$();if(n===o){const l=document.createElement("p");l.className="form-hint text-muted",l.style.cssText="margin:.4rem 0 0;font-size:.8rem;",l.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",t.appendChild(l);return}const[a,s]=await Promise.all([K(o),K(n)]);if(!a||!s)return;const c=Object.keys(s).filter(l=>String(a[l]??"").toLowerCase()!==String(s[l]).toLowerCase()),i=V.filter(l=>c.includes(l)),m=document.createElement("div");m.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:var(--dm-radius-md);background:var(--dm-surface-raised);font-size:.8rem;";const h=document.createElement("p");if(h.style.cssText="margin:0 0 .35rem;",h.textContent=`Changes ${c.length} of ${Object.keys(s).length} tokens here.`,m.appendChild(h),!i.length){const l=document.createElement("p");l.style.cssText="margin:0 0 .35rem;color:var(--dm-warning);",l.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",m.appendChild(l)}const u=document.createElement("div");u.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[..._.filter(l=>c.includes(l)),...c.filter(l=>!_.includes(l))];for(const l of v.slice(0,6)){const p=document.createElement("span");p.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",p.appendChild(H(a[l]||""));const C=document.createElement("span");C.textContent=`${l} `,p.appendChild(C),p.appendChild(H(s[l])),u.appendChild(p)}if(v.length>6){const l=document.createElement("span");l.style.cssText="color:var(--dm-text-muted);",l.textContent=`+${v.length-6} more`,u.appendChild(l)}m.appendChild(u),t.appendChild(m)}let b=Array.isArray(d.overrides)?d.overrides.map(G):[];const f=r.find("#theme-frontend"),B=r.find("#theme-admin");w(f.get(0)),w(B.get(0),{includeAdmin:!0}),f.val(d.baseTheme||d.theme||"charcoal-dark"),B.val(d.adminTheme||"charcoal-dark"),d.baseTheme&&(f.prop("disabled",!0),r.find("#theme-auto-enabled").prop("disabled",!0),r.find("#theme-auto-roller-hint").show());const S=r.find("#theme-day"),D=r.find("#theme-night");w(S.get(0)),w(D.get(0));const g=d.autoTheme||{};S.val(g.dayTheme||"charcoal-light"),D.val(g.nightTheme||"charcoal-dark"),r.find("#theme-day-start").val(g.dayStart||"07:00"),r.find("#theme-night-start").val(g.nightStart||"19:00");const U=r.find("#theme-auto-enabled"),W=r.find("#theme-auto-fields");d.baseTheme||(U.prop("checked",!!g.enabled),g.enabled&&(W.show(),f.prop("disabled",!0)),U.on("change",function(){W.toggle(this.checked),f.prop("disabled",this.checked)})),r.find("#theme-font-family").val(d.font?.family||"Roboto"),r.find("#theme-font-size").val(String(d.font?.size??16));function G(e){return{id:e.id||`ov-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,label:e.label||"",enabled:e.enabled!==!1,scope:e.scope||"",theme:e.theme||"",tokens:{...e.tokens||{}},css:e.css||""}}const y=document.getElementById("theme-overrides"),ee=document.getElementById("theme-overrides-empty");function L(){for(;y.firstChild;)y.removeChild(y.firstChild);ee.style.display=b.length?"none":"",b.forEach((e,t)=>y.appendChild(te(e,t))),Domma.icons.scan("#theme-overrides")}function te(e,t){const n=document.createElement("div");n.className="card mb-3",n.dataset.i=String(t);const o=document.createElement("div");o.className="card-header";const a=document.createElement("h3");a.className="m-0",a.style.fontSize="1rem",a.textContent=e.label||e.scope||`Override ${t+1}`,o.appendChild(a);const s=document.createElement("div");s.style.cssText="display:flex;gap:.5rem;align-items:center;";const c=document.createElement("label");c.style.cssText="display:flex;align-items:center;gap:.35rem;font-size:.85rem;cursor:pointer;";const i=document.createElement("input");i.type="checkbox",i.className="form-check ov-enabled",i.checked=e.enabled,c.appendChild(i),c.appendChild(document.createTextNode("Enabled")),s.appendChild(c);const m=document.createElement("button");m.type="button",m.className="btn btn-ghost btn-sm ov-remove",m.textContent="Remove",s.appendChild(m),o.appendChild(s),n.appendChild(o);const h=document.createElement("div");h.className="card-body";const u=document.createElement("div");u.className="row mb-3",u.appendChild(P("col-4","Name",X("ov-label",e.label,"Navbar island"))),u.appendChild(P("col-8","Target selector",ne(e.scope),"The region to re-theme. Everything inside it follows.")),h.appendChild(u);const v=document.createElement("div");v.className="row mb-3";const l=document.createElement("select");l.className="form-select ov-theme",w(l,{includeDefault:!0,defaultLabel:"Keep the site theme"}),l.value=e.theme||"",v.appendChild(P("col-6","Adopt a theme here",l,"Copies every token of that theme onto this region.")),h.appendChild(v);const p=document.createElement("div");p.className="ov-theme-diff",h.appendChild(p);const C=document.createElement("div");C.className="ov-tokens mb-2",Object.entries(e.tokens||{}).forEach(([oe,ae])=>{C.appendChild(J(oe,ae))}),h.appendChild(Q("Token overrides",C));const q=document.createElement("button");q.type="button",q.className="btn btn-ghost btn-sm ov-add-token",q.textContent="+ Add token",h.appendChild(q);const x=document.createElement("details");x.style.marginTop="1rem";const j=document.createElement("summary");j.style.cssText="cursor:pointer;font-size:.85rem;",j.textContent="Custom CSS for this override",x.appendChild(j);const k=document.createElement("textarea");k.className="form-input ov-css",k.rows=4,k.placeholder="#site-navbar .brand { letter-spacing: .04em; }",k.value=e.css||"",k.style.marginTop=".5rem",x.appendChild(k);const O=document.createElement("p");return O.className="form-hint text-muted",O.style.cssText="font-size:.8rem;margin-top:.35rem;",O.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",x.appendChild(O),h.appendChild(x),n.appendChild(h),setTimeout(()=>I(n),0),n}function J(e,t){const n=document.createElement("div");n.className="ov-token-row",n.style.cssText="display:flex;gap:.5rem;align-items:center;margin-bottom:.5rem;";const o=document.createElement("select");if(o.className="form-select ov-token-name",o.style.flex="1 1 auto",z.length){for(const i of z){const m=document.createElement("option");m.value=i,m.textContent=i,o.appendChild(m)}o.value=e||z[0]}else{const i=document.createElement("option");i.value=e||"",i.textContent=e||"(no tokens found)",o.appendChild(i)}const a=document.createElement("input");a.type="text",a.className="form-input ov-token-value",a.style.flex="0 0 150px",a.placeholder="#0096c7",a.value=t||"";const s=document.createElement("input");s.type="color",s.className="ov-token-swatch",s.style.cssText="flex:0 0 38px;height:34px;padding:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);background:none;cursor:pointer;",/^#[0-9a-f]{6}$/i.test(t||"")&&(s.value=t),s.addEventListener("input",()=>{a.value=s.value,a.dispatchEvent(new Event("input",{bubbles:!0}))});const c=document.createElement("button");return c.type="button",c.className="btn btn-ghost btn-sm ov-token-remove",c.textContent="\xD7",n.append(o,a,s,c),n}function P(e,t,n,o){const a=document.createElement("div");return a.className=e,a.appendChild(Q(t,n,o)),a}function Q(e,t,n){const o=document.createDocumentFragment(),a=document.createElement("label");if(a.className="form-label",a.textContent=e,o.appendChild(a),o.appendChild(t),n){const s=document.createElement("p");s.className="form-hint text-muted",s.style.cssText="margin-top:.35rem;font-size:.8rem;",s.textContent=n,o.appendChild(s)}return o}function X(e,t,n){const o=document.createElement("input");return o.type="text",o.className=`form-input ${e}`,o.value=t||"",o.placeholder=n||"",o}function ne(e){const t=document.createElement("div"),n=X("ov-scope",e,"#site-navbar");if(n.setAttribute("list","theme-scope-options"),t.appendChild(n),!document.getElementById("theme-scope-options")){const o=document.createElement("datalist");o.id="theme-scope-options";for(const a of se){const s=document.createElement("option");s.value=a.value,s.label=a.label,o.appendChild(s)}t.appendChild(o)}return t}function N(){const e=y.querySelectorAll(".card"),t=[];return e.forEach((n,o)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(s=>{const c=s.querySelector(".ov-token-name")?.value?.trim(),i=s.querySelector(".ov-token-value")?.value?.trim();c&&i&&(a[c]=i)}),t.push({id:b[o]?.id,label:n.querySelector(".ov-label")?.value?.trim()||"",enabled:!!n.querySelector(".ov-enabled")?.checked,scope:n.querySelector(".ov-scope")?.value?.trim()||"",theme:n.querySelector(".ov-theme")?.value||"",tokens:a,css:n.querySelector(".ov-css")?.value||""})}),b=t,t}r.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);N(),b.splice(e,1),L()}),r.on("change",".ov-theme",function(){I(this.closest(".card"))}),r.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{y.querySelectorAll(".card").forEach(I)}),r.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(J("",""))}),r.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),r.find("#theme-add-override").on("click",()=>{N(),b.push(G({label:"",scope:"",enabled:!0})),L()}),L();async function M(){const e=document.getElementById("theme-css-preview"),t={...Y(),overrides:N()};try{const n=await T.theme.compile(t);e.textContent=n.css?.trim()||"(no overrides - nothing is injected)",e.style.color=""}catch(n){e.textContent=n.message||String(n),e.style.color="var(--dm-danger)"}}r.find("#theme-refresh-css").on("click",M);function Y(){const e=!d.baseTheme&&r.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:D.val()||"charcoal-dark",dayStart:r.find("#theme-day-start").val()||"07:00",nightStart:r.find("#theme-night-start").val()||"19:00"};return{theme:d.baseTheme?d.theme:e?t.dayTheme:f.val(),...d.baseTheme?{baseTheme:d.baseTheme}:{},adminTheme:B.val(),autoTheme:t,font:{family:r.find("#theme-font-family").val()||"Roboto",size:parseInt(r.find("#theme-font-size").val(),10)||16}}}function Z(e){const t=document.getElementById("theme-errors");for(;t.firstChild;)t.removeChild(t.firstChild);if(!e||!e.length){t.style.display="none";return}t.style.display="";for(const n of e){const o=document.createElement("div");o.textContent=n,t.appendChild(o)}t.scrollIntoView({behavior:"smooth",block:"nearest"})}r.find("#theme-save-btn").on("click",async function(){Z(null);const e={...Y(),overrides:N()};this.disabled=!0;try{d=await T.theme.save(e),Domma.theme.set(e.adminTheme),E.toast("Theme saved.",{type:"success"}),await M()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];Z(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await M()}};
1
+ import{api as x}from"../api.js";import{populateThemeSelect as T}from"../lib/themes.js";const ce=[{value:"#site-navbar",label:"Navigation bar"},{value:"#site-footer",label:"Footer"},{value:".site-content",label:"Page content area"},{value:".page-body",label:"Page body (inside the container)"},{value:".site-sidebar",label:"Sidebar"},{value:".card",label:"Every card"},{value:".cms-form-embed",label:"Embedded forms"},{value:".cms-collection",label:"Collection displays"}];export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(o){E.tabs(o.find("#theme-tabs").get(0));const K=E.loader(o.get(0),{type:"dots"});let d,V;try{[d,V]=await Promise.all([x.theme.get(),x.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){K.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}K.destroy();const z=V.tokens||[],B=new Map;async function _(e){if(!e)return null;if(B.has(e))return B.get(e);const t=await x.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return B.set(e,n),n}function ne(){const e=o.find("#theme-auto-enabled").prop("checked");return d.baseTheme||(e?S.val():f.val())||d.theme}const X=["--dm-card-bg","--dm-surface"],Y=[...X,"--dm-text","--dm-primary","--dm-background"];function H(e){const t=document.createElement("span");return t.style.cssText="display:inline-block;width:.7rem;height:.7rem;border-radius:2px;border:1px solid var(--dm-border);vertical-align:middle;margin-right:.25rem;",/^#[0-9a-f]{3,8}$/i.test(e)?t.style.background=e:t.style.background="transparent",t}async function D(e){const t=e.querySelector(".ov-theme-diff");if(!t)return;for(;t.firstChild;)t.removeChild(t.firstChild);const n=e.querySelector(".ov-theme")?.value;if(!n)return;const s=ne();if(n===s){const r=document.createElement("p");r.className="form-hint text-muted",r.style.cssText="margin:.4rem 0 0;font-size:.8rem;",r.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",t.appendChild(r);return}const[a,l]=await Promise.all([_(s),_(n)]);if(!a||!l)return;const c=Object.keys(l).filter(r=>String(a[r]??"").toLowerCase()!==String(l[r]).toLowerCase()),i=X.filter(r=>c.includes(r)),m=document.createElement("div");m.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:var(--dm-radius-md);background:var(--dm-surface-raised);font-size:.8rem;";const h=document.createElement("p");if(h.style.cssText="margin:0 0 .35rem;",h.textContent=`Changes ${c.length} of ${Object.keys(l).length} tokens here.`,m.appendChild(h),!i.length){const r=document.createElement("p");r.style.cssText="margin:0 0 .35rem;color:var(--dm-warning);",r.textContent="Both themes use the same surface colour, so this region keeps the fill it has now. Set --dm-card-bg (a card) or --dm-surface (most other regions) below to change it.",m.appendChild(r)}const p=document.createElement("div");p.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[...Y.filter(r=>c.includes(r)),...c.filter(r=>!Y.includes(r))];for(const r of v.slice(0,6)){const u=document.createElement("span");u.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",u.appendChild(H(a[r]||""));const C=document.createElement("span");C.textContent=`${r} `,u.appendChild(C),u.appendChild(H(l[r])),p.appendChild(u)}if(v.length>6){const r=document.createElement("span");r.style.cssText="color:var(--dm-text-muted);",r.textContent=`+${v.length-6} more`,p.appendChild(r)}m.appendChild(p),t.appendChild(m)}let b=Array.isArray(d.overrides)?d.overrides.map(J):[];const f=o.find("#theme-frontend"),F=o.find("#theme-admin");T(f.get(0)),T(F.get(0),{includeAdmin:!0}),f.val(d.baseTheme||d.theme||"charcoal-dark"),F.val(d.adminTheme||"charcoal-dark"),d.baseTheme&&(f.prop("disabled",!0),o.find("#theme-auto-enabled").prop("disabled",!0),o.find("#theme-auto-roller-hint").show());const S=o.find("#theme-day"),L=o.find("#theme-night");T(S.get(0)),T(L.get(0));const y=d.autoTheme||{};S.val(y.dayTheme||"charcoal-light"),L.val(y.nightTheme||"charcoal-dark"),o.find("#theme-day-start").val(y.dayStart||"07:00"),o.find("#theme-night-start").val(y.nightStart||"19:00");const N=d.switcher||{},q=o.find("#theme-switcher-enabled"),oe=o.find("#theme-switcher-fields");q.prop("checked",N.enabled===!0),o.find("#theme-switcher-position").val(N.position||"right-center"),o.find("#theme-switcher-offset-x").val(N.offsetX??32),o.find("#theme-switcher-offset-y").val(N.offsetY??32);const U=()=>{oe.get(0).style.display=q.prop("checked")?"":"none"};U(),q.on("change",U);const W=o.find("#theme-auto-enabled"),G=o.find("#theme-auto-fields");d.baseTheme||(W.prop("checked",!!y.enabled),y.enabled&&(G.show(),f.prop("disabled",!0)),W.on("change",function(){G.toggle(this.checked),f.prop("disabled",this.checked)})),o.find("#theme-font-family").val(d.font?.family||"Roboto"),o.find("#theme-font-size").val(String(d.font?.size??16));function J(e){return{id:e.id||`ov-${Date.now()}-${Math.random().toString(36).slice(2,8)}`,label:e.label||"",enabled:e.enabled!==!1,scope:e.scope||"",theme:e.theme||"",tokens:{...e.tokens||{}},css:e.css||""}}const g=document.getElementById("theme-overrides"),se=document.getElementById("theme-overrides-empty");function P(){for(;g.firstChild;)g.removeChild(g.firstChild);se.style.display=b.length?"none":"",b.forEach((e,t)=>g.appendChild(ae(e,t))),Domma.icons.scan("#theme-overrides")}function ae(e,t){const n=document.createElement("div");n.className="card mb-3",n.dataset.i=String(t);const s=document.createElement("div");s.className="card-header";const a=document.createElement("h3");a.className="m-0",a.style.fontSize="1rem",a.textContent=e.label||e.scope||`Override ${t+1}`,s.appendChild(a);const l=document.createElement("div");l.style.cssText="display:flex;gap:.5rem;align-items:center;";const c=document.createElement("label");c.style.cssText="display:flex;align-items:center;gap:.35rem;font-size:.85rem;cursor:pointer;";const i=document.createElement("input");i.type="checkbox",i.className="form-check ov-enabled",i.checked=e.enabled,c.appendChild(i),c.appendChild(document.createTextNode("Enabled")),l.appendChild(c);const m=document.createElement("button");m.type="button",m.className="btn btn-ghost btn-sm ov-remove",m.textContent="Remove",l.appendChild(m),s.appendChild(l),n.appendChild(s);const h=document.createElement("div");h.className="card-body";const p=document.createElement("div");p.className="row mb-3",p.appendChild(M("col-4","Name",$("ov-label",e.label,"Navbar island"))),p.appendChild(M("col-8","Target selector",le(e.scope),"The region to re-theme. Everything inside it follows.")),h.appendChild(p);const v=document.createElement("div");v.className="row mb-3";const r=document.createElement("select");r.className="form-select ov-theme",T(r,{includeDefault:!0,defaultLabel:"Keep the site theme"}),r.value=e.theme||"",v.appendChild(M("col-6","Adopt a theme here",r,"Copies every token of that theme onto this region.")),h.appendChild(v);const u=document.createElement("div");u.className="ov-theme-diff",h.appendChild(u);const C=document.createElement("div");C.className="ov-tokens mb-2",Object.entries(e.tokens||{}).forEach(([re,de])=>{C.appendChild(Q(re,de))}),h.appendChild(Z("Token overrides",C));const A=document.createElement("button");A.type="button",A.className="btn btn-ghost btn-sm ov-add-token",A.textContent="+ Add token",h.appendChild(A);const w=document.createElement("details");w.style.marginTop="1rem";const R=document.createElement("summary");R.style.cssText="cursor:pointer;font-size:.85rem;",R.textContent="Custom CSS for this override",w.appendChild(R);const k=document.createElement("textarea");k.className="form-input ov-css",k.rows=4,k.placeholder="#site-navbar .brand { letter-spacing: .04em; }",k.value=e.css||"",k.style.marginTop=".5rem",w.appendChild(k);const I=document.createElement("p");return I.className="form-hint text-muted",I.style.cssText="font-size:.8rem;margin-top:.35rem;",I.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",w.appendChild(I),h.appendChild(w),n.appendChild(h),setTimeout(()=>D(n),0),n}function Q(e,t){const n=document.createElement("div");n.className="ov-token-row",n.style.cssText="display:flex;gap:.5rem;align-items:center;margin-bottom:.5rem;";const s=document.createElement("select");if(s.className="form-select ov-token-name",s.style.flex="1 1 auto",z.length){for(const i of z){const m=document.createElement("option");m.value=i,m.textContent=i,s.appendChild(m)}s.value=e||z[0]}else{const i=document.createElement("option");i.value=e||"",i.textContent=e||"(no tokens found)",s.appendChild(i)}const a=document.createElement("input");a.type="text",a.className="form-input ov-token-value",a.style.flex="0 0 150px",a.placeholder="#0096c7",a.value=t||"";const l=document.createElement("input");l.type="color",l.className="ov-token-swatch",l.style.cssText="flex:0 0 38px;height:34px;padding:0;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);background:none;cursor:pointer;",/^#[0-9a-f]{6}$/i.test(t||"")&&(l.value=t),l.addEventListener("input",()=>{a.value=l.value,a.dispatchEvent(new Event("input",{bubbles:!0}))});const c=document.createElement("button");return c.type="button",c.className="btn btn-ghost btn-sm ov-token-remove",c.textContent="\xD7",n.append(s,a,l,c),n}function M(e,t,n,s){const a=document.createElement("div");return a.className=e,a.appendChild(Z(t,n,s)),a}function Z(e,t,n){const s=document.createDocumentFragment(),a=document.createElement("label");if(a.className="form-label",a.textContent=e,s.appendChild(a),s.appendChild(t),n){const l=document.createElement("p");l.className="form-hint text-muted",l.style.cssText="margin-top:.35rem;font-size:.8rem;",l.textContent=n,s.appendChild(l)}return s}function $(e,t,n){const s=document.createElement("input");return s.type="text",s.className=`form-input ${e}`,s.value=t||"",s.placeholder=n||"",s}function le(e){const t=document.createElement("div"),n=$("ov-scope",e,"#site-navbar");if(n.setAttribute("list","theme-scope-options"),t.appendChild(n),!document.getElementById("theme-scope-options")){const s=document.createElement("datalist");s.id="theme-scope-options";for(const a of ce){const l=document.createElement("option");l.value=a.value,l.label=a.label,s.appendChild(l)}t.appendChild(s)}return t}function O(){const e=g.querySelectorAll(".card"),t=[];return e.forEach((n,s)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(l=>{const c=l.querySelector(".ov-token-name")?.value?.trim(),i=l.querySelector(".ov-token-value")?.value?.trim();c&&i&&(a[c]=i)}),t.push({id:b[s]?.id,label:n.querySelector(".ov-label")?.value?.trim()||"",enabled:!!n.querySelector(".ov-enabled")?.checked,scope:n.querySelector(".ov-scope")?.value?.trim()||"",theme:n.querySelector(".ov-theme")?.value||"",tokens:a,css:n.querySelector(".ov-css")?.value||""})}),b=t,t}o.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);O(),b.splice(e,1),P()}),o.on("change",".ov-theme",function(){D(this.closest(".card"))}),o.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{g.querySelectorAll(".card").forEach(D)}),o.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(Q("",""))}),o.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),o.find("#theme-add-override").on("click",()=>{O(),b.push(J({label:"",scope:"",enabled:!0})),P()}),P();async function j(){const e=document.getElementById("theme-css-preview"),t={...ee(),overrides:O()};try{const n=await x.theme.compile(t);e.textContent=n.css?.trim()||"(no overrides - nothing is injected)",e.style.color=""}catch(n){e.textContent=n.message||String(n),e.style.color="var(--dm-danger)"}}o.find("#theme-refresh-css").on("click",j);function ee(){const e=!d.baseTheme&&o.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:L.val()||"charcoal-dark",dayStart:o.find("#theme-day-start").val()||"07:00",nightStart:o.find("#theme-night-start").val()||"19:00"};return{theme:d.baseTheme?d.theme:e?t.dayTheme:f.val(),...d.baseTheme?{baseTheme:d.baseTheme}:{},adminTheme:F.val(),autoTheme:t,font:{family:o.find("#theme-font-family").val()||"Roboto",size:parseInt(o.find("#theme-font-size").val(),10)||16},switcher:{enabled:q.prop("checked"),position:o.find("#theme-switcher-position").val()||"right-center",offsetX:parseInt(o.find("#theme-switcher-offset-x").val(),10)||0,offsetY:parseInt(o.find("#theme-switcher-offset-y").val(),10)||0,themes:Array.isArray(d.switcher?.themes)?d.switcher.themes:[]}}}function te(e){const t=document.getElementById("theme-errors");for(;t.firstChild;)t.removeChild(t.firstChild);if(!e||!e.length){t.style.display="none";return}t.style.display="";for(const n of e){const s=document.createElement("div");s.textContent=n,t.appendChild(s)}t.scrollIntoView({behavior:"smooth",block:"nearest"})}o.find("#theme-save-btn").on("click",async function(){te(null);const e={...ee(),overrides:O()};this.disabled=!0;try{d=await x.theme.save(e),Domma.theme.set(e.adminTheme),E.toast("Theme saved.",{type:"success"}),await j()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];te(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await j()}};
@@ -0,0 +1,96 @@
1
+ import{apiRequest as at}from"../api.js";import{addBannerAction as st}from"../lib/plugin-chrome.js";import{helpPopovers as O}from"../lib/help-popover.js";import{onceClosed as j,openMenuAt as lt,settingsStore as nt,sidebarCount as it,viewLifetime as dt}from"../lib/tool-kit.js";import{arrangeTodos as rt,countTodos as ct,daysUntil as ut,DEFAULT_SETTINGS as H,dueDay as pt,dueLabel as bt,isoDay as k,isOverdue as mt,normaliseSettings as K,PRIORITIES as P,STATUSES as B}from"../lib/todo-arrange.js";const u={"not-started":"Not started","in-progress":"In progress",completed:"Completed",highest:"Highest",high:"High",medium:"Medium",low:"Low",lowest:"Lowest",open:"Open",all:"All",overdue:"Overdue"},U=nt("todo.settings",K),v=(b,d="GET",n)=>at(b,{method:d,...n!==void 0?{body:JSON.stringify(n)}:{}}),vt=`
2
+ <div class="todo-row{{doneClass}}" data-id="{{id}}">
3
+ <input type="checkbox" class="todo-check" aria-label="Done" data-bind-checked="isDone">
4
+ <span class="todo-text" title="Click to edit">{{text}}</span>
5
+ <span class="todo-due{{dueClass}}" title="{{dueTitle}}">{{due}}</span>
6
+ <button type="button" class="todo-pill" data-kind="status" data-value="{{status}}" title="Change status">{{statusLabel}}</button>
7
+ <button type="button" class="todo-pill" data-kind="priority" data-value="{{priority}}" title="Change priority">{{priorityLabel}}</button>
8
+ <button type="button" class="todo-kebab" aria-label="Task actions" title="Actions">\u22EE</button>
9
+ </div>`;function z(b){const d=document.createElement("template");return d.innerHTML=b.trim(),d.content.firstElementChild}const p=dt(/^#\/todo\b/);export const todoView={templateUrl:"/admin/js/templates/todo.html",async onMount(b){p.reset();const d=b.get(0),n=M.observable(U.read()),l=M.observable([]),m=M.observable(!0),g=M.observable(""),w=M.observable(n.peek().defaultFilter),f=M.observable(""),T=M.observable(n.peek().defaultPriority),x=M.observable(""),A=M.observable(!1),N=M.observable(!1),S=M.computed(()=>ct(l.value)),G=M.computed(()=>["open","all","not-started","in-progress","completed","overdue"].map(e=>({value:e,label:`${u[e]} (${S.value[e]})`}))),y=M.observable([]);p.add(M.effect(()=>{const e=n.value.relativeDue;y.value=rt(l.value,{filter:w.value,query:g.value,sort:n.value.sort}).map(t=>{const o=ut(t.dueAt),s=mt(t);return{id:t.id,text:t.text,status:t.status,priority:t.priority,statusLabel:u[t.status]??t.status,priorityLabel:u[t.priority]??t.priority,doneClass:t.status==="completed"?" is-done":"",isDone:t.status==="completed",due:bt(t.dueAt,{relative:e,format:i=>D(i).format("D MMM")}),dueClass:s?" is-overdue":o===0&&t.status!=="completed"?" is-today":"",dueTitle:o===null?"":`Due ${D(pt(t.dueAt)).format("dddd D MMMM YYYY")}`}})}));const W={rows:y,loading:m,query:g,filter:w,filterOptions:G,counts:S,draft:f,draftPriority:T,draftDue:x,addFocus:A,searchFocus:N,cannotAdd:M.computed(()=>!f.value.trim()),isEmpty:M.computed(()=>!m.value&&y.value.length===0),emptyText:M.computed(()=>l.value.length?w.value==="open"&&!g.value.trim()?"Nothing open - everything is done.":"No tasks match. Change the filter or clear the search.":"No tasks yet. Add one above."),countText:M.computed(()=>{if(m.value)return"";const e=y.value.length,t=l.value.length;return e===t?`${t} task${t===1?"":"s"}`:`${e} of ${t}`})};p.add(M.effect(()=>U.write(n.value))),p.add(M.effect(()=>{l.value,m.peek()||it("#/todo")}));const _=(e,t)=>{n.value=K({...n.peek(),[e]:t})},C=e=>l.peek().find(t=>t.id===e),J=e=>{l.value=l.peek().map(t=>t.id===e.id?e:t)};async function V(){m.value=!0;try{const e=await v("/todo/items");l.value=Array.isArray(e)?e:e.data??[]}catch{E.toast("Failed to load tasks",{type:"error"})}finally{m.value=!1}}async function c(e,t,o){try{return J(await v(`/todo/items/${e}`,"PUT",t)),o&&E.toast(o,{type:"success"}),!0}catch{return E.toast("Failed to update task",{type:"error"}),!1}}async function Q(){const e=f.peek().trim();if(e)try{const t=await v("/todo/items","POST",{text:e,priority:T.peek(),status:"not-started",dueAt:x.peek()||null});l.value=[...l.peek(),t],f.value="",x.value="",T.value=n.peek().defaultPriority,A.value=!0}catch{E.toast("Failed to add task",{type:"error"})}}const X=()=>(Q(),!1);async function q(e){if(n.peek().confirmDelete&&!await E.confirm("Delete this task?"))return!1;try{return await v(`/todo/items/${e}`,"DELETE"),l.value=l.peek().filter(t=>t.id!==e),E.toast("Task deleted",{type:"success"}),!0}catch{return E.toast("Failed to delete task",{type:"error"}),!1}}async function Z(){const e=S.peek().completed;if(!(!e||!await E.confirm(`Delete ${e} completed task${e===1?"":"s"}?`)))try{await v("/todo/completed","DELETE"),l.value=l.peek().filter(t=>t.status!=="completed"),E.toast("Completed tasks cleared",{type:"success"})}catch{E.toast("Failed to clear completed tasks",{type:"error"})}}const F=e=>{const t=C(e);if(!t)return;const o=t.status==="completed"?"not-started":"completed";c(e,{status:o},o==="completed"?"Task completed":"Task reopened")};function Y(e){const t=C(e);if(!t)return;const o=E.slideover({title:"Edit task",size:"md",position:"right"}),s=z(`
10
+ <div class="todo-panel">
11
+ <div>
12
+ <label class="form-label" for="todo-edit-text">Task</label>
13
+ <textarea id="todo-edit-text" class="form-input" data-model="text.value"></textarea>
14
+ </div>
15
+ <div class="todo-panel-row">
16
+ <div>
17
+ <label class="form-label" for="todo-edit-status">Status</label>
18
+ <select id="todo-edit-status" class="form-input" data-model="status.value">
19
+ ${B.map(a=>`<option value="${a}">${u[a]}</option>`).join("")}
20
+ </select>
21
+ </div>
22
+ <div>
23
+ <label class="form-label" for="todo-edit-priority">Priority</label>
24
+ <select id="todo-edit-priority" class="form-input" data-model="priority.value">
25
+ ${P.map(a=>`<option value="${a}">${u[a]}</option>`).join("")}
26
+ </select>
27
+ </div>
28
+ </div>
29
+ <div>
30
+ <label class="form-label" for="todo-edit-due">Due
31
+ <span data-help="A calendar day. Past due and not completed shows the task as overdue."></span>
32
+ </label>
33
+ <input id="todo-edit-due" type="date" class="form-input" data-model="due.value">
34
+ <div class="todo-quick-due">
35
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="dueIn" data-days="0">Today</button>
36
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="dueIn" data-days="1">Tomorrow</button>
37
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="dueIn" data-days="7">Next week</button>
38
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="clearDue">No date</button>
39
+ </div>
40
+ </div>
41
+ <small class="todo-panel-meta">Ctrl+Enter saves. Added <span data-bind-text="created"></span>.</small>
42
+ <div class="todo-panel-actions">
43
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="del">
44
+ <span data-icon="trash" data-icon-size="13"></span> Delete
45
+ </button>
46
+ <span class="spacer"></span>
47
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
48
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save"
49
+ data-bind-disabled="cannotSave.value">Save</button>
50
+ </div>
51
+ </div>`),i={text:M.observable(t.text||""),status:M.observable(t.status),priority:M.observable(t.priority),due:M.observable(t.dueAt?String(t.dueAt).slice(0,10):""),created:t.createdAt?D(t.createdAt).format("D MMM YYYY"):"earlier"};i.cannotSave=M.computed(()=>!i.text.value.trim());const h=async()=>{await c(t.id,{text:i.text.peek().trim(),status:i.status.peek(),priority:i.priority.peek(),dueAt:i.due.peek()||null},"Task saved")&&o.close()};o.element.appendChild(s);const $=O(s),r=M.applyBindings(i,s,{methods:{save:h,cancel:()=>o.close(),del:async()=>{await q(t.id)&&o.close()},dueIn:a=>{i.due.value=k(Number(a.currentTarget.dataset.days))},clearDue:()=>{i.due.value=""}}});s.addEventListener("keydown",a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),h())}),j(o,()=>{r.dispose(),$()}),o.open(),setTimeout(()=>s.querySelector("#todo-edit-text")?.focus(),60)}function tt(){const e=E.slideover({title:"Todo settings",size:"sm",position:"right"}),t=r=>r.map(a=>`<option value="${a}">${u[a]}</option>`).join(""),o=z(`
52
+ <div class="todo-panel">
53
+ <div>
54
+ <label class="form-label" for="todo-set-filter">Show on opening
55
+ <span data-help="Which tasks the list shows when you open Todo. The filter in the toolbar changes it for this visit only."></span>
56
+ </label>
57
+ <select id="todo-set-filter" class="form-input" data-model="defaultFilter.value">
58
+ ${t(["open","all","not-started","in-progress","completed","overdue"])}
59
+ </select>
60
+ </div>
61
+ <div>
62
+ <label class="form-label" for="todo-set-sort">Sort by
63
+ <span data-help="Completed tasks always sit at the bottom, whatever the sort."></span>
64
+ </label>
65
+ <select id="todo-set-sort" class="form-input" data-model="sort.value">
66
+ <option value="created">Order added</option>
67
+ <option value="priority">Priority, highest first</option>
68
+ <option value="due">Due date, soonest first</option>
69
+ <option value="status">Status</option>
70
+ </select>
71
+ </div>
72
+ <div>
73
+ <label class="form-label" for="todo-set-priority">Priority for new tasks</label>
74
+ <select id="todo-set-priority" class="form-input" data-model="defaultPriority.value">
75
+ ${t(P)}
76
+ </select>
77
+ </div>
78
+ <div class="todo-setting-row">
79
+ <label class="form-label" for="todo-set-relative" style="margin:0">Relative due dates
80
+ <span data-help="On: &quot;Tomorrow&quot;, &quot;In 3 days&quot;. Off: the date itself."></span>
81
+ </label>
82
+ <input id="todo-set-relative" type="checkbox" class="form-switch" data-model="relativeDue.value">
83
+ </div>
84
+ <div class="todo-setting-row">
85
+ <label class="form-label" for="todo-set-confirm" style="margin:0">Confirm before deleting
86
+ <span data-help="Off deletes straight away - there is no undo."></span>
87
+ </label>
88
+ <input id="todo-set-confirm" type="checkbox" class="form-switch" data-model="confirmDelete.value">
89
+ </div>
90
+ <small class="todo-panel-meta">Saved in this browser as you change them.</small>
91
+ <div class="todo-panel-actions">
92
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="reset">Reset to defaults</button>
93
+ <span class="spacer"></span>
94
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="done">Done</button>
95
+ </div>
96
+ </div>`),s=r=>M.computed({read:()=>n.value[r],write:a=>_(r,a)}),i=Object.fromEntries(Object.keys(H).map(r=>[r,s(r)]));e.element.appendChild(o);const h=O(o),$=M.applyBindings(i,o,{methods:{done:()=>e.close(),reset:()=>{n.value={...H}}}});j(e,()=>{$.dispose(),h()}),e.open()}st(b,{id:"todo-settings",icon:"settings",title:"Todo settings",onClick:tt}),d.querySelector(".todo-rows").innerHTML=vt,p.add(O(d,{position:"bottom"}));const et=M.applyBindings(W,d,{methods:{add:X,clearCompleted:Z}});p.add(()=>et.dispose());const L=d.querySelector(".todo-list"),ot=E.contextMenu(L,{match:".todo-row",inherit:!1,density:"compact",items:e=>{const t=e.dataset.id,o=C(t);return o?[{label:"Edit\u2026",icon:"edit",action:()=>Y(t)},{label:o.status==="completed"?"Reopen":"Complete",icon:"check",action:()=>F(t)},{type:"divider"},{label:"Status",icon:"circle",submenu:B.map(s=>({label:u[s],disabled:o.status===s,action:()=>c(t,{status:s})}))},{label:"Priority",icon:"flag",submenu:P.map(s=>({label:u[s],disabled:o.priority===s,action:()=>c(t,{priority:s})}))},{label:"Due",icon:"calendar",submenu:[{label:"Today",action:()=>c(t,{dueAt:k(0)})},{label:"Tomorrow",action:()=>c(t,{dueAt:k(1)})},{label:"Next week",action:()=>c(t,{dueAt:k(7)})},{type:"divider"},{label:"No due date",disabled:!o.dueAt,action:()=>c(t,{dueAt:null})}]},{type:"divider"},{label:"Delete",icon:"trash",danger:!0,action:()=>q(t)}]:[]}});p.add(()=>ot?.destroy?.()),L.addEventListener("click",e=>{const t=e.target.closest(".todo-row");if(t){if(e.target.closest(".todo-kebab, .todo-pill"))return lt(e.target.closest(".todo-kebab, .todo-pill"));e.target.closest(".todo-text")&&Y(t.dataset.id)}}),L.addEventListener("change",e=>{e.target.matches(".todo-check")&&F(e.target.closest(".todo-row").dataset.id)});const R=e=>{e.ctrlKey||e.metaKey||e.altKey||e.target.closest?.("input, textarea, select, [contenteditable], .dm-slideover")||(e.key==="/"?(e.preventDefault(),N.value=!0):(e.key==="n"||e.key==="N")&&(e.preventDefault(),A.value=!0))};document.addEventListener("keydown",R),p.add(()=>document.removeEventListener("keydown",R)),I.scan(d),await V()}};
package/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import {spawnSync} from 'node:child_process';
9
9
  import path from 'node:path';
10
10
  import {fileURLToPath} from 'node:url';
11
11
  import {composeSmtp} from './lib/smtp-defaults.js';
12
+ import {buildGitignore} from '../server/services/siteGitignore.js';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Paths
@@ -324,16 +325,9 @@ done();
324
325
  // Write .gitignore
325
326
  // ---------------------------------------------------------------------------
326
327
 
327
- const GITIGNORE = `node_modules/
328
- .env
329
- content/users/
330
- content/media/
331
- *.log
332
- .domma-backups/
333
-
334
- # Analytics plugin runtime counters (written on every page hit). Never track.
335
- plugins/analytics/data/
336
- `;
328
+ // The content-only allowlist: this repo tracks the site's content, never the
329
+ // engine or distributed plugins. Same rules the manager's scaffolder writes.
330
+ const GITIGNORE = buildGitignore();
337
331
 
338
332
  step('Writing .gitignore');
339
333
  writeFileSync(path.join(target, '.gitignore'), GITIGNORE, 'utf8');
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Plugin versions, for the engine updater.
3
+ *
4
+ * The updater replaces each bundled plugin (Blog, Mail Reader) on a site with
5
+ * the engine's copy. A site can hold a NEWER copy than the engine - installed
6
+ * from the marketplace catalogue, where the plugin is written - and replacing
7
+ * that would quietly take the site back a version. So the updater asks first.
8
+ *
9
+ * @module bin/lib/plugin-version
10
+ */
11
+
12
+ /** -1, 0 or 1. Numeric per dot-separated part; a missing part is 0; junk sorts lowest. */
13
+ export function compareVersions(a, b) {
14
+ const parse = (v) => String(v ?? '').split(/[.+-]/).slice(0, 3).map(n => (/^\d+$/.test(n) ? Number(n) : -1));
15
+ const x = parse(a);
16
+ const y = parse(b);
17
+ for (let i = 0; i < 3; i++) {
18
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
19
+ if (d) return d > 0 ? 1 : -1;
20
+ }
21
+ return 0;
22
+ }
23
+
24
+ /** Should the engine's copy replace the site's? Not when the site's is newer. */
25
+ export function shouldReplacePlugin(siteVersion, engineVersion) {
26
+ if (!siteVersion) return true;
27
+ return compareVersions(siteVersion, engineVersion) <= 0;
28
+ }
package/bin/update.js CHANGED
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import {createInterface} from 'node:readline';
15
15
  import {fileURLToPath} from 'node:url';
16
16
  import {deepMergeNewKeys} from './lib/config-merge.js';
17
+ import {shouldReplacePlugin} from './lib/plugin-version.js';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Paths
@@ -379,6 +380,19 @@ export default async function update(_positional, flags) {
379
380
  }
380
381
  newPlugins.push(name);
381
382
  } else {
383
+ // A site can hold a NEWER copy than the engine - installed from
384
+ // the marketplace catalogue, where the plugin is written.
385
+ // Replacing it would take the site back a version, so keep it.
386
+ const versionOf = (dir) => {
387
+ try { return readJson(path.join(dir, 'plugin.json')).version ?? null; } catch { return null; }
388
+ };
389
+ const siteVersion = versionOf(dstPlugin);
390
+ const engineVersion = versionOf(srcPlugin);
391
+ if (!shouldReplacePlugin(siteVersion, engineVersion)) {
392
+ info(` → plugins/${name}/ kept: ${siteVersion} here is newer than the engine's ${engineVersion}`);
393
+ continue;
394
+ }
395
+
382
396
  // Existing plugin - identify paths to preserve
383
397
  let preservePaths = new Set(['data']);
384
398
  try {
@@ -359,7 +359,7 @@
359
359
  ],
360
360
  "meta": {
361
361
  "createdAt": "2026-05-25T11:11:15.223Z",
362
- "updatedAt": "2026-09-22T03:39:59.212Z",
362
+ "updatedAt": "2026-09-20T11:19:46.452Z",
363
363
  "bundled": false,
364
364
  "presetOwner": null,
365
365
  "project": null
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "port": 4096,
3
3
  "host": "0.0.0.0",
4
+ "requireSignedPlugins": false,
4
5
  "cors": {
5
- "origin": false
6
+ "origin": false,
7
+ "publicPrefixes": []
6
8
  },
7
9
  "uploads": {
8
10
  "maxFileSize": 10485760
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.66.0",
3
+ "version": "0.69.4",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -19,15 +19,11 @@
19
19
  "public/css/",
20
20
  "config/",
21
21
  "!config/connections.json",
22
+ "!config/theme.json",
22
23
  "plugins/",
23
- "!plugins/analytics/data",
24
24
  "!plugins/contacts/data",
25
25
  "!plugins/notes/data",
26
26
  "!plugins/todo/data",
27
- "!plugins/analytics/stats.json",
28
- "!plugins/analytics/daily.json",
29
- "!plugins/analytics/lifetime.json",
30
- "!plugins/analytics/journeys.json",
31
27
  "!plugins/mail-reader/data",
32
28
  "!plugins/_lib/data",
33
29
  "scripts/",
@@ -37,7 +33,7 @@
37
33
  "FEATURES.md"
38
34
  ],
39
35
  "scripts": {
40
- "test": "node --test --test-concurrency=1 'tests/**/*.test.js'",
36
+ "test": "node scripts/run-tests.mjs",
41
37
  "build": "node scripts/build.js",
42
38
  "create-plugin": "node scripts/create-plugin.js",
43
39
  "delete-users": "node -e \"import('fs').then(({readdirSync,rmSync})=>{const d='content/users';readdirSync(d).filter(f=>f.endsWith('.json')).forEach(f=>{rmSync(d+'/'+f);console.log('deleted',f)})})\"",
@@ -4121,6 +4121,11 @@ return {
4121
4121
  if (!folder || !Number.isFinite(folder.unseen)) return;
4122
4122
  folder.unseen = Math.max(0, folder.unseen + delta);
4123
4123
  renderFolders();
4124
+ // The sidebar's unread badge counts the mirror; ask it to look
4125
+ // again. Its url is this screen's plugin (#/plugins/<slug>) - the
4126
+ // reader is shared by more than one mail plugin.
4127
+ const pluginUrl = location.hash.split('/').slice(0, 3).join('/');
4128
+ window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: pluginUrl}}));
4124
4129
  }
4125
4130
 
4126
4131
  /**
@@ -4346,6 +4351,21 @@ return {
4346
4351
  if (features.folders) attachFolderMenu();
4347
4352
 
4348
4353
  $container.find('#mail-settings-btn').on('click', () => openSettings());
4354
+
4355
+ // Settings is a screen-level control, so it belongs in the admin's
4356
+ // plugin banner beside the licence - the same place every other plugin
4357
+ // puts its cog. Imported at RUN time and survivable: an admin that
4358
+ // predates addBannerAction keeps the button in the Folders pane.
4359
+ try {
4360
+ const chrome = await import('/admin/js/lib/plugin-chrome.js');
4361
+ const placed = chrome.addBannerAction?.($container, {
4362
+ id: `${pluginName}-settings`,
4363
+ icon: 'settings',
4364
+ title: 'Mail account settings',
4365
+ onClick: () => openSettings()
4366
+ });
4367
+ if (placed) $container.find('#mail-settings-btn').get(0)?.remove();
4368
+ } catch { /* no banner: the pane button stays */ }
4349
4369
  $container.find('#mail-refresh-btn').on('click', () => loadMessages());
4350
4370
 
4351
4371
  // Folder management was right-click only, which is the same as not
@@ -770,6 +770,26 @@ export function createStore(connection) {
770
770
  return database.collection(MESSAGES).countDocuments(filter);
771
771
  }
772
772
 
773
+ /**
774
+ * The newest unread messages in a mailbox - who from, what about, when -
775
+ * for the sidebar badge's popover. Headers only; never a body.
776
+ *
777
+ * @param {string} accountId
778
+ * @param {string[]|null} [folders] - which to look in, or null for all mirrored
779
+ * @param {number} [limit]
780
+ * @returns {Promise<{from: object[], subject: string, date: string|null, folder: string}[]>}
781
+ */
782
+ async function listUnread(accountId, folders = null, limit = 5) {
783
+ const database = await connect();
784
+ const filter = {accountId, seen: false};
785
+ if (Array.isArray(folders) && folders.length) filter.folder = {$in: folders};
786
+ return database.collection(MESSAGES)
787
+ .find(filter, {projection: {_id: 0, from: 1, subject: 1, date: 1, folder: 1}})
788
+ .sort({date: -1, uid: -1})
789
+ .limit(limit)
790
+ .toArray();
791
+ }
792
+
773
793
  /** Folders already backfilled in this process, so it runs at most once each. */
774
794
  const backfilled = new Set();
775
795
 
@@ -993,7 +1013,7 @@ export function createStore(connection) {
993
1013
  connect,
994
1014
  getFolder, saveFolder,
995
1015
  putMessages, setFlags, removeMessages, clearFolder,
996
- storedUids, listMessages, listThreads, searchMessages, countUnread, forgetAccount,
1016
+ storedUids, listMessages, listThreads, searchMessages, countUnread, listUnread, forgetAccount,
997
1017
  queueSend, listQueuedSends, claimDueSends, finishSend, cancelSend, getQueuedSend,
998
1018
  queueSnooze, listSnoozed, claimDueSnoozes, finishSnooze, wakeSnooze,
999
1019
  lastAutoReply, noteAutoReply, listAutoReplies, clearAutoReplies,