domma-cms 0.74.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.
- package/CLAUDE.md +3 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-arrange.js +1 -0
- package/admin/js/lib/pages-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/pages.html +78 -23
- package/admin/js/templates/theme.html +164 -132
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/pages.js +28 -8
- package/admin/js/views/theme.js +55 -1
- package/admin/js/views/views-list.js +11 -1
- package/package.json +1 -1
- package/server/routes/api/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +53 -1
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/pages.js +46 -0
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/views.js +27 -3
- package/server/server.js +16 -1
- package/server/services/contextMenus.js +41 -0
- package/server/services/views.js +38 -0
package/admin/js/views/blocks.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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{
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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 <dm-${w(e.name)}> 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{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),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()}};
|
package/admin/js/views/forms.js
CHANGED
|
@@ -1 +1,57 @@
|
|
|
1
|
-
import{apiRequest as
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");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)}}};
|
package/admin/js/views/media.js
CHANGED
|
@@ -1 +1,40 @@
|
|
|
1
|
-
import{api as
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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})}}};
|
package/admin/js/views/menus.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),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()}};
|