domma-cms 0.73.0 → 0.75.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 (64) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/actions-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoints-arrange.js +1 -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/forms-arrange.js +1 -0
  11. package/admin/js/lib/media-arrange.js +1 -0
  12. package/admin/js/lib/menus-arrange.js +1 -0
  13. package/admin/js/lib/pages-arrange.js +1 -0
  14. package/admin/js/lib/projects-arrange.js +1 -0
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/theme-settings-arrange.js +1 -0
  17. package/admin/js/lib/views-arrange.js +1 -0
  18. package/admin/js/templates/actions-list.html +73 -14
  19. package/admin/js/templates/api-endpoints.html +63 -10
  20. package/admin/js/templates/blocks.html +76 -15
  21. package/admin/js/templates/collections.html +108 -52
  22. package/admin/js/templates/components.html +82 -15
  23. package/admin/js/templates/context-menus.html +55 -13
  24. package/admin/js/templates/forms.html +67 -14
  25. package/admin/js/templates/media.html +99 -13
  26. package/admin/js/templates/menus.html +57 -11
  27. package/admin/js/templates/pages.html +78 -23
  28. package/admin/js/templates/projects.html +69 -10
  29. package/admin/js/templates/theme.html +164 -132
  30. package/admin/js/templates/views-list.html +63 -9
  31. package/admin/js/views/actions-list.js +37 -1
  32. package/admin/js/views/api-endpoints.js +11 -7
  33. package/admin/js/views/blocks.js +34 -5
  34. package/admin/js/views/collections.js +36 -1
  35. package/admin/js/views/components.js +27 -5
  36. package/admin/js/views/context-menus.js +10 -5
  37. package/admin/js/views/forms.js +57 -1
  38. package/admin/js/views/index.js +1 -1
  39. package/admin/js/views/media.js +40 -1
  40. package/admin/js/views/menus.js +11 -5
  41. package/admin/js/views/pages.js +28 -8
  42. package/admin/js/views/projects.js +58 -3
  43. package/admin/js/views/theme.js +55 -1
  44. package/admin/js/views/views-list.js +11 -1
  45. package/package.json +1 -1
  46. package/server/routes/api/actions.js +64 -2
  47. package/server/routes/api/api-endpoints.js +41 -0
  48. package/server/routes/api/blocks.js +170 -8
  49. package/server/routes/api/collections.js +53 -1
  50. package/server/routes/api/components.js +46 -0
  51. package/server/routes/api/context-menus.js +8 -0
  52. package/server/routes/api/forms.js +123 -10
  53. package/server/routes/api/media.js +56 -1
  54. package/server/routes/api/menus.js +53 -6
  55. package/server/routes/api/pages.js +46 -0
  56. package/server/routes/api/projects.js +16 -2
  57. package/server/routes/api/theme.js +55 -0
  58. package/server/routes/api/views.js +27 -3
  59. package/server/server.js +19 -0
  60. package/server/services/contextMenus.js +41 -0
  61. package/server/services/projects.js +86 -26
  62. package/server/services/views.js +38 -0
  63. package/admin/js/templates/project-editor.html +0 -45
  64. package/admin/js/views/project-editor.js +0 -1
