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,7 +1,11 @@
1
- import{api as d}from"../api.js";import{filterByProject as p,getProjectFromHash as c,renderProjectFilterNotice as r}from"../lib/project-context.js";import{keepTableIconsAlive as b}from"../lib/table-icons.js";function n(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function g(a){return a==="public"?'<span class="badge badge-warning">Public</span>':a==="token"?'<span class="badge badge-info">Token</span>':`<span class="badge badge-secondary">${n(a)}</span>`}export const apiEndpointsView={templateUrl:"/admin/js/templates/api-endpoints.html",async onMount(a){const l=E.loader(a.get(0),{type:"dots"});let o=await d.apiEndpoints.list().catch(()=>[]);l.destroy();const s=c();if(s){const e=o.length;o=p(o,s),r(a,{total:e,shown:o.length,slug:s,allHref:"#/api-endpoints",noun:"endpoints"})}T.create("#api-endpoints-table",{pageSize:25,data:o,emptyMessage:'No endpoints yet. Click "New endpoint" to build one.',columns:[{key:"name",title:"Name",sortable:!0},{key:"path",title:"URL",render:(e,t)=>`<code>/api/x/${n(t.project)}${n(t.path)}</code>`},{key:"collection",title:"Collection",sortable:!0,render:e=>`<code>${n(e)}</code>`},{key:"mode",title:"Mode",render:e=>n(e)},{key:"auth",title:"Auth",render:e=>g(e)},{key:"enabled",title:"Status",render:e=>e?'<span class="badge badge-success">Enabled</span>':'<span class="badge badge-secondary">Disabled</span>'},{key:"_actions",title:"Actions",render:(e,t)=>`
2
- <span style="display:inline-flex;gap:0.25rem;">
3
- <a class="btn btn-sm btn-ghost" href="#/api-endpoints/edit/${encodeURIComponent(t.id)}" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
4
- <button class="btn btn-sm btn-ghost btn-toggle-endpoint" data-id="${n(t.id)}" data-enabled="${t.enabled?"1":""}" data-tooltip="${t.enabled?"Disable":"Enable"}"><span data-icon="${t.enabled?"pause":"play"}" data-icon-size="14"></span></button>
5
- <button class="btn btn-sm btn-ghost btn-delete-endpoint" data-id="${n(t.id)}" data-name="${n(t.name)}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
6
- </span>
7
- `}]}),Domma.icons.scan(a.get(0)),b(a.get(0)),document.querySelectorAll("#api-endpoints-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-toggle-endpoint").on("click",".btn-toggle-endpoint",async function(){const e=$(this).data("id"),t=!!$(this).data("enabled");try{await d.apiEndpoints.update(e,{enabled:!t}),E.toast(`Endpoint ${t?"disabled":"enabled"}.`,{type:"success"}),location.reload()}catch(i){E.toast(`Update failed: ${i.message||i}`,{type:"error"})}}),a.off("click",".btn-delete-endpoint").on("click",".btn-delete-endpoint",async function(){const e=$(this).data("id"),t=$(this).data("name");if(await E.confirm(`Delete endpoint "${t}"? External callers using it will immediately get 404s. This cannot be undone.`))try{await d.apiEndpoints.remove(e),E.toast("Endpoint deleted.",{type:"success"}),location.reload()}catch(i){E.toast(`Delete failed: ${i.message||i}`,{type:"error"})}})}};
1
+ import{apiRequest as y}from"../api.js";import{addBannerAction as z}from"../lib/plugin-chrome.js";import{helpPopovers as Q}from"../lib/help-popover.js";import{openMenuAt as D,sidebarCount as P,viewLifetime as X}from"../lib/tool-kit.js";import{getProjectFromHash as Z}from"../lib/project-context.js";import{FILTERS as ee,arrangeEndpoints as te,countEndpoints as ne,endpointRow as g,endpointUrl as q,pathParams as oe,projectsOf as ae}from"../lib/api-endpoints-arrange.js";const O=/^#\/(api-endpoints|projects\/[^/?#]+\/apis)\/?(\?.*)?$/,N="#/api-endpoints",w="api-endpoints.filter",se=`
2
+ <div class="apx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="apx-verb" title="Endpoints answer GET requests">GET</span>
4
+ <span class="apx-main"><strong>{{name}}</strong><code title="{{url}}">{{url}}</code><small>{{meta}}</small></span>
5
+ <span class="apx-chips">
6
+ <span class="apx-chip" title="Project - the first part of its URL">{{project}}</span>
7
+ <span class="apx-chip" data-bind-class="authCls" title="Who may call it">{{auth}}</span>
8
+ <span class="apx-chip is-off" data-bind-hidden="!off" title="Answers 404 until enabled">Disabled</span>
9
+ </span>
10
+ <button type="button" class="apx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,f=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),c=X(O);async function ie(l){try{return await navigator.clipboard.writeText(l),!0}catch{const o=document.createElement("textarea");o.value=l,o.setAttribute("readonly",""),o.style.cssText="position:fixed;left:-9999px;top:0;",document.body.appendChild(o),o.select();let m=!1;try{m=document.execCommand("copy")}catch{}return o.remove(),m}}export const apiEndpointsView={templateUrl:"/admin/js/templates/api-endpoints.html",async onMount(l){c.reset();const o=l.get(0).querySelector(".apx")||l.get(0),m=()=>O.test(location.hash)&&o.isConnected,s=/^#\/projects\//.test(location.hash)?Z():null,a=M.observable([]),x=M.observable(!0),v=M.observable("");let C="all";try{C=ee.includes(S.get(w))?S.get(w):"all"}catch{}const r=M.observable(C),b=M.observable(s||""),k=M.observable("");c.add(M.effect(()=>{const e=r.value;try{S.set(w,e)}catch{}}));const d=M.computed(()=>s?a.value.filter(e=>e.project===s):a.value),$=M.observable([]);c.add(M.effect(()=>{$.value=te(d.value,{filter:r.value,project:b.value,query:k.value}).map(g)}));const B=M.computed(()=>ne(d.value)),F=M.computed(()=>d.value.length?"No endpoints match.":s?"This project has no endpoints yet - New endpoint in the banner makes one.":"No endpoints yet - New endpoint in the banner builds one."),H=M.computed(()=>s?`Showing the ${d.value.length} endpoint${d.value.length===1?"":"s"} of the "${s}" project (of ${a.value.length}).`:""),j=e=>a.peek().find(t=>t.id===e);async function _(){try{const e=await y("/api-endpoints",{method:"GET"});if(!m())return;a.value=Array.isArray(e)?e:[],v.value=""}catch(e){v.value=`Could not load the endpoints: ${e.message||e}`}finally{x.value=!1}}const T=e=>`#/api-endpoints/edit/${encodeURIComponent(e)}`,G=()=>s?`#/api-endpoints/new?project=${encodeURIComponent(s)}`:"#/api-endpoints/new";async function L(e,t){if(!(!t&&!await E.confirm(`Disable "${f(e.name)}"? Callers of ${f(g(e).url)} get 404s until it is enabled again.`)))try{const n=await y(`/api-endpoints/${encodeURIComponent(e.id)}`,{method:"PUT",body:JSON.stringify({enabled:t})});a.value=a.peek().map(i=>i.id===e.id?{...i,...n||{},enabled:t}:i),E.toast(`"${e.name}" ${t?"enabled":"disabled"}.`,{type:"success"}),P(N)}catch(n){E.toast(`Not changed: ${n.message||n}`,{type:"error"})}}async function K(e){if(await E.confirm(`Delete the endpoint "${f(e.name)}"? Callers of ${f(g(e).url)} get 404s at once. This cannot be undone.`))try{await y(`/api-endpoints/${encodeURIComponent(e.id)}`,{method:"DELETE"}),a.value=a.peek().filter(t=>t.id!==e.id),E.toast(`Deleted "${e.name}".`,{type:"success"}),P(N)}catch(t){E.toast(`Delete failed: ${t.message||t}`,{type:"error"})}}async function W(e){const t=q(e,location.origin);E.toast(await ie(t)?`Copied ${t}`:`Copy this URL: ${t}`,{type:"success"})}function J(e){const t=j(e);if(!t)return[];const n=t.enabled===!1,i=oe(t.path).length>0,u=!n&&t.auth==="public"&&!i;return[{header:t.name||t.path},{label:"Open in the builder",icon:"edit",action:()=>{location.hash=T(t.id)}},{label:"Copy URL",icon:"copy",action:()=>W(t)},{label:i?"Open in a new tab (needs params)":t.auth==="public"?"Open in a new tab":"Open in a new tab (not public)",icon:"external-link",disabled:!u,action:()=>window.open(q(t,location.origin),"_blank","noopener")},{type:"divider"},n?{label:"Enable",icon:"play",action:()=>L(t,!0)}:{label:"Disable\u2026",icon:"pause",action:()=>L(t,!1)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>K(t)}]}const p=o.querySelector(".apx-list");p.innerHTML=se;const h=o.querySelector(".apx-project");c.add(M.effect(()=>{const e=ae(a.value),t=b.peek();h.replaceChildren();const n=document.createElement("option");n.value="",n.textContent="All projects",h.appendChild(n);for(const i of e){const u=document.createElement("option");u.value=i,u.textContent=i,h.appendChild(u)}h.value=e.includes(t)?t:"",!s&&t&&!e.includes(t)&&(b.value="")}));const V=M.applyBindings({endpoints:a,loading:x,loadError:v,filter:r,project:b,query:k,rows:$,counts:B,emptyText:F,scopeText:H,projectLocked:M.observable(!!s)},o,{methods:{showAll:()=>{r.value="all"},showEnabled:()=>{r.value="enabled"},showDisabled:()=>{r.value="disabled"},showPublic:()=>{r.value="public"}}});c.add(()=>V.dispose()),c.add(Q(o,{position:"bottom"}));const Y=E.contextMenu(p,{cursor:"glyph",match:".apx-row",inherit:!1,density:"compact",items:e=>J(e.dataset.id)});c.add(()=>Y?.destroy?.());const R=e=>{j(e)&&(location.hash=T(e))},U=e=>{const t=e.target.closest(".apx-row");if(!t)return;const n=e.target.closest(".apx-kebab");if(n){D(n);return}R(t.dataset.id)},A=e=>{const t=e.target.closest(".apx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),D(t)):e.key==="Enter"&&e.target===t&&R(t.dataset.id))};p.addEventListener("click",U),p.addEventListener("keydown",A),c.add(()=>{p.removeEventListener("click",U),p.removeEventListener("keydown",A)}),z(l,{id:"api-endpoints-new",icon:"plus",title:"New endpoint",onClick:()=>{location.hash=G()}}),I.scan(o),await _()}};
@@ -1,8 +1,62 @@
1
- import{api as m}from"../api.js";import{keepTableIconsAlive as g}from"../lib/table-icons.js";function d(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function f(a){return!Array.isArray(a)||a.length===0?'<span class="text-muted">All collections in project</span>':d(a.map(l=>{const r=Array.isArray(l.verbs)&&l.verbs.length?l.verbs.join(","):"all";return`${l.collection}: ${r}`}).join("; "))}function h(a){const l=[];for(const r of(a||"").split(`
2
- `)){const n=r.trim();if(!n)continue;const[o,e]=n.split(":").map(s=>s.trim());if(!o)return{error:`Invalid scope line: "${n}"`};const t={collection:o};if(e){const s=e.split(",").map(i=>i.trim()).filter(Boolean),c=s.find(i=>!["create","read","update","delete"].includes(i));if(c)return{error:`Unknown verb "${c}" in scope "${n}"`};t.verbs=s}l.push(t)}return{scopes:l}}function b(a,l,r){const n=document.createElement("div");n.style.marginBottom=".75rem";const o=document.createElement("label");if(o.className="form-label",o.textContent=a,n.appendChild(o),n.appendChild(l),r){const e=document.createElement("p");e.className="text-muted",e.style.cssText="font-size:.8rem;margin:.25rem 0 0;",e.textContent=r,n.appendChild(e)}return n}function v(a,l){const r=E.slideover({title:"Token created",size:"sm",position:"right"}),n=document.createElement("div");n.style.padding="1rem";const o=document.createElement("p");o.innerHTML="<strong>Copy this token now.</strong> It is shown once and cannot be recovered - only a hash is stored.",o.style.marginBottom=".75rem",n.appendChild(o);const e=document.createElement("code");e.textContent=a,e.style.cssText="display:block;padding:.6rem;word-break:break-all;user-select:all;margin-bottom:.75rem;",n.appendChild(e);const t=document.createElement("button");t.className="btn btn-primary",t.textContent="Copy to clipboard",t.style.marginRight=".5rem",t.addEventListener("click",async()=>{try{await navigator.clipboard.writeText(a),t.textContent="Copied!"}catch{E.toast("Copy failed - select the token text manually.",{type:"error"})}}),n.appendChild(t);const s=document.createElement("button");s.className="btn btn-secondary",s.textContent="Done",s.addEventListener("click",()=>{r.close(),l()}),n.appendChild(s),r.element.appendChild(n),r.open()}function x(a,l){const r=E.slideover({title:"New API Token",size:"md",position:"right"}),n=document.createElement("div");n.style.padding=".5rem 1rem 1rem";const o=document.createElement("input");o.type="text",o.className="form-input",o.placeholder="e.g. mobile-app, ci-pipeline";const e=document.createElement("select");e.className="form-input";for(const i of a){const p=document.createElement("option");p.value=i.slug,p.textContent=`${i.name} (${i.slug})`,e.appendChild(p)}const t=document.createElement("input");t.type="datetime-local",t.className="form-input";const s=document.createElement("textarea");s.className="form-input",s.rows=3,s.placeholder=`jobs: read
3
- enquiries: create, read`,n.appendChild(b("Name",o)),n.appendChild(b("Project",e,"The token only works on collections in this project.")),n.appendChild(b("Expires (optional)",t,"Leave empty for a non-expiring token.")),n.appendChild(b("Scopes (optional)",s,'One per line: "collection: verb, verb". Leave empty to allow every collection in the project.'));const c=document.createElement("button");c.className="btn btn-primary",c.textContent="Create token",c.addEventListener("click",async()=>{const i=o.value.trim();if(!i){E.toast("A token name is required.",{type:"error"});return}const{scopes:p,error:y}=h(s.value);if(y){E.toast(y,{type:"error"});return}const k={name:i,project:e.value,scopes:p,expiresAt:t.value?new Date(t.value).toISOString():null};try{c.disabled=!0;const{plaintext:u}=await m.apiTokens.create(k);r.close(),v(u,l)}catch(u){c.disabled=!1,E.toast(`Create failed: ${u.message||u}`,{type:"error"})}}),n.appendChild(c),r.element.appendChild(n),r.open(),setTimeout(()=>o.focus(),100)}export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(a){const l=E.loader(a.get(0),{type:"dots"}),[r,n]=await Promise.all([m.apiTokens.list().catch(()=>[]),m.projects.list().catch(()=>[])]);l.destroy(),T.create("#api-tokens-table",{pageSize:25,data:r,emptyMessage:'No API tokens yet. Click "New token" to create one.',columns:[{key:"name",title:"Name",sortable:!0},{key:"project",title:"Project",sortable:!0,render:e=>`<code>${d(e)}</code>`},{key:"tokenHint",title:"Token",render:e=>`<code>dcms_\u2026${d(e||"????")}</code>`},{key:"scopes",title:"Scopes",render:e=>f(e)},{key:"enabled",title:"Status",render:(e,t)=>t.expiresAt&&Date.parse(t.expiresAt)<Date.now()?'<span class="badge badge-warning">Expired</span>':e?'<span class="badge badge-success">Enabled</span>':'<span class="badge badge-secondary">Disabled</span>'},{key:"expiresAt",title:"Expires",render:e=>e?D(e).format("D MMM YYYY, HH:mm"):'<span class="text-muted">-</span>'},{key:"lastUsedAt",title:"Last used",render:e=>e?D(e).format("D MMM YYYY, HH:mm"):'<span class="text-muted">Never</span>'},{key:"_actions",title:"Actions",render:(e,t)=>`
4
- <span style="display:inline-flex;gap:0.25rem;">
5
- <button class="btn btn-sm btn-ghost btn-toggle-token" data-id="${d(t.id)}" data-enabled="${t.enabled?"1":""}" data-tooltip="${t.enabled?"Disable":"Enable"}"><span data-icon="${t.enabled?"pause":"play"}" data-icon-size="14"></span></button>
6
- <button class="btn btn-sm btn-ghost btn-revoke-token" data-id="${d(t.id)}" data-name="${d(t.name)}" data-tooltip="Revoke"><span data-icon="trash" data-icon-size="14"></span></button>
7
- </span>
8
- `}]}),Domma.icons.scan(a.get(0)),g(a.get(0)),document.querySelectorAll("#api-tokens-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})});const o=()=>location.reload();a.off("click","#btn-new-token").on("click","#btn-new-token",()=>{if(!n.length){E.toast("No projects available - create a project first.",{type:"error"});return}x(n,o)}),a.off("click",".btn-toggle-token").on("click",".btn-toggle-token",async function(){const e=$(this).data("id"),t=!!$(this).data("enabled");try{await m.apiTokens.update(e,{enabled:!t}),E.toast(`Token ${t?"disabled":"enabled"}.`,{type:"success"}),o()}catch(s){E.toast(`Update failed: ${s.message||s}`,{type:"error"})}}),a.off("click",".btn-revoke-token").on("click",".btn-revoke-token",async function(){const e=$(this).data("id"),t=$(this).data("name");if(await E.confirm(`Revoke token "${t}"? External callers using it will immediately lose access. This cannot be undone.`))try{await m.apiTokens.revoke(e),E.toast("Token revoked.",{type:"success"}),o()}catch(s){E.toast(`Revoke failed: ${s.message||s}`,{type:"error"})}})}};
1
+ import{api as g}from"../api.js";import{addBannerAction as Ce}from"../lib/plugin-chrome.js";import{helpPopovers as ie}from"../lib/help-popover.js";import{onceClosed as le,openMenuAt as re,sidebarCount as Me,viewLifetime as Le}from"../lib/tool-kit.js";import{FILTERS as Ae,arrangeTokens as $e,checkToken as Ne,countStates as Oe,scopesToText as Ie,toLocalInput as qe,tokenRow as Pe,tokenState as ce}from"../lib/api-tokens-arrange.js";const de=/^#\/api-tokens\/?(\?.*)?$/,Re="#/api-tokens",R="api-tokens.draft",H="api-tokens.filter",De=`
2
+ <div class="atx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="atx-icon" aria-hidden="true"><span data-icon="key" data-icon-size="16"></span></span>
4
+ <span class="atx-main"><strong>{{name}}</strong><small><code>{{hint}}</code> \xB7 {{meta}}</small></span>
5
+ <span class="atx-chips">
6
+ <span class="atx-chip" title="The project it works in">{{project}}</span>
7
+ <span class="atx-chip" data-bind-class="stateCls" data-bind-text="stateLabel"></span>
8
+ </span>
9
+ <span class="atx-used" data-bind-text="used"></span>
10
+ <button type="button" class="atx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,A=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function pe(c){const d=document.createElement("template");return d.innerHTML=c.trim(),d.content.firstElementChild}async function ue(c){try{return await navigator.clipboard.writeText(c),!0}catch{return!1}}const l=Le(de);export const apiTokensView={templateUrl:"/admin/js/templates/api-tokens.html",async onMount(c){l.reset();const d=c.get(0).querySelector(".atx")||c.get(0),$=()=>de.test(location.hash)&&d.isConnected,w=M.observable([]),j=M.observable([]),D=M.observable(!0),_=M.observable("");let U="all";try{U=Ae.includes(S.get(H))?S.get(H):"all"}catch{}const r=M.observable(U),b=M.observable(""),Y=M.observable("");l.add(M.effect(()=>{const e=r.value;try{S.set(H,e)}catch{}}));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=$e(w.value,{filter:r.value,project:b.value,query:Y.value},e).map(t=>Pe(t,e))}));const V=M.computed(()=>Oe(w.value)),fe=M.computed(()=>w.value.length?"No tokens match.":"No API tokens yet - New token in the banner makes one."),T=e=>w.peek().find(t=>t.id===e);async function J(){try{const[e,t]=await Promise.all([g.apiTokens.list(),g.projects.list().catch(()=>[])]);if(!$())return;w.value=Array.isArray(e)?e:[],j.value=Array.isArray(t)?t:[],_.value="",ke()}catch(e){_.value=`Could not load the tokens: ${e.message||e}`}finally{D.value=!1}}const N=async()=>{Me(Re),$()&&await J()};async function W(e,t){if(!(!t&&!await E.confirm(`Switch off "${A(e.name)}"? Callers using it are refused until it is switched back on.`)))try{await g.apiTokens.update(e.id,{enabled:t}),E.toast(`"${e.name}" ${t?"switched on":"switched off"}.`,{type:"success"}),await N()}catch(a){E.toast(`Not changed: ${a.message||a}`,{type:"error"})}}async function me(e){if(await E.confirm(`Revoke "${A(e.name)}"? Anything using it loses access at once. This cannot be undone - a new token would have a different value.`))try{await g.apiTokens.revoke(e.id),E.toast(`Revoked "${e.name}".`,{type:"success"}),await N()}catch(t){E.toast(`Not revoked: ${t.message||t}`,{type:"error"})}}let h=null,y=null;const C=new Set;let ve=!1;function G(e,t,a){const o=e.close.bind(e);let s=!1,u=!1;return e.close=async v=>{if(!s&&v!==!0&&t()){if(u)return;u=!0;const O=await E.confirm(a);if(u=!1,!O)return}return s=!0,o()},()=>{s=!0}}function L(e=null,t=null){const a=e?T(e):null;if(e&&!a){E.toast("That token is no longer there.",{type:"warning"});return}const o=!a,s=j.peek();if(o&&!s.length){E.toast("Make a project first - a token works inside one.",{type:"warning"});return}const u={name:a?.name??"",project:a?.project??(b.peek()||s[0]?.slug||""),expires:qe(a?.expiresAt),scopes:Ie(a?.scopes),enabled:a?a.enabled!==!1:!0,...t||{}},v=Object.fromEntries(Object.entries(u).map(([n,i])=>[n,M.observable(i)])),O=n=>JSON.stringify(Object.fromEntries(Object.entries(n).map(([i,z])=>[i,typeof z=="boolean"?z:String(z??"")]))),ee=()=>Object.fromEntries(Object.keys(v).map(n=>[n,v[n].peek()])),ge=O(u),te=()=>O(ee())!==ge||!!t,q=M.observable(!1),K=M.computed(()=>Ne(Object.fromEntries(Object.keys(v).map(n=>[n,v[n].value])),{isNew:o,projects:j.value.map(n=>n.slug)})),P=n=>M.computed(()=>K.value.errors[n]||""),Ee=a?ce(a):null,ae={...v,saving:q,isNew:o,notNew:!o,nameErr:P("name"),projectErr:P("project"),scopesErr:P("scopes"),expiresErr:P("expires"),cannot:M.computed(()=>q.value||Object.keys(K.value.errors).length>0),saveText:o?"Create token":"Save",fixedProject:a?`${a.project} - fixed for the token's life (revoke and make a new one to move it)`:"",hint:a?`dcms_\u2026${a.tokenHint||"????"}${Ee?.state==="expired"?" - expired: set a later date to bring it back":""}`:""},f=E.slideover({title:o?"New API token":`Edit ${A(a.name)}`,size:"md",position:"right"}),x=pe(`
12
+ <div class="atx-form">
13
+ <div>
14
+ <label class="form-label" for="atx-f-name">Name <span class="text-danger">*</span></label>
15
+ <input id="atx-f-name" type="text" class="form-input" maxlength="80" placeholder="mobile-app, ci-pipeline\u2026" data-model="name.value">
16
+ <small class="atx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
17
+ </div>
18
+ <div data-bind-hidden="notNew">
19
+ <label class="form-label" for="atx-f-project">Project
20
+ <span data-help="The token works only on collections in this project (and API Builder endpoints under it). It cannot be moved later."></span></label>
21
+ <select id="atx-f-project" class="form-input" data-model="project.value"></select>
22
+ <small class="atx-err" data-bind-hidden="!projectErr.value" data-bind-text="projectErr.value"></small>
23
+ </div>
24
+ <div data-bind-hidden="isNew">
25
+ <label class="form-label">Token</label>
26
+ <div class="atx-fixed"><code data-bind-text="hint"></code><br><span data-bind-text="fixedProject"></span></div>
27
+ </div>
28
+ <div>
29
+ <label class="form-label" for="atx-f-scopes">Scopes
30
+ <span data-help-title="Scopes" data-help="Leave empty and the token may use every collection in its project that allows token access. Otherwise one line per collection: the collection's slug, then a colon and the verbs it may use - create, read, update, delete. No verbs means all four. For example: jobs: read"></span></label>
31
+ <textarea id="atx-f-scopes" class="form-input font-mono" rows="4" placeholder="jobs: read&#10;enquiries: create, read" data-model="scopes.value"></textarea>
32
+ <small class="atx-err" data-bind-hidden="!scopesErr.value" data-bind-text="scopesErr.value"></small>
33
+ </div>
34
+ <div>
35
+ <label class="form-label" for="atx-f-expires">Expires
36
+ <span data-help="Leave empty for a token that never expires. From this moment on, calls with it are refused - the sidebar badge warns two weeks ahead."></span></label>
37
+ <input id="atx-f-expires" type="datetime-local" class="form-input" data-model="expires.value">
38
+ <small class="atx-err" data-bind-hidden="!expiresErr.value" data-bind-text="expiresErr.value"></small>
39
+ </div>
40
+ <div class="atx-setting" data-bind-hidden="isNew">
41
+ <label class="form-label" for="atx-f-enabled">Switched on
42
+ <span data-help="Off refuses every call with the token without losing it - switch it back on to carry on. Revoke (from the list) removes it for good."></span></label>
43
+ <input id="atx-f-enabled" type="checkbox" class="form-switch" data-model="enabled.value">
44
+ </div>
45
+ <div class="atx-actions">
46
+ <small class="atx-hint">Ctrl+Enter saves.</small>
47
+ <span class="atx-spacer"></span>
48
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
49
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
50
+ <span data-icon="check" data-icon-size="13"></span> <span data-bind-text="saveText"></span></button>
51
+ </div>
52
+ </div>`),ne=x.querySelector("#atx-f-project");for(const n of s){const i=document.createElement("option");i.value=n.slug,i.textContent=`${n.name||n.slug} (${n.slug})`,ne.appendChild(i)}let B=()=>{};async function oe(){if(ae.cannot.peek())return;q.value=!0;const{payload:n}=K.peek();try{if(o){const i=await g.apiTokens.create(n);B(),f.close(),await N(),be(i?.plaintext,n.name)}else await g.apiTokens.update(a.id,n),E.toast("Saved.",{type:"success"}),B(),f.close(),await N()}catch(i){E.toast(`Not saved: ${i.message||i}`,{type:"error"})}finally{q.value=!1}}f.element.appendChild(x);const je=ie(x),Se=M.applyBindings(ae,x,{methods:{save:oe,cancel:()=>f.close()}});ne.value=v.project.peek();const Te=n=>{(n.ctrlKey||n.metaKey)&&n.key==="Enter"&&(n.preventDefault(),oe())};x.addEventListener("keydown",Te);const se={id:a?.id??null,dirty:te,values:ee};h=se,C.add(f),le(f,()=>{Se.dispose(),je(),C.delete(f),h===se&&(h=null)}),B=G(f,te,"Discard the changes you have not saved?"),I.scan(x),f.open(),x.querySelector("#atx-f-name")?.focus()}function be(e,t){if(!e){E.toast("The token was made, but its value did not come back - revoke it and make another.",{type:"error"});return}let a=!1;const o=E.slideover({title:"Token created",size:"sm",position:"right"}),s=pe(`
53
+ <div class="atx-secret">
54
+ <p class="atx-warn"><strong>Copy it now.</strong> This is the only time it is shown - only a fingerprint is kept. If it is lost, revoke it and make another.</p>
55
+ <code class="atx-token"></code>
56
+ <div class="atx-actions">
57
+ <small class="atx-hint copied-note" hidden>Copied.</small>
58
+ <span class="atx-spacer"></span>
59
+ <button type="button" class="btn btn-sm btn-primary atx-copy"><span data-icon="copy" data-icon-size="13"></span> Copy</button>
60
+ <button type="button" class="btn btn-sm btn-ghost atx-done">Done</button>
61
+ </div>
62
+ </div>`);s.querySelector(".atx-token").textContent=e,s.querySelector(".atx-copy").addEventListener("click",async()=>{await ue(e)?(a=!0,s.querySelector(".copied-note").hidden=!1):E.toast("Copy failed - select the token and copy it by hand.",{type:"error"})}),s.querySelector(".atx-done").addEventListener("click",()=>o.close()),o.element.appendChild(s);const u={copied:()=>a};o.__dmSecret=!0,y=u,C.add(o),le(o,()=>{C.delete(o),y===u&&(y=null),e=null}),G(o,()=>!a,`You have not copied the token for "${A(t)}". Once this closes it can never be shown again. Close anyway?`),I.scan(s),o.open()}function he(e){const t=T(e);if(!t)return[];const{state:a}=ce(t);return[{header:t.name||"Token"},{label:"Edit\u2026",icon:"edit",action:()=>L(t.id)},t.enabled===!1?{label:"Switch on",icon:"play",action:()=>W(t,!0)}:{label:"Switch off\u2026",icon:"pause",disabled:a==="expired",action:()=>W(t,!1)},{type:"divider"},{label:"Copy its ending",icon:"copy",action:async()=>{E.toast(await ue(`dcms_\u2026${t.tokenHint||""}`)?"Copied the ending - the token itself is never shown again.":"Copy failed.",{type:"info"})}},{label:`Show only ${t.project}`,icon:"filter",disabled:b.peek()===t.project,action:()=>{b.value=t.project,ye()}},{type:"divider"},{label:"Revoke\u2026",icon:"trash",danger:!0,action:()=>me(t)}]}const m=d.querySelector(".atx-list");m.innerHTML=De;const k=d.querySelector(".atx-project"),ye=()=>{k&&(k.value=b.peek())};function ke(){if(!k)return;const e=b.peek();for(;k.options.length>1;)k.remove(1);for(const t of j.peek()){const a=document.createElement("option");a.value=t.slug,a.textContent=t.name||t.slug,k.appendChild(a)}k.value=e}const xe=M.applyBindings({tokens:w,projects:j,loading:D,loadError:_,filter:r,project:b,query:Y,rows:F,counts:V,emptyText:fe},d,{methods:{showAll:()=>{r.value="all"},showActive:()=>{r.value="active"},showExpiring:()=>{r.value="expiring"},showExpired:()=>{r.value="expired"},showDisabled:()=>{r.value="disabled"}}});l.add(()=>xe.dispose()),l.add(ie(d,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})})),l.add(M.effect(()=>{const e=V.value,t=r.peek();t!=="all"&&!D.value&&!e[t]&&(r.value="all")}));const we=E.contextMenu(m,{cursor:"glyph",match:".atx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.id)});l.add(()=>we?.destroy?.());const Q=e=>{const t=e.target.closest(".atx-row");if(!t)return;const a=e.target.closest(".atx-kebab");if(a){re(a);return}L(t.dataset.id)},X=e=>{const t=e.target.closest(".atx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(t)):e.key==="Enter"&&e.target===t&&L(t.dataset.id))};m.addEventListener("click",Q),m.addEventListener("keydown",X),l.add(()=>{m.removeEventListener("click",Q),m.removeEventListener("keydown",X)}),Ce(c,{id:"api-tokens-new",icon:"plus",title:"New token",onClick:()=>L()});const Z=e=>{(h?.dirty()||y&&!y.copied())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Z),l.add(()=>window.removeEventListener("beforeunload",Z)),l.add(()=>{if(h?.dirty())try{S.set(R,{id:h.id,values:h.values(),at:new Date().toISOString()})}catch{}ve=!0,[...C].forEach(e=>{if(!(y&&e.__dmSecret&&!y.copied()))try{e.close(!0)}catch{}})}),I.scan(d),await J(),!$())return;let p=null;try{p=S.get(R)}catch{}if(p?.values){try{S.remove?S.remove(R):S.set(R,null)}catch{}const e=p.id&&T(p.id)?`your changes to "${A(T(p.id).name)}"`:"the new token you started";await E.confirm(`You left ${e} unsaved. Pick up where you left off?`)&&$()&&L(p.id&&T(p.id)?p.id:null,p.values)}}};
@@ -1,5 +1,34 @@
1
- import{api as i}from"../api.js";import{filterByProject as m,getProjectFromHash as u,renderProjectFilterNotice as f}from"../lib/project-context.js";import{keepTableIconsAlive as k}from"../lib/table-icons.js";function d(e){return String(e??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function b(e){for(;e.firstChild;)e.removeChild(e.firstChild)}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(e){h(),await l(e),g(e),Domma.icons.scan(e.get(0)),k(e.get(0))}};let p=!1;function h(){p||(p=!0,I.register("download",{viewBox:"0 0 24 24",paths:["M12 3v12","M7 10l5 5 5-5","M5 19h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("upload",{viewBox:"0 0 24 24",paths:["M12 21V9","M7 14l5-5 5 5","M5 5h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("plus",{viewBox:"0 0 24 24",paths:["M12 5v14","M5 12h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}))}async function l(e){const r=e.find("#blocks-table-container").get(0);if(!r)return;let o=[];const n=E.loader(e.get(0),{type:"dots"});try{o=await i.blocks.list()}catch(t){b(r);const s=document.createElement("p");s.className="text-muted",s.textContent=`Failed to load blocks: ${t.message}`,r.appendChild(s);return}finally{n.destroy()}const a=u();if(a){const t=o.length;o=m(o,a),f(e,{total:t,shown:o.length,slug:a,allHref:"#/blocks",noun:"blocks"})}T.create(r,{pageSize:25,data:o,emptyMessage:'No blocks yet. Click "New Block" to create your first template.',columns:[{key:"name",title:"Name",render:t=>`<a href="#/blocks/edit/${d(t)}">${d(t)}.html</a>`},{key:"size",title:"Size",render:t=>`${t} B`},{key:"updatedAt",title:"Updated",render:t=>t?new Date(t).toLocaleString():"-"},{key:"name",title:"Actions",render:t=>{const s=d(t);return`<div style="display:flex;gap:.4rem;justify-content:flex-end;">
2
- <a href="#/blocks/edit/${s}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- <button class="btn btn-sm btn-ghost js-export-block" data-name="${s}" data-tooltip="Export as .dmblock.json"><span data-icon="download" data-icon-size="14"></span></button>
4
- <button class="btn btn-sm btn-ghost js-delete-block" data-name="${s}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
5
- </div>`}}]}),e.off("click",".js-delete-block").on("click",".js-delete-block",async function(){await y(this.dataset.name,e)}),e.off("click",".js-export-block").on("click",".js-export-block",async function(){try{await i.blocks.exportBundle(this.dataset.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}),Domma.icons.scan(r),r.querySelectorAll("[data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})})}async function y(e,r){if(await E.confirm(`Delete block "${e}"? This cannot be undone.`))try{await i.blocks.delete(e),E.toast("Block deleted.",{type:"success"}),await l(r)}catch(n){E.toast(n.message||"Failed to delete block.",{type:"error"})}}function g(e){const r=e.find("#import-block-btn").get(0),o=e.find("#import-block-file").get(0);!r||!o||(r.addEventListener("click",()=>o.click()),o.addEventListener("change",async()=>{const n=o.files?.[0];if(!n)return;o.value="";let a;try{const t=await n.text();a=JSON.parse(t)}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!a||typeof a!="object"||!a.name||typeof a.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const t=await i.blocks.importBundle(a);E.toast(`Imported "${t.name}".`,{type:"success"}),await l(e)}catch(t){if(t.code==="CONFLICT"){if(!await E.confirm(`A block named "${t.name}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const c=await i.blocks.importBundle(a,{overwrite:!0});E.toast(`Overwrote "${c.name}".`,{type:"success"}),await l(e)}catch(c){E.toast(c.message||"Import failed.",{type:"error"})}return}E.toast(t.message||"Import failed.",{type:"error"})}}))}
1
+ import{api as v,apiRequest as Ce}from"../api.js";import{addBannerAction as te}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{onceClosed as ae,openMenuAt as se,sidebarCount as Me,viewLifetime as Le}from"../lib/tool-kit.js";import{filterByProject as Ie,getProjectFromHash as Te,renderProjectFilterNotice as je}from"../lib/project-context.js";import{FILTERS as $e,arrangeBlocks as De,blockRow as Ne,checkNewName as Be,copyName as oe,shortcodeFor as le,usageText as ce}from"../lib/blocks-arrange.js";const re=/^#\/(blocks|projects\/[^/?#]+\/blocks)\/?(\?.*)?$/,Fe="#/blocks",N="blocks.draft",U="blocks.filter",Re=`
2
+ <div class="blx-row" data-name="{{name}}" tabindex="0" data-bind-class="unused && 'is-unused'">
3
+ <span class="blx-icon" aria-hidden="true"><span data-icon="box" data-icon-size="16"></span></span>
4
+ <span class="blx-main"><strong>{{name}}</strong><span data-bind-hidden="!description">{{description}}</span><small>{{meta}}</small></span>
5
+ <span class="blx-chips">
6
+ <span class="blx-chip is-info" data-bind-hidden="!hasCss" title="Has its own CSS">CSS</span>
7
+ <span class="blx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
8
+ <span class="blx-chip" data-bind-hidden="!bundled" title="Ships with the CMS">Built in</span>
9
+ <span class="blx-chip is-warn" data-bind-hidden="!unused" title="No page or view places it">Unused</span>
10
+ </span>
11
+ <span class="blx-uses" title="Pages and views that use it">{{uses}}</span>
12
+ <button type="button" class="blx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
13
+ </div>`,g=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function ie(c){const a=document.createElement("template");return a.innerHTML=c.trim(),a.content.firstElementChild}const r=Le(re);async function qe(c){try{return await navigator.clipboard.writeText(c),!0}catch{const a=document.createElement("textarea");a.value=c,a.style.cssText="position:fixed;opacity:0;",document.body.appendChild(a),a.select();let b=!1;try{b=document.execCommand("copy")}catch{}return a.remove(),b}}export const blocksView={templateUrl:"/admin/js/templates/blocks.html",async onMount(c){r.reset();const a=c.get(0).querySelector(".blx")||c.get(0),b=()=>re.test(location.hash)&&a.isConnected,w=Te(),p=M.observable([]),H=M.observable(!0),B=M.observable("");let O="all";try{O=$e.includes(S.get(U))?S.get(U):"all"}catch{}const y=M.observable(O),P=M.observable("");r.add(M.effect(()=>{const e=y.value;try{S.set(U,e)}catch{}}));const de=e=>{try{return D(e).format("D MMM YYYY")}catch{return String(e).slice(0,10)}},F=M.observable([]);r.add(M.effect(()=>{F.value=De(p.value,{filter:y.value,query:P.value}).map(e=>Ne(e,de))}));const ue=M.computed(()=>{const e=p.value,t=e.filter(n=>(n.usage?.pages?.length||0)+(n.usage?.views?.length||0)>0).length;return{all:e.length,used:t,unused:e.length-t}}),pe=M.computed(()=>p.value.length?"No blocks match.":"No blocks yet - New block in the banner makes one, or Import brings one in."),R=e=>p.peek().find(t=>t.name===e),me=w?`#/blocks/new?project=${encodeURIComponent(w)}`:"#/blocks/new",Y=e=>`#/blocks/edit/${encodeURIComponent(e)}`;async function K(){try{let e=await Ce("/blocks?usage=1",{method:"GET"});if(!b())return;if(e=Array.isArray(e)?e:[],w){const t=e.length;e=Ie(e,w),je(a,{total:t,shown:e.length,slug:w,allHref:"#/blocks",noun:"blocks"})}p.value=e,B.value=""}catch(e){B.value=`Could not load the blocks: ${e.message||e}`}finally{H.value=!1}}const T=async()=>{Me(Fe),b()&&await K()};async function he(e){const n=(e.usage?.pages?.length||0)+(e.usage?.views?.length||0)?` It is ${g(ce(e).toLowerCase())} - they will show nothing where it was.`:"";if(await E.confirm(`Delete the block "${g(e.name)}"?${n} This cannot be undone.`))try{await v.blocks.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await T()}catch(i){E.toast(i.message||"Failed to delete block.",{type:"error"})}}async function be(e){try{await v.blocks.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function fe(e){const t=le(e),n=await qe(t);E.toast(n?`Copied ${t}`:`Could not copy - your browser refused. The shortcode is ${t}`,{type:n?"success":"error"})}const k=a.querySelector(".blx-import-file");async function W(){const e=k.files?.[0];if(!e)return;k.value="";let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmblock.json file (could not parse JSON).",{type:"error"});return}if(!t||typeof t!="object"||!t.name||typeof t.html!="string"){E.toast("Bundle is missing a name or html field.",{type:"error"});return}try{const n=await v.blocks.importBundle(t);E.toast(`Imported "${n.name}".`,{type:"success"}),await T()}catch(n){if(n.code==="CONFLICT"){if(!await E.confirm(`A block named "${g(n.name)}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const i=await v.blocks.importBundle(t,{overwrite:!0});E.toast(`Overwrote "${i.name}".`,{type:"success"}),await T()}catch(i){E.toast(i.message||"Import failed.",{type:"error"})}return}E.toast(n.message||"Import failed.",{type:"error"})}}k.addEventListener("change",W),r.add(()=>k.removeEventListener("change",W));const x=new Set;let f=null,z=!1;function ye(e){const t=E.slideover({title:`Where ${g(e.name)} is used`,size:"sm",position:"right"}),n=ie(`
14
+ <div class="blx-form">
15
+ <p class="blx-note blx-where-sum"></p>
16
+ <ul class="blx-where"></ul>
17
+ <div><div class="form-label">Shortcode</div><div class="blx-code"></div>
18
+ <small class="blx-hint">Right-click the block in the list and Copy shortcode to paste it into a page.</small></div>
19
+ </div>`);n.querySelector(".blx-where-sum").textContent=ce(e)+".",n.querySelector(".blx-code").textContent=le(e);const i=n.querySelector(".blx-where"),h=(o,j,$)=>{const L=document.createElement("li"),s=document.createElement("a");s.href=o,s.textContent=j,s.addEventListener("click",()=>t.close());const d=document.createElement("small");d.textContent=$,L.append(s,d),i.appendChild(L)};for(const o of e.usage?.pages||[])h(`#/pages/edit${o}`,o,"page");for(const o of e.usage?.views||[])h(`#/views/edit/${encodeURIComponent(o)}`,o,"view");t.element.appendChild(n),x.add(t),ae(t,()=>x.delete(t)),t.open()}function _(e,t=null){const n=()=>p.peek().map(l=>l.name),i=t?.name??oe(e.name,n()),h=M.observable(i),o=M.observable(!1),j=M.computed(()=>Be(h.value,p.value.map(l=>l.name))),$={name:h,saving:o,err:j,cannot:M.computed(()=>o.value||!!j.value)},L=()=>h.peek()!==oe(e.name,n())||!!t,s=E.slideover({title:`Duplicate ${g(e.name)}`,size:"sm",position:"right"}),d=ie(`
20
+ <div class="blx-form">
21
+ <div>
22
+ <label class="form-label" for="blx-dup-name">Name of the copy
23
+ <span data-help="Lowercase letters, digits and hyphens. The copy gets the same HTML, CSS and project; its short tag is [name \u2026 /] with this name."></span></label>
24
+ <input id="blx-dup-name" type="text" class="form-input font-mono" maxlength="80" data-model="name.value">
25
+ <small class="blx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
26
+ </div>
27
+ <div class="blx-actions">
28
+ <small class="blx-hint">Enter duplicates.</small>
29
+ <span class="blx-spacer"></span>
30
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
31
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
32
+ <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
33
+ </div>
34
+ </div>`);let q=!1;async function X(){if($.cannot.peek())return;o.value=!0;const l=h.peek().trim();try{const u=await v.blocks.get(e.name);await v.blocks.put(l,{content:u.content,css:u.css||"",...u.meta?.project&&{meta:{project:u.meta.project}}}),E.toast(`Made "${l}".`,{type:"success"}),q=!0,s.close(),await T()}catch(u){E.toast(`Not duplicated: ${u.message||u}`,{type:"error"})}finally{o.value=!1}}s.element.appendChild(d);const ke=ne(d),xe=M.applyBindings($,d,{methods:{save:X,cancel:()=>s.close()}}),Ee=l=>{l.key==="Enter"&&l.target.matches("input")&&(l.preventDefault(),X())};d.addEventListener("keydown",Ee);const Z={dirty:L,values:()=>({name:h.peek()}),source:e.name};f=Z,x.add(s),ae(s,()=>{xe.dispose(),ke(),x.delete(s),f===Z&&(f=null)});const Se=s.close.bind(s);let A=!1;s.close=async l=>{if(!q&&!z&&l!==!0&&L()){if(A)return;A=!0;const u=await E.confirm("Discard the name you typed?");if(A=!1,!u)return}return q=!0,Se()},I.scan(d),s.open();const ee=d.querySelector("#blx-dup-name");ee?.focus(),ee?.select()}function ve(e){const t=R(e);if(!t)return[];const n=(t.usage?.pages?.length||0)+(t.usage?.views?.length||0);return[{header:t.name},{label:"Edit",icon:"edit",action:()=>{location.hash=Y(t.name)}},{label:"Copy shortcode",icon:"copy",action:()=>fe(t)},{label:n?`Where it is used (${n})\u2026`:"Where it is used\u2026",icon:"link",action:()=>ye(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"layers",action:()=>_(t)},{label:"Export (.dmblock.json)",icon:"download",action:()=>be(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>he(t)}]}const m=a.querySelector(".blx-list");m.innerHTML=Re;const ge=M.applyBindings({blocks:p,loading:H,loadError:B,filter:y,query:P,rows:F,counts:ue,emptyText:pe},a,{methods:{showAll:()=>{y.value="all"},showUsed:()=>{y.value="used"},showUnused:()=>{y.value="unused"}}});r.add(()=>ge.dispose()),r.add(ne(a,{position:"bottom"})),r.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const we=E.contextMenu(m,{cursor:"glyph",match:".blx-row",inherit:!1,density:"compact",items:e=>ve(e.dataset.name)});r.add(()=>we?.destroy?.());const J=e=>{R(e)&&(location.hash=Y(e))},V=e=>{const t=e.target.closest(".blx-row");if(!t)return;const n=e.target.closest(".blx-kebab");if(n){se(n);return}J(t.dataset.name)},G=e=>{const t=e.target.closest(".blx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),se(t)):e.key==="Enter"&&e.target===t&&J(t.dataset.name))};m.addEventListener("click",V),m.addEventListener("keydown",G),r.add(()=>{m.removeEventListener("click",V),m.removeEventListener("keydown",G)}),te(c,{id:"blocks-import",icon:"upload",title:"Import a .dmblock.json bundle",onClick:()=>k.click()}),te(c,{id:"blocks-new",icon:"plus",title:"New block",onClick:()=>{location.hash=me}});const Q=e=>{f?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Q),r.add(()=>window.removeEventListener("beforeunload",Q)),r.add(()=>{if(f?.dirty())try{S.set(N,{source:f.source,values:f.values(),at:new Date().toISOString()})}catch{}z=!0,[...x].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(a),await K(),!b())return;let C=null;try{C=S.get(N)}catch{}if(C?.values&&C.source){try{S.remove?S.remove(N):S.set(N,null)}catch{}const e=R(C.source);e&&await E.confirm(`You were duplicating "${g(e.name)}" and left it unfinished. Pick up where you left off?`)&&b()&&_(e,C.values)}}};
@@ -1 +1,36 @@
1
- import{api as y}from"../api.js";import{filterByProject as B,getProjectFromHash as M,renderProjectFilterNotice as A}from"../lib/project-context.js";import{keepTableIconsAlive as H}from"../lib/table-icons.js";function h(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}let d={};export const collectionsView={templateUrl:"/admin/js/templates/collections.html",async onMount(e){const l=E.loader(e.get(0),{type:"dots"});E.tabs(e.find("#collections-tabs").get(0));const p=e.find("#collections-tabs").get(0)?.querySelector(".tab-list");if(p){const t=e.find("#collections-header-actions").get(0);Array.from(p.querySelectorAll(".tab-item")).forEach((n,c)=>{n.addEventListener("click",()=>{t&&(t.style.display=c===0?"":"none")})})}await Promise.all([j(e),q(e)]),l.destroy(),H(e.get(0)),e.off("click",".js-delete-collection").on("click",".js-delete-collection",async function(){const t=this.dataset.slug,n=this.dataset.plugin,c=n?`This collection is managed by the <strong>${n}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete collection "${t}" and all its data? This cannot be undone.`;if(await E.confirm(c))try{await y.collections.delete(t),E.toast("Collection deleted.",{type:"success"}),await j(e)}catch{E.toast("Failed to delete collection.",{type:"error"})}}),e.find("#create-collection-btn").off("click").on("click",()=>{const t=E.slideover({title:"New Collection",size:"sm",position:"right"}),n=document.createElement("div");n.style.cssText="padding:.25rem 0 .5rem;";const c=document.createElement("div");F.create({title:{type:"string",label:"Collection Title",placeholder:"e.g. Products, Blog Posts\u2026",required:!0}},{},{showSubmitButton:!1}).renderTo(c),n.appendChild(c);const a=document.createElement("div");a.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const o=document.createElement("button");o.className="btn btn-ghost",o.textContent="Cancel";const r=document.createElement("button");r.className="btn btn-primary",r.textContent="Create",a.appendChild(o),a.appendChild(r),n.appendChild(a),t.element.appendChild(n),t.open();const s=c.querySelector('input[name="title"]');setTimeout(()=>s?.focus(),50);async function i(){const m=s?.value.trim();if(m)try{const u=await y.collections.create({title:m});t.close(),R.navigate(`/collections/edit/${u.slug}`)}catch(u){E.toast(u.message||"Failed to create collection.",{type:"error"})}}o.addEventListener("click",()=>t.close()),r.addEventListener("click",i),s?.addEventListener("keydown",m=>{m.key==="Enter"&&i()})}),e.find("#connections-raw-toggle").on("change",function(){if(this.checked)d=x(e),v(e),e.find("#connections-form-view").hide(),e.find("#connections-raw-view").show();else{const t=e.find("#connections-raw-json").get(0)?.value||"{}";try{d=JSON.parse(t),C(e),e.find("#connections-form-view").show(),e.find("#connections-raw-view").hide()}catch{E.toast("Invalid JSON - fix before switching to form view.",{type:"warning"}),this.checked=!0}}}),e.find("#add-connection-btn").on("click",()=>{d=x(e);let t=Object.keys(d).length+1,n=`connection${t}`;for(;d[n];)t++,n=`connection${t}`;d[n]={type:"mongodb",uri:"",database:"",options:{}},C(e),v(e)}),e.find("#save-connections-btn").on("click",async()=>{const t=e.find("#connections-raw-toggle").get(0)?.checked;let n;if(t)try{n=JSON.parse(e.find("#connections-raw-json").get(0)?.value||"{}")}catch{E.toast("Invalid JSON - fix before saving.",{type:"warning"});return}else n=x(e);try{await y.collections.saveConnections(n),d=n,E.toast("Connections saved.",{type:"success"})}catch(c){E.toast(c.message||"Failed to save connections.",{type:"error"})}}),Domma.icons.scan()}};async function j(e){let l=[];try{l=await y.collections.list()}catch{E.toast("Could not load collections.",{type:"error"})}const p=M();if(p){const t=l.length;l=B(l,p),A(e,{total:t,shown:l.length,slug:p,allHref:"#/collections",noun:"collections"})}T.create("#collections-table",{pageSize:25,data:l,columns:[{key:"title",title:"Title",render:(t,n)=>{const c=document.createElement("span");c.style.cssText="display:flex;align-items:center;gap:.4rem;";const a=document.createElement("a");if(a.href=`#/collections/${h(n.slug)}/entries`,a.textContent=t,a.style.fontWeight="600",c.appendChild(a),n.storage?.adapter==="mongodb"){const o=document.createElement("span");o.className="badge badge-outline",o.textContent="MongoDB",o.title=`Connection: ${n.storage.connection||"default"}`,o.style.cssText="font-size:0.65rem;padding:1px 6px;color:var(--dm-success,#16a34a);border-color:var(--dm-success,#16a34a);flex-shrink:0;",c.appendChild(o)}if(n.bundled){const o=document.createElement("span");o.className="badge badge-outline",o.textContent="Bundled",o.title="Seeded on fresh installs",o.style.cssText="font-size:0.65rem;padding:1px 6px;color:var(--dm-info,#2563eb);border-color:var(--dm-info,#2563eb);flex-shrink:0;",c.appendChild(o)}if(n.plugin){const o=document.createElement("span");o.className="badge badge-outline",o.textContent=n.plugin,o.title=`Managed by the ${n.plugin} plugin`,o.style.cssText="font-size:0.65rem;padding:1px 6px;color:var(--dm-warning,#d97706);border-color:var(--dm-warning,#d97706);flex-shrink:0;",c.appendChild(o)}return c.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:"entryCount",title:"Entry Count",render:t=>String(t??0)},{key:"slug",title:"Actions",render:(t,n)=>{const c=document.createElement("div");c.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;flex-wrap:wrap;";const a=document.createElement("a");a.href=`#/collections/edit/${h(t)}`,a.className="btn btn-sm btn-ghost",a.setAttribute("data-tooltip","Edit schema"),a.innerHTML='<span data-icon="edit-3" data-icon-size="14"></span>';const o=document.createElement("a");if(o.href=`#/collections/${h(t)}/entries`,o.className="btn btn-sm btn-ghost",o.setAttribute("data-tooltip","Entries"),o.innerHTML='<span data-icon="database" data-icon-size="14"></span>',c.appendChild(a),c.appendChild(o),!new Set(["roles","user-profiles"]).has(t)){const i=document.createElement("a");i.href=`#/forms/edit/${h(t)}`,i.className="btn btn-sm btn-ghost",i.setAttribute("data-tooltip","Form"),i.innerHTML='<span data-icon="layout" data-icon-size="14"></span>',c.appendChild(i)}const s=document.createElement("button");return s.className="btn btn-sm btn-ghost js-delete-collection",s.dataset.slug=t,s.dataset.plugin=n.plugin||"",s.setAttribute("data-tooltip","Delete"),s.innerHTML='<span data-icon="trash" data-icon-size="14"></span>',c.appendChild(s),c.outerHTML}}],emptyMessage:'No collections yet. Click "New Collection" to get started.'}),Domma.icons.scan(),document.querySelectorAll("#collections-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})})}async function q(e){try{d=await y.collections.getConnections()}catch{d={}}C(e),v(e)}function C(e){const l=e.find("#connections-list").get(0),p=e.find("#connections-empty").get(0);if(!l)return;l.textContent="";const t=Object.keys(d);p&&(p.style.display=t.length?"none":""),t.forEach(n=>{const c=d[n],a=document.createElement("div");a.className="conn-card card mb-3",a.dataset.connKey=n;const o=document.createElement("div");o.className="card-header",o.style.cssText="display:flex;align-items:center;gap:.5rem;";const r=document.createElement("input");r.type="text",r.className="form-input conn-name",r.value=n,r.placeholder="Connection name",r.style.cssText="flex:1;";const s=document.createElement("button");s.type="button",s.className="btn btn-sm btn-ghost",s.title="Delete connection",s.innerHTML='<span data-icon="trash"></span>',s.addEventListener("click",()=>{d=x(e);const L=r.value.trim();L&&delete d[L],C(e),v(e)}),o.appendChild(r),o.appendChild(s);const i=document.createElement("div");i.className="card-body",i.style.cssText="display:grid;grid-template-columns:1fr 2fr 1fr;gap:.6rem;";const m=document.createElement("div"),u=document.createElement("label");u.className="form-label",u.textContent="Type";const f=document.createElement("input");f.type="text",f.className="form-input conn-type",f.value=c.type||"mongodb",f.disabled=!0,m.appendChild(u),m.appendChild(f);const w=document.createElement("div"),N=document.createElement("label");N.className="form-label",N.textContent="URI";const g=document.createElement("input");g.type="text",g.className="form-input conn-uri",g.value=c.uri||"",g.placeholder="mongodb://localhost:27017",w.appendChild(N),w.appendChild(g);const S=document.createElement("div"),k=document.createElement("label");k.className="form-label",k.textContent="Database";const b=document.createElement("input");b.type="text",b.className="form-input conn-database",b.value=c.database||"",b.placeholder="mydb",S.appendChild(k),S.appendChild(b),i.appendChild(m),i.appendChild(w),i.appendChild(S),a.appendChild(o),a.appendChild(i),l.appendChild(a)}),Domma.icons.scan(l)}function x(e){const l={},p=e.find("#connections-list").get(0)?.querySelectorAll(".conn-card")||[];for(const t of p){const n=t.querySelector(".conn-name")?.value.trim(),c=t.querySelector(".conn-uri")?.value.trim()||"",a=t.querySelector(".conn-database")?.value.trim()||"";n&&(l[n]={type:"mongodb",uri:c,database:a,options:{}})}return l}function v(e){const l=e.find("#connections-raw-json").get(0);l&&(l.value=JSON.stringify(d,null,2))}
1
+ import{apiRequest as C}from"../api.js";import{addBannerAction as Ue}from"../lib/plugin-chrome.js";import{helpPopovers as ge}from"../lib/help-popover.js";import{onceClosed as Pe,openMenuAt as be,sidebarCount as he,viewLifetime as qe}from"../lib/tool-kit.js";import{FILTERS as Fe,arrangeCollections as Ke,checkNewCollection as Je,collectionRow as ye,connectionRows as T,connectionsFromRows as b,kindCounts as Ye,maskUri as _e,newConnectionRow as ze,parseConnectionsJson as j}from"../lib/collections-arrange.js";const H=/^#\/(collections|projects\/[^/?#]+\/collections)\/?(\?.*)?$/,we="#/collections",A="collections.draft",G="collections.filter",He=`
2
+ <div class="clx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="builtin && 'is-builtin'">
3
+ <span class="clx-icon" aria-hidden="true"><span data-icon="database" data-icon-size="16"></span></span>
4
+ <span class="clx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
5
+ <span class="clx-chips">
6
+ <span class="clx-chip is-ok" data-bind-hidden="!mongo" title="{{mongoTitle}}">MongoDB</span>
7
+ <span class="clx-chip" data-bind-hidden="!builtin" title="Part of the CMS - cannot be deleted">Built in</span>
8
+ <span class="clx-chip is-info" data-bind-hidden="!bundled" title="Seeded on fresh installs">Bundled</span>
9
+ <span class="clx-chip is-warn" data-bind-hidden="!plugin" title="{{pluginTitle}}">{{plugin}}</span>
10
+ </span>
11
+ <span class="clx-num" title="Entries">{{entriesText}}</span>
12
+ <button type="button" class="clx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
13
+ </div>`,Ge=`
14
+ <div class="clx-conn" data-id="{{id}}">
15
+ <label>Name<input type="text" class="form-input" data-f="name" value="{{name}}" placeholder="default"></label>
16
+ <label>URI<input type="text" class="form-input font-mono" data-f="uri" value="{{uri}}" placeholder="mongodb://localhost:27017" autocomplete="off"></label>
17
+ <label>Database<input type="text" class="form-input" data-f="database" value="{{database}}" placeholder="mydb"></label>
18
+ <button type="button" class="btn btn-sm btn-ghost" data-act="remove" title="Remove this connection" aria-label="Remove {{name}}"><span data-icon="trash" data-icon-size="14"></span></button>
19
+ </div>`,L=v=>String(v??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ve(v){const s=document.createElement("template");return s.innerHTML=v.trim(),s.content.firstElementChild}const l=qe(H);let B=null,ke=!1;export const collectionsView={templateUrl:"/admin/js/templates/collections.html",async onMount(v){l.reset();const s=v.get(0).querySelector(".clx")||v.get(0),x=()=>H.test(location.hash)&&s.isConnected,p=M.observable([]),V=M.observable(!0),O=M.observable(""),W=location.hash.match(/^#\/projects\/([^/?#]+)\/collections/),c=M.observable(W?decodeURIComponent(W[1]):"");let Q="all";try{Q=Fe.includes(S.get(G))?S.get(G):"all"}catch{}const m=M.observable(Q),X=M.observable("");l.add(M.effect(()=>{const e=m.value;try{S.set(G,e)}catch{}}));const $=M.observable([]);l.add(M.effect(()=>{$.value=Ke(p.value,{filter:m.value,query:X.value,project:c.value||null}).map(ye)}));const U=M.computed(()=>Ye(p.value,{project:c.value||null})),Ee=M.computed(()=>{const e=$.value.reduce((t,n)=>t+n.entries,0);return`${e.toLocaleString("en-GB")} entr${e===1?"y":"ies"} shown`}),xe=M.computed(()=>U.value.all?"No collections match.":c.value?"Nothing filed under this project yet - New collection makes one here.":"No collections yet - New collection in the banner makes one."),Me=M.computed(()=>{const e=p.value.length-U.value.all;return`Showing only collections in the ${c.value} project${e?` - ${e} more elsewhere.`:"."}`}),Ce=e=>p.peek().find(t=>t.slug===e);async function Z(){try{const e=await C("/collections",{method:"GET"});if(!x())return;p.value=Array.isArray(e)?e:[],O.value=""}catch(e){O.value=`Could not load the collections: ${e.message||e}`}finally{V.value=!1}}const R=e=>{location.hash=e},P=e=>R(`#/collections/${encodeURIComponent(e)}/entries`);async function Se(e){const t=L(e.title||e.slug),n=e.plugin?`"${t}" belongs to the <strong>${L(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it and all ${Number(e.entryCount)||0} entries anyway?`:`Delete "${t}" and all ${Number(e.entryCount)||0} of its entries? This cannot be undone.`;if(await E.confirm(n))try{await C(`/collections/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),he(we),x()&&await Z()}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error"})}}async function Te(e){try{await navigator.clipboard.writeText(e.slug),E.toast(`Copied "${e.slug}".`,{type:"success",duration:2e3})}catch{E.toast(`The slug is ${e.slug}`,{type:"info"})}}const q=new Set;let h=null;function ee(e=null){const t=M.observable(e?.title??""),n=M.observable(!1),o=M.computed(()=>Je(t.value,p.value.map(i=>i.slug))),pe={title:t,saving:n,slugText:M.computed(()=>o.value.slug?`Its slug will be "${o.value.slug}" - the address in the API and in shortcodes.`:""),error:M.computed(()=>o.value.error),cannot:M.computed(()=>n.value||!t.value.trim()||!!o.value.error),projectText:c.value?`It will be filed under the ${c.value} project.`:""},fe=()=>!!t.peek().trim()&&!D,r=E.slideover({title:"New collection",size:"sm",position:"right"}),k=Ve(`
20
+ <div class="clx-form">
21
+ <div>
22
+ <label class="form-label" for="clx-new-title">Title <span class="text-danger">*</span>
23
+ <span data-help="What the collection holds, in the plural - Products, Events, Testimonials. You add its fields next, in the collection editor."></span></label>
24
+ <input id="clx-new-title" type="text" class="form-input" maxlength="80" placeholder="e.g. Products, Blog posts\u2026" data-model="title.value">
25
+ <small class="clx-err" data-bind-hidden="!error.value" data-bind-text="error.value"></small>
26
+ <small class="clx-hint" data-bind-hidden="error.value" data-bind-text="slugText.value"></small>
27
+ <small class="clx-hint" data-bind-hidden="!projectText" data-bind-text="projectText"></small>
28
+ </div>
29
+ <div class="clx-actions">
30
+ <small class="clx-hint">Enter creates it and opens the editor.</small>
31
+ <span class="clx-spacer"></span>
32
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
33
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
34
+ <span data-icon="plus" data-icon-size="13"></span> Create</button>
35
+ </div>
36
+ </div>`);async function ve(){if(!pe.cannot.peek()){n.value=!0;try{const i={title:t.peek().trim(),...c.value&&c.value!=="core"&&{meta:{project:c.value}}},z=await C("/collections",{method:"POST",body:JSON.stringify(i)});D=!0,r.close(),he(we),R(`#/collections/edit/${encodeURIComponent(z.slug)}`)}catch(i){E.toast(`Not created: ${i.message||i}`,{type:"error"})}finally{n.value=!1}}}r.element.appendChild(k);const je=ge(k),Ae=M.applyBindings(pe,k,{methods:{create:ve,cancel:()=>r.close()}}),Be=i=>{i.key==="Enter"&&i.target.id==="clx-new-title"&&(i.preventDefault(),ve())};k.addEventListener("keydown",Be);const me={dirty:fe,values:()=>({title:t.peek()})};h=me,q.add(r),Pe(r,()=>{Ae.dispose(),je(),q.delete(r),h===me&&(h=null)});const Oe=r.close.bind(r);let D=!1,_=!1;r.close=async i=>{if(!D&&i!==!0&&fe()){if(_)return;_=!0;const z=await E.confirm("Discard the collection you started?");if(_=!1,!z)return}return D=!0,Oe()},I.scan(k),r.open(),k.querySelector("#clx-new-title")?.focus()}const y=M.observable(!1),te=M.observable(!0),a=M.observable([]),g=M.observable(!1),f=M.observable("{}"),d=M.observable(""),F=M.observable(!1);let K="{}";const J=M.observable(0),Y=M.computed(()=>{J.value,a.value,f.value,g.value;const e=g.peek()?j(f.peek()).config??f.peek():b(a.peek()).config;return JSON.stringify(e)!==K});async function Le(){try{const e=await C("/collections/connections",{method:"GET"});if(!x())return;y.value=!0,K=JSON.stringify(b(T(e)).config),a.value=T(B??e),B&&(ke||E.toast("Your unsaved connection changes are back - Save to keep them.",{type:"info",duration:6e3}),B=null)}catch{y.value=!1}finally{te.value=!1}}let ne=!1;l.add(M.effect(()=>{const e=g.value;if(e!==ne){if(e)f.value=JSON.stringify(b(a.peek()).config,null,2),d.value="";else{const{error:t,config:n}=j(f.peek());if(t){d.value=`${t} Fix it before switching back.`,queueMicrotask(()=>{g.value=!0});return}a.value=T(n),d.value=""}ne=e}}));async function oe(){let e;if(g.peek()){const t=j(f.peek());if(t.error){d.value=t.error;return}e=t.config;const n=b(T(e));if(n.errors.length){d.value=n.errors.join(" ");return}}else{const t=b(a.peek());if(t.errors.length){d.value=t.errors.join(" ");return}e=t.config}F.value=!0;try{await C("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),K=JSON.stringify(b(T(e)).config),J.value++,d.value="",E.toast("Connections saved. Collections already on MongoDB pick up a changed connection when the server restarts.",{type:"success",duration:6e3})}catch(t){d.value=`Not saved: ${t.message||t}`}finally{F.value=!1}}async function $e(e){const t=a.peek().find(o=>o.id===e);if(!t)return;const n=p.peek().filter(o=>o.storage?.adapter==="mongodb"&&(o.storage.connection||"default")===t.name);n.length&&!await E.confirm(`${n.length} collection${n.length===1?" uses":"s use"} "${L(t.name)}" (${n.map(o=>L(o.slug)).join(", ")}). Remove it anyway? Nothing changes until you Save.`)||(a.value=a.peek().filter(o=>o.id!==e))}function Re(e){const t=Ce(e);if(!t)return[];const n=ye(t);return[{header:t.title||t.slug},{label:"Open entries",icon:"database",action:()=>P(t.slug)},{label:"Edit fields & settings",icon:"edit-3",action:()=>R(`#/collections/edit/${encodeURIComponent(t.slug)}`)},...n.hasForm?[{label:"Open its form",icon:"layout",action:()=>R(`#/forms/edit/${encodeURIComponent(t.slug)}`)}]:[],{type:"divider"},{label:"Copy slug",icon:"copy",action:()=>Te(t)},...n.canDelete?[{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Se(t)}]:[]]}const w=s.querySelector(".clx-list");w.innerHTML=He;const u=s.querySelector(".clx-conns");u.innerHTML=Ge;const Ne=E.tabs(s.querySelector(".clx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),De=M.applyBindings({collections:p,loading:V,loadError:O,project:c,filter:m,query:X,rows:$,kinds:U,totalText:Ee,emptyText:xe,scopeText:Me,canConnect:y,connLoading:te,connRows:a,raw:g,rawText:f,connError:d,savingConn:F,connDirty:Y},s,{methods:{showAll:()=>{m.value="all"},showOwn:()=>{m.value="own"},showBuiltin:()=>{m.value="builtin"},showPlugin:()=>{m.value="plugin"},addConnection:()=>{a.value=[...a.peek(),ze(a.peek())]},saveConnections:oe}});l.add(()=>De.dispose()),l.add(ge(s,{position:"bottom"})),l.add(M.effect(()=>{$.value,a.value,requestAnimationFrame(()=>{s.isConnected&&I.scan(s)})}));const ae=e=>{const t=e.target.dataset.f,n=a.peek().find(o=>o.id===e.target.closest("[data-id]")?.dataset.id);!t||!n||(n[t]=e.target.value,J.value++)},le=e=>{const t=e.target.closest('[data-act="remove"]');t&&$e(t.closest("[data-id]")?.dataset.id)};u.addEventListener("input",ae),u.addEventListener("click",le),l.add(()=>{u.removeEventListener("input",ae),u.removeEventListener("click",le)});const se=e=>{e.target.dataset.f==="uri"&&(e.target.title="")},ie=e=>{e.target.dataset.f==="uri"&&(e.target.title=_e(e.target.value))};u.addEventListener("focusin",se),u.addEventListener("focusout",ie),l.add(()=>{u.removeEventListener("focusin",se),u.removeEventListener("focusout",ie)});const Ie=E.contextMenu(w,{cursor:"glyph",match:".clx-row",inherit:!1,density:"compact",items:e=>Re(e.dataset.slug)});l.add(()=>Ie?.destroy?.());const ce=e=>{const t=e.target.closest(".clx-row");if(!t)return;const n=e.target.closest(".clx-kebab");if(n){be(n);return}P(t.dataset.slug)},re=e=>{const t=e.target.closest(".clx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),be(t)):e.key==="Enter"&&e.target===t&&P(t.dataset.slug))};w.addEventListener("click",ce),w.addEventListener("keydown",re),l.add(()=>{w.removeEventListener("click",ce),w.removeEventListener("keydown",re)});const de=e=>{(e.ctrlKey||e.metaKey)&&e.key==="s"&&Ne.getActive?.()===1&&y.peek()&&(e.preventDefault(),oe())};document.addEventListener("keydown",de),l.add(()=>document.removeEventListener("keydown",de)),Ue(v,{id:"collections-new",icon:"plus",title:"New collection",onClick:()=>ee()});const ue=e=>{(h?.dirty()||y.peek()&&Y.peek())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ue),l.add(()=>window.removeEventListener("beforeunload",ue)),l.add(()=>{if(h?.dirty())try{S.set(A,{values:h.values(),project:c.peek(),at:new Date().toISOString()})}catch{}y.peek()&&Y.peek()&&(B=g.peek()?j(f.peek()).config??null:b(a.peek()).config,ke=H.test(location.hash)),[...q].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(s),await Promise.all([Z(),Le()]),!x())return;let N=null;try{N=S.get(A)}catch{}if(N?.values?.title){try{S.remove?S.remove(A):S.set(A,null)}catch{}await E.confirm(`You started a collection called "${L(N.values.title)}" and left it. Pick up where you left off?`)&&x()&&ee(N.values)}}};
@@ -1,5 +1,27 @@
1
- import{api as c}from"../api.js";import{filterByProject as u,getProjectFromHash as f,renderProjectFilterNotice as g}from"../lib/project-context.js";import{keepTableIconsAlive as y}from"../lib/table-icons.js";function l(t){return String(t??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function h(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export const componentsView={templateUrl:"/admin/js/templates/components.html",async onMount(t){k(),await p(t),b(t),Domma.icons.scan(t.get(0)),y(t.get(0))}};let d=!1;function k(){d||(d=!0,I.register("download",{viewBox:"0 0 24 24",paths:["M12 3v12","M7 10l5 5 5-5","M5 19h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("upload",{viewBox:"0 0 24 24",paths:["M12 21V9","M7 14l5-5 5 5","M5 5h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}),I.register("plus",{viewBox:"0 0 24 24",paths:["M12 5v14","M5 12h14"],stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"}))}async function p(t){const n=t.find("#components-table-container").get(0);if(!n)return;let o=[];const i=E.loader(t.get(0),{type:"dots"});try{o=await c.components.list()}catch(e){h(n);const s=document.createElement("p");s.className="text-muted",s.textContent=`Failed to load components: ${e.message}`,n.appendChild(s);return}finally{i.destroy()}const r=f();if(r){const e=o.length;o=u(o,r),g(t,{total:e,shown:o.length,slug:r,allHref:"#/components",noun:"components"})}T.create(n,{pageSize:25,data:o,emptyMessage:'No components yet. Click "New Component" to create your first one.',columns:[{key:"name",title:"Name",render:(e,s)=>{const a=s.origin==="plugin"?' <span class="badge badge-secondary" title="Provided by a plugin - read-only">plugin</span>':"";return`<a href="#/components/edit/${l(e)}">${l(e)}.dmc</a>${a}`}},{key:"props",title:"Props",render:e=>{const s=Object.keys(e||{});return s.length===0?'<span class="text-muted">-</span>':s.map(a=>`<span class="badge badge-secondary" style="margin-right:.25rem;">${l(a)}</span>`).join("")}},{key:"size",title:"Size",render:e=>`${e} B`},{key:"updatedAt",title:"Updated",render:e=>e?new Date(e).toLocaleString():"-"},{key:"name",title:"Actions",render:(e,s)=>{const a=l(e),m=s.origin==="plugin"?"":`<button class="btn btn-sm btn-ghost js-delete-component" data-name="${a}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>`;return`<div style="display:flex;gap:.4rem;justify-content:flex-end;">
2
- <a href="#/components/edit/${a}" class="btn btn-sm btn-ghost" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- <button class="btn btn-sm btn-ghost js-export-component" data-name="${a}" data-tooltip="Export as .dmcomponent.json"><span data-icon="download" data-icon-size="14"></span></button>
4
- ${m}
5
- </div>`}}]}),t.off("click",".js-delete-component").on("click",".js-delete-component",async function(){await w(this.dataset.name,t)}),t.off("click",".js-export-component").on("click",".js-export-component",async function(){try{await c.components.exportBundle(this.dataset.name)}catch(e){E.toast(e.message||"Export failed.",{type:"error"})}}),Domma.icons.scan(n),n.querySelectorAll("[data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}async function w(t,n){if(await E.confirm(`Delete component "${t}"? This cannot be undone.`))try{await c.components.delete(t),E.toast("Component deleted.",{type:"success"}),await p(n)}catch(i){E.toast(i.message||"Failed to delete component.",{type:"error"})}}function b(t){const n=t.find("#import-component-btn").get(0),o=t.find("#import-component-file").get(0);!n||!o||(n.addEventListener("click",()=>o.click()),o.addEventListener("change",async()=>{const i=o.files?.[0];if(!i)return;o.value="";let r;try{const e=await i.text();r=JSON.parse(e)}catch{E.toast("Not a valid .dmcomponent.json file (could not parse JSON).",{type:"error"});return}if(!r||typeof r!="object"||!r.name||typeof r.source!="string"){E.toast("Bundle is missing a name or source field.",{type:"error"});return}try{const e=await c.components.importBundle(r);E.toast(`Imported "${e.name}".`,{type:"success"}),await p(t)}catch(e){if(e.code==="CONFLICT"){if(!await E.confirm(`A component named "${e.name}" already exists. Overwrite it with the imported version?`)){E.toast("Import cancelled.",{type:"info"});return}try{const a=await c.components.importBundle(r,{overwrite:!0});E.toast(`Overwrote "${a.name}".`,{type:"success"}),await p(t)}catch(a){E.toast(a.message||"Import failed.",{type:"error"})}return}E.toast(e.message||"Import failed.",{type:"error"})}}))}
1
+ import{api as h}from"../api.js";import{addBannerAction as te}from"../lib/plugin-chrome.js";import{helpPopovers as ne}from"../lib/help-popover.js";import{onceClosed as Ee,openMenuAt as oe,sidebarCount as xe,viewLifetime as ke}from"../lib/tool-kit.js";import{filterByProject as Me,getProjectFromHash as Se}from"../lib/project-context.js";import{FILTERS as Ce,SORTS as je,arrangeComponents as Ie,checkBundle as $e,checkName as Le,componentRow as Te,copyName as Ne,kindCounts as Be,usageTag as Ae}from"../lib/components-arrange.js";const ae=/^#\/(projects\/[^/?#]+\/)?components\/?(\?.*)?$/,De="#/components",C="components.draft",se="components.prefs",Re=`
2
+ <div class="cpx-row" data-name="{{name}}" tabindex="0" data-bind-class="kindCls">
3
+ <span class="cpx-icon" aria-hidden="true"><span data-icon="component" data-icon-size="16"></span></span>
4
+ <span class="cpx-main"><strong>{{file}}<span class="cpx-tag">{{tag}}</span></strong><small>{{meta}}</small></span>
5
+ <span class="cpx-chips">
6
+ <span class="cpx-chip is-project" data-bind-hidden="!project || $root.projectSlug" title="Filed under this project">{{project}}</span>
7
+ <span class="cpx-chip" data-bind-class="kindCls" data-bind-hidden="!showKind">{{kindLabel}}</span>
8
+ </span>
9
+ <button type="button" class="cpx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
10
+ </div>`,w=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Fe(n){const c=document.createElement("template");return c.innerHTML=n.trim(),c.content.firstElementChild}function Pe(){try{const n=S.get(se)||{};return{filter:Ce.includes(n.filter)?n.filter:"all",sort:je.includes(n.sort)?n.sort:"name"}}catch{return{filter:"all",sort:"name"}}}const s=ke(ae);let re=!1;function Oe(){if(re)return;re=!0;const n={stroke:"currentColor",fill:"none",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};try{I.has?.("download")||I.register("download",{viewBox:"0 0 24 24",paths:["M12 3v12","M7 10l5 5 5-5","M5 19h14"],...n}),I.has?.("upload")||I.register("upload",{viewBox:"0 0 24 24",paths:["M12 21V9","M7 14l5-5 5 5","M5 5h14"],...n}),I.has?.("plus")||I.register("plus",{viewBox:"0 0 24 24",paths:["M12 5v14","M5 12h14"],...n})}catch{}}export const componentsView={templateUrl:"/admin/js/templates/components.html",async onMount(n){s.reset(),Oe();const c=n.get(0).querySelector(".cpx")||n.get(0),x=()=>ae.test(location.hash)&&c.isConnected,m=Se(),P=Pe(),u=M.observable([]),O=M.observable(0),q=M.observable(!0),j=M.observable(""),f=M.observable(P.filter),$=M.observable(P.sort),K=M.observable("");s.add(M.effect(()=>{const e={filter:f.value,sort:$.value};try{S.set(se,e)}catch{}}));const L=M.observable([]);s.add(M.effect(()=>{const e=Date.now();L.value=Ie(u.value,{filter:f.value,query:K.value,sort:$.value}).map(t=>Te(t,e))}));const ce=M.computed(()=>Be(u.value)),ie=M.computed(()=>u.value.length?"No components match.":"No components yet - New component in the banner makes one."),le=M.computed(()=>{if(!m)return"";const e=O.value-u.value.length;return`Showing only components in ${m}.${e>0?` ${e} other${e===1?" is":"s are"} filed elsewhere.`:""}`}),U=e=>u.peek().find(t=>t.name===e);let T=[];async function H(){try{const e=await h.components.list();if(!x())return;const t=Array.isArray(e)?e:[];T=t.map(o=>o.name),O.value=t.length,u.value=m?Me(t,m):t,j.value=""}catch(e){j.value=`Could not load the components: ${e.message||e}`}finally{q.value=!1}}const N=async()=>{xe(De),x()&&await H()},B=e=>{location.hash=`#/components/edit/${encodeURIComponent(e)}`},pe=()=>{location.hash=`#/components/new${m?`?project=${encodeURIComponent(m)}`:""}`};async function de(e){const t=Ae(e.name);try{await navigator.clipboard.writeText(t),E.toast(`Copied the tag for ${e.name} - paste it into a page.`,{type:"success"})}catch{E.toast(`Copy failed - the tag is dm-${e.name}, written as an HTML element.`,{type:"warning",duration:6e3})}}async function me(e){try{await h.components.exportBundle(e.name)}catch(t){E.toast(t.message||"Export failed.",{type:"error"})}}async function ue(e){if(await E.confirm(`Delete the component "${w(e.name)}"? Pages that use &lt;dm-${w(e.name)}&gt; will show nothing in its place. This cannot be undone.`))try{await h.components.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),await N()}catch(t){E.toast(t.message||"Failed to delete the component.",{type:"error"})}}let y=null;const A=new Set;function V(e,t=null){if(!e||e.origin==="plugin")return;const o=t?.name??Ne(e.name,T),a=M.observable(o),d=M.observable(!1),W=M.computed(()=>Le(a.value,T)),G={name:a,saving:d,nameErr:W,tagText:M.computed(()=>`Placed on a page as <dm-${a.value.trim()||"\u2026"}>.`),cannot:M.computed(()=>d.value||!!W.value)},Q=()=>a.peek()!==o||!!t,r=E.slideover({title:`Duplicate ${w(e.name)}`,size:"sm",position:"right"}),v=Fe(`
11
+ <div class="cpx-form">
12
+ <p class="cpx-hint">A copy of the source, the props and the project it is filed under, under a new name. The original is not changed.</p>
13
+ <div>
14
+ <label class="form-label" for="cpx-f-name">New name
15
+ <span data-help="The component's name is also its tag - counter becomes <dm-counter>. A letter first, then lowercase letters, digits and hyphens. Names are shared by every project."></span></label>
16
+ <input id="cpx-f-name" type="text" class="form-input font-mono" maxlength="64" data-model="name.value">
17
+ <small class="cpx-err" data-bind-hidden="!nameErr.value" data-bind-text="nameErr.value"></small>
18
+ <small class="cpx-hint" data-bind-hidden="nameErr.value" data-bind-text="tagText.value"></small>
19
+ </div>
20
+ <div class="cpx-actions">
21
+ <small class="cpx-hint">Ctrl+Enter saves.</small>
22
+ <span class="cpx-spacer"></span>
23
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
24
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
25
+ <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
26
+ </div>
27
+ </div>`);let D=!1;async function X(){if(G.cannot.peek())return;d.value=!0;const i=a.peek().trim();try{const l=await h.components.get(e.name),b=l.meta&&typeof l.meta=="object"?{...l.meta}:void 0;b&&delete b.bundled;const F=await h.components.put(i,{source:l.source,...b&&Object.keys(b).length&&{meta:b}});if(F&&F.success===!1)throw new Error((F.errors||[]).join("; ")||"Compile failed");E.toast(`Made "${i}".`,{type:"success"}),D=!0,r.close(),await N()}catch(l){E.toast(`Not duplicated: ${l.message||l}`,{type:"error"})}finally{d.value=!1}}r.element.appendChild(v);const ge=ne(v),be=M.applyBindings(G,v,{methods:{save:X,cancel:()=>r.close()}});v.addEventListener("keydown",i=>{(i.ctrlKey||i.metaKey)&&i.key==="Enter"&&(i.preventDefault(),X())});const Z={dirty:Q,values:()=>({source:e.name,name:a.peek()})};y=Z,A.add(r),Ee(r,()=>{be.dispose(),ge(),A.delete(r),y===Z&&(y=null)});const we=r.close.bind(r);let R=!1;r.close=async i=>{if(!D&&i!==!0&&Q()){if(R)return;R=!0;const l=await E.confirm("Discard the name you typed?");if(R=!1,!l)return}return D=!0,we()},I.scan(v),r.open();const ee=v.querySelector("#cpx-f-name");ee?.focus(),ee?.select()}const g=c.querySelector(".cpx-import-file");async function fe(e){let t;try{t=JSON.parse(await e.text())}catch{E.toast("Not a valid .dmcomponent.json file (could not parse JSON).",{type:"error"});return}const o=$e(t);if(o){E.toast(o,{type:"error"});return}try{const a=await h.components.importBundle(t);E.toast(`Imported "${a.name}".`,{type:"success"})}catch(a){if(a.code!=="CONFLICT"){E.toast(a.message||"Import failed.",{type:"error"});return}if(!await E.confirm(`A component named "${w(a.name)}" already exists. Replace it with the imported one?`)){E.toast("Import cancelled.",{type:"info"});return}try{const d=await h.components.importBundle(t,{overwrite:!0});E.toast(`Replaced "${d.name}".`,{type:"success"})}catch(d){E.toast(d.message||"Import failed.",{type:"error"});return}}await N()}const _=()=>{const e=g.files?.[0];g.value="",e&&fe(e)};g.addEventListener("change",_),s.add(()=>g.removeEventListener("change",_));function he(e){const t=U(e);if(!t)return[];const o=t.origin==="plugin";return[{header:`<dm-${t.name}>`},{label:o?"View source":"Edit",icon:"edit",action:()=>B(t.name)},{label:"Copy tag",icon:"copy",action:()=>de(t)},{type:"divider"},{label:"Duplicate\u2026",icon:"copy",disabled:o,action:()=>V(t)},{label:"Export .dmcomponent.json",icon:"download",action:()=>me(t)},...o?[]:[{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ue(t)}]]}const p=c.querySelector(".cpx-list");p.innerHTML=Re;const ye=M.applyBindings({components:u,loading:q,loadError:j,filter:f,sort:$,query:K,rows:L,counts:ce,emptyText:ie,projectNote:le,projectSlug:m||""},c,{methods:{showAll:()=>{f.value="all"},showMine:()=>{f.value="mine"},showBuiltin:()=>{f.value="builtin"},showPlugin:()=>{f.value="plugin"}}});s.add(()=>ye.dispose()),s.add(ne(c,{position:"bottom"})),s.add(M.effect(()=>{L.value,requestAnimationFrame(()=>{p.isConnected&&I.scan(p)})}));const ve=E.contextMenu(p,{cursor:"glyph",match:".cpx-row",inherit:!1,density:"compact",items:e=>he(e.dataset.name)});s.add(()=>ve?.destroy?.());const z=e=>{const t=e.target.closest(".cpx-row");if(!t)return;const o=e.target.closest(".cpx-kebab");if(o){oe(o);return}B(t.dataset.name)},Y=e=>{const t=e.target.closest(".cpx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),oe(t)):e.key==="Enter"&&e.target===t&&B(t.dataset.name))};p.addEventListener("click",z),p.addEventListener("keydown",Y),s.add(()=>{p.removeEventListener("click",z),p.removeEventListener("keydown",Y)}),te(n,{id:"components-new",icon:"plus",title:"New component",onClick:pe}),te(n,{id:"components-import",icon:"upload",title:"Import a .dmcomponent.json",onClick:()=>g.click()});const J=e=>{y?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",J),s.add(()=>window.removeEventListener("beforeunload",J)),s.add(()=>{if(y?.dirty())try{S.set(C,{...y.values(),at:new Date().toISOString()})}catch{}[...A].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(c),await H(),!x())return;let k=null;try{k=S.get(C)}catch{}if(k?.source){try{S.remove?S.remove(C):S.set(C,null)}catch{}const e=U(k.source);e&&await E.confirm(`You left a copy of "${w(e.name)}" unfinished. Pick up where you left off?`)&&x()&&V(e,{name:String(k.name??"")})}}};
@@ -1,5 +1,10 @@
1
- import{api as l}from"../api.js";import{filterByProject as u,getProjectFromHash as g}from"../lib/project-context.js";import{keepTableIconsAlive as m}from"../lib/table-icons.js";function o(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const contextMenusView={templateUrl:"/admin/js/templates/context-menus.html",async onMount(s){const r=E.loader(s.get(0),{type:"dots"});let c;try{c=await l.contextMenus.list().catch(()=>[])}finally{r.destroy()}const i=g(),d=i?u(c,i):c;T.create("#context-menus-table",{pageSize:25,data:d,emptyMessage:'No context menus yet. Click "New context menu" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:e=>`<code>${o(e)}</code>`},{key:"name",title:"Name",sortable:!0,render:(e,t)=>`<a href="#/context-menus/edit/${encodeURIComponent(t.slug)}" style="font-weight:600;">${o(e||t.slug)}</a>`},{key:"selector",title:"Binds to",render:(e,t)=>{const n=t.selector?`<code>${o(t.selector)}</code>`:'<span class="text-muted">-</span>',a=t.match?` &rarr; <code>${o(t.match)}</code>`:"";return n+a}},{key:"itemsCount",title:"Items",render:(e,t)=>String(t.itemsCount??0)},{key:"binding",title:"Scope",render:(e,t)=>{const n=t.binding;if(!n)return'<span class="badge badge-secondary">site-wide</span>';const a=[...(n.projects||[]).map(p=>`project:${p}`),...n.pages||[]];return a.length?`<span class="badge badge-info" data-tooltip="${o(a.join(", "))}">${a.length} rule${a.length===1?"":"s"}</span>`:'<span class="badge badge-warning" data-tooltip="Binding matches nothing, so this menu never appears">never matches</span>'}},{key:"enabled",title:"Enabled",render:(e,t)=>t.enabled===!1?'<span class="badge badge-secondary">off</span>':'<span class="badge badge-success">on</span>'},{key:"_actions",title:"Actions",render:(e,t)=>{const n=o(t.slug);return`
2
- <a class="btn btn-sm btn-ghost" href="#/context-menus/edit/${encodeURIComponent(t.slug)}" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- <button class="btn btn-sm btn-ghost btn-duplicate" data-slug="${n}" data-tooltip="Duplicate"><span data-icon="copy" data-icon-size="14"></span></button>
4
- <button class="btn btn-sm btn-ghost btn-delete" data-slug="${n}" data-tooltip="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
5
- `}}]}),Domma.icons.scan("#context-menus-table"),m(s.get(0)),document.querySelectorAll("#context-menus-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})}),s.off("click",".btn-duplicate").on("click",".btn-duplicate",async function(){const e=$(this).data("slug");try{const t=await l.contextMenus.duplicate(e);E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),location.reload()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}),s.off("click",".btn-delete").on("click",".btn-delete",async function(){const e=$(this).data("slug");if(await E.confirm(`Delete context menu "${e}"? This cannot be undone.`))try{await l.contextMenus.remove(e),E.toast("Context menu deleted.",{type:"success"}),location.reload()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}})}};
1
+ import{apiRequest as u}from"../api.js";import{addBannerAction as F}from"../lib/plugin-chrome.js";import{helpPopovers as K}from"../lib/help-popover.js";import{openMenuAt as $,sidebarCount as _,viewLifetime as H}from"../lib/tool-kit.js";import{filterByProject as G,getProjectFromHash as J}from"../lib/project-context.js";import{FILTERS as V,arrangeMenus as W,countMenus as Y,menuRow as z}from"../lib/context-menus-arrange.js";const R=/^#\/context-menus\/?(\?.*)?$/,Q="#/context-menus",g="context-menus.filter",X=`
2
+ <div class="cmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="cmx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
4
+ <span class="cmx-chips">
5
+ <span class="cmx-chip" data-bind-class="scopeCls" title="{{scopeTitle}}">{{scopeText}}</span>
6
+ <span class="cmx-chip is-ok" data-bind-hidden="off">On</span>
7
+ <span class="cmx-chip" data-bind-hidden="!off" title="Switched off - it never appears">Off</span>
8
+ </span>
9
+ <button type="button" class="cmx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
10
+ </div>`,Z=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),a=H(R),d={list:()=>u("/context-menus",{method:"GET"}),update:(n,o)=>u(`/context-menus/${encodeURIComponent(n)}`,{method:"PUT",body:JSON.stringify(o)}),remove:n=>u(`/context-menus/${encodeURIComponent(n)}`,{method:"DELETE"}),duplicate:n=>u(`/context-menus/${encodeURIComponent(n)}/duplicate`,{method:"POST"})};export const contextMenusView={templateUrl:"/admin/js/templates/context-menus.html",async onMount(n){a.reset();const o=n.get(0).querySelector(".cmx")||n.get(0),r=J(),h=()=>o.isConnected&&R.test(location.hash),c=M.observable([]),y=M.observable(!0),m=M.observable("");let b="all";try{b=V.includes(S.get(g))?S.get(g):"all"}catch{}const i=M.observable(b),v=M.observable("");a.add(M.effect(()=>{const e=i.value;try{S.set(g,e)}catch{}}));const w=M.observable([]);a.add(M.effect(()=>{w.value=W(c.value,{filter:i.value,query:v.value}).map(z)}));const L=M.computed(()=>Y(c.value)),A=M.computed(()=>c.value.length?"No context menus match.":"No context menus yet - New context menu in the banner makes one."),D=M.observable(r?`Showing the menus filed under the project "${r}".`:""),O=e=>c.peek().find(t=>t.slug===e),p=e=>{location.hash=`#/context-menus/edit/${encodeURIComponent(e)}`};async function x(){try{const e=await d.list();if(!h())return;const t=Array.isArray(e)?e:[];c.value=r?G(t,r):t,m.value=""}catch(e){m.value=`Could not load the context menus: ${e.message||e}`}finally{y.value=!1}}const f=async()=>{_(Q),h()&&await x()};async function C(e,t){try{await d.update(e.slug,{enabled:t}),E.toast(`"${e.name||e.slug}" ${t?"switched on":"switched off"}.`,{type:"success"}),await f()}catch(s){E.toast(`Not changed: ${s.message||s}`,{type:"error"})}}async function U(e){try{const t=await d.duplicate(e.slug);E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),await f()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}async function N(e){if(await E.confirm(`Delete the context menu "${Z(e.name||e.slug)}"? This cannot be undone.`))try{await d.remove(e.slug),E.toast("Context menu deleted.",{type:"success"}),await f()}catch(t){E.toast(`Delete failed: ${t.message||t}`,{type:"error"})}}async function j(e){try{await navigator.clipboard.writeText(e.slug),E.toast(`Copied "${e.slug}".`,{type:"success",duration:2e3})}catch{E.toast(`Could not copy - the slug is ${e.slug}`,{type:"warning"})}}function q(e){const t=O(e);if(!t)return[];const s=t.enabled===!1;return[{header:t.name||t.slug},{label:"Edit",icon:"edit",action:()=>p(t.slug)},s?{label:"Switch on",icon:"play",action:()=>C(t,!0)}:{label:"Switch off",icon:"pause",action:()=>C(t,!1)},{type:"divider"},{label:"Duplicate",icon:"copy",action:()=>U(t)},{label:"Copy slug",icon:"clipboard",action:()=>j(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>N(t)}]}const l=o.querySelector(".cmx-list");l.innerHTML=X;const P=M.applyBindings({menus:c,loading:y,loadError:m,filter:i,query:v,rows:w,counts:L,emptyText:A,projectNote:D},o,{methods:{showAll:()=>{i.value="all"},showOn:()=>{i.value="on"},showOff:()=>{i.value="off"}}});a.add(()=>P.dispose()),a.add(K(o,{position:"bottom"}));const B=E.contextMenu(l,{cursor:"glyph",match:".cmx-row",inherit:!1,density:"compact",items:e=>q(e.dataset.slug)});a.add(()=>B?.destroy?.());const T=e=>{const t=e.target.closest(".cmx-row");if(!t)return;const s=e.target.closest(".cmx-kebab");if(s){$(s);return}p(t.dataset.slug)},k=e=>{const t=e.target.closest(".cmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),$(t)):e.key==="Enter"&&e.target===t&&p(t.dataset.slug))};l.addEventListener("click",T),l.addEventListener("keydown",k),a.add(()=>{l.removeEventListener("click",T),l.removeEventListener("keydown",k)}),F(n,{id:"context-menus-new",icon:"plus",title:"New context menu",onClick:()=>{location.hash="#/context-menus/new"}}),I.scan(o),await x()}};
@@ -1 +1,31 @@
1
- import{apiRequest as a}from"/admin/js/api.js";export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(t){let e={};const f=E.loader(t.get(0),{type:"dots"});try{e=await a("/effects/settings")}catch{E.toast("Could not load settings.",{type:"error"})}finally{f.destroy()}t.find("#field-respect-motion").prop("checked",e.respectMotion!==!1),t.find("#field-default-duration").val(e.defaultDuration??600),t.find("#field-default-animation").val(e.defaultAnimation||"fade"),t.find("#field-default-threshold").val(e.defaultThreshold??.1),t.find("#save-settings-btn").off("click").on("click",async()=>{const s={respectMotion:t.find("#field-respect-motion").prop("checked"),defaultDuration:parseInt(t.find("#field-default-duration").val(),10)||600,defaultAnimation:t.find("#field-default-animation").val(),defaultThreshold:parseFloat(t.find("#field-default-threshold").val())||.1};try{await a("/effects/settings",{method:"PUT",body:JSON.stringify(s)}),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}}),E.tabs(t.find("#effects-tabs").get(0)),t.find(".effects-tab-btn").on("click",function(){const s=this.getAttribute("data-fx-tab");t.find(".effects-tab-btn").removeClass("active"),$(this).addClass("active"),t.find(".effects-tab-panel").hide(),t.find(`#tab-${s}`).show()}),t.find(".effects-tab-panel").hide(),t.find("#tab-entrance").show(),Domma.icons.scan()}};
1
+ import{apiRequest as ft}from"../api.js";import{addBannerAction as At}from"../lib/plugin-chrome.js";import{helpPopovers as pt}from"../lib/help-popover.js";import{onceClosed as jt,openMenuAt as mt}from"../lib/tool-kit.js";import{EFFECTS as D,getEffect as bt}from"../lib/effect-defs.js";import{arrangeEffects as yt,attrRow as $t,categoriesOf as Ft,changedFromDefaults as Rt,checkSettings as A,DEFAULT_SETTINGS as j,effectRow as ht,exampleShortcode as w,formFromSettings as p,settingsSnapshot as $}from"../lib/effects-arrange.js";const V=/^\/effects(\?.*)?$/,m="effects.draft",vt="effects.tab",It=`
2
+ <div class="fxx-row" data-name="{{name}}" tabindex="0">
3
+ <code class="fxx-tag">{{tag}}</code>
4
+ <span class="fxx-main"><strong>{{label}}</strong><small>{{description}}</small><small>{{meta}}</small></span>
5
+ <span class="fxx-chip is-info">{{category}}</span>
6
+ <span class="fxx-chip is-warn" data-bind-hidden="!fullPage" title="Runs full-page on the published site - the editor cannot preview it">Full page</span>
7
+ <button type="button" class="fxx-kebab" aria-label="Actions for {{label}}" title="Actions">\u22EE</button>
8
+ </div>`,Kt=`<button type="button" data-cat="{{key}}" data-bind-class="on && 'is-on'">{{label}} <b>{{count}}</b></button>`,Nt=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Bt(s){const r=document.createElement("template");return r.innerHTML=s.trim(),r.content.firstElementChild}async function k(s,r){try{await navigator.clipboard.writeText(s),E.toast(`${r} copied.`,{type:"success"})}catch{E.toast(`Could not reach the clipboard - select the ${r.toLowerCase()} and copy it.`,{type:"warning"})}}let f=null,gt=!1;function Pt(){gt||!window.R?.use||(gt=!0,R.use((s,r,o)=>!(V.test(r?.path||"")&&!V.test(s?.path||""))||!f?.dirty()?o():E.confirm("You have unsaved effect settings. Leave anyway? They are kept as a draft for your next visit.").then(i=>{i?o():history.replaceState(null,"","#/effects")})),M.subscribe("router:afterChange",({to:s}={})=>{if(f&&!V.test(s?.path||"")){const r=f;f=null,r.dispose()}}))}export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(s){if(Pt(),f){const t=f;f=null,t.dispose()}const r=[],o=t=>(r.push(t),t);let F=!1;const i=s.get(0).querySelector(".fxx")||s.get(0),C=()=>!F&&i.isConnected,b=M.observable({...j}),y=M.observable($(p(j))),xt=p(j),d=Object.fromEntries(Object.entries(xt).map(([t,e])=>[t,M.observable(e)])),K=()=>Object.fromEntries(Object.keys(d).map(t=>[t,d[t].peek()])),T=t=>{for(const[e,a]of Object.entries(t))d[e]&&(d[e].value=a)},N=M.observable(!1),J=M.observable(!1),L=M.computed(()=>A(Object.fromEntries(Object.keys(d).map(t=>[t,d[t].value])))),h=M.computed(()=>J.value&&$(Object.fromEntries(Object.keys(d).map(t=>[t,d[t].value])))!==y.value),B=t=>M.computed(()=>L.value.errors[t]||""),P=M.observable("all"),_=M.observable(""),Q=M.observable("entrance"),X=M.observable([]);o(M.effect(()=>{X.value=yt(D,{category:P.value,query:_.value}).map(ht)}));const Z=M.observable([]);o(M.effect(()=>{const t=P.value,e=_.value,a=n=>yt(D,{category:n,query:e}).length;Z.value=[{key:"all",label:"All",count:a("all"),on:t==="all"},...Ft(D).map(n=>({key:n,label:n,count:a(n),on:t===n}))]}));const tt={...d,saved:b,dirty:h,rows:X,cats:Z,query:_,guide:Q,effectCount:D.length,durErr:B("defaultDuration"),thrErr:B("defaultThreshold"),animErr:B("defaultAnimation"),cannotSave:M.computed(()=>N.value||!h.value||Object.keys(L.value.errors).length>0),motionText:M.computed(()=>b.value.respectMotion?"Respects reduced motion":"Ignores reduced motion"),defaultsText:M.computed(()=>{const t=Rt(b.value).length;return t?`${t} changed from Domma's defaults`:"Domma's defaults"})};async function Et(){try{const t=await ft("/effects/settings");if(!C())return;const e=p(t);b.value=A(e).payload,y.value=$(e),T(e)}catch(t){E.toast(`Could not load the effect settings: ${t.message||t}`,{type:"error"})}finally{J.value=!0}}async function H(){if(tt.cannotSave.peek()){Object.keys(L.peek().errors).length&&E.toast("Fix the highlighted settings first.",{type:"warning"});return}N.value=!0;const{payload:t}=L.peek();try{const a=(await ft("/effects/settings",{method:"PUT",body:JSON.stringify(t)}))?.settings||t;b.value=A(p(a)).payload,y.value=$(K());try{S.remove?S.remove(m):S.set(m,null)}catch{}E.toast("Effect settings saved - the site uses them on its next page load.",{type:"success"})}catch(e){E.toast(`Not saved: ${e.message||e}`,{type:"error"})}finally{N.value=!1}}async function St(){(!h.peek()||await E.confirm("Throw away the changes you have not saved?"))&&T(p(b.peek()))}function wt(){T(p(j)),E.toast("Domma's defaults are filled in - Save to use them.",{type:"info"})}const U=new Set;function W(t){const e=bt(t);if(!e)return;const a=ht(e),n=E.slideover({title:Nt(`${a.label} - ${a.tag}`),size:"md",position:"right"}),ct=(e.attrs||[]).map($t),l=Bt(`
9
+ <div class="fxx-form">
10
+ <p class="fxx-note" data-part="desc"></p>
11
+ <div class="fxx-chips" data-part="chips"></div>
12
+ <div>
13
+ <div class="fxx-sub">The shortcode
14
+ <span data-help="The bare tag runs with every setting at its default; the full form spells them all out, ready to edit."></span></div>
15
+ <pre data-part="bare"></pre>
16
+ </div>
17
+ <div data-part="full-wrap">
18
+ <div class="fxx-sub">With every setting</div>
19
+ <pre data-part="full"></pre>
20
+ </div>
21
+ <div data-part="attrs-wrap">
22
+ <div class="fxx-sub">Settings</div>
23
+ <table class="fxx-attrs"><thead><tr><th>Attribute</th><th>Takes</th><th>Default</th></tr></thead><tbody data-part="attrs"></tbody></table>
24
+ </div>
25
+ <div class="fxx-actions">
26
+ <span class="fxx-spacer"></span>
27
+ <button type="button" class="btn btn-sm btn-ghost" data-act="close">Close</button>
28
+ <button type="button" class="btn btn-sm btn-outline" data-act="copy-full">Copy full</button>
29
+ <button type="button" class="btn btn-sm btn-primary" data-act="copy"><span data-icon="copy" data-icon-size="13"></span> Copy shortcode</button>
30
+ </div>
31
+ </div>`);l.querySelector('[data-part="desc"]').textContent=e.description||"";const qt=l.querySelector('[data-part="chips"]'),O=(c,u="")=>{const x=document.createElement("span");x.className=`fxx-chip ${u}`,x.textContent=c,qt.appendChild(x)};O(e.category,"is-info"),O(e.selfClosing?"Self-closing":"Wraps content"),e.previewable===!1&&O("Full page","is-warn"),(e.hosts||[]).length&&O(`Also as an attribute of ${e.hosts.map(c=>`[${c}]`).join(" ")}`);const z=w(e),G=w(e,{full:!0});l.querySelector('[data-part="bare"]').textContent=z,l.querySelector('[data-part="full"]').textContent=G,l.querySelector('[data-part="full-wrap"]').hidden=G===z;const Ot=l.querySelector('[data-part="attrs"]');ct.length||(l.querySelector('[data-part="attrs-wrap"]').hidden=!0);for(const c of ct){const u=document.createElement("tr"),x=document.createElement("td"),lt=document.createElement("code");lt.textContent=c.name;const it=document.createElement("small");it.textContent=c.label+(c.hint?` - ${c.hint}`:""),x.append(lt,it);const dt=document.createElement("td");dt.textContent=c.takes;const ut=document.createElement("td");ut.textContent=c.def,u.append(x,dt,ut),Ot.appendChild(u)}l.addEventListener("click",c=>{const u=c.target.closest("[data-act]")?.dataset.act;u==="close"?n.close():u==="copy"?k(z,"Shortcode"):u==="copy-full"&&k(G,"Shortcode")}),n.element.appendChild(l);const Dt=pt(l);I.scan(l),U.add(n),jt(n,()=>{Dt(),U.delete(n)}),n.open()}function kt(t){const e=bt(t);if(!e)return[];const a=w(e,{full:!0});return[{header:`${e.label} [${e.name}]`},{label:"Details\u2026",icon:"eye",action:()=>W(t)},{type:"divider"},{label:"Copy shortcode",icon:"copy",action:()=>k(w(e),"Shortcode")},{label:"Copy with every setting",icon:"copy",disabled:a===w(e),action:()=>k(a,"Shortcode")},{label:"Copy tag name",icon:"code",action:()=>k(e.name,"Tag name")}]}const v=i.querySelector(".fxx-list");v.innerHTML=It;const Y=i.querySelector(".fxx-cats");Y.innerHTML=Kt;const q=i.querySelector(".fxx-tabs"),Ct=E.tabs(q,{tabSelector:".tab-item",panelSelector:".tab-panel"}),Mt=M.applyBindings(tt,i,{methods:{save:()=>H(),discard:()=>St(),resetDefaults:()=>wt(),showGuide:t=>{Q.value=t}}});o(()=>Mt.dispose()),o(pt(i,{position:"bottom"}));const et=t=>{const e=t.target.closest("[data-cat]");e&&(P.value=e.dataset.cat)};Y.addEventListener("click",et),o(()=>Y.removeEventListener("click",et));try{const t=Number(S.get(vt));Number.isInteger(t)&&t>0&&t<3&&Ct.activate?.(t)}catch{}const Tt=[...q.querySelectorAll(":scope > .tab-list > .tab-item")],at=t=>{const e=Tt.indexOf(t.target.closest(".tab-item"));if(e>=0)try{S.set(vt,e)}catch{}};q.querySelector(".tab-list").addEventListener("click",at),o(()=>q.querySelector(".tab-list")?.removeEventListener("click",at));const Lt=E.contextMenu(v,{cursor:"glyph",match:".fxx-row",inherit:!1,density:"compact",items:t=>kt(t.dataset.name)});o(()=>Lt?.destroy?.());const st=t=>{const e=t.target.closest(".fxx-row");if(!e)return;const a=t.target.closest(".fxx-kebab");if(a){mt(a);return}W(e.dataset.name)},ot=t=>{const e=t.target.closest(".fxx-row");e&&(t.key==="F10"&&t.shiftKey?(t.preventDefault(),mt(e)):t.key==="Enter"&&t.target===e&&W(e.dataset.name))};v.addEventListener("click",st),v.addEventListener("keydown",ot),o(()=>{v.removeEventListener("click",st),v.removeEventListener("keydown",ot)});const nt=t=>{(t.ctrlKey||t.metaKey)&&t.key==="s"&&C()&&(t.preventDefault(),H())};document.addEventListener("keydown",nt),o(()=>document.removeEventListener("keydown",nt)),At(s,{id:"effects-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>H()});const rt=t=>{if(h.peek()){try{S.set(m,{form:K(),base:y.peek(),at:new Date().toISOString()})}catch{}t.preventDefault(),t.returnValue=""}};if(window.addEventListener("beforeunload",rt),o(()=>window.removeEventListener("beforeunload",rt)),f={dirty:()=>h.peek(),dispose(){if(F=!0,h.peek())try{S.set(m,{form:K(),base:y.peek(),at:new Date().toISOString()})}catch{}[...U].forEach(t=>{try{t.close()}catch{}}),r.splice(0).forEach(t=>{try{t()}catch{}})}},I.scan(i),await Et(),!C())return;let g=null;try{g=S.get(m)}catch{}if(g?.form){try{S.remove?S.remove(m):S.set(m,null)}catch{}const e=`You left unsaved effect settings.${g.base&&g.base!==y.peek()?" The saved settings have changed since - restoring puts yours over them.":""} Restore them?`;await E.confirm(e)&&C()&&T(p({...A(g.form).payload,...g.form}))}}};