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,57 @@
1
- import{apiRequest as p}from"/admin/js/api.js";import{filterByProject as b,getProjectFromHash as h,renderProjectFilterNotice as C}from"/admin/js/lib/project-context.js";import{keepTableIconsAlive as x}from"/admin/js/lib/table-icons.js";function u(c){return String(c).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(c){await g(c),x(c.get(0)),c.off("click",".js-delete-form").on("click",".js-delete-form",async function(){const d=this.dataset.slug,e=this.dataset.plugin,i=e?`This form is managed by the <strong>${e}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete form "${d}" and all its submissions? This cannot be undone.`;if(await E.confirm(i))try{await p(`/forms/${d}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),await g(c)}catch{E.toast("Failed to delete form.",{type:"error"})}}),c.off("click",".js-clone-form").on("click",".js-clone-form",async function(){const d=this.dataset.slug;try{const e=await p(`/forms/${d}`),{collection:i,...t}=e.actions||{},n=await p("/forms",{method:"POST",body:JSON.stringify({title:`${e.title} (Copy)`,description:e.description||"",fields:e.fields||[],settings:e.settings||{},actions:t})});E.toast(`Form duplicated as "${n.title}".`,{type:"success"}),R.navigate(`/forms/edit/${n.slug}`)}catch(e){E.toast(e.message||"Failed to duplicate form.",{type:"error"})}}),c.find("#create-form-btn").off("click").on("click",()=>{const d=E.slideover({title:"Create Form",size:"sm",position:"right"}),e=document.createElement("div");e.style.cssText="padding:.25rem 0 .5rem;";const i=document.createElement("div");F.create({title:{type:"string",label:"Form Title",placeholder:"e.g. Contact, Feedback\u2026",required:!0}},{},{showSubmitButton:!1}).renderTo(i),e.appendChild(i);const t=document.createElement("div");t.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const n=document.createElement("button");n.className="btn btn-ghost",n.textContent="Cancel";const o=document.createElement("button");o.className="btn btn-primary",o.textContent="Create",t.appendChild(n),t.appendChild(o),e.appendChild(t),d.element.appendChild(e),d.open();const s=i.querySelector('input[name="title"]');setTimeout(()=>s?.focus(),50);async function a(){const l=s?.value.trim();if(l)try{const r=await p("/forms",{method:"POST",body:JSON.stringify({title:l})});d.close(),R.navigate(`/forms/edit/${r.slug}`)}catch(r){E.toast(r.message||"Failed to create form.",{type:"error"})}}n.addEventListener("click",()=>d.close()),o.addEventListener("click",a),s?.addEventListener("keydown",l=>{l.key==="Enter"&&a()})}),c.find("#test-email-btn").off("click").on("click",async()=>{const e=(window.__CMS_SITE__?.smtp||{}).fromAddress||"",i=E.slideover({title:"Send Test Email",size:"sm",position:"right"}),t=document.createElement("div");t.style.cssText="padding:.25rem 0 .5rem;";const n=document.createElement("div");n.className="mb-3";const o=document.createElement("label");o.className="form-label",o.textContent="Send to";const s=document.createElement("input");s.type="email",s.className="form-input",s.value=e,s.placeholder="test@example.com",n.appendChild(o),n.appendChild(s),t.appendChild(n);const a=document.createElement("p");a.className="text-muted",a.style.cssText="font-size:.8rem;margin-bottom:.75rem;",a.textContent="SMTP settings are configured in Site Settings.",t.appendChild(a);const l=document.createElement("div");l.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const r=document.createElement("button");r.className="btn btn-ghost",r.textContent="Cancel";const m=document.createElement("button");m.className="btn btn-primary",m.textContent="Send",l.appendChild(r),l.appendChild(m),t.appendChild(l),i.element.appendChild(t),i.open(),setTimeout(()=>s?.focus(),50),r.addEventListener("click",()=>i.close()),m.addEventListener("click",async()=>{const f=s.value.trim();if(f){m.disabled=!0;try{await p("/forms/test-email",{method:"POST",body:JSON.stringify({to:f})}),i.close(),E.toast("Test email sent.",{type:"success"})}catch(y){E.toast(y.message||"Failed to send test email.",{type:"error"})}finally{m.disabled=!1}}})}),Domma.icons.scan()}};async function g(c){const d=E.loader(c.get(0),{type:"dots"});let e=[];try{e=await p("/forms")}catch{E.toast("Could not load forms.",{type:"error"})}finally{d.destroy()}const i=h();if(i){const t=e.length;e=b(e,i),C(c,{total:t,shown:e.length,slug:i,allHref:"#/forms",noun:"forms"})}T.create("#forms-table",{pageSize:25,data:e,columns:[{key:"title",title:"Title",render:(t,n)=>{const o=document.createElement("span");o.style.cssText="display:inline-flex;align-items:center;gap:.4rem;flex-wrap:wrap;";const s=document.createElement("a");if(s.href=`#/forms/edit/${u(n.slug)}`,s.textContent=t,s.style.fontWeight="600",o.appendChild(s),n.plugin){const a=document.createElement("span");a.className="badge badge-outline",a.textContent=n.plugin,a.title=`Managed by the ${n.plugin} plugin`,a.style.cssText="font-size:0.65rem;padding:1px 6px;color:var(--dm-warning,#d97706);border-color:var(--dm-warning,#d97706);flex-shrink:0;",o.appendChild(a)}return o.outerHTML}},{key:"slug",title:"Slug",render:t=>{const n=document.createElement("code");return n.textContent=t,n.outerHTML}},{key:"fields",title:"Field Count",render:t=>String(t?.length??0)},{key:"submissionCount",title:"Submission Count",render:t=>String(t??0)},{key:"slug",title:"Actions",render:(t,n)=>{const o=document.createElement("div");o.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const s=document.createElement("a");s.href=`#/forms/edit/${u(t)}`,s.className="btn btn-sm btn-ghost",s.setAttribute("data-tooltip","Edit"),s.innerHTML='<span data-icon="edit" data-icon-size="14"></span>';const a=document.createElement("a");a.href=`#/forms/${u(t)}/submissions`,a.className="btn btn-sm btn-ghost",a.setAttribute("data-tooltip","Submissions"),a.innerHTML='<span data-icon="inbox" data-icon-size="14"></span>';const l=document.createElement("button");l.className="btn btn-sm btn-ghost js-clone-form",l.dataset.slug=t,l.setAttribute("data-tooltip","Duplicate"),l.innerHTML='<span data-icon="copy" data-icon-size="14"></span>';const r=document.createElement("button");return r.className="btn btn-sm btn-ghost js-delete-form",r.dataset.slug=t,r.dataset.plugin=n.plugin||"",r.setAttribute("data-tooltip","Delete"),r.innerHTML='<span data-icon="trash" data-icon-size="14"></span>',o.appendChild(s),o.appendChild(a),o.appendChild(l),o.appendChild(r),o.outerHTML}}],emptyMessage:'No forms yet. Click "Create Form" to get started.'}),Domma.icons.scan(),document.querySelectorAll("#forms-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})})}
1
+ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/admin/js/lib/plugin-chrome.js";import{helpPopovers as J}from"/admin/js/lib/help-popover.js";import{onceClosed as me,openMenuAt as Y,sidebarCount as R,viewLifetime as fe}from"/admin/js/lib/tool-kit.js";import{filterByProject as pe,getProjectFromHash as be,renderProjectFilterNotice as ve}from"/admin/js/lib/project-context.js";import{FILTERS as ge,arrangeForms as he,duplicatePayload as ye,formRow as we,shortcodeFor as xe}from"/admin/js/lib/forms-arrange.js";const V=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,N="#/forms",w="forms.draft",P="forms.filter",Ee=`
2
+ <div class="fmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="fmx-icon" aria-hidden="true"><span data-icon="layout" data-icon-size="16"></span></span>
4
+ <span class="fmx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
5
+ <span class="fmx-chips">
6
+ <span class="fmx-chip is-ok" data-bind-hidden="!week">{{weekText}}</span>
7
+ <span class="fmx-chip" data-bind-hidden="!wizard">Wizard</span>
8
+ <span class="fmx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
9
+ <span class="fmx-chip is-warn" data-bind-hidden="!plugin" title="Managed by the {{plugin}} plugin">{{plugin}}</span>
10
+ </span>
11
+ <span class="fmx-count" title="Submissions">{{totalText}}</span>
12
+ <button type="button" class="fmx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
13
+ </div>`,x=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function ke(l){const r=document.createElement("template");return r.innerHTML=l.trim(),r.content.firstElementChild}const G=l=>String(l??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),i=fe(V);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(l){i.reset();const r=l.get(0).querySelector(".fmx")||l.get(0),h=()=>V.test(location.hash)&&r.isConnected,k=be(),u=M.observable([]),A=M.observable(!0),T=M.observable("");let q="all";try{q=ge.includes(S.get(P))?S.get(P):"all"}catch{}const p=M.observable(q),j=M.observable("");i.add(M.effect(()=>{const t=p.value;try{S.set(P,t)}catch{}}));const C=M.observable([]);i.add(M.effect(()=>{const t=Date.now();C.value=he(u.value,{filter:p.value,query:j.value}).map(e=>we(e,t))}));const Q=M.computed(()=>{const t=u.value;return{all:t.length,recent:t.filter(e=>Number(e.submissionsThisWeek)>0).length,empty:t.filter(e=>!Number(e.submissionCount)).length}}),X=M.computed(()=>{const t=u.value.reduce((e,s)=>e+(Number(s.submissionCount)||0),0);return u.value.length?`${t} submission${t===1?"":"s"} in all`:""}),Z=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),$=t=>u.peek().find(e=>e.slug===t);async function O(){try{let t=await v("/forms");if(!h())return;if(t=Array.isArray(t)?t:[],k){const e=t.length;t=pe(t,k),ve(r,{total:e,shown:t.length,slug:k,allHref:"#/forms",noun:"forms"})}u.value=t,T.value=""}catch(t){T.value=`Could not load the forms: ${t.message||t}`}finally{A.value=!1}}const g=t=>{location.hash=`#/forms/edit/${encodeURIComponent(t)}`},ee=t=>{location.hash=`#/forms/${encodeURIComponent(t)}/submissions`};async function te(t){const e=xe(t.slug);try{await navigator.clipboard.writeText(e),E.toast(`Copied ${e}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${e}`,{type:"info",duration:8e3})}}async function se(t){const e=t.plugin?`This form is managed by the <strong>${x(t.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${x(t.title||t.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(e))try{await v(`/forms/${encodeURIComponent(t.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),R(N),h()&&await O()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const D=new Set;let b=null,_=!1;function L({title:t,html:e,model:s,methods:c,dirty:d=()=>!1,cleanup:n=()=>{}}){const a=E.slideover({title:t,size:"sm",position:"right"}),o=ke(e);a.element.appendChild(o);const f=J(o),re=M.applyBindings(s,o,{methods:c});D.add(a),me(a,()=>{n(),re.dispose(),f(),D.delete(a)});const ce=a.close.bind(a);let K=!1,F=!1;return a.close=async de=>{if(!K&&de!==!0&&!_&&d()){if(F)return;F=!0;const ue=await E.confirm("Discard what you have typed?");if(F=!1,!ue)return}return K=!0,ce()},I.scan(o),a.open(),{so:a,body:o}}function z(t=null){const e={title:M.observable(t?.title??""),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.title.value.trim()?e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),e.hint=M.computed(()=>e.slug.value&&!e.err.value?`Its address will be "${e.slug.value}" - [form name="${e.slug.value}" /].`:""),e.cannot=M.computed(()=>e.saving.value||!e.title.value.trim()||!!e.err.value);const s=()=>!!e.title.peek().trim(),c={kind:"create",dirty:s,values:()=>({title:e.title.peek()})};async function d(){if(!e.cannot.peek()){e.saving.value=!0;try{const f=await v("/forms",{method:"POST",body:JSON.stringify({title:e.title.peek().trim()})});R(N),n.close(!0),g(f.slug)}catch(f){E.toast(f.message||"Failed to create the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:n,body:a}=L({title:"New form",model:e,dirty:s,cleanup:()=>{b===c&&(b=null)},methods:{save:d,cancel:()=>n.close()},html:`
14
+ <div class="fmx-form">
15
+ <div>
16
+ <label class="form-label" for="fmx-new-title">Title
17
+ <span data-help="What the form is called here and in its collection of submissions. Its address (the slug) is made from it and cannot be changed later."></span></label>
18
+ <input id="fmx-new-title" type="text" class="form-input" maxlength="100" placeholder="e.g. Contact, Feedback\u2026" data-model="title.value">
19
+ <small class="fmx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
20
+ <small class="fmx-hint" data-bind-hidden="!hint.value" data-bind-text="hint.value"></small>
21
+ </div>
22
+ <div class="fmx-actions">
23
+ <small class="fmx-hint">Enter creates it and opens the editor.</small>
24
+ <span class="fmx-spacer"></span>
25
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
26
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
27
+ <span data-icon="plus" data-icon-size="13"></span> Create</button>
28
+ </div>
29
+ </div>`});b=c;const o=a.querySelector("#fmx-new-title");o.addEventListener("keydown",f=>{f.key==="Enter"&&(f.preventDefault(),d())}),setTimeout(()=>o?.focus(),50)}function ae(t){const e={title:M.observable(`${t.title||t.slug} (Copy)`),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}".`:"":"The title needs at least one letter or number."),e.cannot=M.computed(()=>e.saving.value||!!e.err.value);async function s(){if(!e.cannot.peek()){e.saving.value=!0;try{const a=await v(`/forms/${encodeURIComponent(t.slug)}`),o=await v("/forms",{method:"POST",body:JSON.stringify(ye(a,e.title.peek()))});E.toast(`Duplicated as "${o.title}".`,{type:"success"}),R(N),c.close(!0),g(o.slug)}catch(a){E.toast(a.message||"Failed to duplicate the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:c,body:d}=L({title:`Duplicate ${x(t.title||t.slug)}`,model:e,methods:{save:s,cancel:()=>c.close()},html:`
30
+ <div class="fmx-form">
31
+ <div>
32
+ <label class="form-label" for="fmx-dup-title">Title of the copy</label>
33
+ <input id="fmx-dup-title" type="text" class="form-input" maxlength="100" data-model="title.value">
34
+ <small class="fmx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
35
+ <small class="fmx-hint">Fields, settings, email and webhook are copied. The copy gets its own, empty collection of submissions.</small>
36
+ </div>
37
+ <div class="fmx-actions">
38
+ <span class="fmx-spacer"></span>
39
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
40
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
41
+ <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
42
+ </div>
43
+ </div>`}),n=d.querySelector("#fmx-dup-title");n.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),s())}),setTimeout(()=>{n?.focus(),n?.select()},50)}function ne(){const t={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};t.cannot=M.computed(()=>t.sending.value||!/^[^\s@]+@[^\s@]+$/.test(t.to.value.trim()));async function e(){if(!t.cannot.peek()){t.sending.value=!0;try{await v("/forms/test-email",{method:"POST",body:JSON.stringify({to:t.to.peek().trim()})}),s.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{t.sending.value=!1}}}const{so:s,body:c}=L({title:"Send test email",model:t,methods:{send:e,cancel:()=>s.close()},html:`
44
+ <div class="fmx-form">
45
+ <div>
46
+ <label class="form-label" for="fmx-test-to">Send to
47
+ <span data-help="Sends one email through the site's SMTP settings (Site Settings), so you can check a form's email action will be delivered before anyone relies on it."></span></label>
48
+ <input id="fmx-test-to" type="email" class="form-input" placeholder="test@example.com" data-model="to.value">
49
+ <small class="fmx-hint">SMTP settings are configured in Site Settings.</small>
50
+ </div>
51
+ <div class="fmx-actions">
52
+ <span class="fmx-spacer"></span>
53
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
54
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="cannot.value">
55
+ <span data-icon="mail" data-icon-size="13"></span> Send</button>
56
+ </div>
57
+ </div>`}),d=c.querySelector("#fmx-test-to");d.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),e())}),setTimeout(()=>d?.focus(),50)}function oe(t){const e=$(t);if(!e)return[];const s=Number(e.submissionCount)||0;return[{header:e.title||e.slug},{label:"Edit",icon:"edit",action:()=>g(e.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ee(e.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>te(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>ae(e)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>se(e)}]}const m=r.querySelector(".fmx-list");m.innerHTML=Ee;const le=M.applyBindings({forms:u,loading:A,loadError:T,filter:p,query:j,rows:C,counts:Q,summary:X,emptyText:Z},r,{methods:{showAll:()=>{p.value="all"},showRecent:()=>{p.value="recent"},showEmpty:()=>{p.value="empty"}}});i.add(()=>le.dispose()),i.add(J(r,{position:"bottom"})),i.add(M.effect(()=>{C.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ie=E.contextMenu(m,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:t=>oe(t.dataset.slug)});i.add(()=>ie?.destroy?.());const B=t=>{const e=t.target.closest(".fmx-row");if(!e)return;const s=t.target.closest(".fmx-kebab");if(s){Y(s);return}g(e.dataset.slug)},U=t=>{const e=t.target.closest(".fmx-row");e&&(t.key==="F10"&&t.shiftKey?(t.preventDefault(),Y(e)):t.key==="Enter"&&t.target===e&&g(e.dataset.slug))};m.addEventListener("click",B),m.addEventListener("keydown",U),i.add(()=>{m.removeEventListener("click",B),m.removeEventListener("keydown",U)}),W(l,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>z()}),W(l,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ne()});const H=t=>{b?.dirty()&&(t.preventDefault(),t.returnValue="")};if(window.addEventListener("beforeunload",H),i.add(()=>window.removeEventListener("beforeunload",H)),i.add(()=>{if(b?.dirty())try{S.set(w,{values:b.values(),at:new Date().toISOString()})}catch{}_=!0,[...D].forEach(t=>{try{t.close(!0)}catch{}})}),I.scan(r),await O(),!h())return;let y=null;try{y=S.get(w)}catch{}if(y?.values?.title){try{S.remove?S.remove(w):S.set(w,null)}catch{}await E.confirm(`You started a new form, "${x(y.values.title)}", and left it. Pick up where you left off?`)&&h()&&z(y.values)}}};
@@ -1 +1,81 @@
1
- import{api as f}from"../api.js";const N={narrow:"Narrow",normal:"Normal",wide:"Wide",full:"Full"},B="display:block;font-size:.7rem;font-weight:600;color:var(--dm-text-muted,#aaa);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem;",S="width:100%;padding:.4rem .6rem;background:var(--dm-input-bg,#1a1a1a);border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text,#eee);font-size:.9em;box-sizing:border-box;";function x(n,t){const e=document.createElement("div");e.style.cssText="display:flex;flex-direction:column;gap:.25rem;";const a=document.createElement("label");return a.style.cssText=B,a.textContent=n,e.appendChild(a),e.appendChild(t),e}function L(n,t){const e=document.createElement("input");return e.type="text",e.placeholder=n||"",e.value=t||"",e.style.cssText=S,e}function k(n,t){const e=document.createElement("label");e.style.cssText="display:flex;align-items:center;gap:.5rem;cursor:pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!!t;const o=document.createElement("span");return o.style.cssText="font-size:.9em;color:var(--dm-text,#eee);",o.textContent=n,e.appendChild(a),e.appendChild(o),{wrap:e,cb:a}}function F(n){const t=document.createElement("select");return t.style.cssText=S,[["narrow","Narrow (768px)"],["normal","Normal (1100px)"],["wide","Wide (75% of viewport, max 2000px)"],["full","Full width"]].forEach(([e,a])=>{const o=document.createElement("option");o.value=e,o.textContent=a,e===(n||"normal")&&(o.selected=!0),t.appendChild(o)}),t}function D(n){const t=document.createElement("div");t.style.cssText="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;";const e=document.createElement("input");e.type="color",e.value=n||"#ffffff",e.disabled=!n,e.style.cssText="width:38px;height:32px;padding:2px;border:1px solid var(--dm-border,#333);border-radius:4px;background:transparent;cursor:pointer;";const a=document.createElement("span");a.style.cssText="font-size:.85em;color:var(--dm-text-muted,#aaa);min-width:4rem;",a.textContent=n||"None";const o=document.createElement("button");o.type="button",o.textContent="Choose",o.style.cssText="padding:.25rem .6rem;font-size:.8em;background:transparent;border:1px solid var(--dm-border,#333);border-radius:4px;color:var(--dm-text-muted,#aaa);cursor:pointer;";const r=document.createElement("button");return r.type="button",r.textContent="Clear",r.style.cssText=o.style.cssText,r.disabled=!n,o.addEventListener("click",()=>{e.disabled=!1,r.disabled=!1,e.click()}),e.addEventListener("input",()=>{a.textContent=e.value,r.disabled=!1}),r.addEventListener("click",()=>{e.value="#ffffff",e.disabled=!0,r.disabled=!0,a.textContent="None"}),t.appendChild(e),t.appendChild(a),t.appendChild(o),t.appendChild(r),{wrap:t,getValue(){return e.disabled?"":e.value}}}function O(n,t,e){const a=document.createElement("div");a.className="card preset-card",a.style.cssText="cursor:pointer;transition:box-shadow .15s;",a.addEventListener("mouseenter",()=>{a.style.boxShadow="0 0 0 2px var(--dm-primary,#4f7fff)"}),a.addEventListener("mouseleave",()=>{a.style.boxShadow=""}),a.addEventListener("click",()=>e(n,t));const o=document.createElement("div");o.className="card-header",o.style.cssText="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;";const r=document.createElement("strong");if(r.textContent=t.label||n,o.appendChild(r),t.builtin){const s=document.createElement("span");s.className="badge",s.textContent="built-in",s.style.cssText="font-size:.65rem;",o.appendChild(s)}const d=document.createElement("span");d.className="badge badge-outline",d.textContent=N[t.width]||"Normal",d.style.cssText="margin-left:auto;font-size:.65rem;",o.appendChild(d),a.appendChild(o);const c=document.createElement("div");if(c.className="card-body",t.description){const s=document.createElement("p");s.className="text-muted",s.style.cssText="font-size:.85em;margin:0 0 .5rem;",s.textContent=t.description,c.appendChild(s)}const l=document.createElement("div");if(l.style.cssText="display:flex;gap:.75rem;flex-wrap:wrap;",[["Navbar",t.navbar],["Footer",t.footer],["Sidebar",t.sidebar]].forEach(([s,u])=>{const p=document.createElement("span");p.style.cssText=`font-size:.75em;color:${u?"var(--dm-success,#4caf50)":"var(--dm-text-muted,#666)"};display:flex;align-items:center;gap:.25rem;`,p.textContent=(u?"\u25CF ":"\u25CB ")+s,l.appendChild(p)}),c.appendChild(l),t.bgColor){const s=document.createElement("span");s.title=t.bgColor,s.style.cssText=`display:inline-block;width:14px;height:14px;border-radius:3px;background:${t.bgColor};border:1px solid var(--dm-border,#333);margin-top:.4rem;vertical-align:middle;`,c.appendChild(s)}return a.appendChild(c),a}function z(n,t,e,a,o){const r=E.slideover({title:e?"New Layout":"Edit Layout",size:"md"}),d=document.createElement("div");d.style.cssText="display:flex;flex-direction:column;gap:1rem;padding:1rem;";const c=L("e.g. Dark Landing",t.label||""),l=L("Optional description",t.description||""),s=F(t.width),u=k("Show Navbar",t.navbar!==!1),p=k("Show Footer",t.footer!==!1),g=k("Show Sidebar",t.sidebar===!0),C=D(t.bgColor||""),b=L("https://...",t.bgImage||""),v=L("e.g. no-hero wide-content",t.class||"");d.appendChild(x("Label",c)),d.appendChild(x("Description",l)),d.appendChild(x("Width",s));const y=document.createElement("div");y.style.cssText="display:flex;gap:1.5rem;flex-wrap:wrap;",y.appendChild(u.wrap),y.appendChild(p.wrap),y.appendChild(g.wrap),d.appendChild(y),d.appendChild(x("Background Colour",C.wrap)),d.appendChild(x("Background Image URL",b)),d.appendChild(x("Custom CSS Class",v));const w=document.createElement("div");w.style.cssText="display:flex;gap:.5rem;flex-wrap:wrap;padding-top:.5rem;border-top:1px solid var(--dm-border,#333);";const h=document.createElement("button");if(h.type="button",h.className="btn btn-primary",h.textContent="Save",w.appendChild(h),!e&&!t.builtin){const i=document.createElement("button");i.type="button",i.className="btn btn-danger",i.style.marginLeft="auto",i.textContent="Delete",i.addEventListener("click",async()=>{if(await E.confirm(`Delete the "${t.label||n}" layout?`))try{await f.layouts.remove(n),E.toast("Layout deleted.",{type:"success"}),r.close(),o(n)}catch(m){E.toast(m?.message||"Failed to delete layout.",{type:"error"})}}),w.appendChild(i)}d.appendChild(w),h.addEventListener("click",async()=>{const i=c.value.trim();if(!i){E.toast("Label is required.",{type:"warning"});return}const T={label:i,description:l.value.trim(),width:s.value,navbar:u.cb.checked,footer:p.cb.checked,sidebar:g.cb.checked,bgColor:C.getValue(),bgImage:b.value.trim(),class:v.value.trim()};try{let m;e?m=await f.layouts.create(T):m=await f.layouts.update(n,T),E.toast("Layout saved.",{type:"success"}),r.close(),a(e?m.key:n,m.preset||{...t,...T})}catch(m){E.toast(m?.message||"Failed to save layout.",{type:"error"})}}),r.setContent(d),r.open()}export const layoutsView={templateUrl:"/admin/js/templates/layouts.html",async onMount(n){E.tabs(n.find("#layouts-tabs").get(0));const t=E.loader(n.get(0),{type:"dots"});let e=await f.layouts.get().catch(()=>({}));const a=await f.layouts.getOptions().catch(()=>({spacerSize:8,spacerClass:""}));t.destroy();const o=n.find("#presets-grid");function r(){o.empty();const l=o.get(0);Object.entries(e).forEach(([s,u])=>{const p=O(s,u,(g,C)=>{z(g,C,!1,(b,v)=>{e[b]={...e[b],...v},r()},b=>{delete e[b],r()})});l.appendChild(p)}),I.scan(l)}r(),n.find("#create-layout-btn").on("click",()=>{z(null,{},!0,(l,s)=>{if(!l){E.toast("Layout saved but key missing - please refresh.",{type:"warning"});return}e[l]=s,r()},()=>{})});const d=n.find("#spacer-size-input"),c=n.find("#spacer-class-input");d.val(a.spacerSize??8),c.val(a.spacerClass??""),n.find("#save-options-btn").on("click",async()=>{const l=parseInt(d.val(),10)||8,s=c.val().trim();try{await f.layouts.saveOptions({spacerSize:l,spacerClass:s}),E.toast("Layout options saved.",{type:"success"})}catch{E.toast("Failed to save layout options.",{type:"error"})}})}};
1
+ import{api as L,apiRequest as Qe}from"../api.js";import{addBannerAction as Xe}from"../lib/plugin-chrome.js";import{helpPopovers as Me}from"../lib/help-popover.js";import{onceClosed as $e,openMenuAt as Oe,sidebarCount as Ze}from"../lib/tool-kit.js";import{FILTERS as et,WIDTHS as tt,WIDTH_HINTS as at,WIDTH_LABELS as st,WIDTH_SHARE as De,arrangeLayouts as lt,checkLayout as nt,checkOptions as ot,formFromPreset as it}from"../lib/layouts-arrange.js";const ae=/^\/layouts\/?(\?.*)?$/,Te="#/layouts",se="layouts.draft",N="layouts.options.draft",le="layouts.filter",Le=(s="")=>`
2
+ <span class="lyx-shape" aria-hidden="true" data-bind-style-background-color="bg${s}">
3
+ <span class="lyx-shape-nav" data-bind-hidden="!hasNav${s}"></span>
4
+ <span class="lyx-shape-body"><span class="lyx-shape-col" data-bind-style-width="share${s}"></span><span class="lyx-shape-side" data-bind-hidden="!hasSide${s}"></span></span>
5
+ <span class="lyx-shape-foot" data-bind-hidden="!hasFoot${s}"></span>
6
+ </span>`,rt=`
7
+ <div class="lyx-row" data-key="{{key}}" tabindex="0">
8
+ ${Le()}
9
+ <span class="lyx-main"><strong>{{label}}</strong><small>{{meta}}</small></span>
10
+ <span class="lyx-chips">
11
+ <span class="lyx-chip" data-bind-hidden="!builtin" title="Built in - can be edited, not deleted">Built in</span>
12
+ <span class="lyx-chip">{{widthLabel}}</span>
13
+ <span class="lyx-chip" data-bind-class="usedTone" title="Pages that use this layout">{{usedText}}</span>
14
+ </span>
15
+ <button type="button" class="lyx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
16
+ </div>`,W=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Be(s){const r=document.createElement("template");return r.innerHTML=s.trim(),r.content.firstElementChild}const je=(s,r,i=`${r}s`)=>`${s} ${s===1?r:i}`,ct=s=>`#/pages/edit${s}`;let f=null,ze=!1;function ut(){ze||!window.R?.use||(ze=!0,R.use((s,r,i)=>!(ae.test(r?.path||"")&&!ae.test(s?.path||""))||!f?.dirty()?i():E.confirm("You have unsaved layout changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?i():history.replaceState(null,"",Te)})),M.subscribe("router:afterChange",({to:s}={})=>{if(f&&!ae.test(s?.path||"")){const r=f;f=null,r.dispose()}}))}export const layoutsView={templateUrl:"/admin/js/templates/layouts.html",async onMount(s){if(ut(),f){const e=f;f=null,e.dispose()}const r=[],i=e=>(r.push(e),e);let Y=!1;const d=s.get(0).querySelector(".lyx")||s.get(0),p=()=>!Y&&d.isConnected,y=M.observable({}),A=M.observable({byKey:{},missing:[]}),ne=M.observable(!0),U=M.observable("");let oe="all";try{oe=et.includes(S.get(le))?S.get(le):"all"}catch{}const k=M.observable(oe),ie=M.observable("");i(M.effect(()=>{const e=k.value;try{S.set(le,e)}catch{}}));const re=M.observable([]);i(M.effect(()=>{re.value=lt(y.value,A.value,{filter:k.value,query:ie.value})}));const Pe=M.computed(()=>{const e=Object.values(y.value),t=e.filter(n=>n.builtin).length;return{all:e.length,builtin:t,custom:e.length-t}}),V=M.computed(()=>A.value.missing||[]),Ne=M.computed(()=>{const e=V.value.length;return`${je(e,"page")} name${e===1?"s":""} a layout that no longer exists and show${e===1?"s":""} as Default.`}),H=M.observable({spacerSize:"8",spacerClass:""}),m=M.observable("8"),g=M.observable(""),G=M.observable(!1),ce=()=>({spacerSize:String(m.value??""),spacerClass:String(g.value??"")}),K=M.computed(()=>ot(ce())),x=M.computed(()=>{const e=H.value;return String(m.value??"")!==e.spacerSize||String(g.value??"")!==e.spacerClass}),ue=M.computed(()=>G.value||!x.value||Object.keys(K.value.errors).length>0),Ae=M.computed(()=>x.value?"Unsaved changes - Ctrl+S saves.":"Saved."),B=e=>({...y.peek()[e]||{},key:e}),J=e=>(A.peek().byKey||{})[e]||[];async function Q(){try{const[e,t]=await Promise.all([L.layouts.get(),Qe("/layouts/_usage",{method:"GET"}).catch(()=>({byKey:{},missing:[]}))]);if(!p())return;y.value=e&&typeof e=="object"?e:{},A.value=t&&typeof t=="object"?t:{byKey:{},missing:[]},U.value=""}catch(e){U.value=`Could not load the layouts: ${e.message||e}`}finally{ne.value=!1}}async function He(){const e=await L.layouts.getOptions().catch(()=>({spacerSize:8,spacerClass:""}));if(!p())return;const t={spacerSize:String(e?.spacerSize??8),spacerClass:String(e?.spacerClass??"")};H.value=t,m.value=t.spacerSize,g.value=t.spacerClass}async function de(){if(ue.peek())return;G.value=!0;const{payload:e}=K.peek();try{await L.layouts.saveOptions(e),H.value={spacerSize:String(e.spacerSize),spacerClass:e.spacerClass},m.value=String(e.spacerSize),g.value=e.spacerClass;try{S.remove?S.remove(N):S.set(N,null)}catch{}E.toast("Layout options saved.",{type:"success"})}catch(t){E.toast(`Not saved: ${t.message||t}`,{type:"error"})}finally{G.value=!1}}function Ie(){const e=H.peek();m.value=e.spacerSize,g.value=e.spacerClass}const pe=()=>Ze(Te);async function Ke(e){const t=B(e);if(t.builtin)return;const n=J(e).length,c=n?` ${je(n,"page")} use${n===1?"s":""} it and will show as Default.`:" No page uses it.";if(await E.confirm(`Delete the layout "${W(t.label||e)}"?${c}`))try{await L.layouts.remove(e),E.toast(`Deleted "${t.label||e}".`,{type:"success"}),pe(),p()&&await Q()}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error"})}}async function Re(e){const t=`layout: ${e}`;try{await navigator.clipboard.writeText(t),E.toast(`Copied "${t}" - paste it into a page's frontmatter.`,{type:"success"})}catch{E.toast(`Copy this into a page's frontmatter: ${t}`,{type:"info",duration:7e3})}}const C=new Set;let b=null,ye=!1;function be(e){const t=e===null,n=t?V.peek():J(e),c=t?null:B(e),o=E.slideover({title:t?"Pages on a missing layout":`Pages using ${W(c.label||e)}`,size:"sm",position:"right"}),j=Be(`
17
+ <div class="lyx-form">
18
+ <p class="lyx-note"></p>
19
+ <div class="lyx-pages"></div>
20
+ </div>`);j.querySelector(".lyx-note").textContent=t?"These pages name a layout that no longer exists, so they show as Default. Open one and choose a layout for it.":n.length?"Click a page to open it in the editor.":"No page uses this layout.";const F=j.querySelector(".lyx-pages");for(const l of n){const w=document.createElement("a");w.href=ct(l.urlPath);const z=document.createElement("span");z.textContent=l.title||l.urlPath;const q=document.createElement("small");q.textContent=[l.urlPath,t?`"${l.layout}"`:"",l.status==="draft"?"draft":""].filter(Boolean).join(" \xB7 "),w.append(z,q),w.addEventListener("click",()=>o.close()),F.appendChild(w)}o.element.appendChild(j),C.add(o),$e(o,()=>C.delete(o)),o.open()}function $(e=null,{from:t=null,draft:n=null}={}){const c=e?B(e):null;if(e&&!y.peek()[e]){E.toast(`There is no layout "${e}".`,{type:"warning"});return}const o=!c,j=c||(t?{...B(t),label:`${B(t).label||t} copy`,builtin:!1}:{}),F={...it(j),...n||{}},l=Object.fromEntries(Object.entries(F).map(([a,h])=>[a,M.observable(h)])),w=()=>Object.fromEntries(Object.keys(l).map(a=>[a,l[a].peek()])),z=a=>JSON.stringify(Object.fromEntries(Object.entries(a).map(([h,te])=>[h,typeof te=="boolean"?te:String(te??"")]))),q=z(F),Se=()=>z(w())!==q||!!n||!!(t&&o),_=M.observable(!1),P=M.computed(()=>nt(Object.fromEntries(Object.keys(l).map(a=>[a,l[a].value])),{isNew:o,taken:Object.keys(y.value)})),X=a=>M.computed(()=>P.value.errors[a]||""),We={hasNav:M.computed(()=>l.navbar.value!==!1),hasFoot:M.computed(()=>l.footer.value!==!1),hasSide:M.computed(()=>l.sidebar.value===!0),share:M.computed(()=>De[l.width.value]||De.normal),bg:M.computed(()=>l.useBg.value?l.bgColor.value:"")},Ee={...l,...We,saving:_,labelErr:X("label"),bgErr:X("bgColor"),classErr:X("cssClass"),keyHint:M.computed(()=>o?P.value.key?`Pages will name it "layout: ${P.value.key}".`:"":`Pages name it "layout: ${c.key}" - renaming does not change that.`),widthHint:M.computed(()=>at[l.width.value]||""),builtinNote:!o&&c.builtin===!0,noBg:M.computed(()=>!l.useBg.value),cannot:M.computed(()=>_.value||Object.keys(P.value.errors).length>0),saveText:o?"Create layout":"Save"},Ye=o?t?"Duplicate layout":"New layout":`Edit ${W(c.label||c.key)}`,u=E.slideover({title:Ye,size:"md",position:"right"}),T=Be(`
21
+ <div class="lyx-form">
22
+ <div class="lyx-preview">${Le(".value")}
23
+ <small class="lyx-hint" data-bind-text="keyHint.value"></small></div>
24
+ <p class="lyx-note" data-bind-hidden="!builtinNote">A built-in layout: you can change it, but not delete it.</p>
25
+ <div class="lyx-grid">
26
+ <div>
27
+ <label class="form-label" for="lyx-f-label">Name <span class="text-danger">*</span></label>
28
+ <input id="lyx-f-label" type="text" class="form-input" maxlength="60" placeholder="Dark landing" data-model="label.value">
29
+ <small class="lyx-err" data-bind-hidden="!labelErr.value" data-bind-text="labelErr.value"></small>
30
+ </div>
31
+ <div>
32
+ <label class="form-label" for="lyx-f-width">Width
33
+ <span data-help="How wide the page's content column is. The navbar and footer are not affected."></span></label>
34
+ <select id="lyx-f-width" class="form-input" data-model="width.value">
35
+ ${tt.map(a=>`<option value="${a}">${st[a]}</option>`).join("")}
36
+ </select>
37
+ <small class="lyx-hint" data-bind-text="widthHint.value"></small>
38
+ </div>
39
+ <div class="lyx-wide">
40
+ <label class="form-label" for="lyx-f-desc">Description</label>
41
+ <input id="lyx-f-desc" type="text" class="form-input" maxlength="200" placeholder="What it is for - shown in the page editor's picker" data-model="description.value">
42
+ </div>
43
+ <div class="lyx-wide">
44
+ <span class="form-label">Around the content
45
+ <span data-help="Whether pages on this layout show the site's navbar and footer, and the sidebar (for sites whose theme has one)."></span></span>
46
+ <div class="lyx-switches">
47
+ <label><input type="checkbox" class="form-switch" data-model="navbar.value"> Navbar</label>
48
+ <label><input type="checkbox" class="form-switch" data-model="footer.value"> Footer</label>
49
+ <label><input type="checkbox" class="form-switch" data-model="sidebar.value"> Sidebar</label>
50
+ </div>
51
+ </div>
52
+ <div>
53
+ <span class="form-label">Background colour
54
+ <span data-help="A colour behind the page's content. Off leaves the theme's own background."></span></span>
55
+ <div class="lyx-colour">
56
+ <input type="checkbox" class="form-switch" aria-label="Use a background colour" data-model="useBg.value">
57
+ <input type="color" aria-label="Background colour" data-model="bgColor.value" data-bind-disabled="noBg.value">
58
+ <small class="lyx-hint" data-bind-text="bg.value || 'Theme background'"></small>
59
+ </div>
60
+ <small class="lyx-err" data-bind-hidden="!bgErr.value" data-bind-text="bgErr.value"></small>
61
+ </div>
62
+ <div>
63
+ <label class="form-label" for="lyx-f-class">CSS class
64
+ <span data-help="Added to the page's body, for your own CSS (content/custom.css) to hook onto - e.g. no-hero."></span></label>
65
+ <input id="lyx-f-class" type="text" class="form-input font-mono" placeholder="e.g. no-hero" data-model="cssClass.value">
66
+ <small class="lyx-err" data-bind-hidden="!classErr.value" data-bind-text="classErr.value"></small>
67
+ </div>
68
+ <div class="lyx-wide">
69
+ <label class="form-label" for="lyx-f-img">Background image
70
+ <span data-help="An image address - /media/hero.jpg for one in the Media library, or a full https:// address."></span></label>
71
+ <input id="lyx-f-img" type="text" class="form-input" placeholder="/media/background.jpg" data-model="bgImage.value">
72
+ </div>
73
+ </div>
74
+ <div class="lyx-actions">
75
+ <small class="lyx-hint">Ctrl+Enter saves.</small>
76
+ <span class="lyx-spacer"></span>
77
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
78
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
79
+ <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
80
+ </div>
81
+ </div>`);async function ke(){if(Ee.cannot.peek())return;_.value=!0;const{payload:a}=P.peek();try{o?await L.layouts.create(a):await L.layouts.update(c.key,a),E.toast(o?`Created "${a.label}".`:"Saved.",{type:"success"}),Z=!0,u.close(),pe(),p()&&await Q()}catch(h){E.toast(`Not saved: ${h.message||h}`,{type:"error"})}finally{_.value=!1}}u.element.appendChild(T);const Ue=Me(T),Ve=M.applyBindings(Ee,T,{methods:{save:ke,cancel:()=>u.close()}}),Ge=a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),ke())};T.addEventListener("keydown",Ge);const Ce={key:c?.key??null,dirty:Se,values:w};b=Ce,C.add(u),$e(u,()=>{Ve.dispose(),Ue(),C.delete(u),b===Ce&&(b=null)});const Je=u.close.bind(u);let Z=!1,ee=!1;u.close=async a=>{if(!Z&&!ye&&a!==!0&&Se()){if(ee)return;ee=!0;const h=await E.confirm("Discard the changes you have not saved?");if(ee=!1,!h)return}return Z=!0,Je()},I.scan(T),u.open(),T.querySelector("#lyx-f-label")?.focus()}function Fe(e){const t=y.peek()[e];if(!t)return[];const n=J(e).length;return[{header:t.label||e},{label:"Edit\u2026",icon:"edit",action:()=>$(e)},{label:n?`Pages using it (${n})\u2026`:"Pages using it",icon:"file-text",disabled:!n,action:()=>be(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>$(null,{from:e})},{label:"Copy frontmatter line",icon:"clipboard",action:()=>Re(e)},...t.builtin?[]:[{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Ke(e)}]]}const O=d.querySelector(".lyx-list");O.innerHTML=rt;const ve=E.tabs(d.querySelector(".lyx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),qe=M.applyBindings({loading:ne,loadError:U,filter:k,query:ie,rows:re,counts:Pe,missing:V,missingText:Ne,spacerSize:m,spacerClass:g,optDirty:x,optCannot:ue,optStatus:Ae,optErrSize:M.computed(()=>K.value.errors.spacerSize||""),optErrClass:M.computed(()=>K.value.errors.spacerClass||"")},d,{methods:{showAll:()=>{k.value="all"},showBuiltin:()=>{k.value="builtin"},showCustom:()=>{k.value="custom"},showMissing:()=>be(null),saveOptions:()=>de(),resetOptions:()=>Ie()}});i(()=>qe.dispose()),i(Me(d,{position:"bottom"}));const _e=E.contextMenu(O,{cursor:"glyph",match:".lyx-row",inherit:!1,density:"compact",items:e=>Fe(e.dataset.key)});i(()=>_e?.destroy?.());const he=e=>{const t=e.target.closest(".lyx-row");if(!t)return;const n=e.target.closest(".lyx-kebab");if(n){Oe(n);return}$(t.dataset.key)},fe=e=>{const t=e.target.closest(".lyx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Oe(t)):e.key==="Enter"&&e.target===t&&$(t.dataset.key))};O.addEventListener("click",he),O.addEventListener("keydown",fe),i(()=>{O.removeEventListener("click",he),O.removeEventListener("keydown",fe)});const me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!p()||C.size||(e.preventDefault(),x.peek()&&de())};document.addEventListener("keydown",me,!0),i(()=>document.removeEventListener("keydown",me,!0)),Xe(s,{id:"layouts-new",icon:"plus",title:"New layout",onClick:()=>{ve.activate?.(0),$()}});const ge=e=>{(x.peek()||b?.dirty())&&(xe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",ge),i(()=>window.removeEventListener("beforeunload",ge));function xe(){try{b?.dirty()&&S.set(se,{key:b.key,values:b.values(),at:new Date().toISOString()}),x.peek()&&S.set(N,{...ce(),at:new Date().toISOString()})}catch{}}if(f={dirty:()=>x.peek()||!!b?.dirty(),dispose(){Y=!0,xe(),ye=!0,[...C].forEach(e=>{try{e.close(!0)}catch{}}),r.splice(0).forEach(e=>{try{e()}catch{}})}},I.scan(d),await Promise.all([Q(),He()]),!p())return;I.scan(d);let D=null,v=null;try{D=S.get(N),v=S.get(se)}catch{}const we=e=>{try{S.remove?S.remove(e):S.set(e,null)}catch{}};if(D&&typeof D=="object"&&"spacerSize"in D&&(we(N),await E.confirm("You left unsaved spacer options. Put them back?")&&p()&&(m.value=String(D.spacerSize??""),g.value=String(D.spacerClass??""),ve.activate?.(1))),v?.values&&p()){we(se);const e=v.key&&y.peek()[v.key],t=e?`your changes to "${W(y.peek()[v.key].label||v.key)}"`:"the new layout you started";await E.confirm(`You left ${t} unsaved. Pick up where you left off?`)&&p()&&$(e?v.key:null,{draft:v.values})}}};
@@ -1 +1,40 @@
1
- import{api as m}from"../api.js";import{openImageEditor as B}from"../lib/image-editor.js";export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(t){const i=c=>{const s=t.find("#media-grid").empty().get(0);if(!c.length){const e=document.createElement("p");e.className="text-muted",e.textContent="No media files yet. Upload one above.",s.appendChild(e);return}c.forEach(e=>{const r=/\.(png|jpe?g|gif|webp|svg)$/i.test(e.name),p=/\.(png|jpe?g|gif|webp|tiff?)$/i.test(e.name),n=document.createElement("div");n.className="media-card";const u=document.createElement("div");if(u.className="media-preview",r){const o=document.createElement("img");o.src=e.url+"?t="+Date.now(),o.alt=e.name,o.className="media-thumb",u.appendChild(o)}else{const o=document.createElement("div");o.className="media-thumb media-thumb--file";const w=document.createElement("span");w.setAttribute("data-icon","file"),o.appendChild(w),u.appendChild(o)}n.appendChild(u);const C=document.createElement("div");C.className="media-info";const l=document.createElement("span");l.className="media-name",l.title="Double-click to rename",l.textContent=e.name;const v=document.createElement("span");v.className="media-size",v.textContent=L(e.size),C.appendChild(l),C.appendChild(v),n.appendChild(C),l.addEventListener("dblclick",()=>D(l,e,i));const h=document.createElement("div");h.className="media-actions";const b=document.createElement("button");b.className="btn btn-sm btn-ghost";const N=document.createElement("span");N.setAttribute("data-icon","copy"),b.appendChild(N),b.addEventListener("click",()=>{navigator.clipboard.writeText(window.location.origin+e.url).then(()=>E.toast("URL copied.",{type:"success"})).catch(()=>E.toast("Copy failed.",{type:"error"}))});const g=document.createElement("button");g.className="btn btn-sm btn-ghost";const k=document.createElement("span");k.setAttribute("data-icon","type"),g.appendChild(k),g.addEventListener("click",()=>D(l,e,i));let d=null;if(p){d=document.createElement("button"),d.className="btn btn-sm btn-ghost";const o=document.createElement("span");o.setAttribute("data-icon","edit"),d.appendChild(o),d.addEventListener("click",async()=>{await B(e)&&i(await m.media.list().catch(()=>[]))})}const y=document.createElement("button");y.className="btn btn-sm btn-ghost";const x=document.createElement("span");x.setAttribute("data-icon","trash"),y.appendChild(x),y.addEventListener("click",async()=>{if(await E.confirm(`Delete "${e.name}"?`))try{await m.media.delete(e.name),E.toast("File deleted.",{type:"success"}),i(await m.media.list().catch(()=>[]))}catch{E.toast("Delete failed.",{type:"error"})}}),h.appendChild(b),h.appendChild(g),d&&h.appendChild(d),h.appendChild(y),n.appendChild(h),s.appendChild(n),E.tooltip(b,{content:"Copy URL",position:"top"}),E.tooltip(g,{content:"Rename",position:"top"}),d&&E.tooltip(d,{content:"Edit image",position:"top"}),E.tooltip(y,{content:"Delete file",position:"top"})}),Domma.icons.scan(),Domma.effects.reveal(".media-card",{animation:"fade",stagger:40,duration:350})},f=E.loader(t.get(0),{type:"dots"}),a=await m.media.list().catch(()=>[]);f.destroy(),i(a),Domma.effects.ripple("#upload-btn"),t.find("#upload-btn").on("click",()=>t.find("#file-input").get(0).click()),t.find("#file-input").on("change",async function(){if(!this.files.length)return;const c=new FormData;for(const r of this.files)c.append("file",r);const s=t.find("#upload-btn").get(0),e=s.lastChild;s.disabled=!0,e.textContent=" Uploading\u2026";try{if(!(await fetch("/api/media",{method:"POST",body:c,headers:{Authorization:"Bearer "+(S.get("auth_token")||"")}})).ok)throw new Error("Upload failed");E.toast("File uploaded.",{type:"success"}),i(await m.media.list().catch(()=>[]))}catch{E.toast("Upload failed.",{type:"error"})}finally{s.disabled=!1,e.textContent=" Upload",this.value=""}})}};function D(t,i,f){if(t.querySelector("input"))return;const a=i.name,c=a.lastIndexOf("."),s=c>0?a.slice(0,c):a,e=document.createElement("input");e.type="text",e.className="media-rename-input",e.value=a,e.title="",t.textContent="",t.appendChild(e),e.focus(),e.setSelectionRange(0,s.length);async function r(){const n=e.value.trim();if(!n||n===a)return p();e.disabled=!0;try{await m.media.rename(a,n),E.toast("File renamed.",{type:"success"}),f(await m.media.list().catch(()=>[]))}catch(u){E.toast(u.message||"Rename failed.",{type:"error"}),p()}}function p(){t.textContent=a,t.title="Double-click to rename"}e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),r()),n.key==="Escape"&&(n.preventDefault(),p())}),e.addEventListener("blur",()=>{setTimeout(()=>{t.contains(e)&&p()},150)})}function L(t){if(!t)return"0 B";const i=1024,f=["B","KB","MB","GB"],a=Math.floor(Math.log(t)/Math.log(i));return`${(t/Math.pow(i,a)).toFixed(1)} ${f[a]}`}
1
+ import{api as R}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Oe,openMenuAt as De,settingsStore as Ke,sidebarCount as te,viewLifetime as Ye}from"../lib/tool-kit.js";import{openImageEditor as _e}from"../lib/image-editor.js";import{KIND_LABEL as Le,arrangeMedia as je,clashes as Ve,formatBytes as Ae,isEditable as ae,isPreviewable as Ge,kindCounts as Je,kindOf as Qe,markdownFor as ne,mediaTile as We,normalisePrefs as Xe,safeName as B}from"../lib/media-arrange.js";const Se=/^#\/media\/?(\?.*)?$/,se="#/media",O="media.draft",Ce=Ke("media.prefs",Xe),Ze=`
2
+ <div class="mdx-tile" data-name="{{name}}" tabindex="0" title="{{name}}">
3
+ <div class="mdx-thumb">
4
+ <img data-bind-hidden="!preview" data-bind-src="thumb" alt="" loading="lazy" decoding="async">
5
+ <span class="mdx-glyph" data-bind-hidden="preview"><span data-icon="{{icon}}" data-icon-size="32"></span></span>
6
+ </div>
7
+ <span class="mdx-chip">{{kindLabel}}</span>
8
+ <button type="button" class="mdx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
9
+ <div class="mdx-info"><span class="mdx-name">{{name}}</span><span class="mdx-meta">{{meta}}</span></div>
10
+ </div>`,q=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function et(u){const c=document.createElement("template");return c.innerHTML=u.trim(),c.content.firstElementChild}const l=Ye(Se);async function K(u,c){try{await navigator.clipboard.writeText(u),E.toast(`${c} copied.`,{type:"success"})}catch{E.toast("Copy failed - the browser refused the clipboard.",{type:"error"})}}export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(u){l.reset();const c=u.get(0).querySelector(".mdx")||u.get(0),b=()=>Se.test(location.hash)&&c.isConnected,oe=Ce.read(),p=M.observable([]),ie=M.observable(!0),Y=M.observable(""),y=M.observable(oe.kind),_=M.observable(oe.sort),re=M.observable(""),L=M.observable(""),U=M.observable(!1);let j=String(Date.now());l.add(M.effect(()=>Ce.write({kind:y.value,sort:_.value})));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=je(p.value,{kind:y.value,query:re.value,sort:_.value}).map(t=>We(t,{now:e,stamp:j}))}));const le=M.computed(()=>Je(p.value));l.add(M.effect(()=>{y.value!=="all"&&!le.value[y.value]&&p.value.length&&(y.value="all")}));const Te=M.computed(()=>{const e=p.value,t=F.value.length,s=Ae(e.reduce((n,i)=>n+(Number(i.size)||0),0));return t===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${s}`:`${t} of ${e.length} \xB7 ${s} in all`}),Ne=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),A=e=>p.peek().find(t=>t.name===e);async function C(){try{const e=await R.media.list();if(!b())return;j=String(Date.now()),p.value=Array.isArray(e)?e:[],Y.value=""}catch(e){Y.value=`Could not load the media: ${e.message||e}`}finally{ie.value=!1}}async function de(e){const t=[...e||[]].filter(o=>o&&o.size!==void 0);if(!t.length)return;if(L.peek()){E.toast("An upload is still running.",{type:"warning"});return}const s=Ve(t.map(o=>o.name),p.peek().map(o=>o.name));if(s.length){const o=s.slice(0,5).map(w=>`"${q(w)}"`).join(", ")+(s.length>5?` and ${s.length-5} more`:"");if(!await E.confirm(`${s.length===1?"A file":`${s.length} files`} with the same name ${s.length===1?"is":"are"} already here: ${o}. Replace ${s.length===1?"it":"them"}?`))return}const n=[];let i=0;try{for(const o of t){L.value=`Uploading ${i+1} of ${t.length} - ${o.name}`;const w=new FormData;w.append("file",o);try{await R.media.upload(w),i++}catch(Q){n.push(`${o.name}: ${Q.message||"failed"}`)}}}finally{L.value=""}i&&E.toast(`Uploaded ${i} file${i===1?"":"s"}.`,{type:"success"}),n.length&&E.toast(`Not uploaded - ${n.join("; ")}`,{type:"error",duration:9e3}),te(se),b()&&await C()}async function V(e){if(!await E.confirm(`Delete "${q(e.name)}"? Pages that show it will show a broken image or link. This cannot be undone.`))return!1;try{return await R.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),te(se),b()&&await C(),!0}catch(t){return E.toast(`Not deleted: ${t.message||t}`,{type:"error"}),!1}}async function ce(e){const t=await _e(e);return t&&b()&&(te(se),await C()),t}const G=e=>window.location.origin+e.url;let h=null,me=!1;const J=new Set;function k(e,{focusRename:t=!1,draftName:s=null}={}){const n=A(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const i=M.observable(s??n.name),o=M.observable(!1),w=M.computed(()=>{const a=i.value.trim();if(!a)return"A file needs a name.";const r=B(a);return r!==n.name&&A(r)?`"${r}" is already here.`:""}),Q=M.computed(()=>{const a=i.value.trim(),r=B(a);return a&&r!==a?`Saved as "${r}" - only letters, numbers, dots, hyphens and underscores are kept.`:""}),we=()=>i.peek().trim()!==n.name,ke={newName:i,saving:o,renameErr:w,renameHint:Q,cannotRename:M.computed(()=>o.value||!!w.value||B(i.value.trim())===n.name),editable:ae(n.name)},m=E.slideover({title:q(n.name),size:"md",position:"right"}),d=et(`
11
+ <div class="mdx-panel">
12
+ <div class="mdx-preview"></div>
13
+ <dl class="mdx-facts"></dl>
14
+ <div>
15
+ <label class="form-label">Address
16
+ <span data-help="The file's address on your site. Paste it wherever a link or an image address is asked for."></span></label>
17
+ <div class="mdx-copy"><input class="form-input mdx-url" readonly aria-label="Address">
18
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copyUrl"><span data-icon="copy" data-icon-size="13"></span> Copy</button></div>
19
+ </div>
20
+ <div>
21
+ <label class="form-label">For a page
22
+ <span data-help="Markdown to paste into a page's body: an image shows the picture, anything else becomes a link. The text in [ ] is an image's alt text - change it to describe the picture."></span></label>
23
+ <div class="mdx-copy"><input class="form-input mdx-md" readonly aria-label="Markdown">
24
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copyMd"><span data-icon="copy" data-icon-size="13"></span> Copy</button></div>
25
+ </div>
26
+ <div>
27
+ <label class="form-label" for="mdx-rename">Name
28
+ <span data-help="Renaming changes the file's address: pages that already use the old address will not find it. Enter saves."></span></label>
29
+ <div class="mdx-copy"><input id="mdx-rename" class="form-input" maxlength="200" data-model="newName.value">
30
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="rename" data-bind-disabled="cannotRename.value"><span data-icon="check" data-icon-size="13"></span> Rename</button></div>
31
+ <small class="mdx-err" data-bind-hidden="!renameErr.value" data-bind-text="renameErr.value"></small>
32
+ <small class="mdx-hint" data-bind-hidden="!renameHint.value" data-bind-text="renameHint.value"></small>
33
+ </div>
34
+ <div class="mdx-actions">
35
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="del"><span data-icon="trash" data-icon-size="13"></span> Delete</button>
36
+ <span class="mdx-spacer"></span>
37
+ <a class="btn btn-sm btn-ghost mdx-open" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="13"></span> Open</a>
38
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="edit" data-bind-hidden="!editable"><span data-icon="edit" data-icon-size="13"></span> Edit image</button>
39
+ </div>
40
+ </div>`),W=d.querySelector(".mdx-preview"),$=Qe(n.name);if(Ge(n.name)){const a=document.createElement("img");a.src=`${n.url}?t=${encodeURIComponent(j)}`,a.alt=n.name,W.appendChild(a)}else if($==="video"||$==="audio"){const a=document.createElement($);a.src=n.url,a.controls=!0,a.preload="metadata",W.appendChild(a)}else{const a=document.createElement("span");a.setAttribute("data-icon",$==="document"?"file-text":"paperclip"),a.setAttribute("data-icon-size","40"),W.appendChild(a)}const Ie=d.querySelector(".mdx-facts"),P=(a,r)=>{const Me=document.createElement("dt");Me.textContent=a;const ee=document.createElement("dd");return ee.textContent=r,Ie.append(Me,ee),ee};if(P("Type",`${Le[$]==="Images"?"Image":Le[$].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),P("Size",Ae(n.size)),P("Uploaded",n.createdAt?D(n.createdAt).format("D MMM YYYY, HH:mm"):"-"),ae(n.name)){const a=P("Dimensions","\u2026");R.media.info(n.name).then(r=>{a.textContent=r?.width?`${r.width} \xD7 ${r.height} px`:"-"}).catch(()=>{a.textContent="-"})}d.querySelector(".mdx-url").value=G(n),d.querySelector(".mdx-md").value=ne(n),d.querySelector(".mdx-open").href=n.url;let z=!1;async function Ee(){if(!ke.cannotRename.peek()){o.value=!0;try{const a=await R.media.rename(n.name,i.peek().trim());E.toast(`Renamed to "${a?.name||B(i.peek().trim())}".`,{type:"success"}),z=!0,m.close(),b()&&await C()}catch(a){E.toast(`Not renamed: ${a.message||a}`,{type:"error"})}finally{o.value=!1}}}m.element.appendChild(d);const Ue=$e(d),Fe=M.applyBindings(ke,d,{methods:{rename:Ee,copyUrl:()=>K(G(n),"Address"),copyMd:()=>K(ne(n),"Markdown"),async del(){await V(n)&&(z=!0,m.close())},async edit(){z=!0,m.close(),await ce(n)}}}),X=d.querySelector("#mdx-rename"),He=a=>{a.key==="Enter"&&a.target===X&&(a.preventDefault(),Ee())};d.addEventListener("keydown",He);const xe={name:n.name,dirty:we,newName:()=>i.peek()};h=xe,J.add(m),Oe(m,()=>{Fe.dispose(),Ue(),d.querySelectorAll("video, audio").forEach(a=>{try{a.pause()}catch{}}),J.delete(m),h===xe&&(h=null)});const Pe=m.close.bind(m);let Z=!1;m.close=async a=>{if(!z&&!me&&a!==!0&&we()){if(Z)return;Z=!0;const r=await E.confirm("Discard the new name you typed?");if(Z=!1,!r)return}return z=!0,Pe()},I.scan(d),m.open(),(t||s)&&(X.focus(),X.select())}function ze(e){const t=A(e);return t?[{header:t.name},{label:"Details\u2026",icon:"info",action:()=>k(t.name)},{label:"Copy address",icon:"link",action:()=>K(G(t),"Address")},{label:"Copy for a page",icon:"copy",action:()=>K(ne(t),"Markdown")},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")},{type:"divider"},{label:"Rename\u2026",icon:"type",action:()=>k(t.name,{focusRename:!0})},...ae(t.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>ce(t)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>V(t)}]:[]}const f=c.querySelector(".mdx-grid");f.innerHTML=Ze;const x=e=>()=>{y.value=e},Re=M.applyBindings({files:p,loading:ie,loadError:Y,kind:y,sort:_,query:re,tiles:F,counts:le,totalText:Te,emptyText:Ne,busyText:L,dragging:U},c,{methods:{kindAll:x("all"),kindImage:x("image"),kindVideo:x("video"),kindAudio:x("audio"),kindDocument:x("document"),kindOther:x("other")}});l.add(()=>Re.dispose()),l.add($e(c,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const qe=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>ze(e.dataset.name)});l.add(()=>qe?.destroy?.());const ue=e=>{const t=e.target.closest(".mdx-tile");if(!t)return;const s=e.target.closest(".mdx-kebab");if(s){De(s);return}k(t.dataset.name)},pe=e=>{const t=e.target.closest(".mdx-tile");if(!t||e.target!==t)return;const s=A(t.dataset.name);s&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),De(t)):e.key==="Enter"?k(s.name):e.key==="F2"?(e.preventDefault(),k(s.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),V(s)))};f.addEventListener("click",ue),f.addEventListener("keydown",pe),l.add(()=>{f.removeEventListener("click",ue),f.removeEventListener("keydown",pe)});const T=c.querySelector(".mdx-file-input"),fe=()=>{const e=[...T.files];T.value="",de(e)};T.addEventListener("change",fe),l.add(()=>T.removeEventListener("change",fe)),Be(u,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>T.click()});const H=e=>[...e.dataTransfer?.types||[]].includes("Files");let N=0;const ve=e=>{H(e)&&(e.preventDefault(),N++,U.value=!0)},be=e=>{H(e)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")},ye=e=>{H(e)&&(N=Math.max(0,N-1),N||(U.value=!1))},he=e=>{H(e)&&(e.preventDefault(),N=0,U.value=!1,de(e.dataTransfer.files))},v=u.get(0);v.addEventListener("dragenter",ve),v.addEventListener("dragover",be),v.addEventListener("dragleave",ye),v.addEventListener("drop",he),l.add(()=>{v.removeEventListener("dragenter",ve),v.removeEventListener("dragover",be),v.removeEventListener("dragleave",ye),v.removeEventListener("drop",he)});const ge=e=>{(L.peek()||h?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ge),l.add(()=>window.removeEventListener("beforeunload",ge)),l.add(()=>{if(h?.dirty())try{S.set(O,{name:h.name,newName:h.newName(),at:new Date().toISOString()})}catch{}me=!0,[...J].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(c),await C(),!b())return;let g=null;try{g=S.get(O)}catch{}if(g?.name){try{S.remove?S.remove(O):S.set(O,null)}catch{}A(g.name)&&await E.confirm(`You were renaming "${q(g.name)}" to "${q(g.newName)}" and left before saving. Pick up where you left off?`)&&b()&&k(g.name,{draftName:g.newName})}}};
@@ -1,5 +1,11 @@
1
- import{api as i}from"../api.js";import{filterByProject as b,getProjectFromHash as y}from"../lib/project-context.js";import{keepTableIconsAlive as f}from"../lib/table-icons.js";function o(a){return String(a).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(a){const m=E.loader(a.get(0),{type:"dots"});let r,u;try{[r,u]=await Promise.all([i.menus.list().catch(()=>[]),i.menuLocations.get().catch(()=>({}))])}finally{m.destroy()}const p=y(),g=p?b(r,p):r,l={};for(const[e,t]of Object.entries(u||{}))t&&(l[t]||(l[t]=[]),l[t].push(e));T.create("#menus-table",{pageSize:25,data:g,emptyMessage:'No menus yet. Click "New menu" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:e=>`<code>${o(e)}</code>`},{key:"name",title:"Name",sortable:!0,render:(e,t)=>`<a href="#/menus/edit/${encodeURIComponent(t.slug)}" style="font-weight:600;">${o(e||t.slug)}</a>`},{key:"itemsCount",title:"Items",render:(e,t)=>String(t.itemsCount??(Array.isArray(t.items)?t.items.length:0))},{key:"mapped",title:"Mapped",render:(e,t)=>{const n=l[t.slug]||[];return n.length?n.map(s=>`<span class="badge badge-info">${o(s)}</span>`).join(" "):'<span class="text-muted">-</span>'}},{key:"binding",title:"Bound to",render:(e,t)=>{const n=t.binding;if(!n?.slot)return'<span class="text-muted">-</span>';const s=[...(n.projects||[]).map(c=>`project:${c}`),...n.pages||[]],d=s.length?s.join(", "):"nothing - binding never matches";return`<span class="badge badge-warning" data-tooltip="${o(d)}">${o(n.slot)}</span>`}},{key:"bundled",title:"Bundled",render:(e,t)=>t.meta?.bundled?'<span class="badge badge-secondary">bundled</span>':""},{key:"_actions",title:"Actions",render:(e,t)=>{const n=(l[t.slug]||[]).length>0,s=n?'data-tooltip="Unmap this menu before deleting"':"",d=n?"disabled":"",c=o(t.slug);return`
2
- <a class="btn btn-sm btn-ghost" href="#/menus/edit/${encodeURIComponent(t.slug)}" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- <button class="btn btn-sm btn-ghost btn-duplicate" data-slug="${c}" data-tooltip="Duplicate"><span data-icon="copy" data-icon-size="14"></span></button>
4
- <button class="btn btn-sm btn-ghost btn-delete" data-slug="${c}" ${d} ${s}><span data-icon="trash" data-icon-size="14"></span></button>
5
- `}}]}),Domma.icons.scan("#menus-table"),f(a.get(0)),document.querySelectorAll("#menus-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-duplicate").on("click",".btn-duplicate",async function(){const e=$(this).data("slug");try{const t=await i.menus.duplicate(e);E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),location.reload()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}),a.off("click",".btn-delete").on("click",".btn-delete",async function(){if(this.disabled)return;const e=$(this).data("slug");if(await E.confirm(`Delete menu "${e}"? This cannot be undone.`))try{await i.menus.remove(e),E.toast("Menu deleted.",{type:"success"}),location.reload()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}})}};
1
+ import{apiRequest as u}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as z}from"../lib/help-popover.js";import{openMenuAt as R,sidebarCount as j,viewLifetime as V}from"../lib/tool-kit.js";import{filterByProject as Y}from"../lib/project-context.js";import{FILTERS as J,arrangeMenus as Q,isPlaced as X,menuRow as D,menuShortcode as Z,slotsByMenu as ee}from"../lib/menus-arrange.js";const $=/^#\/(menus|projects\/[^/]+\/menus)\/?(\?.*)?$/,U="#/menus",f="menus.filter",te=`
2
+ <div class="mnx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="placed ? '' : 'is-unplaced'">
3
+ <span class="mnx-icon" aria-hidden="true"><span data-icon="menu" data-icon-size="16"></span></span>
4
+ <span class="mnx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
5
+ <span class="mnx-chips">
6
+ <span class="mnx-chip is-ok" data-bind-hidden="!hasSlots" title="Locations this menu fills">{{slotsText}}</span>
7
+ <span class="mnx-chip" data-bind-class="bindingDead ? 'is-warn' : 'is-info'" data-bind-hidden="!bindingText" title="{{bindingTitle}}">{{bindingText}}</span>
8
+ <span class="mnx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS or a plugin">Bundled</span>
9
+ </span>
10
+ <button type="button" class="mnx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,ne=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),s=V($);function ae(){const n=location.hash.match(/^#\/projects\/([^/?#]+)\/menus/);if(!n)return null;try{return decodeURIComponent(n[1])}catch{return n[1]}}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(n){s.reset();const r=n.get(0).querySelector(".mnx")||n.get(0),d=()=>$.test(location.hash)&&r.isConnected,i=ae(),c=M.observable([]),v=M.observable({}),m=M.observable([]),w=M.observable(!0),p=M.observable("");let x="all";try{x=J.includes(S.get(f))?S.get(f):"all"}catch{}const l=M.observable(x),T=M.observable("");s.add(M.effect(()=>{const e=l.value;try{S.set(f,e)}catch{}}));const h=M.computed(()=>ee(v.value)),y=M.observable([]);s.add(M.effect(()=>{const e={bySlug:h.value,registry:m.value};y.value=Q(c.value,{...e,filter:l.value,query:T.value}).map(t=>D(t,e))}));const q=M.computed(()=>{const e=c.value,t=e.filter(a=>X(a,h.value)).length;return{all:e.length,placed:t,unplaced:e.length-t}}),N=M.computed(()=>c.value.length?"No menus match.":i?"No menus in this project yet - New menu in the banner makes one.":"No menus yet - New menu in the banner makes one."),P=M.computed(()=>i?`Showing the menus in the project "${i}".`:""),B=e=>c.peek().find(t=>t.slug===e);async function g(){try{const[e,t,a]=await Promise.all([u("/menus",{method:"GET"}),u("/menu-locations",{method:"GET"}).catch(()=>({})),u("/menu-locations/registry",{method:"GET"}).catch(()=>[])]);if(!d())return;const L=Array.isArray(e)?e:[];c.value=i?Y(L,i):L,v.value=t&&typeof t=="object"?t:{},m.value=Array.isArray(a)?a:[],p.value=""}catch(e){p.value=`Could not load the menus: ${e.message||e}`}finally{w.value=!1}}const b=e=>{location.hash=`#/menus/edit/${encodeURIComponent(e)}`};function _(){if(i)try{sessionStorage.setItem("__projectContext",i)}catch{}location.hash="#/menus/new"}async function F(e){try{const t=await u(`/menus/${encodeURIComponent(e.slug)}/duplicate`,{method:"POST"});E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}async function G(e){const t=Z(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function K(e){if(await E.confirm(`Delete the menu "${ne(e.name||e.slug)}"? This cannot be undone.`))try{await u(`/menus/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Menu deleted.",{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error",duration:7e3})}}function O(e){const t=B(e);if(!t)return[];const a=D(t,{bySlug:h.peek(),registry:m.peek()});return[{header:t.name||t.slug},{label:"Edit",icon:"edit",action:()=>b(t.slug)},{label:"Duplicate",icon:"copy",action:()=>F(t)},{label:"Copy shortcode",icon:"code",action:()=>G(t)},{type:"divider"},{label:"Locations\u2026",icon:"map",action:()=>{location.hash="#/menu-locations"}},{type:"divider"},a.canDelete?{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>K(t)}:{label:"Delete - in a location",icon:"trash",disabled:!0,title:a.deleteWhy}]}const o=r.querySelector(".mnx-list");o.innerHTML=te;const H=M.applyBindings({menus:c,loading:w,loadError:p,filter:l,query:T,rows:y,counts:q,emptyText:N,projectText:P},r,{methods:{showAll:()=>{l.value="all"},showPlaced:()=>{l.value="placed"},showUnplaced:()=>{l.value="unplaced"}}});s.add(()=>H.dispose()),s.add(z(r,{position:"bottom"})),s.add(M.effect(()=>{y.value,requestAnimationFrame(()=>{o.isConnected&&I.scan(o)})}));const W=E.contextMenu(o,{cursor:"glyph",match:".mnx-row",inherit:!1,density:"compact",items:e=>O(e.dataset.slug)});s.add(()=>W?.destroy?.());const k=e=>{const t=e.target.closest(".mnx-row");if(!t)return;const a=e.target.closest(".mnx-kebab");if(a){R(a);return}b(t.dataset.slug)},C=e=>{const t=e.target.closest(".mnx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),R(t)):e.key==="Enter"&&e.target===t&&b(t.dataset.slug))};o.addEventListener("click",k),o.addEventListener("keydown",C),s.add(()=>{o.removeEventListener("click",k),o.removeEventListener("keydown",C)}),A(n,{id:"menus-new",icon:"plus",title:"New menu",onClick:_}),A(n,{id:"menus-locations",icon:"map",title:"Locations",onClick:()=>{location.hash="#/menu-locations"}}),I.scan(r),await g()}};
@@ -1 +1 @@
1
- import{api as s}from"../api.js";function f(o){return{string:"string",email:"email",tel:"string",number:"number",textarea:"textarea",select:"select",radio:"select",checkbox:"boolean","checkbox-group":"select",date:"string",time:"string",url:"string",hidden:"string"}[o]||"string"}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(o){const i=E.loader(o.get(0),{type:"dots"});let r,c;try{[r,c]=await Promise.all([s.auth.me().catch(()=>null),s.collections.get("user-profiles").catch(()=>null)])}finally{i.destroy()}if(!r){E.toast("Could not load your account details.",{type:"error"});return}const u={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Jane Smith"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"jane@example.com"}},password:{type:"password",required:!1,minLength:0,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Leave blank to keep your current password"}}},d={name:r.name||"",email:r.email||""};F.render("#account-form-container",u,d,{layout:"stacked",submitText:"Save Account",onSubmit:async t=>{if(t.password&&t.password.length<8)return E.toast("Password must be at least 8 characters.",{type:"warning"}),!1;const a={name:t.name,email:t.email};t.password&&(a.password=t.password);try{await s.auth.updateMe(a),E.toast("Account updated.",{type:"success"})}catch(e){return E.toast(`Save failed: ${e.message||"Unknown error"}`,{type:"error"}),!1}}});const p=c?.fields||[];if(p.length>0){o.find("#profile-card").show();const t={},a={};for(const e of p){const l={type:f(e.type),label:e.label||e.name};e.required&&(l.required=!0),e.placeholder&&(l.formConfig={placeholder:e.placeholder}),e.options&&Array.isArray(e.options)&&(l.options=e.options.map(n=>typeof n=="string"?{value:n,label:n}:n)),t[e.name]=l,a[e.name]=r.profile?.[e.name]??""}F.render("#profile-form-container",t,a,{layout:"stacked",submitText:"Save Profile",onSubmit:async e=>{try{await s.auth.updateMe({profile:e}),E.toast("Profile saved.",{type:"success"})}catch(m){return E.toast(`Save failed: ${m.message||"Unknown error"}`,{type:"error"}),!1}}})}Domma.icons.scan()}};
1
+ import{api as V}from"../api.js";import{addBannerAction as fe}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{accountFacts as re,buildPayload as he,checkForm as se,draftOf as be,fieldKind as H,fieldOptions as U,formFromMe as W,initials as ye,snapshot as T}from"../lib/my-profile-arrange.js";const z=/^\/my-profile\/?(\?.*)?$/,y=t=>String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),Y=t=>{if(!t)return"";try{return D(t).format("D MMM YYYY")}catch{return String(t).slice(0,10)}};let $=null,le=!1;function $e(){le||!window.R?.use||(le=!0,R.use((t,o,r)=>!(z.test(o?.path||"")&&!z.test(t?.path||""))||!$?.dirty()?r():E.confirm("You have unsaved changes to your profile. Leave anyway? They are kept for your next visit - except a new password, which you would type again.").then(i=>{i?r():history.replaceState(null,"","#/my-profile")})),M.subscribe("router:afterChange",({to:t}={})=>{if($&&!z.test(t?.path||"")){const o=$;$=null,o.dispose()}}))}function ge(t,o){const r=H(t.type),m=`mpx-pf-${o}`,i=t.required?' <span class="text-danger">*</span>':"",v=t.help||t.description?` <span data-help="${y(t.help||t.description)}"></span>`:"",u=`<label class="form-label" for="${m}">${y(t.label||t.name)}${i}${v}</label>`,b=`<small class="mpx-err" data-bind-hidden="!pe_${o}.value" data-bind-text="pe_${o}.value"></small>`,s=t.placeholder?` placeholder="${y(t.placeholder)}"`:"";switch(r){case"textarea":return`<div class="mpx-wide">${u}<textarea id="${m}" class="form-input" rows="3"${s} data-model="pf_${o}.value"></textarea>${b}</div>`;case"select":return`<div>${u}<select id="${m}" class="form-input" data-model="pf_${o}.value"><option value="">-</option>${U(t).map(c=>`<option value="${y(c.value)}">${y(c.label)}</option>`).join("")}</select>${b}</div>`;case"checkbox":return`<div><label class="mpx-check"><input id="${m}" type="checkbox" data-model="pf_${o}.value"> ${y(t.label||t.name)}${i}</label>${v}${b}</div>`;case"multi":return`<div class="mpx-wide">${u}<div data-multi="${o}">${U(t).map((c,f)=>`<label class="mpx-check"><input type="checkbox" data-opt="${y(c.value)}" data-bind-checked="po_${o}_${f}.value"> ${y(c.label)}</label>`).join("")}</div>${b}</div>`;default:return`<div>${u}<input id="${m}" type="${r}" class="form-input"${s} data-model="pf_${o}.value">${b}</div>`}}export const myProfileView={templateUrl:"/admin/js/templates/my-profile.html",async onMount(t){if($e(),$){const e=$;$=null,e.dispose()}const o=[],r=e=>(o.push(e),e);let m=!1;const i=t.get(0).querySelector(".mpx")||t.get(0),v=()=>!m&&i.isConnected,u=M.observable(!0),b=M.observable(""),s=M.observable(null),c=M.observable(""),f=M.observable(""),w=M.observable(""),j=M.observable(""),ce=M.observable(!1),P=M.observable(!1),G=M.observable(!1);let p=[];const g=new Map,J=M.observable(0),h=M.observable(W({},[])),ie=()=>(J.value,Object.fromEntries([...g].map(([e,a])=>[e,a.value]))),ue=()=>Object.fromEntries([...g].map(([e,a])=>[e,a.peek()])),Q=()=>({name:c.value,email:f.value,password:w.value,confirm:j.value,profile:ie()}),X=()=>({name:c.peek(),email:f.peek(),password:w.peek(),confirm:j.peek(),profile:ue()}),B=M.computed(()=>se(Q(),p,{saved:h.value})),k=M.computed(()=>!u.value&&T(Q())!==T(h.value)),pe={loading:u,loadError:b,name:c,email:f,password:w,confirm:j,showPw:ce,hasProfile:G,err:B,dirty:k,cannotSave:M.computed(()=>P.value||!k.value||Object.keys(B.value).length>0),emailChanged:M.computed(()=>f.value.trim()!==""&&f.value.trim()!==h.value.email),pwMatch:M.computed(()=>w.value.length>=8&&w.value===j.value),avatar:M.computed(()=>ye(c.value||s.value?.name)),shownName:M.computed(()=>c.value.trim()||s.value?.name||""),shownEmail:M.computed(()=>f.value.trim()||s.value?.email||""),rolesText:M.computed(()=>re(s.value).roles.join(" + ")),accessText:M.computed(()=>re(s.value).access),factsText:M.computed(()=>{const e=s.value;return e?[e.createdAt&&`Member since ${Y(e.createdAt)}`,e.lastLogin&&`last signed in ${Y(e.lastLogin)}`,e.updatedAt&&`changed ${Y(e.updatedAt)}`,e.isActive===!1&&"account disabled"].filter(Boolean).join(" \xB7 "):""})},A=()=>`my-profile.draft.${s.peek()?.id||"me"}`;function N(e){c.value=e.name,f.value=e.email,w.value="",j.value="";for(const[a,l]of g)a in(e.profile||{})&&(l.value=e.profile[a])}async function q(){if(P.peek()||u.peek())return;const e=X();if(Object.keys(se(e,p,{saved:h.peek()})).length){E.toast("Check the fields marked in red first.",{type:"warning"});return}const a=he(h.peek(),e,p);if(!a){E.toast("Nothing has changed.",{type:"info"});return}P.value=!0;try{const l=await V.auth.updateMe(a.payload);if(!v())return;s.value=l;const C=W(l,p);h.value=C,N(C),F();try{S.set("auth_user",{...S.get("auth_user")||{},name:l.name,email:l.email})}catch{}const n=document.getElementById("topbar-user-name");n&&l.name&&(n.textContent=l.name);const d={name:"your name",email:"your email",password:"your password",profile:"your profile"};E.toast(`Saved ${a.parts.map(_=>d[_]).join(", ").replace(/, ([^,]*)$/," and $1")}.`,{type:"success"})}catch(l){E.toast(`Not saved: ${l.message||l}`,{type:"error"})}finally{P.value=!1}}async function de(){k.peek()&&(!await E.confirm("Throw away the changes you have not saved?")||!v()||(N(h.peek()),F()))}function Z(){if(!k.peek())return;const e=be(X());if(T({...e,password:"",confirm:""})!==T(h.peek()))try{S.set(A(),{form:e,at:new Date().toISOString()})}catch{}}function F(){try{S.remove?S.remove(A()):S.set(A(),null)}catch{}}const me=M.applyBindings(pe,i,{methods:{save:q,discard:de}});r(()=>me.dispose()),r(ne(i,{position:"bottom"})),fe(t,{id:"my-profile-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>q()});const ee=e=>{!(e.ctrlKey||e.metaKey)||e.key.toLowerCase()!=="s"||!v()||(e.preventDefault(),e.stopImmediatePropagation(),q())};document.addEventListener("keydown",ee,!0),r(()=>document.removeEventListener("keydown",ee,!0));const te=e=>{k.peek()&&(Z(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",te),r(()=>window.removeEventListener("beforeunload",te)),$={dirty:()=>k.peek(),dispose(){m=!0,Z(),o.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let ae=null;try{const[e,a]=await Promise.all([V.auth.me(),V.collections.get("user-profiles").catch(()=>null)]);if(!v())return;s.value=e,ae=a}catch(e){if(!v())return;b.value=`Could not load your account: ${e.message||e}`,u.value=!1;return}p=(ae?.fields||[]).filter(e=>e&&e.name);const L=W(s.peek(),p),K=p.map((e,a)=>({f:e,i:a})).filter(({f:e})=>H(e.type)!=="hidden");if(p.forEach((e,a)=>g.set(e.name,M.observable(L.profile[e.name]))),K.length){const e=i.querySelector(".mpx-profile");e.innerHTML=K.map(({f:n,i:d})=>ge(n,d)).join("");const a={};p.forEach((n,d)=>{a[`pf_${d}`]=g.get(n.name),a[`pe_${d}`]=M.computed(()=>B.value[`profile.${n.name}`]||""),H(n.type)==="multi"&&U(n).forEach((_,O)=>{a[`po_${d}_${O}`]=M.computed(()=>(g.get(n.name).value||[]).includes(_.value))})});const l=M.applyBindings(a,e);r(()=>l.dispose());const C=n=>{const d=n.target.closest("[data-multi]");if(!d||!n.target.matches("input[data-opt]"))return;const _=g.get(p[Number(d.dataset.multi)].name),O=n.target.dataset.opt,oe=_.peek()||[];_.value=n.target.checked?[...new Set([...oe,O])]:oe.filter(ve=>ve!==O)};e.addEventListener("change",C),r(()=>e.removeEventListener("change",C)),r(ne(e,{position:"bottom"})),I.scan(e)}G.value=K.length>0,h.value=L,N(L),J.value++,u.value=!1;let x=null;try{x=S.get(A())}catch{}if(x?.form){F();const e=x.at?` (${Y(x.at)})`:"";await E.confirm(`You left unsaved changes to your profile${e}. Restore them?`)&&v()&&N({...L,...x.form,profile:{...L.profile,...x.form.profile||{}}})}}};
@@ -1 +1,18 @@
1
- import{api as p}from"../api.js";export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(o){const f=o.find("#notif-list"),C=o.find("#notif-empty"),v=E.loader(o.get(0),{type:"dots"});let c=[];try{c=await p.system.notifications.list()}catch{E.toast("Could not load notifications.",{type:"error"});return}finally{v.destroy()}if(!Array.isArray(c)||c.length===0){C.removeAttr("hidden"),Domma.icons.scan(o.get(0));return}c.forEach(e=>{e.unread&&p.system.notifications.markRead(e.id).catch(()=>{})}),c.forEach(e=>{const t=e.data||{},s=t.severity||"info",l=t.createdAt?new Date(t.createdAt).toLocaleString():"",d=s==="critical"?"danger":s==="warning"?"warning":s==="success"?"success":"info",n=document.createElement("div");n.className="card mb-3 notif-card",n.dataset.id=String(e.id);const i=document.createElement("div");i.className="card-header",i.style.cssText="display:flex;align-items:center;gap:.5rem;";const y=document.createElement("span");y.className=`badge badge-${d}`,y.textContent=s.toUpperCase();const g=document.createElement("strong");g.style.flex="1",g.textContent=t.title||"";const h=document.createElement("small");h.className="text-muted",h.textContent=l,i.appendChild(y),i.appendChild(g),i.appendChild(h);const r=document.createElement("div");r.className="card-body";const u=document.createElement("p");if(u.style.cssText="white-space:pre-wrap;margin:0 0 .75rem",u.textContent=t.body||"",r.appendChild(u),t.link){const a=document.createElement("a");a.href=t.link,a.target="_blank",a.rel="noopener",a.className="btn btn-sm btn-secondary",a.textContent="Open Link ";const x=document.createElement("span");x.dataset.icon="external-link",a.appendChild(x),r.appendChild(a)}const b=document.createElement("div");b.style.cssText="margin-top:.5rem;display:flex;gap:.5rem;";const m=document.createElement("button");m.className="btn btn-sm btn-secondary js-dismiss",m.dataset.id=String(e.id),m.textContent="Dismiss",b.appendChild(m),r.appendChild(b),n.appendChild(i),n.appendChild(r),f.get(0).appendChild(n)}),f.get(0).addEventListener("click",async e=>{const t=e.target.closest(".js-dismiss");if(!t)return;const s=t.dataset.id;try{await p.system.notifications.dismiss(s);const l=t.closest(".notif-card");l&&l.remove(),f.get(0).querySelectorAll(".notif-card").length===0&&C.removeAttr("hidden");const{count:d}=await p.system.notifications.unreadCount().catch(()=>({count:0})),n=$("#topbar-bell-badge");d>0?n.text(d>99?"99+":String(d)).removeAttr("hidden"):n.attr("hidden","").text("")}catch{E.toast("Could not dismiss notification.",{type:"error"})}}),Domma.icons.scan(o.get(0))}};
1
+ import{api as d,apiRequest as b}from"../api.js";import{addBannerAction as O}from"../lib/plugin-chrome.js";import{helpPopovers as Y}from"../lib/help-popover.js";import{openMenuAt as q,sidebarCount as z,viewLifetime as G}from"../lib/tool-kit.js";import{FILTERS as J,arrangeNotifications as Q,filterCounts as X,notificationRow as B}from"../lib/notifications-arrange.js";const P=/^#\/system\/notifications\/?(\?.*)?$/,Z="#/system/notifications",g="notifications.filter",tt=`
2
+ <div class="ntx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls" data-bind-aria-expanded="open ? 'true' : 'false'">
3
+ <span class="ntx-dot" aria-hidden="true"></span>
4
+ <span class="ntx-main">
5
+ <span class="ntx-head"><strong>{{title}}</strong>
6
+ <span class="ntx-chip" data-bind-class="'is-' + tone">{{severityLabel}}</span>
7
+ <span class="ntx-chip is-new" data-bind-hidden="!isNew">New</span></span>
8
+ <small>{{meta}}</small>
9
+ <span class="ntx-preview" data-bind-hidden="open">{{preview}}</span>
10
+ <span class="ntx-body" data-bind-hidden="!open">{{body}}</span>
11
+ <span class="ntx-actions" data-bind-hidden="!open || !link">
12
+ <a class="btn btn-sm btn-outline" href="{{link}}" target="_blank" rel="noopener">Open link \u2197</a>
13
+ </span>
14
+ </span>
15
+ <button type="button" class="ntx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
16
+ </div>`,et=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),o=G(P);async function p(){z(Z);try{const{count:s}=await d.system.notifications.unreadCount(),a=$("#topbar-bell-badge");s>0?a.text(s>99?"99+":String(s)).removeAttr("hidden"):a.attr("hidden","").text("")}catch{}}export const notificationsView={templateUrl:"/admin/js/templates/notifications.html",async onMount(s){o.reset();const a=s.get(0).querySelector(".ntx")||s.get(0),m=()=>P.test(location.hash)&&a.isConnected,i=M.observable([]),f=M.observable(!0),y=M.observable("");let v="all";try{v=J.includes(S.get(g))?S.get(g):"all"}catch{}const r=M.observable(v),w=M.observable(""),u=M.observable("");o.add(M.effect(()=>{const t=r.value;try{S.set(g,t)}catch{}}));const k=M.computed(()=>X(i.value));o.add(M.effect(()=>{const t=k.value;!f.value&&r.peek()!=="all"&&!t[r.peek()]&&(r.value="all")}));const x=M.observable([]);o.add(M.effect(()=>{const t=Date.now(),e=u.value;x.value=Q(i.value,{filter:r.value,query:w.value}).map(n=>B(n,{now:t,expanded:e}))}));const _=M.computed(()=>i.value.length?"No notifications match.":"No notifications - you're all caught up."),C=t=>i.peek().find(e=>String(e.id)===String(t)),U=(t,e)=>{i.value=i.peek().map(n=>String(n.id)===String(t)?e(n):n)},R=t=>{i.value=i.peek().filter(e=>String(e.id)!==String(t))};async function T(){try{const t=await d.system.notifications.list();if(!m())return;const e=Array.isArray(t)?t:[];if(i.value=e.map(n=>({...n,isNew:!!n.unread})),y.value="",e.some(n=>n.unread)){if(await b("/system/notifications/read-all",{method:"POST"}).catch(()=>{}),!m())return;i.value=i.peek().map(n=>({...n,unread:!1})),p()}}catch(t){y.value=`Could not load notifications: ${t.message||t}`}finally{f.value=!1}}async function D(t,e){try{e?await d.system.notifications.markRead(t.id):await b(`/system/notifications/${encodeURIComponent(t.id)}/unread`,{method:"POST"}),U(t.id,n=>({...n,unread:!e})),p()}catch(n){E.toast(`Not changed: ${n.message||n}`,{type:"error"})}}async function A(t){try{await d.system.notifications.dismiss(t.id),R(t.id),p()}catch(e){E.toast(`Could not dismiss it: ${e.message||e}`,{type:"error"})}}async function F(t){if(await E.confirm(`Delete "${et(t.data?.title||"this notification")}" for everyone? Dismissing hides it for you only.`))try{await d.system.notifications.remove(t.id),R(t.id),E.toast("Notification deleted.",{type:"success"}),p()}catch(e){E.toast(/403|forbidden|permission/i.test(String(e.message||e))?"Deleting needs the Notifications delete permission - Dismiss hides it for you.":`Could not delete it: ${e.message||e}`,{type:"error"})}}async function K(){const t=i.peek().length;if(!t){E.toast("Nothing to dismiss.",{type:"info"});return}if(await E.confirm(`Dismiss all ${t} notification${t===1?"":"s"}? They are hidden for you only.`))try{await b("/system/notifications/dismiss-all",{method:"POST"}),i.value=[],p()}catch(e){E.toast(`Could not dismiss them: ${e.message||e}`,{type:"error"})}}async function j(t){const e=t.data||{},n=[e.title,e.body,e.link].filter(Boolean).join(`
17
+
18
+ `);try{await navigator.clipboard.writeText(n),E.toast("Copied.",{type:"success"})}catch{E.toast("Copying is blocked here - select the text instead.",{type:"warning"})}}const h=t=>{u.value=u.peek()===String(t)?"":String(t)};function W(t){const e=C(t);if(!e)return[];const n=B(e);return[{header:n.title},{label:n.open||u.peek()===n.id?"Close":"Read it",icon:"eye",action:()=>h(t)},...n.link?[{label:"Open link",icon:"external-link",action:()=>window.open(n.link,"_blank","noopener")}]:[],{label:"Copy text",icon:"copy",action:()=>j(e)},e.unread?{label:"Mark as read",icon:"check",action:()=>D(e,!0)}:{label:"Mark as unread",icon:"mail",action:()=>D(e,!1)},{type:"divider"},{label:"Dismiss",icon:"x",action:()=>A(e)},{label:"Delete for everyone\u2026",icon:"trash",danger:!0,action:()=>F(e)}]}const c=a.querySelector(".ntx-list");c.innerHTML=tt;const l=t=>()=>{r.value=t},H=M.applyBindings({items:i,loading:f,loadError:y,filter:r,query:w,rows:x,counts:k,emptyText:_},a,{methods:{showAll:l("all"),showNew:l("new"),showCritical:l("critical"),showWarning:l("warning"),showInfo:l("info"),showSuccess:l("success")}});o.add(()=>H.dispose()),o.add(Y(a,{position:"bottom"}));const V=E.contextMenu(c,{cursor:"glyph",match:".ntx-row",inherit:!1,density:"compact",items:t=>W(t.dataset.id)});o.add(()=>V?.destroy?.());const L=t=>{const e=t.target.closest(".ntx-row");if(!e)return;const n=t.target.closest(".ntx-kebab");if(n){q(n);return}t.target.closest("a")||h(e.dataset.id)},N=t=>{const e=t.target.closest(".ntx-row");if(e){if(t.key==="F10"&&t.shiftKey)t.preventDefault(),q(e);else if(t.key==="Enter"&&t.target===e)h(e.dataset.id);else if(t.key==="Delete"&&t.target===e){const n=C(e.dataset.id);n&&A(n)}}};c.addEventListener("click",L),c.addEventListener("keydown",N),o.add(()=>{c.removeEventListener("click",L),c.removeEventListener("keydown",N)}),O(s,{id:"notifications-dismiss-all",icon:"check-circle",title:"Dismiss all",onClick:()=>K()}),O(s,{id:"notifications-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>{f.value=!0,T()}}),I.scan(a),await T(),m()&&I.scan(a)}};
@@ -1,8 +1,28 @@
1
- import{api as c}from"../api.js";import{carryProjectContext as m,filterByProject as f,getProjectFromHash as y,renderProjectFilterNotice as v}from"../lib/project-context.js";export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(n){const g=E.loader(n.get(0),{type:"dots"});let o=await c.pages.list().catch(()=>[]);g.destroy();const r=y();if(r){const s=o.length;o=f(o,r),m(n,r),v(n,{total:s,shown:o.length,slug:r,allHref:"#/pages",noun:"pages"})}const d=s=>{T.create("#pages-table",{pageSize:25,data:s,columns:[{key:"title",title:"Title",render:(t,e)=>{const a=e.bundled?'<span class="badge badge-outline" style="font-size:0.65rem;padding:1px 6px;color:var(--dm-info,#2563eb);border-color:var(--dm-info,#2563eb);margin-left:.35rem;">Bundled</span>':"",i=e.plugin?`<span class="badge badge-outline" style="font-size:0.65rem;padding:1px 6px;color:var(--dm-warning,#d97706);border-color:var(--dm-warning,#d97706);margin-left:.35rem;" title="Managed by the ${e.plugin} plugin">${e.plugin}</span>`:"";return`<a href="#/pages/edit${e.urlPath}" style="font-weight:600;">${t}</a>${a}${i}`}},{key:"urlPath",title:"URL",render:t=>`<code>${t}</code>`},{key:"layout",title:"Layout"},{key:"status",title:"Status",render:t=>`<span class="badge badge-${t==="published"?"success":"warning"}">${t}</span>`},{key:"tags",title:"Tags",render:t=>Array.isArray(t)&&t.length?t.map(e=>`<span class="badge badge-info badge-pill badge-sm">${e}</span>`).join(" "):"-"},{key:"versionCount",title:"Revisions",render:t=>Number.isFinite(t)&&t>0?`<span class="badge badge-secondary badge-pill badge-sm">${t}</span>`:'<span style="color:var(--dm-text-muted);">0</span>'},{key:"updatedAt",title:"Updated",render:t=>t?D(t).format("DD MMM YYYY"):"-"},{key:"actions",title:"Actions",render:(t,e)=>`
2
- <a href="#/pages/edit${e.urlPath}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit"></span></a>
3
- <a href="${e.urlPath}" target="_blank" class="btn btn-sm btn-ghost" data-tooltip="View"><span data-icon="external-link"></span></a>
4
- <button class="btn btn-sm btn-ghost btn-prune" data-path="${e.urlPath}" data-count="${e.versionCount||0}" data-tooltip="Prune revisions"${(e.versionCount||0)===0?" disabled":""}><span data-icon="archive"></span></button>
5
- <button class="btn btn-sm btn-ghost btn-delete" data-path="${e.urlPath}" data-plugin="${e.plugin||""}" data-tooltip="Delete"><span data-icon="trash"></span></button>
6
- `}],emptyMessage:'No pages found. <a href="#/pages/new">Create one</a>.'}),Domma.icons.scan(),document.querySelectorAll("#pages-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})}),Domma.effects.reveal(".card",{animation:"fade",duration:350})};d(o);const b=s=>{const t=n.find("#pages-tree").empty().get(0);if(!s.length){t.textContent="No pages found.";return}const e=s.map(a=>{const i=a.urlPath.split("/").filter(Boolean),l=i.length>1?"/"+i.slice(0,-1).join("/"):null,p=l&&s.some(h=>h.urlPath===l);return{id:a.urlPath,parent_id:p?l:null,name:a.title||a.urlPath,icon:a.status==="published"?"check-circle":"file-text"}});E.treeView(t,{data:e,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>R.navigate(`/pages/edit${a}`)}),Domma.icons.scan(t)};n.find("#view-table-btn").on("click",function(){n.find("#pages-table").show(),n.find("#pages-tree").hide(),$(this).addClass("btn-primary").removeClass("btn-ghost"),n.find("#view-tree-btn").addClass("btn-ghost").removeClass("btn-primary")}),n.find("#view-tree-btn").on("click",function(){n.find("#pages-table").hide(),n.find("#pages-tree").show(),$(this).addClass("btn-primary").removeClass("btn-ghost"),n.find("#view-table-btn").addClass("btn-ghost").removeClass("btn-primary"),b(o)}),n.find("#view-table-btn, #view-tree-btn").each(function(){E.tooltip(this,{content:this.getAttribute("data-tooltip"),position:"top"})});const u=()=>{const s=n.find("#status-filter").val(),t=n.find("#pages-search").val().toLowerCase().trim(),e=o.filter(a=>!(s&&a.status!==s||t&&!`${a.title} ${a.urlPath} ${(a.tags||[]).join(" ")}`.toLowerCase().includes(t)));d(e)};n.find("#status-filter").off("change").on("change",u),n.find("#pages-search").get(0).addEventListener("input",u),n.off("click",".btn-delete").on("click",".btn-delete",async function(){const s=$(this).data("path"),t=$(this).data("plugin"),e=t?`This page is managed by the <strong>${t}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete page at <strong>${s}</strong>? This cannot be undone.`;if(await E.confirm(e))try{await c.pages.delete(s),E.toast("Page deleted.",{type:"success"}),o=o.filter(a=>a.urlPath!==s),d(o)}catch{E.toast("Failed to delete page.",{type:"error"})}}),n.off("click",".btn-prune").on("click",".btn-prune",async function(){const s=$(this).data("path"),t=Number($(this).data("count"))||0,e=window.prompt(`This page has ${t} revision${t===1?"":"s"}.
7
-
8
- How many of the most recent revisions do you want to keep?`,"5");if(e===null)return;const a=Number.parseInt(e,10);if(!Number.isFinite(a)||a<0){E.toast("Please enter a non-negative number.",{type:"error"});return}try{const i=await c.versions.prune(s,a);E.toast(`Pruned ${i.deleted} revision${i.deleted===1?"":"s"}; kept ${i.kept}.`,{type:"success"});const l=o.find(p=>p.urlPath===s);l&&(l.versionCount=i.kept),d(o)}catch(i){E.toast(i.message||"Failed to prune revisions.",{type:"error"})}})}};
1
+ import{api as K}from"../api.js";import{addBannerAction as be}from"../lib/plugin-chrome.js";import{helpPopovers as X}from"../lib/help-popover.js";import{onceClosed as ye,openMenuAt as Z,settingsStore as we,sidebarCount as ke,viewLifetime as xe}from"../lib/tool-kit.js";import{filterByProject as Me,getProjectFromHash as Pe}from"../lib/project-context.js";import{arrangePages as Ee,checkPrune as Se,normaliseSettings as ee,pageRow as Ce,statusCounts as $e,treeRows as Te}from"../lib/pages-arrange.js";const te=/^#\/(pages|projects\/[^/?#]+\/pages)\/?(\?.*)?$/,Le="#/pages",P="pages.prune.draft",De=`
2
+ <div class="pgx-row" data-path="{{urlPath}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="pgx-main"><strong>{{title}}</strong><small>{{meta}} <span class="pgx-tags">{{tagsText}}</span></small></span>
4
+ <span class="pgx-chips">
5
+ <span class="pgx-chip" data-bind-hidden="!visibility" title="Who can see it">{{visibility}}</span>
6
+ <span class="pgx-chip is-info" data-bind-hidden="!project" title="Project">{{project}}</span>
7
+ <span class="pgx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS">Bundled</span>
8
+ <span class="pgx-chip is-warn" data-bind-hidden="!plugin" title="Managed by this plugin">{{plugin}}</span>
9
+ <span class="pgx-chip" data-bind-class="statusCls">{{statusText}}</span>
10
+ </span>
11
+ <button type="button" class="pgx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
12
+ </div>`,y=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ae(i){const l=document.createElement("template");return l.innerHTML=i.trim(),l.content.firstElementChild}const ae=we("pages.settings",ee),s=xe(te),je=i=>`#/pages/edit${i}`;export const pagesView={templateUrl:"/admin/js/templates/pages.html",async onMount(i){s.reset();const l=i.get(0).querySelector(".pgx")||i.get(0),C=()=>te.test(location.hash)&&l.isConnected,d=Pe(),$=ae.read(),o=M.observable([]),N=M.observable(0),q=M.observable(!0),T=M.observable(""),p=M.observable($.status),L=M.observable($.sort),g=M.observable($.mode),H=M.observable("");s.add(M.effect(()=>ae.write(ee({status:p.value,sort:L.value,mode:g.value}))));const D=M.observable([]);s.add(M.effect(()=>{D.value=Ee(o.value,{status:p.value,query:H.value,sort:L.value})}));const U=M.observable([]);s.add(M.effect(()=>{U.value=D.value.map(e=>Ce(e))}));const ne=M.computed(()=>$e(o.value)),se=M.computed(()=>o.value.length?"No pages match.":"No pages yet - New page in the banner makes one."),oe=M.computed(()=>{const e=N.value;return!d||!e?"":`Showing only pages in ${d}. ${e} other page${e===1?" is":"s are"} filed elsewhere.`}),_=e=>o.peek().find(t=>t.urlPath===e);async function re(){try{const e=await K.pages.list();if(!C())return;const t=Array.isArray(e)?e:[],a=d?Me(t,d):t;N.value=t.length-a.length,o.value=a,T.value=""}catch(e){T.value=`Could not load the pages: ${e.message||e}`}finally{q.value=!1}}const w=e=>{location.hash=je(e)},ie=()=>{location.hash=d?`#/pages/new?project=${encodeURIComponent(d)}`:"#/pages/new"};async function le(e){const t=e.plugin?`"${y(e.title||e.urlPath)}" is managed by the <strong>${y(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it anyway?`:`Delete the page at <strong>${y(e.urlPath)}</strong>? This cannot be undone.`;if(await E.confirm(t))try{await K.pages.delete(e.urlPath),E.toast("Page deleted.",{type:"success"}),o.value=o.peek().filter(a=>a.urlPath!==e.urlPath),ke(Le)}catch(a){E.toast(`Not deleted: ${a.message||a}`,{type:"error"})}}async function ce(e){const t=new URL(e.urlPath,location.origin).href;try{await navigator.clipboard.writeText(t),E.toast("Address copied.",{type:"success",duration:2e3})}catch{E.toast(t,{type:"info",duration:8e3})}}let c=null;const A=new Set;let F=!1;function V(e,t=null){const a=e.versionCount||0,m=M.observable(String(t?.keep??Math.min(5,a))),x=M.observable(!1),f=M.computed(()=>Se(m.value,a)),Y={keep:m,saving:x,err:M.computed(()=>f.value.error),summary:M.computed(()=>{const n=f.value;return n.error?"":n.deletes?`Deletes the ${n.deletes} oldest, keeps the newest ${Math.min(n.keep,a)}.`:"Nothing to delete - that keeps them all."}),cannot:M.computed(()=>x.value||!!f.value.error||f.value.deletes===0),intro:`${e.title||e.urlPath} has ${a} saved revision${a===1?"":"s"} - earlier versions you can compare with and restore from the editor's History.`},he=String(m.peek()),G=()=>String(m.peek())!==he||!!t,r=E.slideover({title:`Trim revisions - ${y(e.title||e.urlPath)}`,size:"sm",position:"right"}),h=Ae(`
13
+ <div class="pgx-form">
14
+ <p class="pgx-note" data-bind-text="intro"></p>
15
+ <div>
16
+ <label class="form-label" for="pgx-f-keep">Keep the newest
17
+ <span data-help="Revisions are kept each time the page is saved. Trimming removes the oldest ones for good; the page itself is not changed. 0 removes them all."></span></label>
18
+ <input id="pgx-f-keep" type="number" min="0" step="1" class="form-input" data-model="keep.value">
19
+ <small class="pgx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
20
+ <small class="pgx-hint" data-bind-hidden="!summary.value" data-bind-text="summary.value"></small>
21
+ </div>
22
+ <div class="pgx-actions">
23
+ <span class="pgx-spacer"></span>
24
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
25
+ <button type="button" class="btn btn-sm btn-danger" data-on-click="trim" data-bind-disabled="cannot.value">
26
+ <span data-icon="archive" data-icon-size="13"></span> Trim</button>
27
+ </div>
28
+ </div>`);async function J(){if(!Y.cannot.peek()){x.value=!0;try{const n=await K.versions.prune(e.urlPath,f.peek().keep);E.toast(`Removed ${n.deleted} revision${n.deleted===1?"":"s"}; kept ${n.kept}.`,{type:"success"}),o.value=o.peek().map(b=>b.urlPath===e.urlPath?{...b,versionCount:n.kept}:b),R=!0,r.close()}catch(n){E.toast(n.message||"Could not trim the revisions.",{type:"error"})}finally{x.value=!1}}}r.element.appendChild(h);const ge=X(h),ve=M.applyBindings(Y,h,{methods:{trim:J,cancel:()=>r.close()}}),me=n=>{n.key==="Enter"&&n.target.id==="pgx-f-keep"&&(n.preventDefault(),J())};h.addEventListener("keydown",me);const Q={path:e.urlPath,dirty:G,values:()=>({keep:m.peek()})};c=Q,A.add(r),ye(r,()=>{ve.dispose(),ge(),A.delete(r),c===Q&&(c=null)});const fe=r.close.bind(r);let R=!1,B=!1;r.close=async n=>{if(!R&&n!==!0&&!F&&G()){if(B)return;B=!0;const b=await E.confirm("Discard the change you have not applied?");if(B=!1,!b)return}return R=!0,fe()},I.scan(h),r.open(),h.querySelector("#pgx-f-keep")?.select()}function de(e){const t=_(e);if(!t)return[];const a=t.versionCount||0;return[{header:t.title||t.urlPath},{label:"Edit",icon:"edit",action:()=>w(t.urlPath)},{label:t.status==="published"?"View on the site":"Preview (draft)",icon:"external-link",action:()=>window.open(t.urlPath,"_blank","noopener")},{label:"Copy address",icon:"copy",action:()=>ce(t)},{type:"divider"},{label:a?`Trim revisions (${a})\u2026`:"No revisions to trim",icon:"archive",disabled:!a,action:()=>V(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>le(t)}]}const u=l.querySelector(".pgx-list");u.innerHTML=De;const pe=M.applyBindings({pages:o,loading:q,loadError:T,status:p,sort:L,mode:g,query:H,rows:U,counts:ne,emptyText:se,projectNote:oe},l,{methods:{showAll:()=>{p.value="all"},showPublished:()=>{p.value="published"},showDrafts:()=>{p.value="draft"},showList:()=>{g.value="list"},showTree:()=>{g.value="tree"}}});s.add(()=>pe.dispose()),s.add(X(l,{position:"bottom"}));const j=l.querySelector(".pgx-tree");let k=null;s.add(M.effect(()=>{const e=g.value==="tree",t=e?Te(D.value):null;try{k?.destroy?.()}catch{}k=null,j.replaceChildren(),!(!e||!t.length)&&(k=E.treeView(j,{data:t,idKey:"id",parentKey:"parent_id",labelKey:"name",iconKey:"icon",expandedByDefault:!0,onSelect:a=>w(a)}),I.scan(j))})),s.add(()=>{try{k?.destroy?.()}catch{}});const ue=E.contextMenu(u,{cursor:"glyph",match:".pgx-row",inherit:!1,density:"compact",items:e=>de(e.dataset.path)});s.add(()=>ue?.destroy?.());const O=e=>{const t=e.target.closest(".pgx-row");if(!t)return;const a=e.target.closest(".pgx-kebab");if(a){Z(a);return}w(t.dataset.path)},z=e=>{const t=e.target.closest(".pgx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Z(t)):e.key==="Enter"&&e.target===t&&w(t.dataset.path))};u.addEventListener("click",O),u.addEventListener("keydown",z),s.add(()=>{u.removeEventListener("click",O),u.removeEventListener("keydown",z)}),be(i,{id:"pages-new",icon:"plus",title:"New page",onClick:ie});const W=e=>{c?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",W),s.add(()=>window.removeEventListener("beforeunload",W)),s.add(()=>{if(c?.dirty())try{S.set(P,{path:c.path,values:c.values(),at:new Date().toISOString()})}catch{}F=!0,[...A].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await re(),!C())return;let v=null;try{v=S.get(P)}catch{}if(v?.path&&v.values){try{S.remove?S.remove(P):S.set(P,null)}catch{}const e=_(v.path);e&&(e.versionCount||0)>0&&await E.confirm(`You left trimming the revisions of "${y(e.title||e.urlPath)}" unfinished. Pick up where you left off?`)&&C()&&V(e,v.values)}}};