@@ -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,57 @@
1
- import{apiRequest as p}from"/admin/js/api.js";import{filterByProject as b,getProjectFromHash as h,renderProjectFilterNotice as C}from"/admin/js/lib/project-context.js";import{keepTableIconsAlive as x}from"/admin/js/lib/table-icons.js";function u(c){return String(c).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(c){await g(c),x(c.get(0)),c.off("click",".js-delete-form").on("click",".js-delete-form",async function(){const d=this.dataset.slug,e=this.dataset.plugin,i=e?`This form is managed by the <strong>${e}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete form "${d}" and all its submissions? This cannot be undone.`;if(await E.confirm(i))try{await p(`/forms/${d}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),await g(c)}catch{E.toast("Failed to delete form.",{type:"error"})}}),c.off("click",".js-clone-form").on("click",".js-clone-form",async function(){const d=this.dataset.slug;try{const e=await p(`/forms/${d}`),{collection:i,...t}=e.actions||{},n=await p("/forms",{method:"POST",body:JSON.stringify({title:`${e.title} (Copy)`,description:e.description||"",fields:e.fields||[],settings:e.settings||{},actions:t})});E.toast(`Form duplicated as "${n.title}".`,{type:"success"}),R.navigate(`/forms/edit/${n.slug}`)}catch(e){E.toast(e.message||"Failed to duplicate form.",{type:"error"})}}),c.find("#create-form-btn").off("click").on("click",()=>{const d=E.slideover({title:"Create Form",size:"sm",position:"right"}),e=document.createElement("div");e.style.cssText="padding:.25rem 0 .5rem;";const i=document.createElement("div");F.create({title:{type:"string",label:"Form Title",placeholder:"e.g. Contact, Feedback\u2026",required:!0}},{},{showSubmitButton:!1}).renderTo(i),e.appendChild(i);const t=document.createElement("div");t.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const n=document.createElement("button");n.className="btn btn-ghost",n.textContent="Cancel";const o=document.createElement("button");o.className="btn btn-primary",o.textContent="Create",t.appendChild(n),t.appendChild(o),e.appendChild(t),d.element.appendChild(e),d.open();const s=i.querySelector('input[name="title"]');setTimeout(()=>s?.focus(),50);async function a(){const l=s?.value.trim();if(l)try{const r=await p("/forms",{method:"POST",body:JSON.stringify({title:l})});d.close(),R.navigate(`/forms/edit/${r.slug}`)}catch(r){E.toast(r.message||"Failed to create form.",{type:"error"})}}n.addEventListener("click",()=>d.close()),o.addEventListener("click",a),s?.addEventListener("keydown",l=>{l.key==="Enter"&&a()})}),c.find("#test-email-btn").off("click").on("click",async()=>{const e=(window.__CMS_SITE__?.smtp||{}).fromAddress||"",i=E.slideover({title:"Send Test Email",size:"sm",position:"right"}),t=document.createElement("div");t.style.cssText="padding:.25rem 0 .5rem;";const n=document.createElement("div");n.className="mb-3";const o=document.createElement("label");o.className="form-label",o.textContent="Send to";const s=document.createElement("input");s.type="email",s.className="form-input",s.value=e,s.placeholder="test@example.com",n.appendChild(o),n.appendChild(s),t.appendChild(n);const a=document.createElement("p");a.className="text-muted",a.style.cssText="font-size:.8rem;margin-bottom:.75rem;",a.textContent="SMTP settings are configured in Site Settings.",t.appendChild(a);const l=document.createElement("div");l.style.cssText="display:flex;justify-content:flex-end;gap:1rem;margin-top:.75rem;";const r=document.createElement("button");r.className="btn btn-ghost",r.textContent="Cancel";const m=document.createElement("button");m.className="btn btn-primary",m.textContent="Send",l.appendChild(r),l.appendChild(m),t.appendChild(l),i.element.appendChild(t),i.open(),setTimeout(()=>s?.focus(),50),r.addEventListener("click",()=>i.close()),m.addEventListener("click",async()=>{const f=s.value.trim();if(f){m.disabled=!0;try{await p("/forms/test-email",{method:"POST",body:JSON.stringify({to:f})}),i.close(),E.toast("Test email sent.",{type:"success"})}catch(y){E.toast(y.message||"Failed to send test email.",{type:"error"})}finally{m.disabled=!1}}})}),Domma.icons.scan()}};async function g(c){const d=E.loader(c.get(0),{type:"dots"});let e=[];try{e=await p("/forms")}catch{E.toast("Could not load forms.",{type:"error"})}finally{d.destroy()}const i=h();if(i){const t=e.length;e=b(e,i),C(c,{total:t,shown:e.length,slug:i,allHref:"#/forms",noun:"forms"})}T.create("#forms-table",{pageSize:25,data:e,columns:[{key:"title",title:"Title",render:(t,n)=>{const o=document.createElement("span");o.style.cssText="display:inline-flex;align-items:center;gap:.4rem;flex-wrap:wrap;";const s=document.createElement("a");if(s.href=`#/forms/edit/${u(n.slug)}`,s.textContent=t,s.style.fontWeight="600",o.appendChild(s),n.plugin){const a=document.createElement("span");a.className="badge badge-outline",a.textContent=n.plugin,a.title=`Managed by the ${n.plugin} plugin`,a.style.cssText="font-size:0.65rem;padding:1px 6px;color:var(--dm-warning,#d97706);border-color:var(--dm-warning,#d97706);flex-shrink:0;",o.appendChild(a)}return o.outerHTML}},{key:"slug",title:"Slug",render:t=>{const n=document.createElement("code");return n.textContent=t,n.outerHTML}},{key:"fields",title:"Field Count",render:t=>String(t?.length??0)},{key:"submissionCount",title:"Submission Count",render:t=>String(t??0)},{key:"slug",title:"Actions",render:(t,n)=>{const o=document.createElement("div");o.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const s=document.createElement("a");s.href=`#/forms/edit/${u(t)}`,s.className="btn btn-sm btn-ghost",s.setAttribute("data-tooltip","Edit"),s.innerHTML='<span data-icon="edit" data-icon-size="14"></span>';const a=document.createElement("a");a.href=`#/forms/${u(t)}/submissions`,a.className="btn btn-sm btn-ghost",a.setAttribute("data-tooltip","Submissions"),a.innerHTML='<span data-icon="inbox" data-icon-size="14"></span>';const l=document.createElement("button");l.className="btn btn-sm btn-ghost js-clone-form",l.dataset.slug=t,l.setAttribute("data-tooltip","Duplicate"),l.innerHTML='<span data-icon="copy" data-icon-size="14"></span>';const r=document.createElement("button");return r.className="btn btn-sm btn-ghost js-delete-form",r.dataset.slug=t,r.dataset.plugin=n.plugin||"",r.setAttribute("data-tooltip","Delete"),r.innerHTML='<span data-icon="trash" data-icon-size="14"></span>',o.appendChild(s),o.appendChild(a),o.appendChild(l),o.appendChild(r),o.outerHTML}}],emptyMessage:'No forms yet. Click "Create Form" to get started.'}),Domma.icons.scan(),document.querySelectorAll("#forms-table [data-tooltip]").forEach(t=>{E.tooltip(t,{content:t.getAttribute("data-tooltip"),position:"top"})})}
1
+ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/admin/js/lib/plugin-chrome.js";import{helpPopovers as J}from"/admin/js/lib/help-popover.js";import{onceClosed as me,openMenuAt as Y,sidebarCount as R,viewLifetime as fe}from"/admin/js/lib/tool-kit.js";import{filterByProject as pe,getProjectFromHash as be,renderProjectFilterNotice as ve}from"/admin/js/lib/project-context.js";import{FILTERS as ge,arrangeForms as he,duplicatePayload as ye,formRow as we,shortcodeFor as xe}from"/admin/js/lib/forms-arrange.js";const V=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,N="#/forms",w="forms.draft",P="forms.filter",Ee=`
2
+ <div class="fmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
3
+ <span class="fmx-icon" aria-hidden="true"><span data-icon="layout" data-icon-size="16"></span></span>
4
+ <span class="fmx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
5
+ <span class="fmx-chips">
6
+ <span class="fmx-chip is-ok" data-bind-hidden="!week">{{weekText}}</span>
7
+ <span class="fmx-chip" data-bind-hidden="!wizard">Wizard</span>
8
+ <span class="fmx-chip" data-bind-hidden="!project" title="Project">{{project}}</span>
9
+ <span class="fmx-chip is-warn" data-bind-hidden="!plugin" title="Managed by the {{plugin}} plugin">{{plugin}}</span>
10
+ </span>
11
+ <span class="fmx-count" title="Submissions">{{totalText}}</span>
12
+ <button type="button" class="fmx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
13
+ </div>`,x=l=>String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");function ke(l){const r=document.createElement("template");return r.innerHTML=l.trim(),r.content.firstElementChild}const G=l=>String(l??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),i=fe(V);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(l){i.reset();const r=l.get(0).querySelector(".fmx")||l.get(0),h=()=>V.test(location.hash)&&r.isConnected,k=be(),u=M.observable([]),A=M.observable(!0),T=M.observable("");let q="all";try{q=ge.includes(S.get(P))?S.get(P):"all"}catch{}const p=M.observable(q),j=M.observable("");i.add(M.effect(()=>{const t=p.value;try{S.set(P,t)}catch{}}));const C=M.observable([]);i.add(M.effect(()=>{const t=Date.now();C.value=he(u.value,{filter:p.value,query:j.value}).map(e=>we(e,t))}));const Q=M.computed(()=>{const t=u.value;return{all:t.length,recent:t.filter(e=>Number(e.submissionsThisWeek)>0).length,empty:t.filter(e=>!Number(e.submissionCount)).length}}),X=M.computed(()=>{const t=u.value.reduce((e,s)=>e+(Number(s.submissionCount)||0),0);return u.value.length?`${t} submission${t===1?"":"s"} in all`:""}),Z=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),$=t=>u.peek().find(e=>e.slug===t);async function O(){try{let t=await v("/forms");if(!h())return;if(t=Array.isArray(t)?t:[],k){const e=t.length;t=pe(t,k),ve(r,{total:e,shown:t.length,slug:k,allHref:"#/forms",noun:"forms"})}u.value=t,T.value=""}catch(t){T.value=`Could not load the forms: ${t.message||t}`}finally{A.value=!1}}const g=t=>{location.hash=`#/forms/edit/${encodeURIComponent(t)}`},ee=t=>{location.hash=`#/forms/${encodeURIComponent(t)}/submissions`};async function te(t){const e=xe(t.slug);try{await navigator.clipboard.writeText(e),E.toast(`Copied ${e}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${e}`,{type:"info",duration:8e3})}}async function se(t){const e=t.plugin?`This form is managed by the <strong>${x(t.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${x(t.title||t.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(e))try{await v(`/forms/${encodeURIComponent(t.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),R(N),h()&&await O()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const D=new Set;let b=null,_=!1;function L({title:t,html:e,model:s,methods:c,dirty:d=()=>!1,cleanup:n=()=>{}}){const a=E.slideover({title:t,size:"sm",position:"right"}),o=ke(e);a.element.appendChild(o);const f=J(o),re=M.applyBindings(s,o,{methods:c});D.add(a),me(a,()=>{n(),re.dispose(),f(),D.delete(a)});const ce=a.close.bind(a);let K=!1,F=!1;return a.close=async de=>{if(!K&&de!==!0&&!_&&d()){if(F)return;F=!0;const ue=await E.confirm("Discard what you have typed?");if(F=!1,!ue)return}return K=!0,ce()},I.scan(o),a.open(),{so:a,body:o}}function z(t=null){const e={title:M.observable(t?.title??""),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.title.value.trim()?e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),e.hint=M.computed(()=>e.slug.value&&!e.err.value?`Its address will be "${e.slug.value}" - [form name="${e.slug.value}" /].`:""),e.cannot=M.computed(()=>e.saving.value||!e.title.value.trim()||!!e.err.value);const s=()=>!!e.title.peek().trim(),c={kind:"create",dirty:s,values:()=>({title:e.title.peek()})};async function d(){if(!e.cannot.peek()){e.saving.value=!0;try{const f=await v("/forms",{method:"POST",body:JSON.stringify({title:e.title.peek().trim()})});R(N),n.close(!0),g(f.slug)}catch(f){E.toast(f.message||"Failed to create the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:n,body:a}=L({title:"New form",model:e,dirty:s,cleanup:()=>{b===c&&(b=null)},methods:{save:d,cancel:()=>n.close()},html:`
14
+ <div class="fmx-form">
15
+ <div>
16
+ <label class="form-label" for="fmx-new-title">Title
17
+ <span data-help="What the form is called here and in its collection of submissions. Its address (the slug) is made from it and cannot be changed later."></span></label>
18
+ <input id="fmx-new-title" type="text" class="form-input" maxlength="100" placeholder="e.g. Contact, Feedback\u2026" data-model="title.value">
19
+ <small class="fmx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
20
+ <small class="fmx-hint" data-bind-hidden="!hint.value" data-bind-text="hint.value"></small>
21
+ </div>
22
+ <div class="fmx-actions">
23
+ <small class="fmx-hint">Enter creates it and opens the editor.</small>
24
+ <span class="fmx-spacer"></span>
25
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
26
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
27
+ <span data-icon="plus" data-icon-size="13"></span> Create</button>
28
+ </div>
29
+ </div>`});b=c;const o=a.querySelector("#fmx-new-title");o.addEventListener("keydown",f=>{f.key==="Enter"&&(f.preventDefault(),d())}),setTimeout(()=>o?.focus(),50)}function ae(t){const e={title:M.observable(`${t.title||t.slug} (Copy)`),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}".`:"":"The title needs at least one letter or number."),e.cannot=M.computed(()=>e.saving.value||!!e.err.value);async function s(){if(!e.cannot.peek()){e.saving.value=!0;try{const a=await v(`/forms/${encodeURIComponent(t.slug)}`),o=await v("/forms",{method:"POST",body:JSON.stringify(ye(a,e.title.peek()))});E.toast(`Duplicated as "${o.title}".`,{type:"success"}),R(N),c.close(!0),g(o.slug)}catch(a){E.toast(a.message||"Failed to duplicate the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:c,body:d}=L({title:`Duplicate ${x(t.title||t.slug)}`,model:e,methods:{save:s,cancel:()=>c.close()},html:`
30
+ <div class="fmx-form">
31
+ <div>
32
+ <label class="form-label" for="fmx-dup-title">Title of the copy</label>
33
+ <input id="fmx-dup-title" type="text" class="form-input" maxlength="100" data-model="title.value">
34
+ <small class="fmx-err" data-bind-hidden="!err.value" data-bind-text="err.value"></small>
35
+ <small class="fmx-hint">Fields, settings, email and webhook are copied. The copy gets its own, empty collection of submissions.</small>
36
+ </div>
37
+ <div class="fmx-actions">
38
+ <span class="fmx-spacer"></span>
39
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
40
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
41
+ <span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
42
+ </div>
43
+ </div>`}),n=d.querySelector("#fmx-dup-title");n.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),s())}),setTimeout(()=>{n?.focus(),n?.select()},50)}function ne(){const t={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};t.cannot=M.computed(()=>t.sending.value||!/^[^\s@]+@[^\s@]+$/.test(t.to.value.trim()));async function e(){if(!t.cannot.peek()){t.sending.value=!0;try{await v("/forms/test-email",{method:"POST",body:JSON.stringify({to:t.to.peek().trim()})}),s.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{t.sending.value=!1}}}const{so:s,body:c}=L({title:"Send test email",model:t,methods:{send:e,cancel:()=>s.close()},html:`
44
+ <div class="fmx-form">
45
+ <div>
46
+ <label class="form-label" for="fmx-test-to">Send to
47
+ <span data-help="Sends one email through the site's SMTP settings (Site Settings), so you can check a form's email action will be delivered before anyone relies on it."></span></label>
48
+ <input id="fmx-test-to" type="email" class="form-input" placeholder="test@example.com" data-model="to.value">
49
+ <small class="fmx-hint">SMTP settings are configured in Site Settings.</small>
50
+ </div>
51
+ <div class="fmx-actions">
52
+ <span class="fmx-spacer"></span>
53
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
54
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="cannot.value">
55
+ <span data-icon="mail" data-icon-size="13"></span> Send</button>
56
+ </div>
57
+ </div>`}),d=c.querySelector("#fmx-test-to");d.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),e())}),setTimeout(()=>d?.focus(),50)}function oe(t){const e=$(t);if(!e)return[];const s=Number(e.submissionCount)||0;return[{header:e.title||e.slug},{label:"Edit",icon:"edit",action:()=>g(e.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ee(e.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>te(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>ae(e)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>se(e)}]}const m=r.querySelector(".fmx-list");m.innerHTML=Ee;const le=M.applyBindings({forms:u,loading:A,loadError:T,filter:p,query:j,rows:C,counts:Q,summary:X,emptyText:Z},r,{methods:{showAll:()=>{p.value="all"},showRecent:()=>{p.value="recent"},showEmpty:()=>{p.value="empty"}}});i.add(()=>le.dispose()),i.add(J(r,{position:"bottom"})),i.add(M.effect(()=>{C.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ie=E.contextMenu(m,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:t=>oe(t.dataset.slug)});i.add(()=>ie?.destroy?.());const B=t=>{const e=t.target.closest(".fmx-row");if(!e)return;const s=t.target.closest(".fmx-kebab");if(s){Y(s);return}g(e.dataset.slug)},U=t=>{const e=t.target.closest(".fmx-row");e&&(t.key==="F10"&&t.shiftKey?(t.preventDefault(),Y(e)):t.key==="Enter"&&t.target===e&&g(e.dataset.slug))};m.addEventListener("click",B),m.addEventListener("keydown",U),i.add(()=>{m.removeEventListener("click",B),m.removeEventListener("keydown",U)}),W(l,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>z()}),W(l,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ne()});const H=t=>{b?.dirty()&&(t.preventDefault(),t.returnValue="")};if(window.addEventListener("beforeunload",H),i.add(()=>window.removeEventListener("beforeunload",H)),i.add(()=>{if(b?.dirty())try{S.set(w,{values:b.values(),at:new Date().toISOString()})}catch{}_=!0,[...D].forEach(t=>{try{t.close(!0)}catch{}})}),I.scan(r),await O(),!h())return;let y=null;try{y=S.get(w)}catch{}if(y?.values?.title){try{S.remove?S.remove(w):S.set(w,null)}catch{}await E.confirm(`You started a new form, "${x(y.values.title)}", and left it. Pick up where you left off?`)&&h()&&z(y.values)}}};
@@ -1 +1 @@
1
- import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{navigationView as m}from"./navigation.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as n}from"./layouts.js";import{mediaView as s}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as d}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as E}from"./doc-pages.js";import{apiReferenceView as u}from"./api-reference.js";import{collectionsView as g}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as b}from"./collection-entries.js";import{formsView as h}from"./forms.js";import{formEditorView as j}from"./form-editor.js";import{formSubmissionsView as y}from"./form-submissions.js";import{viewsListView as k}from"./views-list.js";import{viewEditorView as L}from"./view-editor.js";import{viewPreviewView as x}from"./view-preview.js";import{actionsListView as M}from"./actions-list.js";import{actionEditorView as D}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import"./block-editor-enhance.js";import{componentsView as C}from"./components.js";import{componentEditorView as T}from"./component-editor.js";import{myProfileView as U}from"./my-profile.js";import{rolesView as q}from"./roles.js";import{roleEditorView as z}from"./role-editor.js";import{effectsView as A}from"./effects.js";import{searchView as B}from"./search.js";import{notesView as F}from"./notes.js";import{todoView as G}from"./todo.js";import{analyticsView as H}from"./analytics.js";import{contactsView as I}from"./contacts.js";import{menusView as J}from"./menus.js";import{contextMenusView as K}from"./context-menus.js";import{contextMenuEditorView as N}from"./context-menu-editor.js";import{menuEditorView as O}from"./menu-editor.js";import{menuLocationsView as Q}from"./menu-locations.js";import{projectsView as W}from"./projects.js";import{projectEditorView as X}from"./project-editor.js";import{projectDetailView as Y}from"./project-detail.js";import{projectSettingsView as Z}from"./project-settings.js";import{apiTokensView as _}from"./api-tokens.js";import{apiEndpointsView as $}from"./api-endpoints.js";import{apiEndpointEditorView as oo}from"./api-endpoint-editor.js";const io={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:W,projectEditor:X,projectDetail:Y,projectSettings:Z,apiTokens:_,apiEndpoints:$,apiEndpointEditor:oo,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,navigation:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:d,tutorials:l,apiReference:u,collections:g,collectionEditor:v,collectionEntries:b,forms:h,formEditor:j,formSubmissions:y,viewsList:k,viewEditor:L,viewPreview:x,actionsList:M,actionEditor:D,proDocs:P,blocks:R,blockEditor:S,components:C,componentEditor:T,myProfile:U,roles:q,roleEditor:z,effects:A,search:B,notes:F,todo:G,analytics:H,contacts:I,notifications:p,menus:J,contextMenus:K,contextMenuEditor:N,menuEditor:O,menuLocations:Q,menusRedirect:io,...E};
1
+ import{dashboardView as o}from"./dashboard.js";import{pagesView as i}from"./pages.js";import{pageEditorView as r}from"./page-editor.js";import{settingsView as t}from"./settings.js";import{themeView as e}from"./theme.js";import{navigationView as m}from"./navigation.js";import{notificationsView as p}from"./notifications.js";import{layoutsView as n}from"./layouts.js";import{mediaView as s}from"./media.js";import{loginView as f}from"./login.js";import{usersView as w}from"./users.js";import{userEditorView as c}from"./user-editor.js";import{pluginsView as V}from"./plugins.js";import{pluginCodeView as a}from"./plugin-code.js";import{documentationView as d}from"./documentation.js";import{tutorialsView as l}from"./tutorials.js";import{docViews as u}from"./doc-pages.js";import{apiReferenceView as E}from"./api-reference.js";import{collectionsView as g}from"./collections.js";import{collectionEditorView as v}from"./collection-editor.js";import{collectionEntriesView as b}from"./collection-entries.js";import{formsView as h}from"./forms.js";import{formEditorView as y}from"./form-editor.js";import{formSubmissionsView as j}from"./form-submissions.js";import{viewsListView as k}from"./views-list.js";import{viewEditorView as L}from"./view-editor.js";import{viewPreviewView as x}from"./view-preview.js";import{actionsListView as M}from"./actions-list.js";import{actionEditorView as D}from"./action-editor.js";import{proDocsView as P}from"./pro-docs.js";import{blocksView as R}from"./blocks.js";import{blockEditorView as S}from"./block-editor.js";import"./block-editor-enhance.js";import{componentsView as C}from"./components.js";import{componentEditorView as T}from"./component-editor.js";import{myProfileView as U}from"./my-profile.js";import{rolesView as q}from"./roles.js";import{roleEditorView as z}from"./role-editor.js";import{effectsView as A}from"./effects.js";import{searchView as B}from"./search.js";import{notesView as F}from"./notes.js";import{todoView as G}from"./todo.js";import{analyticsView as H}from"./analytics.js";import{contactsView as I}from"./contacts.js";import{menusView as J}from"./menus.js";import{contextMenusView as K}from"./context-menus.js";import{contextMenuEditorView as N}from"./context-menu-editor.js";import{menuEditorView as O}from"./menu-editor.js";import{menuLocationsView as Q}from"./menu-locations.js";import{projectsView as W}from"./projects.js";import{projectDetailView as X}from"./project-detail.js";import{projectSettingsView as Y}from"./project-settings.js";import{apiTokensView as Z}from"./api-tokens.js";import{apiEndpointsView as _}from"./api-endpoints.js";import{apiEndpointEditorView as $}from"./api-endpoint-editor.js";const oo={templateUrl:"",async onMount(){location.hash="#/menus"}};export const views={projects:W,projectDetail:X,projectSettings:Y,apiTokens:Z,apiEndpoints:_,apiEndpointEditor:$,dashboard:o,pages:i,pageEditor:r,settings:t,theme:e,navigation:m,layouts:n,media:s,login:f,users:w,userEditor:c,plugins:V,pluginCode:a,documentation:d,tutorials:l,apiReference:E,collections:g,collectionEditor:v,collectionEntries:b,forms:h,formEditor:y,formSubmissions:j,viewsList:k,viewEditor:L,viewPreview:x,actionsList:M,actionEditor:D,proDocs:P,blocks:R,blockEditor:S,components:C,componentEditor:T,myProfile:U,roles:q,roleEditor:z,effects:A,search:B,notes:F,todo:G,analytics:H,contacts:I,notifications:p,menus:J,contextMenus:K,contextMenuEditor:N,menuEditor:O,menuLocations:Q,menusRedirect:oo,...u};
@@ -1 +1,40 @@
1
- import{api as m}from"../api.js";import{openImageEditor as B}from"../lib/image-editor.js";export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(t){const i=c=>{const s=t.find("#media-grid").empty().get(0);if(!c.length){const e=document.createElement("p");e.className="text-muted",e.textContent="No media files yet. Upload one above.",s.appendChild(e);return}c.forEach(e=>{const r=/\.(png|jpe?g|gif|webp|svg)$/i.test(e.name),p=/\.(png|jpe?g|gif|webp|tiff?)$/i.test(e.name),n=document.createElement("div");n.className="media-card";const u=document.createElement("div");if(u.className="media-preview",r){const o=document.createElement("img");o.src=e.url+"?t="+Date.now(),o.alt=e.name,o.className="media-thumb",u.appendChild(o)}else{const o=document.createElement("div");o.className="media-thumb media-thumb--file";const w=document.createElement("span");w.setAttribute("data-icon","file"),o.appendChild(w),u.appendChild(o)}n.appendChild(u);const C=document.createElement("div");C.className="media-info";const l=document.createElement("span");l.className="media-name",l.title="Double-click to rename",l.textContent=e.name;const v=document.createElement("span");v.className="media-size",v.textContent=L(e.size),C.appendChild(l),C.appendChild(v),n.appendChild(C),l.addEventListener("dblclick",()=>D(l,e,i));const h=document.createElement("div");h.className="media-actions";const b=document.createElement("button");b.className="btn btn-sm btn-ghost";const N=document.createElement("span");N.setAttribute("data-icon","copy"),b.appendChild(N),b.addEventListener("click",()=>{navigator.clipboard.writeText(window.location.origin+e.url).then(()=>E.toast("URL copied.",{type:"success"})).catch(()=>E.toast("Copy failed.",{type:"error"}))});const g=document.createElement("button");g.className="btn btn-sm btn-ghost";const k=document.createElement("span");k.setAttribute("data-icon","type"),g.appendChild(k),g.addEventListener("click",()=>D(l,e,i));let d=null;if(p){d=document.createElement("button"),d.className="btn btn-sm btn-ghost";const o=document.createElement("span");o.setAttribute("data-icon","edit"),d.appendChild(o),d.addEventListener("click",async()=>{await B(e)&&i(await m.media.list().catch(()=>[]))})}const y=document.createElement("button");y.className="btn btn-sm btn-ghost";const x=document.createElement("span");x.setAttribute("data-icon","trash"),y.appendChild(x),y.addEventListener("click",async()=>{if(await E.confirm(`Delete "${e.name}"?`))try{await m.media.delete(e.name),E.toast("File deleted.",{type:"success"}),i(await m.media.list().catch(()=>[]))}catch{E.toast("Delete failed.",{type:"error"})}}),h.appendChild(b),h.appendChild(g),d&&h.appendChild(d),h.appendChild(y),n.appendChild(h),s.appendChild(n),E.tooltip(b,{content:"Copy URL",position:"top"}),E.tooltip(g,{content:"Rename",position:"top"}),d&&E.tooltip(d,{content:"Edit image",position:"top"}),E.tooltip(y,{content:"Delete file",position:"top"})}),Domma.icons.scan(),Domma.effects.reveal(".media-card",{animation:"fade",stagger:40,duration:350})},f=E.loader(t.get(0),{type:"dots"}),a=await m.media.list().catch(()=>[]);f.destroy(),i(a),Domma.effects.ripple("#upload-btn"),t.find("#upload-btn").on("click",()=>t.find("#file-input").get(0).click()),t.find("#file-input").on("change",async function(){if(!this.files.length)return;const c=new FormData;for(const r of this.files)c.append("file",r);const s=t.find("#upload-btn").get(0),e=s.lastChild;s.disabled=!0,e.textContent=" Uploading\u2026";try{if(!(await fetch("/api/media",{method:"POST",body:c,headers:{Authorization:"Bearer "+(S.get("auth_token")||"")}})).ok)throw new Error("Upload failed");E.toast("File uploaded.",{type:"success"}),i(await m.media.list().catch(()=>[]))}catch{E.toast("Upload failed.",{type:"error"})}finally{s.disabled=!1,e.textContent=" Upload",this.value=""}})}};function D(t,i,f){if(t.querySelector("input"))return;const a=i.name,c=a.lastIndexOf("."),s=c>0?a.slice(0,c):a,e=document.createElement("input");e.type="text",e.className="media-rename-input",e.value=a,e.title="",t.textContent="",t.appendChild(e),e.focus(),e.setSelectionRange(0,s.length);async function r(){const n=e.value.trim();if(!n||n===a)return p();e.disabled=!0;try{await m.media.rename(a,n),E.toast("File renamed.",{type:"success"}),f(await m.media.list().catch(()=>[]))}catch(u){E.toast(u.message||"Rename failed.",{type:"error"}),p()}}function p(){t.textContent=a,t.title="Double-click to rename"}e.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),r()),n.key==="Escape"&&(n.preventDefault(),p())}),e.addEventListener("blur",()=>{setTimeout(()=>{t.contains(e)&&p()},150)})}function L(t){if(!t)return"0 B";const i=1024,f=["B","KB","MB","GB"],a=Math.floor(Math.log(t)/Math.log(i));return`${(t/Math.pow(i,a)).toFixed(1)} ${f[a]}`}
1
+ import{api as R}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Oe,openMenuAt as De,settingsStore as Ke,sidebarCount as te,viewLifetime as Ye}from"../lib/tool-kit.js";import{openImageEditor as _e}from"../lib/image-editor.js";import{KIND_LABEL as Le,arrangeMedia as je,clashes as Ve,formatBytes as Ae,isEditable as ae,isPreviewable as Ge,kindCounts as Je,kindOf as Qe,markdownFor as ne,mediaTile as We,normalisePrefs as Xe,safeName as B}from"../lib/media-arrange.js";const Se=/^#\/media\/?(\?.*)?$/,se="#/media",O="media.draft",Ce=Ke("media.prefs",Xe),Ze=`
2
+ <div class="mdx-tile" data-name="{{name}}" tabindex="0" title="{{name}}">
3
+ <div class="mdx-thumb">
4
+ <img data-bind-hidden="!preview" data-bind-src="thumb" alt="" loading="lazy" decoding="async">
5
+ <span class="mdx-glyph" data-bind-hidden="preview"><span data-icon="{{icon}}" data-icon-size="32"></span></span>
6
+ </div>
7
+ <span class="mdx-chip">{{kindLabel}}</span>
8
+ <button type="button" class="mdx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
9
+ <div class="mdx-info"><span class="mdx-name">{{name}}</span><span class="mdx-meta">{{meta}}</span></div>
10
+ </div>`,q=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function et(u){const c=document.createElement("template");return c.innerHTML=u.trim(),c.content.firstElementChild}const l=Ye(Se);async function K(u,c){try{await navigator.clipboard.writeText(u),E.toast(`${c} copied.`,{type:"success"})}catch{E.toast("Copy failed - the browser refused the clipboard.",{type:"error"})}}export const mediaView={templateUrl:"/admin/js/templates/media.html",async onMount(u){l.reset();const c=u.get(0).querySelector(".mdx")||u.get(0),b=()=>Se.test(location.hash)&&c.isConnected,oe=Ce.read(),p=M.observable([]),ie=M.observable(!0),Y=M.observable(""),y=M.observable(oe.kind),_=M.observable(oe.sort),re=M.observable(""),L=M.observable(""),U=M.observable(!1);let j=String(Date.now());l.add(M.effect(()=>Ce.write({kind:y.value,sort:_.value})));const F=M.observable([]);l.add(M.effect(()=>{const e=Date.now();F.value=je(p.value,{kind:y.value,query:re.value,sort:_.value}).map(t=>We(t,{now:e,stamp:j}))}));const le=M.computed(()=>Je(p.value));l.add(M.effect(()=>{y.value!=="all"&&!le.value[y.value]&&p.value.length&&(y.value="all")}));const Te=M.computed(()=>{const e=p.value,t=F.value.length,s=Ae(e.reduce((n,i)=>n+(Number(i.size)||0),0));return t===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${s}`:`${t} of ${e.length} \xB7 ${s} in all`}),Ne=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),A=e=>p.peek().find(t=>t.name===e);async function C(){try{const e=await R.media.list();if(!b())return;j=String(Date.now()),p.value=Array.isArray(e)?e:[],Y.value=""}catch(e){Y.value=`Could not load the media: ${e.message||e}`}finally{ie.value=!1}}async function de(e){const t=[...e||[]].filter(o=>o&&o.size!==void 0);if(!t.length)return;if(L.peek()){E.toast("An upload is still running.",{type:"warning"});return}const s=Ve(t.map(o=>o.name),p.peek().map(o=>o.name));if(s.length){const o=s.slice(0,5).map(w=>`"${q(w)}"`).join(", ")+(s.length>5?` and ${s.length-5} more`:"");if(!await E.confirm(`${s.length===1?"A file":`${s.length} files`} with the same name ${s.length===1?"is":"are"} already here: ${o}. Replace ${s.length===1?"it":"them"}?`))return}const n=[];let i=0;try{for(const o of t){L.value=`Uploading ${i+1} of ${t.length} - ${o.name}`;const w=new FormData;w.append("file",o);try{await R.media.upload(w),i++}catch(Q){n.push(`${o.name}: ${Q.message||"failed"}`)}}}finally{L.value=""}i&&E.toast(`Uploaded ${i} file${i===1?"":"s"}.`,{type:"success"}),n.length&&E.toast(`Not uploaded - ${n.join("; ")}`,{type:"error",duration:9e3}),te(se),b()&&await C()}async function V(e){if(!await E.confirm(`Delete "${q(e.name)}"? Pages that show it will show a broken image or link. This cannot be undone.`))return!1;try{return await R.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),te(se),b()&&await C(),!0}catch(t){return E.toast(`Not deleted: ${t.message||t}`,{type:"error"}),!1}}async function ce(e){const t=await _e(e);return t&&b()&&(te(se),await C()),t}const G=e=>window.location.origin+e.url;let h=null,me=!1;const J=new Set;function k(e,{focusRename:t=!1,draftName:s=null}={}){const n=A(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const i=M.observable(s??n.name),o=M.observable(!1),w=M.computed(()=>{const a=i.value.trim();if(!a)return"A file needs a name.";const r=B(a);return r!==n.name&&A(r)?`"${r}" is already here.`:""}),Q=M.computed(()=>{const a=i.value.trim(),r=B(a);return a&&r!==a?`Saved as "${r}" - only letters, numbers, dots, hyphens and underscores are kept.`:""}),we=()=>i.peek().trim()!==n.name,ke={newName:i,saving:o,renameErr:w,renameHint:Q,cannotRename:M.computed(()=>o.value||!!w.value||B(i.value.trim())===n.name),editable:ae(n.name)},m=E.slideover({title:q(n.name),size:"md",position:"right"}),d=et(`
11
+ <div class="mdx-panel">
12
+ <div class="mdx-preview"></div>
13
+ <dl class="mdx-facts"></dl>
14
+ <div>
15
+ <label class="form-label">Address
16
+ <span data-help="The file's address on your site. Paste it wherever a link or an image address is asked for."></span></label>
17
+ <div class="mdx-copy"><input class="form-input mdx-url" readonly aria-label="Address">
18
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copyUrl"><span data-icon="copy" data-icon-size="13"></span> Copy</button></div>
19
+ </div>
20
+ <div>
21
+ <label class="form-label">For a page
22
+ <span data-help="Markdown to paste into a page's body: an image shows the picture, anything else becomes a link. The text in [ ] is an image's alt text - change it to describe the picture."></span></label>
23
+ <div class="mdx-copy"><input class="form-input mdx-md" readonly aria-label="Markdown">
24
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="copyMd"><span data-icon="copy" data-icon-size="13"></span> Copy</button></div>
25
+ </div>
26
+ <div>
27
+ <label class="form-label" for="mdx-rename">Name
28
+ <span data-help="Renaming changes the file's address: pages that already use the old address will not find it. Enter saves."></span></label>
29
+ <div class="mdx-copy"><input id="mdx-rename" class="form-input" maxlength="200" data-model="newName.value">
30
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="rename" data-bind-disabled="cannotRename.value"><span data-icon="check" data-icon-size="13"></span> Rename</button></div>
31
+ <small class="mdx-err" data-bind-hidden="!renameErr.value" data-bind-text="renameErr.value"></small>
32
+ <small class="mdx-hint" data-bind-hidden="!renameHint.value" data-bind-text="renameHint.value"></small>
33
+ </div>
34
+ <div class="mdx-actions">
35
+ <button type="button" class="btn btn-sm btn-ghost text-danger" data-on-click="del"><span data-icon="trash" data-icon-size="13"></span> Delete</button>
36
+ <span class="mdx-spacer"></span>
37
+ <a class="btn btn-sm btn-ghost mdx-open" target="_blank" rel="noopener"><span data-icon="external-link" data-icon-size="13"></span> Open</a>
38
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="edit" data-bind-hidden="!editable"><span data-icon="edit" data-icon-size="13"></span> Edit image</button>
39
+ </div>
40
+ </div>`),W=d.querySelector(".mdx-preview"),$=Qe(n.name);if(Ge(n.name)){const a=document.createElement("img");a.src=`${n.url}?t=${encodeURIComponent(j)}`,a.alt=n.name,W.appendChild(a)}else if($==="video"||$==="audio"){const a=document.createElement($);a.src=n.url,a.controls=!0,a.preload="metadata",W.appendChild(a)}else{const a=document.createElement("span");a.setAttribute("data-icon",$==="document"?"file-text":"paperclip"),a.setAttribute("data-icon-size","40"),W.appendChild(a)}const Ie=d.querySelector(".mdx-facts"),P=(a,r)=>{const Me=document.createElement("dt");Me.textContent=a;const ee=document.createElement("dd");return ee.textContent=r,Ie.append(Me,ee),ee};if(P("Type",`${Le[$]==="Images"?"Image":Le[$].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),P("Size",Ae(n.size)),P("Uploaded",n.createdAt?D(n.createdAt).format("D MMM YYYY, HH:mm"):"-"),ae(n.name)){const a=P("Dimensions","\u2026");R.media.info(n.name).then(r=>{a.textContent=r?.width?`${r.width} \xD7 ${r.height} px`:"-"}).catch(()=>{a.textContent="-"})}d.querySelector(".mdx-url").value=G(n),d.querySelector(".mdx-md").value=ne(n),d.querySelector(".mdx-open").href=n.url;let z=!1;async function Ee(){if(!ke.cannotRename.peek()){o.value=!0;try{const a=await R.media.rename(n.name,i.peek().trim());E.toast(`Renamed to "${a?.name||B(i.peek().trim())}".`,{type:"success"}),z=!0,m.close(),b()&&await C()}catch(a){E.toast(`Not renamed: ${a.message||a}`,{type:"error"})}finally{o.value=!1}}}m.element.appendChild(d);const Ue=$e(d),Fe=M.applyBindings(ke,d,{methods:{rename:Ee,copyUrl:()=>K(G(n),"Address"),copyMd:()=>K(ne(n),"Markdown"),async del(){await V(n)&&(z=!0,m.close())},async edit(){z=!0,m.close(),await ce(n)}}}),X=d.querySelector("#mdx-rename"),He=a=>{a.key==="Enter"&&a.target===X&&(a.preventDefault(),Ee())};d.addEventListener("keydown",He);const xe={name:n.name,dirty:we,newName:()=>i.peek()};h=xe,J.add(m),Oe(m,()=>{Fe.dispose(),Ue(),d.querySelectorAll("video, audio").forEach(a=>{try{a.pause()}catch{}}),J.delete(m),h===xe&&(h=null)});const Pe=m.close.bind(m);let Z=!1;m.close=async a=>{if(!z&&!me&&a!==!0&&we()){if(Z)return;Z=!0;const r=await E.confirm("Discard the new name you typed?");if(Z=!1,!r)return}return z=!0,Pe()},I.scan(d),m.open(),(t||s)&&(X.focus(),X.select())}function ze(e){const t=A(e);return t?[{header:t.name},{label:"Details\u2026",icon:"info",action:()=>k(t.name)},{label:"Copy address",icon:"link",action:()=>K(G(t),"Address")},{label:"Copy for a page",icon:"copy",action:()=>K(ne(t),"Markdown")},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(t.url,"_blank","noopener")},{type:"divider"},{label:"Rename\u2026",icon:"type",action:()=>k(t.name,{focusRename:!0})},...ae(t.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>ce(t)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>V(t)}]:[]}const f=c.querySelector(".mdx-grid");f.innerHTML=Ze;const x=e=>()=>{y.value=e},Re=M.applyBindings({files:p,loading:ie,loadError:Y,kind:y,sort:_,query:re,tiles:F,counts:le,totalText:Te,emptyText:Ne,busyText:L,dragging:U},c,{methods:{kindAll:x("all"),kindImage:x("image"),kindVideo:x("video"),kindAudio:x("audio"),kindDocument:x("document"),kindOther:x("other")}});l.add(()=>Re.dispose()),l.add($e(c,{position:"bottom"})),l.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const qe=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>ze(e.dataset.name)});l.add(()=>qe?.destroy?.());const ue=e=>{const t=e.target.closest(".mdx-tile");if(!t)return;const s=e.target.closest(".mdx-kebab");if(s){De(s);return}k(t.dataset.name)},pe=e=>{const t=e.target.closest(".mdx-tile");if(!t||e.target!==t)return;const s=A(t.dataset.name);s&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),De(t)):e.key==="Enter"?k(s.name):e.key==="F2"?(e.preventDefault(),k(s.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),V(s)))};f.addEventListener("click",ue),f.addEventListener("keydown",pe),l.add(()=>{f.removeEventListener("click",ue),f.removeEventListener("keydown",pe)});const T=c.querySelector(".mdx-file-input"),fe=()=>{const e=[...T.files];T.value="",de(e)};T.addEventListener("change",fe),l.add(()=>T.removeEventListener("change",fe)),Be(u,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>T.click()});const H=e=>[...e.dataTransfer?.types||[]].includes("Files");let N=0;const ve=e=>{H(e)&&(e.preventDefault(),N++,U.value=!0)},be=e=>{H(e)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")},ye=e=>{H(e)&&(N=Math.max(0,N-1),N||(U.value=!1))},he=e=>{H(e)&&(e.preventDefault(),N=0,U.value=!1,de(e.dataTransfer.files))},v=u.get(0);v.addEventListener("dragenter",ve),v.addEventListener("dragover",be),v.addEventListener("dragleave",ye),v.addEventListener("drop",he),l.add(()=>{v.removeEventListener("dragenter",ve),v.removeEventListener("dragover",be),v.removeEventListener("dragleave",ye),v.removeEventListener("drop",he)});const ge=e=>{(L.peek()||h?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ge),l.add(()=>window.removeEventListener("beforeunload",ge)),l.add(()=>{if(h?.dirty())try{S.set(O,{name:h.name,newName:h.newName(),at:new Date().toISOString()})}catch{}me=!0,[...J].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(c),await C(),!b())return;let g=null;try{g=S.get(O)}catch{}if(g?.name){try{S.remove?S.remove(O):S.set(O,null)}catch{}A(g.name)&&await E.confirm(`You were renaming "${q(g.name)}" to "${q(g.newName)}" and left before saving. Pick up where you left off?`)&&b()&&k(g.name,{draftName:g.newName})}}};
@@ -1,5 +1,11 @@
1
- import{api as i}from"../api.js";import{filterByProject as b,getProjectFromHash as y}from"../lib/project-context.js";import{keepTableIconsAlive as f}from"../lib/table-icons.js";function o(a){return String(a).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(a){const m=E.loader(a.get(0),{type:"dots"});let r,u;try{[r,u]=await Promise.all([i.menus.list().catch(()=>[]),i.menuLocations.get().catch(()=>({}))])}finally{m.destroy()}const p=y(),g=p?b(r,p):r,l={};for(const[e,t]of Object.entries(u||{}))t&&(l[t]||(l[t]=[]),l[t].push(e));T.create("#menus-table",{pageSize:25,data:g,emptyMessage:'No menus yet. Click "New menu" to create one.',columns:[{key:"slug",title:"Slug",sortable:!0,render:e=>`<code>${o(e)}</code>`},{key:"name",title:"Name",sortable:!0,render:(e,t)=>`<a href="#/menus/edit/${encodeURIComponent(t.slug)}" style="font-weight:600;">${o(e||t.slug)}</a>`},{key:"itemsCount",title:"Items",render:(e,t)=>String(t.itemsCount??(Array.isArray(t.items)?t.items.length:0))},{key:"mapped",title:"Mapped",render:(e,t)=>{const n=l[t.slug]||[];return n.length?n.map(s=>`<span class="badge badge-info">${o(s)}</span>`).join(" "):'<span class="text-muted">-</span>'}},{key:"binding",title:"Bound to",render:(e,t)=>{const n=t.binding;if(!n?.slot)return'<span class="text-muted">-</span>';const s=[...(n.projects||[]).map(c=>`project:${c}`),...n.pages||[]],d=s.length?s.join(", "):"nothing - binding never matches";return`<span class="badge badge-warning" data-tooltip="${o(d)}">${o(n.slot)}</span>`}},{key:"bundled",title:"Bundled",render:(e,t)=>t.meta?.bundled?'<span class="badge badge-secondary">bundled</span>':""},{key:"_actions",title:"Actions",render:(e,t)=>{const n=(l[t.slug]||[]).length>0,s=n?'data-tooltip="Unmap this menu before deleting"':"",d=n?"disabled":"",c=o(t.slug);return`
2
- <a class="btn btn-sm btn-ghost" href="#/menus/edit/${encodeURIComponent(t.slug)}" data-tooltip="Edit"><span data-icon="edit" data-icon-size="14"></span></a>
3
- <button class="btn btn-sm btn-ghost btn-duplicate" data-slug="${c}" data-tooltip="Duplicate"><span data-icon="copy" data-icon-size="14"></span></button>
4
- <button class="btn btn-sm btn-ghost btn-delete" data-slug="${c}" ${d} ${s}><span data-icon="trash" data-icon-size="14"></span></button>
5
- `}}]}),Domma.icons.scan("#menus-table"),f(a.get(0)),document.querySelectorAll("#menus-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})}),a.off("click",".btn-duplicate").on("click",".btn-duplicate",async function(){const e=$(this).data("slug");try{const t=await i.menus.duplicate(e);E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),location.reload()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}),a.off("click",".btn-delete").on("click",".btn-delete",async function(){if(this.disabled)return;const e=$(this).data("slug");if(await E.confirm(`Delete menu "${e}"? This cannot be undone.`))try{await i.menus.remove(e),E.toast("Menu deleted.",{type:"success"}),location.reload()}catch(n){E.toast(`Delete failed: ${n.message||n}`,{type:"error"})}})}};
1
+ import{apiRequest as u}from"../api.js";import{addBannerAction as A}from"../lib/plugin-chrome.js";import{helpPopovers as z}from"../lib/help-popover.js";import{openMenuAt as R,sidebarCount as j,viewLifetime as V}from"../lib/tool-kit.js";import{filterByProject as Y}from"../lib/project-context.js";import{FILTERS as J,arrangeMenus as Q,isPlaced as X,menuRow as D,menuShortcode as Z,slotsByMenu as ee}from"../lib/menus-arrange.js";const $=/^#\/(menus|projects\/[^/]+\/menus)\/?(\?.*)?$/,U="#/menus",f="menus.filter",te=`
2
+ <div class="mnx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="placed ? '' : 'is-unplaced'">
3
+ <span class="mnx-icon" aria-hidden="true"><span data-icon="menu" data-icon-size="16"></span></span>
4
+ <span class="mnx-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
5
+ <span class="mnx-chips">
6
+ <span class="mnx-chip is-ok" data-bind-hidden="!hasSlots" title="Locations this menu fills">{{slotsText}}</span>
7
+ <span class="mnx-chip" data-bind-class="bindingDead ? 'is-warn' : 'is-info'" data-bind-hidden="!bindingText" title="{{bindingTitle}}">{{bindingText}}</span>
8
+ <span class="mnx-chip" data-bind-hidden="!bundled" title="Shipped with the CMS or a plugin">Bundled</span>
9
+ </span>
10
+ <button type="button" class="mnx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
11
+ </div>`,ne=n=>String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),s=V($);function ae(){const n=location.hash.match(/^#\/projects\/([^/?#]+)\/menus/);if(!n)return null;try{return decodeURIComponent(n[1])}catch{return n[1]}}export const menusView={templateUrl:"/admin/js/templates/menus.html",async onMount(n){s.reset();const r=n.get(0).querySelector(".mnx")||n.get(0),d=()=>$.test(location.hash)&&r.isConnected,i=ae(),c=M.observable([]),v=M.observable({}),m=M.observable([]),w=M.observable(!0),p=M.observable("");let x="all";try{x=J.includes(S.get(f))?S.get(f):"all"}catch{}const l=M.observable(x),T=M.observable("");s.add(M.effect(()=>{const e=l.value;try{S.set(f,e)}catch{}}));const h=M.computed(()=>ee(v.value)),y=M.observable([]);s.add(M.effect(()=>{const e={bySlug:h.value,registry:m.value};y.value=Q(c.value,{...e,filter:l.value,query:T.value}).map(t=>D(t,e))}));const q=M.computed(()=>{const e=c.value,t=e.filter(a=>X(a,h.value)).length;return{all:e.length,placed:t,unplaced:e.length-t}}),N=M.computed(()=>c.value.length?"No menus match.":i?"No menus in this project yet - New menu in the banner makes one.":"No menus yet - New menu in the banner makes one."),P=M.computed(()=>i?`Showing the menus in the project "${i}".`:""),B=e=>c.peek().find(t=>t.slug===e);async function g(){try{const[e,t,a]=await Promise.all([u("/menus",{method:"GET"}),u("/menu-locations",{method:"GET"}).catch(()=>({})),u("/menu-locations/registry",{method:"GET"}).catch(()=>[])]);if(!d())return;const L=Array.isArray(e)?e:[];c.value=i?Y(L,i):L,v.value=t&&typeof t=="object"?t:{},m.value=Array.isArray(a)?a:[],p.value=""}catch(e){p.value=`Could not load the menus: ${e.message||e}`}finally{w.value=!1}}const b=e=>{location.hash=`#/menus/edit/${encodeURIComponent(e)}`};function _(){if(i)try{sessionStorage.setItem("__projectContext",i)}catch{}location.hash="#/menus/new"}async function F(e){try{const t=await u(`/menus/${encodeURIComponent(e.slug)}/duplicate`,{method:"POST"});E.toast(`Duplicated as "${t.slug}".`,{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Duplicate failed: ${t.message||t}`,{type:"error"})}}async function G(e){const t=Z(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function K(e){if(await E.confirm(`Delete the menu "${ne(e.name||e.slug)}"? This cannot be undone.`))try{await u(`/menus/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Menu deleted.",{type:"success"}),j(U),d()&&await g()}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error",duration:7e3})}}function O(e){const t=B(e);if(!t)return[];const a=D(t,{bySlug:h.peek(),registry:m.peek()});return[{header:t.name||t.slug},{label:"Edit",icon:"edit",action:()=>b(t.slug)},{label:"Duplicate",icon:"copy",action:()=>F(t)},{label:"Copy shortcode",icon:"code",action:()=>G(t)},{type:"divider"},{label:"Locations\u2026",icon:"map",action:()=>{location.hash="#/menu-locations"}},{type:"divider"},a.canDelete?{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>K(t)}:{label:"Delete - in a location",icon:"trash",disabled:!0,title:a.deleteWhy}]}const o=r.querySelector(".mnx-list");o.innerHTML=te;const H=M.applyBindings({menus:c,loading:w,loadError:p,filter:l,query:T,rows:y,counts:q,emptyText:N,projectText:P},r,{methods:{showAll:()=>{l.value="all"},showPlaced:()=>{l.value="placed"},showUnplaced:()=>{l.value="unplaced"}}});s.add(()=>H.dispose()),s.add(z(r,{position:"bottom"})),s.add(M.effect(()=>{y.value,requestAnimationFrame(()=>{o.isConnected&&I.scan(o)})}));const W=E.contextMenu(o,{cursor:"glyph",match:".mnx-row",inherit:!1,density:"compact",items:e=>O(e.dataset.slug)});s.add(()=>W?.destroy?.());const k=e=>{const t=e.target.closest(".mnx-row");if(!t)return;const a=e.target.closest(".mnx-kebab");if(a){R(a);return}b(t.dataset.slug)},C=e=>{const t=e.target.closest(".mnx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),R(t)):e.key==="Enter"&&e.target===t&&b(t.dataset.slug))};o.addEventListener("click",k),o.addEventListener("keydown",C),s.add(()=>{o.removeEventListener("click",k),o.removeEventListener("keydown",C)}),A(n,{id:"menus-new",icon:"plus",title:"New menu",onClick:_}),A(n,{id:"menus-locations",icon:"map",title:"Locations",onClick:()=>{location.hash="#/menu-locations"}}),I.scan(r),await g()}};