domma-cms 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/api-tokens-arrange.js +3 -0
  6. package/admin/js/lib/blocks-arrange.js +1 -0
  7. package/admin/js/lib/collections-arrange.js +1 -0
  8. package/admin/js/lib/components-arrange.js +1 -0
  9. package/admin/js/lib/context-menus-arrange.js +1 -0
  10. package/admin/js/lib/effects-arrange.js +3 -0
  11. package/admin/js/lib/forms-arrange.js +1 -0
  12. package/admin/js/lib/layouts-arrange.js +1 -0
  13. package/admin/js/lib/media-arrange.js +1 -0
  14. package/admin/js/lib/menus-arrange.js +1 -0
  15. package/admin/js/lib/my-profile-arrange.js +1 -0
  16. package/admin/js/lib/notifications-arrange.js +1 -0
  17. package/admin/js/lib/pages-arrange.js +1 -0
  18. package/admin/js/lib/plugins-arrange.js +2 -0
  19. package/admin/js/lib/roles-arrange.js +1 -0
  20. package/admin/js/lib/search-settings-arrange.js +1 -0
  21. package/admin/js/lib/sidebar-renderer.js +3 -3
  22. package/admin/js/lib/site-settings-arrange.js +1 -0
  23. package/admin/js/lib/theme-settings-arrange.js +1 -0
  24. package/admin/js/lib/users-arrange.js +1 -0
  25. package/admin/js/lib/views-arrange.js +1 -0
  26. package/admin/js/templates/actions-list.html +73 -14
  27. package/admin/js/templates/api-endpoints.html +63 -10
  28. package/admin/js/templates/api-tokens.html +83 -10
  29. package/admin/js/templates/blocks.html +76 -15
  30. package/admin/js/templates/collections.html +108 -52
  31. package/admin/js/templates/components.html +82 -15
  32. package/admin/js/templates/context-menus.html +55 -13
  33. package/admin/js/templates/effects.html +155 -72
  34. package/admin/js/templates/forms.html +67 -14
  35. package/admin/js/templates/layouts.html +126 -43
  36. package/admin/js/templates/media.html +99 -13
  37. package/admin/js/templates/menus.html +57 -11
  38. package/admin/js/templates/my-profile.html +100 -12
  39. package/admin/js/templates/notifications.html +70 -10
  40. package/admin/js/templates/pages.html +78 -23
  41. package/admin/js/templates/plugins.html +100 -28
  42. package/admin/js/templates/roles.html +78 -7
  43. package/admin/js/templates/search.html +193 -216
  44. package/admin/js/templates/settings.html +404 -472
  45. package/admin/js/templates/theme.html +164 -132
  46. package/admin/js/templates/users.html +69 -9
  47. package/admin/js/templates/views-list.html +63 -9
  48. package/admin/js/views/actions-list.js +37 -1
  49. package/admin/js/views/api-endpoints.js +11 -7
  50. package/admin/js/views/api-tokens.js +62 -8
  51. package/admin/js/views/blocks.js +34 -5
  52. package/admin/js/views/collections.js +36 -1
  53. package/admin/js/views/components.js +27 -5
  54. package/admin/js/views/context-menus.js +10 -5
  55. package/admin/js/views/effects.js +31 -1
  56. package/admin/js/views/forms.js +57 -1
  57. package/admin/js/views/layouts.js +81 -1
  58. package/admin/js/views/media.js +40 -1
  59. package/admin/js/views/menus.js +11 -5
  60. package/admin/js/views/my-profile.js +1 -1
  61. package/admin/js/views/notifications.js +18 -1
  62. package/admin/js/views/pages.js +28 -8
  63. package/admin/js/views/plugins.js +97 -39
  64. package/admin/js/views/roles.js +49 -4
  65. package/admin/js/views/search.js +7 -1
  66. package/admin/js/views/settings.js +13 -3
  67. package/admin/js/views/theme.js +55 -1
  68. package/admin/js/views/users.js +13 -4
  69. package/admin/js/views/views-list.js +11 -1
  70. package/package.json +1 -1
  71. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  72. package/plugins/_lib/admin/mail/contacts.js +4 -1
  73. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  74. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  75. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  76. package/plugins/_lib/admin/mail/mail.css +1 -1
  77. package/plugins/_lib/admin/mail/mail.html +1 -1
  78. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  79. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  80. package/plugins/_lib/mail/readRoutes.js +43 -1
  81. package/plugins/_lib/mail/unreadBadge.js +42 -0
  82. package/plugins/free-tier.lock.json +5 -5
  83. package/plugins/mail-reader/CLAUDE.md +1 -0
  84. package/plugins/mail-reader/plugin.js +3 -1
  85. package/plugins/mail-reader/plugin.json +5 -4
  86. package/server/routes/api/actions.js +64 -2
  87. package/server/routes/api/api-endpoints.js +41 -0
  88. package/server/routes/api/api-tokens.js +56 -0
  89. package/server/routes/api/auth.js +7 -1
  90. package/server/routes/api/blocks.js +170 -8
  91. package/server/routes/api/collections.js +66 -2
  92. package/server/routes/api/components.js +46 -0
  93. package/server/routes/api/context-menus.js +8 -0
  94. package/server/routes/api/effects.js +68 -13
  95. package/server/routes/api/forms.js +123 -10
  96. package/server/routes/api/layouts.js +93 -2
  97. package/server/routes/api/media.js +56 -1
  98. package/server/routes/api/menus.js +53 -6
  99. package/server/routes/api/notifications.js +140 -14
  100. package/server/routes/api/pages.js +46 -0
  101. package/server/routes/api/plugins.js +135 -4
  102. package/server/routes/api/roles.js +111 -0
  103. package/server/routes/api/search.js +38 -4
  104. package/server/routes/api/settings.js +52 -0
  105. package/server/routes/api/theme.js +55 -0
  106. package/server/routes/api/users.js +53 -1
  107. package/server/routes/api/views.js +27 -3
  108. package/server/server.js +28 -1
  109. package/server/services/contextMenus.js +41 -0
  110. package/server/services/plugins.js +7 -0
  111. package/server/services/roles.js +3 -0
  112. package/server/services/search.js +58 -0
  113. package/server/services/users.js +14 -0
  114. package/server/services/views.js +38 -0
  115. package/admin/js/templates/plugin-marketplace.html +0 -29
  116. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -1 +1,55 @@
1
- import{api as w}from"../api.js";import{populateThemeSelect as A}from"../lib/themes.js";const he=[{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(s){E.tabs(s.find("#theme-tabs").get(0));const X=E.loader(s.get(0),{type:"dots"});let m,q;try{[m,q]=await Promise.all([w.theme.get(),w.theme.catalog().catch(()=>({themes:[],tokens:[]}))])}catch(e){X.destroy(),E.toast(`Could not load theme settings: ${e.message||e}`,{type:"error"});return}X.destroy();const D=q.tokens||[],Y=Array.isArray(q.custom)?q.custom:[];function x(e){if(!Y.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const n of Y){const o=document.createElement("option");o.value=n.id,o.textContent=`${n.label} (on ${n.baseTheme})`,t.appendChild(o)}e.appendChild(t)}function T(e,t){const n=e.get?e.get(0):e;if(t&&![...n.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,n.appendChild(o)}n.value=t}const M=new Map;async function H(e){if(!e)return null;if(M.has(e))return M.get(e);const t=await w.theme.themeTokens(e).catch(()=>null),n=t&&t.tokens?t.tokens:null;return M.set(e,n),n}function ae(){return(s.find("#theme-auto-enabled").prop("checked")?S.val():f.val())||m.theme}const U=["--dm-card-bg","--dm-surface"],W=[...U,"--dm-text","--dm-primary","--dm-background"];function G(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 j(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=ae();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,r]=await Promise.all([H(o),H(n)]);if(!a||!r)return;const c=Object.keys(r).filter(l=>String(a[l]??"").toLowerCase()!==String(r[l]).toLowerCase()),d=U.filter(l=>c.includes(l)),i=document.createElement("div");i.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(r).length} tokens here.`,i.appendChild(h),!d.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.",i.appendChild(l)}const p=document.createElement("div");p.style.cssText="display:flex;flex-wrap:wrap;gap:.15rem .75rem;";const v=[...W.filter(l=>c.includes(l)),...c.filter(l=>!W.includes(l))];for(const l of v.slice(0,6)){const u=document.createElement("span");u.style.cssText="white-space:nowrap;color:var(--dm-text-muted);",u.appendChild(G(a[l]||""));const C=document.createElement("span");C.textContent=`${l} `,u.appendChild(C),u.appendChild(G(r[l])),p.appendChild(u)}if(v.length>6){const l=document.createElement("span");l.style.cssText="color:var(--dm-text-muted);",l.textContent=`+${v.length-6} more`,p.appendChild(l)}i.appendChild(p),t.appendChild(i)}let b=Array.isArray(m.overrides)?m.overrides.map($):[];const f=s.find("#theme-frontend"),O=s.find("#theme-admin");A(f.get(0)),A(O.get(0),{includeAdmin:!0}),x(f.get(0)),x(O.get(0)),T(f,m.theme||"charcoal-dark"),T(O,m.adminTheme||"charcoal-dark");const S=s.find("#theme-day"),I=s.find("#theme-night");A(S.get(0)),A(I.get(0)),x(S.get(0)),x(I.get(0));const g=m.autoTheme||{};T(S,g.dayTheme||"charcoal-light"),T(I,g.nightTheme||"charcoal-dark"),s.find("#theme-day-start").val(g.dayStart||"07:00"),s.find("#theme-night-start").val(g.nightStart||"19:00");const z=m.switcher||{},B=s.find("#theme-switcher-enabled"),le=s.find("#theme-switcher-fields");B.prop("checked",z.enabled===!0),s.find("#theme-switcher-position").val(z.position||"right-center"),s.find("#theme-switcher-offset-x").val(z.offsetX??32),s.find("#theme-switcher-offset-y").val(z.offsetY??32);const J=()=>{le.get(0).style.display=B.prop("checked")?"":"none"};J(),B.on("change",J);const Q=s.find("#theme-auto-enabled"),Z=s.find("#theme-auto-fields");Q.prop("checked",!!g.enabled),g.enabled&&(Z.show(),f.prop("disabled",!0)),Q.on("change",function(){Z.toggle(this.checked),f.prop("disabled",this.checked)}),s.find("#theme-font-family").val(m.font?.family||"Roboto"),s.find("#theme-font-size").val(String(m.font?.size??16));function $(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"),re=document.getElementById("theme-overrides-empty");function R(){for(;y.firstChild;)y.removeChild(y.firstChild);re.style.display=b.length?"none":"",b.forEach((e,t)=>y.appendChild(ce(e,t))),Domma.icons.scan("#theme-overrides")}function ce(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 r=document.createElement("div");r.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 d=document.createElement("input");d.type="checkbox",d.className="form-check ov-enabled",d.checked=e.enabled,c.appendChild(d),c.appendChild(document.createTextNode("Enabled")),r.appendChild(c);const i=document.createElement("button");i.type="button",i.className="btn btn-ghost btn-sm ov-remove",i.textContent="Remove",r.appendChild(i),o.appendChild(r),n.appendChild(o);const h=document.createElement("div");h.className="card-body";const p=document.createElement("div");p.className="row mb-3",p.appendChild(V("col-4","Name",ne("ov-label",e.label,"Navbar island"))),p.appendChild(V("col-8","Target selector",de(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 l=document.createElement("select");l.className="form-select ov-theme",A(l,{includeDefault:!0,defaultLabel:"Keep the site theme"}),x(l),T(l,e.theme||""),v.appendChild(V("col-6","Adopt a theme here",l,"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(([ie,me])=>{C.appendChild(ee(ie,me))}),h.appendChild(te("Token overrides",C));const L=document.createElement("button");L.type="button",L.className="btn btn-ghost btn-sm ov-add-token",L.textContent="+ Add token",h.appendChild(L);const N=document.createElement("details");N.style.marginTop="1rem";const _=document.createElement("summary");_.style.cssText="cursor:pointer;font-size:.85rem;",_.textContent="Custom CSS for this override",N.appendChild(_);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",N.appendChild(k);const P=document.createElement("p");return P.className="form-hint text-muted",P.style.cssText="font-size:.8rem;margin-top:.35rem;",P.textContent="Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade, hand-written rules fight the cascade.",N.appendChild(P),h.appendChild(N),n.appendChild(h),setTimeout(()=>j(n),0),n}function ee(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",D.length){for(const d of D){const i=document.createElement("option");i.value=d,i.textContent=d,o.appendChild(i)}o.value=e||D[0]}else{const d=document.createElement("option");d.value=e||"",d.textContent=e||"(no tokens found)",o.appendChild(d)}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 r=document.createElement("input");r.type="color",r.className="ov-token-swatch",r.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||"")&&(r.value=t),r.addEventListener("input",()=>{a.value=r.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,r,c),n}function V(e,t,n,o){const a=document.createElement("div");return a.className=e,a.appendChild(te(t,n,o)),a}function te(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 r=document.createElement("p");r.className="form-hint text-muted",r.style.cssText="margin-top:.35rem;font-size:.8rem;",r.textContent=n,o.appendChild(r)}return o}function ne(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 de(e){const t=document.createElement("div"),n=ne("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 he){const r=document.createElement("option");r.value=a.value,r.label=a.label,o.appendChild(r)}t.appendChild(o)}return t}function F(){const e=y.querySelectorAll(".card"),t=[];return e.forEach((n,o)=>{const a={};n.querySelectorAll(".ov-token-row").forEach(r=>{const c=r.querySelector(".ov-token-name")?.value?.trim(),d=r.querySelector(".ov-token-value")?.value?.trim();c&&d&&(a[c]=d)}),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}s.on("click",".ov-remove",function(){const e=Number(this.closest(".card").dataset.i);F(),b.splice(e,1),R()}),s.on("change",".ov-theme",function(){j(this.closest(".card"))}),s.on("change","#theme-frontend, #theme-auto-enabled, #theme-day",()=>{y.querySelectorAll(".card").forEach(j)}),s.on("click",".ov-add-token",function(){this.closest(".card").querySelector(".ov-tokens").appendChild(ee("",""))}),s.on("click",".ov-token-remove",function(){this.closest(".ov-token-row").remove()}),s.find("#theme-add-override").on("click",()=>{F(),b.push($({label:"",scope:"",enabled:!0})),R()}),R();async function K(){const e=document.getElementById("theme-css-preview"),t={...oe(),overrides:F()};try{const n=await w.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)"}}s.find("#theme-refresh-css").on("click",K);function oe(){const e=s.find("#theme-auto-enabled").prop("checked"),t={enabled:e,dayTheme:S.val()||"charcoal-light",nightTheme:I.val()||"charcoal-dark",dayStart:s.find("#theme-day-start").val()||"07:00",nightStart:s.find("#theme-night-start").val()||"19:00"};return{theme:e?t.dayTheme:f.val(),adminTheme:O.val(),autoTheme:t,font:{family:s.find("#theme-font-family").val()||"Roboto",size:parseInt(s.find("#theme-font-size").val(),10)||16},switcher:{enabled:B.prop("checked"),position:s.find("#theme-switcher-position").val()||"right-center",offsetX:parseInt(s.find("#theme-switcher-offset-x").val(),10)||0,offsetY:parseInt(s.find("#theme-switcher-offset-y").val(),10)||0,themes:Array.isArray(m.switcher?.themes)?m.switcher.themes:[]}}}function se(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"})}s.find("#theme-save-btn").on("click",async function(){se(null);const e={...oe(),overrides:F()};this.disabled=!0;try{m=await w.theme.save(e);const{refreshAdminTheme:n}=await import("../lib/admin-theme.js");await n(w),E.toast("Theme saved.",{type:"success"}),await K()}catch(t){const n=t.validation||[t.message||"Could not save the theme."];se(n),E.toast("Theme not saved.",{type:"error"})}finally{this.disabled=!1}}),await K()}};
1
+ import{api as N}from"../api.js";import{addBannerAction as Ie}from"../lib/plugin-chrome.js";import{helpPopovers as Pe}from"../lib/help-popover.js";import{onceClosed as lt,openMenuAt as Ke,sidebarCount as it}from"../lib/tool-kit.js";import{populateThemeSelect as Fe}from"../lib/themes.js";import{cloneOverride as Q,dayOrNight as ct,formFromConfig as X,knownThemeIds as dt,moveItem as He,overrideRow as mt,payloadFromForm as Z,snapshot as pe,swatchColour as ze,themeDiff as ht,themeLabel as ut,themeProblems as pt}from"../lib/theme-settings-arrange.js";const fe=/^\/theme(\?.*)?$/,ft="#/theme",L="theme.draft",vt=[{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"}],bt=`
2
+ <div class="thx-row" data-id="{{id}}" tabindex="0" data-bind-class="off && 'is-off'">
3
+ <span class="thx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
4
+ <span class="thx-chip is-danger" data-bind-hidden="!missing" title="Its theme no longer exists">Theme gone</span>
5
+ <span class="thx-chip is-warn" data-bind-hidden="!empty" title="No theme, token or CSS - it changes nothing">Empty</span>
6
+ <span class="thx-chip" data-bind-hidden="!off">Off</span>
7
+ <button type="button" class="thx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
8
+ </div>`,ve=m=>String(m??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function yt(m){const y=document.createElement("template");return y.innerHTML=m.trim(),y.content.firstElementChild}let x=null,Be=!1;function kt(){Be||!window.R?.use||(Be=!0,R.use((m,y,u)=>!(fe.test(y?.path||"")&&!fe.test(m?.path||""))||!x?.dirty()?u():E.confirm("You have unsaved theme changes. Leave anyway? They are kept as a draft for your next visit.").then(p=>{p?u():history.replaceState(null,"","#/theme")})),M.subscribe("router:afterChange",({to:m}={})=>{if(x&&!fe.test(m?.path||"")){const y=x;x=null,y.dispose()}}))}export const themeView={templateUrl:"/admin/js/templates/theme.html",async onMount(m){if(kt(),x){const e=x;x=null,e.dispose()}const y=[],u=e=>(y.push(e),e);let ee=!1;const p=m.get(0).querySelector(".thx")||m.get(0),j=()=>!ee&&p.isConnected,be=E.loader(m.get(0),{type:"dots"});let P,q;try{[P,q]=await Promise.all([N.theme.get(),N.theme.catalog().catch(()=>({themes:[],tokens:[],custom:[]}))])}catch(e){be.destroy(),E.toast(`Could not load the theme settings: ${e.message||e}`,{type:"error"});return}if(be.destroy(),!p.isConnected)return;const te=q.tokens||[],K=Array.isArray(q.custom)?q.custom:[],ye=dt(q),f=M.observable(P),$=X(P),s={};for(const[e,t]of Object.entries($))s[e]=M.observable(t);const ke=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].value])),oe=()=>Object.fromEntries(Object.keys(s).map(e=>[e,s[e].peek()])),F=M.observable(pe($,P)),H=M.observable(!1),z=M.observable([]),ne=M.observable("(nothing yet)"),ae=M.observable(!1),_e=M.computed(()=>pe(ke(),f.value)),B=M.computed(()=>_e.value!==F.value),ge=M.computed(()=>pt(ke(),ye)),we=M.observable([]);u(M.effect(()=>{we.value=s.overrides.value.map((e,t)=>mt(e,t,{custom:K,known:ye}))}));const _=e=>ut(e,K),Ve={...s,saving:H,rows:we,cssText:ne,cssError:ae,dirty:B,errorsText:M.computed(()=>z.value.join(" \xB7 ")),problemsText:M.computed(()=>ge.value.length?`${ge.value.map(e=>`${e.where}: "${e.id}"`).join(", ")} - no longer available, so its base theme shows. Pick another.`:""),liveSite:M.computed(()=>{const e=f.value;if(e.autoTheme?.enabled){const t=ct(e.autoTheme)==="day"?"day":"night";return`Site: ${_(e.autoTheme.dayTheme)} by day, ${_(e.autoTheme.nightTheme)} by night (${t} now)`}return`Site: ${_(e.theme||"charcoal-dark")}`}),liveAdmin:M.computed(()=>`Admin: ${_(f.value.adminTheme||"charcoal-dark")}`),liveOverrides:M.computed(()=>{const e=f.value.overrides||[],t=e.filter(o=>o.enabled!==!1).length;return e.length?`${t} override${t===1?"":"s"} on`:""}),liveSwitcher:M.computed(()=>f.value.switcher?.enabled?"Theme switcher on":"")};function xe(e){if(!K.length)return;const t=document.createElement("optgroup");t.label="Custom themes";for(const o of K){const a=document.createElement("option");a.value=o.id,a.textContent=`${o.label} (on ${o.baseTheme})`,t.appendChild(a)}e.appendChild(t)}function Ee(e,t){if(t&&![...e.options].some(o=>o.value===t)){const o=document.createElement("option");o.value=t,o.textContent=`${t} (not available)`,e.appendChild(o)}}const V=(e,t,o)=>{const a=p.querySelector(e);return Fe(a,o),xe(a),Ee(a,t),a};V("#thx-site",$.theme),V("#thx-admin",$.adminTheme,{includeAdmin:!0}),V("#thx-day",$.dayTheme),V("#thx-night",$.nightTheme);let se=null,re=0;async function Y(){const e=++re;try{const t=await N.theme.compile(Z(oe(),f.peek()));if(e!==re||!j())return;ne.value=t.css?.trim()||"(no overrides - nothing is injected)",ae.value=!1}catch(t){if(e!==re||!j())return;ne.value=t.message||String(t),ae.value=!0}}u(M.effect(()=>{s.overrides.value,clearTimeout(se),se=setTimeout(Y,400)})),u(()=>clearTimeout(se));async function le(){if(!H.peek()){H.value=!0,z.value=[];try{const e=await N.theme.save(Z(oe(),f.peek()));if(!j())return;f.value=e;const t=X(e);for(const[a,l]of Object.entries(t))s[a].value=l;F.value=pe(t,e);try{S.remove?S.remove(L):S.set(L,null)}catch{}const{refreshAdminTheme:o}=await import("../lib/admin-theme.js");await o(N),it(ft),E.toast("Theme saved.",{type:"success"}),Y()}catch(e){z.value=e.validation||[e.message||"Could not save the theme."],E.toast("Theme not saved - see the message at the top.",{type:"error"})}finally{H.value=!1}}}async function Ye(){if(!await E.confirm("Throw away your unsaved theme changes?"))return;const e=X(f.peek());for(const[t,o]of Object.entries(e))s[t].value=o;z.value=[]}const Se=e=>s.overrides.peek().find(t=>t.id===e),C=e=>{s.overrides.value=e},ie=(e,t)=>C(s.overrides.peek().map(o=>o.id===e?t:o)),Ue=e=>s.overrides.peek().findIndex(t=>t.id===e),ce=new Map;async function Ce(e){if(!e)return null;if(ce.has(e))return ce.get(e);const o=(await N.theme.themeTokens(e).catch(()=>null))?.tokens||null;return ce.set(e,o),o}const Je=()=>(s.autoEnabled.peek()?s.dayTheme.peek():s.theme.peek())||"charcoal-dark",U=new Set;function J(e){const t=e?Se(e):Q({});if(!t)return;const o=!e,a=Q(t),l={label:M.observable(a.label),scope:M.observable(a.scope),enabled:M.observable(a.enabled),theme:M.observable(a.theme),css:M.observable(a.css),tokenErr:M.observable("")},c=E.slideover({title:o?"New override":`Override: ${ve(t.label||t.scope||"untitled")}`,size:"md",position:"right"}),v=yt(`
9
+ <div class="thx-form">
10
+ <div class="thx-grid">
11
+ <div>
12
+ <label class="form-label" for="thx-o-label">Name</label>
13
+ <input id="thx-o-label" type="text" class="form-input" maxlength="80" placeholder="Navbar island" data-model="label.value">
14
+ </div>
15
+ <div class="thx-setting" style="align-self:end">
16
+ <label class="form-label" for="thx-o-on">On
17
+ <span data-help="Off keeps the override but leaves it out of the page."></span></label>
18
+ <input id="thx-o-on" type="checkbox" class="form-switch" data-model="enabled.value">
19
+ </div>
20
+ </div>
21
+ <div>
22
+ <label class="form-label" for="thx-o-scope">Target selector
23
+ <span data-help-title="Target selector" data-help="The region to re-theme - a CSS selector. Everything inside it follows. Pick a common region from the list or type your own (#site-navbar, .card, .page-body \u2026)."></span></label>
24
+ <input id="thx-o-scope" type="text" class="form-input font-mono" list="thx-scope-options" placeholder="#site-navbar" data-model="scope.value">
25
+ <datalist id="thx-scope-options">${vt.map(n=>`<option value="${n.value}" label="${n.label}"></option>`).join("")}</datalist>
26
+ </div>
27
+ <div>
28
+ <label class="form-label" for="thx-o-theme">Adopt a theme here
29
+ <span data-help-title="Adopt a theme" data-help="Copies every token of that theme onto this region. Two themes often share a surface colour, so the panel below says what really changes - set --dm-card-bg or --dm-surface as a token when the fill should change too."></span></label>
30
+ <select id="thx-o-theme" class="form-select" data-model="theme.value"></select>
31
+ <div class="thx-diff-host"></div>
32
+ </div>
33
+ <div>
34
+ <label class="form-label">Token overrides
35
+ <span data-help-title="Tokens" data-help="Individual --dm-* design tokens for this region, set after any adopted theme. Only tokens the shipped stylesheet defines are accepted - start typing to see them. The colour box fills in a hex value; lengths and keywords go in the text box."></span></label>
36
+ <div class="thx-toks"></div>
37
+ <datalist id="thx-token-names"></datalist>
38
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="addToken">
39
+ <span data-icon="plus" data-icon-size="13"></span> Add token</button>
40
+ <small class="thx-err" data-bind-hidden="!tokenErr.value" data-bind-text="tokenErr.value"></small>
41
+ </div>
42
+ <details class="thx-o-css"${a.css?" open":""}>
43
+ <summary>Custom CSS for this override</summary>
44
+ <textarea class="form-input font-mono" rows="4" style="margin-top:.5rem" placeholder="#site-navbar .brand { letter-spacing: .04em; }" data-model="css.value"></textarea>
45
+ <small class="thx-hint">Appended verbatim after the generated rule - you own these selectors. Prefer tokens: they survive a Domma upgrade; hand-written rules fight the cascade.</small>
46
+ </details>
47
+ <div class="thx-actions">
48
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="remove"${o?" hidden":""}>
49
+ <span data-icon="trash" data-icon-size="13"></span> Remove</button>
50
+ <span class="thx-spacer"></span>
51
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
52
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="done">
53
+ <span data-icon="check" data-icon-size="13"></span> ${o?"Add":"Done"}</button>
54
+ </div>
55
+ </div>`),de=v.querySelector("#thx-o-theme");Fe(de,{includeDefault:!0,defaultLabel:"Keep the site theme"}),xe(de),Ee(de,a.theme);const Xe=v.querySelector("#thx-token-names");for(const n of te){const i=document.createElement("option");i.value=n,Xe.appendChild(i)}const me=v.querySelector(".thx-toks");function Ne(n="",i=""){const b=document.createElement("div");b.className="thx-tok";const r=document.createElement("input");r.type="text",r.className="form-input font-mono thx-tok-name",r.placeholder="--dm-primary",r.setAttribute("list","thx-token-names"),r.value=n,r.setAttribute("aria-label","Token");const h=document.createElement("input");h.type="text",h.className="form-input thx-tok-value",h.placeholder="#0096c7",h.value=i,h.setAttribute("aria-label","Value");const k=document.createElement("input");k.type="color",k.title="Pick a colour";const g=ze(i);/^#[0-9a-f]{6}$/i.test(g)&&(k.value=g),k.addEventListener("input",()=>{h.value=k.value});const w=document.createElement("button");return w.type="button",w.className="btn btn-sm btn-ghost",w.textContent="\xD7",w.title="Remove this token",w.addEventListener("click",()=>b.remove()),b.append(r,h,k,w),b}for(const[n,i]of Object.entries(a.tokens))me.appendChild(Ne(n,i));function De(){const n={},i=[];return me.querySelectorAll(".thx-tok").forEach(b=>{const r=b.querySelector(".thx-tok-name").value.trim(),h=b.querySelector(".thx-tok-value").value.trim();!r||!h||(te.length&&!te.includes(r)&&i.push(r),n[r]=h)}),{tokens:n,bad:i}}const Le=()=>({...a,label:l.label.peek(),scope:l.scope.peek(),enabled:l.enabled.peek(),theme:l.theme.peek(),css:l.css.peek(),tokens:De().tokens}),Ze=JSON.stringify(Z({overrides:[a]}).overrides),et=()=>JSON.stringify(Z({overrides:[Le()]}).overrides)!==Ze,he=v.querySelector(".thx-diff-host");let Re=0;async function tt(){const n=++Re;he.replaceChildren();const i=l.theme.peek();if(!i)return;const b=Je(),r=document.createElement("div");if(r.className="thx-diff",r.style.marginTop=".5rem",i===b){const d=document.createElement("p");d.textContent="That is already the site theme, so this changes nothing on its own. Set a token below instead.",r.appendChild(d),he.appendChild(r);return}const[h,k]=await Promise.all([Ce(b),Ce(i)]);if(n!==Re||!h||!k)return;const g=ht(h,k),w=document.createElement("p");if(w.textContent=`Changes ${g.changed} of ${g.total} tokens here.`,r.appendChild(w),g.keepsFill){const d=document.createElement("p");d.className="is-warn",d.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.",r.appendChild(d)}const G=document.createElement("div");G.className="thx-diff-list";const je=d=>{const A=document.createElement("span");return A.className="thx-sw",A.style.background=ze(d)||"transparent",A};for(const d of g.shown){const A=document.createElement("span");A.append(je(d.from),document.createTextNode(`${d.name} `),je(d.to)),G.appendChild(A)}if(g.more){const d=document.createElement("span");d.textContent=`+${g.more} more`,G.appendChild(d)}r.appendChild(G),he.appendChild(r)}const ot=M.effect(()=>{l.theme.value,tt()});let W=!1,ue=!1;const qe={addToken:()=>{const n=Ne();me.appendChild(n),n.querySelector("input").focus()},cancel:()=>c.close(),async remove(){await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&(C(s.overrides.peek().filter(n=>n.id!==t.id)),W=!0,c.close())},done(){const{bad:n}=De();if(n.length){l.tokenErr.value=`Not a token this Domma defines: ${n.join(", ")}. Pick one from the list.`;return}const i=Le();o?C([...s.overrides.peek(),i]):ie(t.id,i),W=!0,c.close()}};c.element.appendChild(v);const nt=Pe(v),at=M.applyBindings(l,v,{methods:qe}),st=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),qe.done())};v.addEventListener("keydown",st),U.add(c),lt(c,()=>{ot(),at.dispose(),nt(),U.delete(c)});const rt=c.close.bind(c);c.close=async n=>{if(!W&&n!==!0&&et()){if(ue)return;ue=!0;const i=await E.confirm("Discard your changes to this override?");if(ue=!1,!i)return}return W=!0,rt()},I.scan(v),c.open(),v.querySelector(o?"#thx-o-scope":"#thx-o-label")?.focus()}function We(e){const t=Se(e);if(!t)return[];const o=Ue(e),a=s.overrides.peek().length;return[{header:t.label||t.scope||`Override ${o+1}`},{label:"Edit\u2026",icon:"edit",action:()=>J(e)},t.enabled?{label:"Switch off",icon:"pause",action:()=>ie(e,{...t,enabled:!1})}:{label:"Switch on",icon:"play",action:()=>ie(e,{...t,enabled:!0})},{label:"Duplicate",icon:"copy",action:()=>{const l=Q({...t,id:"",label:t.label?`${t.label} copy`:""}),c=[...s.overrides.peek()];c.splice(o+1,0,l),C(c)}},{type:"divider"},{label:"Move up",icon:"arrow-up",disabled:o<=0,action:()=>C(He(s.overrides.peek(),o,-1))},{label:"Move down",icon:"arrow-down",disabled:o>=a-1,action:()=>C(He(s.overrides.peek(),o,1))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:async()=>{await E.confirm(`Remove the override "${ve(t.label||t.scope||"untitled")}"? It goes when you save.`)&&C(s.overrides.peek().filter(l=>l.id!==e))}}]}const O=p.querySelector(".thx-list");O.innerHTML=bt,E.tabs(p.querySelector(".thx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"});const Ge=M.applyBindings(Ve,p,{methods:{save:le,discard:Ye,refreshCss:Y,addOverride:()=>J(null)}});u(()=>Ge.dispose()),u(Pe(p,{position:"bottom"}));const Qe=E.contextMenu(O,{cursor:"glyph",match:".thx-row",inherit:!1,density:"compact",items:e=>We(e.dataset.id)});u(()=>Qe?.destroy?.());const Te=e=>{const t=e.target.closest(".thx-row");if(t){if(e.target.closest(".thx-kebab")){Ke(e.target.closest(".thx-kebab"));return}J(t.dataset.id)}},Me=e=>{const t=e.target.closest(".thx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ke(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.id))};O.addEventListener("click",Te),O.addEventListener("keydown",Me),u(()=>{O.removeEventListener("click",Te),O.removeEventListener("keydown",Me)}),Ie(m,{id:"theme-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),Ie(m,{id:"theme-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const $e=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||U.size||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",$e,!0),u(()=>document.removeEventListener("keydown",$e,!0));const Oe=()=>{if(B.peek())try{S.set(L,{form:oe(),base:F.peek(),at:new Date().toISOString()})}catch{}},Ae=e=>{B.peek()&&(Oe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ae),u(()=>window.removeEventListener("beforeunload",Ae)),x={dirty:()=>B.peek(),dispose(){ee=!0,Oe(),[...U].forEach(e=>{try{e.close(!0)}catch{}}),y.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(p),Y();let T=null;try{T=S.get(L)}catch{}if(T?.form){try{S.remove?S.remove(L):S.set(L,null)}catch{}const e=T.base&&T.base!==F.peek(),t=T.at?` (${D(T.at).format("D MMM HH:mm")})`:"",o=e?`You left unsaved theme changes${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved theme changes${t}. Restore them?`;if(await E.confirm(o)&&j()){const a={...X(f.peek()),...T.form};a.overrides=(a.overrides||[]).map(l=>Q(l));for(const[l,c]of Object.entries(a))s[l]&&(s[l].value=c)}}}};
@@ -1,4 +1,13 @@
1
- import{api as m,getUser as A}from"../api.js";import{filterByProject as k,getProjectFromHash as M,renderProjectFilterNotice as j}from"../lib/project-context.js";import{keepTableIconsAlive as v}from"../lib/table-icons.js";export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(o){const u=A(),f=E.loader(o.get(0),{type:"dots"});let[s,y]=await Promise.all([m.users.list().catch(()=>[]),m.get("/collections/roles/entries?limit=100").catch(()=>({entries:[]}))]);f.destroy();const c=M();if(c){const r=s.length;s=k(s,c),j(o,{total:r,shown:s.length,slug:c,allHref:"#/users",noun:"users"})}const d=y.entries||[],p=r=>{T.create("#users-table",{pageSize:25,data:r,columns:[{key:"name",title:"Name"},{key:"email",title:"Email"},{key:"role",title:"Role",render:(e,t)=>{const a=d.find(n=>n.data?.name===e),l=a?.data?.badgeClass||"badge-secondary",g=a?.data?.label||e,b=`<span class="badge ${l}">${g}</span>`,i=Array.isArray(t.additionalRoles)?t.additionalRoles.filter(n=>n&&n!==e).map(n=>`<span class="badge badge-outline" title="Additional role" style="margin-left:.25rem;">${d.find(h=>h.data?.name===n)?.data?.label||n}</span>`).join(""):"";return b+i}},{key:"isActive",title:"Status",render:e=>e?'<span class="badge badge-success">Active</span>':'<span class="badge badge-secondary">Inactive</span>'},{key:"lastLogin",title:"Last login",render:e=>e?D(e).format("DD MMM YYYY HH:mm"):"Never"},{key:"actions",title:"Actions",render:(e,t)=>{const a=t.id===u?.id,l=Object.fromEntries(d.map(i=>[i.data?.name,i.data?.level??99]));return!((l[u?.role]??99)<(l[t.role]??99))&&!a?"":`
2
- <a href="#/users/edit/${t.id}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- ${a?"":`<button class="btn btn-sm btn-ghost btn-delete-user" data-id="${t.id}" data-name="${L(t.name)}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>`}
4
- `}}],emptyMessage:"No users found."}),Domma.icons.scan(),document.querySelectorAll("#users-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})};p(s),v(o.get(0)),o.off("click",".btn-delete-user").on("click",".btn-delete-user",async function(){const r=$(this).data("id"),e=$(this).data("name");if(await E.confirm(`Delete user <strong>${e}</strong>? This cannot be undone.`))try{await m.delete(`/users/${r}`),E.toast("User deleted.",{type:"success"}),s=s.filter(a=>a.id!==r),p(s)}catch(a){E.toast(`Failed to delete: ${a.message}`,{type:"error"})}})}};function L(o){return String(o).replace(/"/g,"&quot;")}
1
+ import{api as N,apiRequest as U,getUser as X}from"../api.js";import{addBannerAction as Z}from"../lib/plugin-chrome.js";import{helpPopovers as ee}from"../lib/help-popover.js";import{openMenuAt as L,settingsStore as te,sidebarCount as q,viewLifetime as se}from"../lib/tool-kit.js";import{filterByProject as oe}from"../lib/project-context.js";import{FILTERS as ae,SORTS as ne,arrangeUsers as ie,canManage as P,roleIndex as re,userRow as le}from"../lib/users-arrange.js";const Y=/^#\/(projects\/[^/?#]+\/)?users\/?(\?.*)?$/,B="#/users",ce=`
2
+ <div class="usx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="usx-avatar" aria-hidden="true">{{initials}}</span>
4
+ <span class="usx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
5
+ <span class="usx-chips">
6
+ <span class="usx-chip is-ok" data-bind-hidden="!self">You</span>
7
+ <span class="usx-chip" data-bind-class="roleTone">{{roleLabel}}</span>
8
+ <span class="usx-chip is-outline" data-bind-hidden="!extraRoles" title="Additional roles">+ {{extraRoles}}</span>
9
+ <span class="usx-chip" data-bind-hidden="!scope" title="Project access - sees only these (and Core)">{{scope}}</span>
10
+ <span class="usx-chip is-warn" data-bind-hidden="!inactive" title="Cannot sign in">Inactive</span>
11
+ </span>
12
+ <button type="button" class="usx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
+ </div>`,O=o=>String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),F=te("users.list",o=>({filter:ae.includes(o?.filter)?o.filter:"all",sort:ne.includes(o?.sort)?o.sort:"name"})),n=se(Y);function de(){const o=location.hash.match(/^#\/projects\/([^/?#]+)\/users/);if(!o)return null;try{return decodeURIComponent(o[1])}catch{return o[1]}}export const usersView={templateUrl:"/admin/js/templates/users.html",async onMount(o){n.reset();const i=o.get(0).querySelector(".usx")||o.get(0),p=()=>Y.test(location.hash)&&i.isConnected,m=X(),r=de(),w=F.read(),l=M.observable([]),u=M.observable({}),x=M.observable(!0),h=M.observable(""),c=M.observable(w.filter),v=M.observable(w.sort),f=M.observable(""),k=M.observable(""),b=M.observable(0);n.add(M.effect(()=>{F.write({filter:c.value,sort:v.value})}));const R=M.observable([]);n.add(M.effect(()=>{const e=u.value,t=s=>{try{return D(s).fromNow?D(s).fromNow():D(s).format("D MMM YYYY HH:mm")}catch{return s}};R.value=ie(l.value,{filter:c.value,role:f.value,query:k.value,sort:v.value}).map(s=>le(s,{roles:e,me:m,when:t}))}));const H=M.computed(()=>{const e=l.value,t=e.filter(s=>s.isActive===!1).length;return{all:e.length,active:e.length-t,inactive:t}}),K=M.computed(()=>l.value.length?"Nobody matches.":"No users yet - New user in the banner adds one."),_=M.computed(()=>r?`Showing only users in the project "${r}"${b.value?` - ${b.value} more elsewhere`:""}.`:""),$=e=>l.peek().find(t=>t.id===e);async function y(){try{const[e,t]=await Promise.all([N.users.list(),U("/collections/roles/entries?limit=100",{method:"GET"}).catch(()=>({entries:[]}))]);if(!p())return;let s=Array.isArray(e)?e:[];if(r){const a=oe(s,r);b.value=s.length-a.length,s=a}u.value=re(t?.entries||[]),G(),l.value=s,h.value=""}catch(e){h.value=`Could not load the users: ${e.message||e}`}finally{x.value=!1}}function G(){const e=i.querySelector('.usx-select[aria-label="Role"]');if(!e)return;const t=f.peek();e.querySelectorAll('option:not([value=""])').forEach(s=>s.remove()),Object.entries(u.peek()).sort((s,a)=>s[1].level-a[1].level).forEach(([s,a])=>{const g=document.createElement("option");g.value=s,g.textContent=a.label,e.appendChild(g)}),e.value=t}const A=e=>{location.hash=`#/users/edit/${encodeURIComponent(e)}`};async function C(e,t){if(!(!t&&!await E.confirm(`Make ${O(e.name||e.email)} inactive? They will not be able to sign in until the account is made active again. Their work stays.`)))try{await U(`/users/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({isActive:t})}),E.toast(`${e.name||e.email} is ${t?"active":"inactive"}.`,{type:"success"}),q(B),p()&&await y()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function J(e){if(await E.confirm(`Delete ${O(e.name||e.email)}? They lose access at once and this cannot be undone.`))try{await N.delete(`/users/${encodeURIComponent(e.id)}`),E.toast(`Deleted ${e.name||e.email}.`,{type:"success"}),q(B),p()&&await y()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function V(e){try{await navigator.clipboard.writeText(e.email||""),E.toast("Email copied.",{type:"success"})}catch{E.toast(e.email||"No email",{type:"info",duration:6e3})}}function W(e){const t=$(e);if(!t)return[];const s=P(m,t,u.peek()),a=s.self?"not on your own account":"your role is not senior to theirs";return[{header:t.name||t.email},{label:s.self?"Edit my account":s.edit?"Edit":`Edit - ${a}`,icon:"edit",disabled:!s.edit,action:()=>A(t.id)},{label:"Copy email",icon:"copy",disabled:!t.email,action:()=>V(t)},{type:"divider"},t.isActive===!1?{label:s.other?"Make active":`Make active - ${a}`,icon:"play",disabled:!s.other,action:()=>C(t,!0)}:{label:s.other?"Make inactive\u2026":`Make inactive - ${a}`,icon:"pause",disabled:!s.other,action:()=>C(t,!1)},{label:s.other?"Delete\u2026":`Delete - ${a}`,icon:"trash",danger:s.other,disabled:!s.other,action:()=>J(t)}]}const d=i.querySelector(".usx-list");d.innerHTML=ce;const z=M.applyBindings({users:l,loading:x,loadError:h,filter:c,sort:v,role:f,query:k,rows:R,counts:H,emptyText:K,projectNote:_},i,{methods:{showAll:()=>{c.value="all"},showActive:()=>{c.value="active"},showInactive:()=>{c.value="inactive"}}});n.add(()=>z.dispose()),n.add(ee(i,{position:"bottom"}));const Q=E.contextMenu(d,{cursor:"glyph",match:".usx-row",inherit:!1,density:"compact",items:e=>W(e.dataset.id)});n.add(()=>Q?.destroy?.());const S=e=>{const t=$(e);t&&(P(m,t,u.peek()).edit?A(e):E.toast("Your role is not senior to theirs, so you cannot edit this account.",{type:"info"}))},T=e=>{const t=e.target.closest(".usx-row");if(!t)return;const s=e.target.closest(".usx-kebab");if(s){L(s);return}S(t.dataset.id)},j=e=>{const t=e.target.closest(".usx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&S(t.dataset.id))};d.addEventListener("click",T),d.addEventListener("keydown",j),n.add(()=>{d.removeEventListener("click",T),d.removeEventListener("keydown",j)}),Z(o,{id:"users-new",icon:"plus",title:"New user",onClick:()=>{location.hash=r?`#/users/new?project=${encodeURIComponent(r)}`:"#/users/new"}}),I.scan(i),await y()}};
@@ -1 +1,11 @@
1
- import{api as c}from"../api.js";import{filterByProject as m,getProjectFromHash as u,renderProjectFilterNotice as w}from"../lib/project-context.js";import{keepTableIconsAlive as g}from"../lib/table-icons.js";function s(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(t){await d(t),t.find("#create-view-btn").off("click").on("click",()=>{R.navigate("/views/new")}),Domma.icons.scan(),g(t.get(0))}};async function d(t){const p=E.loader(t.get(0),{type:"dots"});let a=[];try{a=await c.views.list()}catch{E.toast("Could not load views.",{type:"error"})}finally{p.destroy()}const r=u();if(r){const e=a.length;a=m(a,r),w(t,{total:e,shown:a.length,slug:r,allHref:"#/views",noun:"views"})}T.create("#views-table",{pageSize:25,data:a,columns:[{key:"title",title:"Title",render:(e,i)=>{const o=document.createElement("a");return o.href=`#/views/${s(i.slug)}/preview`,o.textContent=e,o.style.fontWeight="600",o.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${s(e)}</code>`},{key:"pipeline",title:"Source",render:e=>`<code>${s(e?.source||"-")}</code>`},{key:"display",title:"Mode",render:e=>s(e?.mode||"table")},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(i=>`<span class="badge badge-secondary">${s(i)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const i=document.createElement("div");i.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const o=document.createElement("a");o.href=`#/views/${s(e)}/preview`,o.className="btn btn-sm btn-ghost",o.setAttribute("data-tooltip","Preview"),o.innerHTML='<span data-icon="eye" data-icon-size="14"></span>';const n=document.createElement("a");n.href=`#/views/edit/${s(e)}`,n.className="btn btn-sm btn-ghost",n.setAttribute("data-tooltip","Edit"),n.innerHTML='<span data-icon="edit" data-icon-size="14"></span>';const l=document.createElement("button");return l.className="btn btn-sm btn-ghost js-delete-view",l.dataset.slug=e,l.setAttribute("data-tooltip","Delete"),l.innerHTML='<span data-icon="trash" data-icon-size="14"></span>',i.appendChild(o),i.appendChild(n),i.appendChild(l),i.outerHTML}}],emptyMessage:'No views yet. Click "New View" to create your first view.'}),t.off("click",".js-delete-view").on("click",".js-delete-view",async function(){const e=this.dataset.slug;if(await E.confirm(`Delete view "${e}"? This cannot be undone.`))try{await c.views.delete(e),E.toast("View deleted.",{type:"success"}),await d(t)}catch{E.toast("Failed to delete view.",{type:"error"})}}),Domma.icons.scan(),document.querySelectorAll("#views-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}
1
+ import{apiRequest as m}from"../api.js";import{addBannerAction as W}from"../lib/plugin-chrome.js";import{helpPopovers as G}from"../lib/help-popover.js";import{openMenuAt as L,sidebarCount as g,viewLifetime as J}from"../lib/tool-kit.js";import{FILTERS as Y,arrangeViews as z,countViews as Q,newViewHref as X,projectOf as y,projectScope as Z,shortcodeFor as ee,viewRow as te}from"../lib/views-arrange.js";const A=/^#\/(views|projects\/[^/]+\/views)\/?(\?.*)?$/,b="#/views",f="views.filter",se=`
2
+ <div class="vwx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="vwx-main"><strong>{{title}}</strong><small>{{meta}}</small>
4
+ <small class="is-bad" data-bind-hidden="!broken">{{brokenText}}</small></span>
5
+ <span class="vwx-chips">
6
+ <span class="vwx-chip is-info" data-bind-hidden="!engine">{{engine}}</span>
7
+ <span class="vwx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
8
+ <span class="vwx-chip" data-bind-class="accessCls" title="Who can see it">{{access}}</span>
9
+ </span>
10
+ <button type="button" class="vwx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
11
+ </div>`,x=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),o=J(A);export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(c){o.reset();const r=c.get(0).querySelector(".vwx")||c.get(0),u=()=>A.test(location.hash)&&r.isConnected,a=Z(location.hash),i=M.observable([]),p=M.observable(!0),w=M.observable("");let k="all";try{k=Y.includes(S.get(f))?S.get(f):"all"}catch{}const s=M.observable(k),$=M.observable("");o.add(M.effect(()=>{const e=s.value;try{S.set(f,e)}catch{}}));const T=M.observable([]);o.add(M.effect(()=>{T.value=z(i.value,{filter:s.value,query:$.value,project:a}).map(te)}));const d=M.computed(()=>Q(i.value,{project:a}));o.add(M.effect(()=>{s.value==="broken"&&!d.value.broken&&!p.value&&(s.value="all")}));const D=M.computed(()=>{const e=i.value.length-d.value.all;return`Showing only views in ${a}.${e?` ${e} other${e===1?" is":"s are"} filed elsewhere.`:""}`}),N=M.computed(()=>d.value.all?"No views match.":"No views yet - New view in the banner makes one."),P=e=>i.peek().find(t=>t.slug===e);async function v(){try{const e=await m("/views?sources=1",{method:"GET"});if(!u())return;i.value=Array.isArray(e)?e:[],w.value=""}catch(e){w.value=`Could not load the views: ${e.message||e}`}finally{p.value=!1}}const h=e=>{location.hash=`#/views/edit/${encodeURIComponent(e)}`},U=e=>{location.hash=`#/views/${encodeURIComponent(e)}/preview`};async function q(e){const t=ee(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this: ${t}`,{type:"info",duration:8e3})}}async function O(e){const t=await E.prompt("Title of the copy",{inputValue:x(`${e.title||e.slug} copy`)});if(!(!t||!String(t).trim()||!u()))try{const{id:l,slug:oe,meta:R,sourceInfo:ae,...K}=e,H=await m("/views",{method:"POST",body:JSON.stringify({...K,title:String(t).trim(),...R?.project!=null&&{meta:{project:R.project}}})});E.toast(`Made "${H.title}".`,{type:"success"}),g(b),await v()}catch(l){E.toast(`Not duplicated: ${l.message||l}`,{type:"error"})}}async function _(e){if(await E.confirm(`Delete the view "${x(e.title||e.slug)}"? Pages that show it with [view slug="${x(e.slug)}"] will show nothing. This cannot be undone.`))try{await m(`/views/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),g(b),await v()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}function V(e){const t=P(e);return t?[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>h(t.slug)},{label:"Preview results",icon:"eye",action:()=>U(t.slug)},{label:"Copy shortcode",icon:"copy",action:()=>q(t)},...y(t)!=="core"&&!a?[{label:`Open project "${y(t)}"`,icon:"folder",action:()=>{location.hash=`#/projects/${encodeURIComponent(y(t))}/views`}}]:[],{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>O(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>_(t)}]:[]}const n=r.querySelector(".vwx-list");n.innerHTML=se;const B=M.applyBindings({views:i,loading:p,loadError:w,filter:s,query:$,rows:T,counts:d,emptyText:N,scopeText:D,project:a},r,{methods:{showAll:()=>{s.value="all"},showPublic:()=>{s.value="public"},showRestricted:()=>{s.value="restricted"},showBroken:()=>{s.value="broken"}}});o.add(()=>B.dispose()),o.add(G(r,{position:"bottom"}));const F=E.contextMenu(n,{cursor:"glyph",match:".vwx-row",inherit:!1,density:"compact",items:e=>V(e.dataset.slug)});o.add(()=>F?.destroy?.());const C=e=>{const t=e.target.closest(".vwx-row");if(!t)return;const l=e.target.closest(".vwx-kebab");if(l){L(l);return}h(t.dataset.slug)},j=e=>{const t=e.target.closest(".vwx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),L(t)):e.key==="Enter"&&e.target===t&&h(t.dataset.slug))};n.addEventListener("click",C),n.addEventListener("keydown",j),o.add(()=>{n.removeEventListener("click",C),n.removeEventListener("keydown",j)}),W(c,{id:"views-new",icon:"plus",title:"New view",onClick:()=>{location.hash=X(a)}}),I.scan(r),await v(),u()&&g(b)}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
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",
@@ -72,6 +72,7 @@ import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identi
72
72
  import {describeWhen, quickTimes} from './scheduling.js';
73
73
  import {applyTemplate, expandTemplate} from './templates.js';
74
74
  import {makeResizable} from './resizable.js';
75
+ import {whenClosed} from './when-closed.js';
75
76
  import {
76
77
  collectGroups,
77
78
  currentFragment,
@@ -509,16 +510,6 @@ export function openCompose({
509
510
  }
510
511
  form.appendChild(bodyWrap);
511
512
 
512
- // What the window opened showing, read back out of the thing showing it.
513
- //
514
- // NOT `draft.body`: the editor is seeded from `draft.html` and derives its
515
- // own text from that, so the two already differed on every HTML reply -
516
- // which is why discarding an untouched reply asked whether to lose
517
- // changes nobody had made. A signature makes that worse, since it means
518
- // even a new message opens "changed". Comparing against what was actually
519
- // put in front of the person is both the fix and the only baseline that
520
- // stays true once anything else seeds the body.
521
- const openedText = editor ? editor.getText() : bodyInput.value;
522
513
 
523
514
  // Attachments
524
515
  // Seeded from the draft, so reopening one keeps the files that were
@@ -653,7 +644,13 @@ export function openCompose({
653
644
  const saveDraftBtn = document.createElement('button');
654
645
  saveDraftBtn.className = 'btn btn-sm btn-ghost';
655
646
  saveDraftBtn.textContent = 'Save draft';
656
- saveDraftBtn.addEventListener('click', async () => {
647
+ /**
648
+ * Save this message to the mailbox's Drafts folder - the button, and what
649
+ * leaving the screen does with a message that has changed.
650
+ *
651
+ * @returns {Promise<object|null>} the server's answer, or null if it failed
652
+ */
653
+ async function saveDraft() {
657
654
  saveDraftBtn.disabled = true;
658
655
  say('Saving\u2026');
659
656
  try {
@@ -663,47 +660,56 @@ export function openCompose({
663
660
  replaces: savedUid
664
661
  });
665
662
  savedUid = result.uid ?? savedUid;
663
+ // Saved is the new baseline: closing now loses nothing.
664
+ savedSnapshot = snapshot();
666
665
  say(`Saved to ${result.savedTo}.`, 'ok');
667
666
  // The Drafts folder now holds a different set of UIDs, so a list
668
667
  // showing it is out of date whether or not the window closes.
669
668
  onSaved(result);
669
+ return result;
670
670
  } catch (err) {
671
671
  say(err.message, 'bad');
672
+ return null;
672
673
  } finally {
673
674
  saveDraftBtn.disabled = false;
674
675
  }
675
- });
676
+ }
677
+ saveDraftBtn.addEventListener('click', () => { saveDraft(); });
676
678
 
677
679
  const cancel = document.createElement('button');
678
680
  cancel.className = 'btn btn-sm btn-ghost';
679
681
  cancel.textContent = 'Discard';
680
- cancel.addEventListener('click', async () => {
681
- // Closing on something half-written should ask, because the window is
682
- // the only place that text exists until it is saved.
682
+ /** What the message says now - compared with the last saved (or opened) state. */
683
+ function snapshot() {
683
684
  const current = editor ? editor.getText() : bodyInput.value;
684
- const changed = subjectInput.value.trim() !== (draft.subject ?? '').trim()
685
- || current.trim() !== openedText.trim();
686
- // A reopened draft is already saved, so closing it unchanged is not a
687
- // loss and should not ask. Changed since opening, it is.
688
- //
689
- // `changed` is the whole test. A new message that has had a subject
690
- // typed into it is already changed, because a new draft opens with an
691
- // empty one - so the extra "or the subject is non-empty" clause this
692
- // used to carry caught nothing new, and DID catch every reply and
693
- // forward, which open with `Re:` / `Fwd:` prefilled and so were
694
- // always treated as unsaved work.
695
- const unsaved = changed;
696
- if (unsaved) {
697
- // Two different losses, and saying the wrong one is worse than
698
- // saying nothing: one has never been saved, the other has changes
699
- // since it was.
700
- const question = savedUid
701
- ? 'Close without saving? The changes since you opened this draft will be lost.'
702
- : 'Discard this message? It has not been saved as a draft.';
703
- if (!await E.confirm(question)) return;
704
- }
705
- slideover.close();
706
- });
685
+ return JSON.stringify([subjectInput.value.trim(), current.trim(), toInput.value.trim(),
686
+ ccInput.value.trim(), bccInput.value.trim(), attachments.length]);
687
+ }
688
+ let savedSnapshot = null; // set once the window is built (below)
689
+
690
+ /**
691
+ * Whether closing now would lose something.
692
+ *
693
+ * Compared with the last saved state, or what the window opened with - so
694
+ * a reopened draft closed unchanged does not ask, and a reply or forward,
695
+ * which opens with `Re:` / `Fwd:` and a quote already in it, does not
696
+ * count as unsaved work until the reader actually changes something.
697
+ */
698
+ function unsaved() {
699
+ return savedSnapshot !== null && snapshot() !== savedSnapshot;
700
+ }
701
+
702
+ // Discard, the X, Escape and the backdrop all go through slideover.close,
703
+ // which asks (see the guard at the end) - so this button just closes.
704
+ cancel.addEventListener('click', () => slideover.close());
705
+
706
+ /** The question the close guard asks, for what would be lost. */
707
+ function discardQuestion() {
708
+ return savedUid
709
+ ? 'Close without saving? The changes since you last saved this draft will be lost.'
710
+ : 'Discard this message? It has not been saved as a draft.';
711
+ }
712
+
707
713
 
708
714
  /**
709
715
  * The message as it currently stands.
@@ -855,7 +861,7 @@ export function openCompose({
855
861
  replaces: savedUid
856
862
  });
857
863
 
858
- slideover.close();
864
+ slideover.close(true);
859
865
 
860
866
  // Three outcomes, three different things to say. A message held
861
867
  // for ten seconds has NOT been sent, and saying it has is how
@@ -905,10 +911,61 @@ export function openCompose({
905
911
  }
906
912
  }, 60);
907
913
 
914
+ // The baseline for "changed": what the window opened showing, read back out
915
+ // of the thing showing it (taken once the editor has been seeded).
916
+ //
917
+ // NOT `draft.body`: the editor is seeded from `draft.html` and derives its
918
+ // own text from that, so the two already differed on every HTML reply -
919
+ // which is why discarding an untouched reply asked whether to lose
920
+ // changes nobody had made. A signature makes that worse, since it means
921
+ // even a new message opens "changed". Comparing against what was actually
922
+ // put in front of the person is both the fix and the only baseline that
923
+ // stays true once anything else seeds the body.
924
+ setTimeout(() => { if (savedSnapshot === null) savedSnapshot = snapshot(); }, 0);
925
+
926
+ // Every way out asks when something would be lost - Discard, the X, Escape
927
+ // and the backdrop all call close(). Once closed it goes straight through:
928
+ // a later close() (the cleanup below, or the screen being left) must not ask again.
929
+ const rawClose = slideover.close.bind(slideover);
930
+ let closed = false;
931
+ let asking = false;
932
+ const closedListeners = [];
933
+ slideover.close = async (force) => {
934
+ if (!closed && force !== true && unsaved()) {
935
+ if (asking) return undefined;
936
+ asking = true;
937
+ // Two different losses, and saying the wrong one is worse than
938
+ // saying nothing: one has never been saved, the other has changes
939
+ // since it was.
940
+ const ok = await E.confirm(discardQuestion());
941
+ asking = false;
942
+ if (!ok) return undefined;
943
+ }
944
+ if (closed) return undefined;
945
+ closed = true;
946
+ return rawClose();
947
+ };
948
+
908
949
  // The editor is a live component with its own listeners and autosave.
909
- slideover.on?.('close', () => {
950
+ whenClosed(slideover, () => {
951
+ closed = true;
910
952
  editor?.destroy?.();
911
953
  stopResizing();
912
954
  for (const suggester of suggesters) suggester.destroy?.();
955
+ closedListeners.splice(0).forEach(fn => { try { fn(); } catch { /* ignore */ } });
956
+ // Domma hides a closed slideover rather than removing it: every message
957
+ // written would otherwise leave its window behind in the page.
958
+ setTimeout(() => { try { slideover.element?.remove(); } catch { /* gone */ } }, 400);
913
959
  });
960
+
961
+ return {
962
+ /** Whether closing would lose something the reader wrote. */
963
+ dirty: () => !closed && unsaved(),
964
+ /** Save to Drafts now (what leaving the screen does with a changed message). */
965
+ saveDraft,
966
+ /** Close; `true` skips the question. */
967
+ close: (force) => slideover.close(force),
968
+ /** Called once, when the window has closed however it closed. */
969
+ onClosed: (fn) => { if (closed) fn(); else closedListeners.push(fn); }
970
+ };
914
971
  }
@@ -28,7 +28,10 @@
28
28
  * not hold. A 404 here is indistinguishable from "not installed", which is
29
29
  * exactly the behaviour wanted.
30
30
  */
31
- const CONTACTS_ENDPOINT = '/api/plugins/contacts/contacts';
31
+ // Core since 0.67.0 (it was the Contacts plugin, at /api/plugins/contacts/contacts:
32
+ // that address has 404'd ever since, so "Add sender to contacts" and group
33
+ // expansion in compose silently vanished).
34
+ const CONTACTS_ENDPOINT = '/api/contacts/contacts';
32
35
 
33
36
  /**
34
37
  * Fetch the address book once.
@@ -129,5 +129,8 @@ export function createDiagnosticsSection(host) {
129
129
  container.appendChild(actions);
130
130
  }
131
131
 
132
- return {id: 'diagnostics', label: 'Diagnostics', render};
132
+ return {
133
+ id: 'diagnostics', label: 'Diagnostics', render,
134
+ help: 'What this screen can see about itself - whether it runs the build the server is serving, the mailbox and folder, where the list came from, and recent errors. Copy diagnostics puts it on the clipboard for a report.'
135
+ };
133
136
  }
@@ -245,7 +245,7 @@ export function folderMenuItems(roots, {current = null, onPick, label = null}) {
245
245
  return {
246
246
  label: node.name,
247
247
  icon: iconForFolder(node),
248
- submenu: [target(node), {type: 'separator'}, ...walk(node.children)]
248
+ submenu: [target(node), {type: 'divider'}, ...walk(node.children)]
249
249
  };
250
250
  });
251
251
  }