domma-cms 1.10.15 → 1.10.17
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/CHANGELOG.md +14 -0
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/lib/collection-editor-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/form-submissions-arrange.js +1 -1
- package/admin/js/lib/hotkeys-ui.js +7 -16
- package/admin/js/lib/hotkeys.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/media-picker.js +17 -5
- package/admin/js/lib/media-upload.js +1 -0
- package/admin/js/lib/palette-commands.js +1 -0
- package/admin/js/templates/collection-entries.html +4 -0
- package/admin/js/templates/form-submissions.html +3 -0
- package/admin/js/templates/media.html +19 -10
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/media.js +3 -3
- package/docs/DommaFeatures.md +5 -4
- package/docs/api-reference.md +6 -1
- package/docs/configuration.md +57 -10
- package/docs/guides/multi-page-forms.md +1 -1
- package/docs/markdown-shortcodes.md +3 -2
- package/docs/plugin-development.md +3 -0
- package/package.json +2 -2
- package/public/css/forms.css +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +2 -2
- package/public/js/collection-range.mjs +1 -0
- package/public/js/form-files.mjs +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/site.js +1 -1
- package/server/routes/api/forms.js +211 -60
- package/server/routes/api/media.js +25 -2
- package/server/server.js +8 -2
- package/server/services/collections.js +3 -0
- package/server/services/email.js +15 -4
- package/server/services/forms.js +11 -3
- package/server/services/markdown.js +20 -9
- package/server/services/mediaTypes.js +22 -0
- package/server/templates/page.html +8 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as
|
|
1
|
+
import{apiRequest as R}from"/admin/js/api.js";import{addBannerAction as Q}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as Le,whileLoading as Re}from"/admin/js/lib/loading.js";import{helpPopovers as me}from"/admin/js/lib/help-popover.js";import{onceClosed as Oe,openMenuAt as pe,sidebarCount as Z,viewLifetime as Ie}from"/admin/js/lib/tool-kit.js";import{FILTERS as qe,answerFields as Ue,answerText as F,arrangeSubmissions as Fe,fileRefsOf as je,safeFileHref as ze,submissionCounts as Be,submissionRow as O,submissionText as Pe}from"/admin/js/lib/form-submissions-arrange.js";import{fmtDateTime as Ke}from"../lib/dates.js";import{openEmail as _e}from"../lib/mail-compose.js";const ue=/^#\/forms\/[^/?#]+\/submissions\/?(\?.*)?$/,ee="form-submissions.filter",He=`
|
|
2
2
|
<div class="fsx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="fsx-dot" aria-hidden="true"></span>
|
|
4
4
|
<span class="fsx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
|
|
@@ -8,4 +8,4 @@ import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as Y}from"/
|
|
|
8
8
|
</span>
|
|
9
9
|
<span class="fsx-when" title="{{whenFull}}">{{when}}</span>
|
|
10
10
|
<button type="button" class="fsx-kebab" aria-label="Actions for this submission" title="Actions">\u22EE</button>
|
|
11
|
-
</div>`,ee=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"),U=(i,r,y=`${r}s`)=>`${i} ${i===1?r:y}`,te=i=>i?Pe(i):"";async function fe(i){try{return await navigator.clipboard.writeText(i),!0}catch{}try{const r=document.createElement("textarea");r.value=i,r.style.cssText="position:fixed;opacity:0;",document.body.appendChild(r),r.select();const y=document.execCommand("copy");return r.remove(),y}catch{return!1}}const b=Ie(ue);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(i){b.reset();const r=i.get(0).querySelector(".fsx")||i.get(0),y=()=>ue.test(location.hash)&&r.isConnected,u=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!u){E.toast("No form selected.",{type:"error"});return}const k=encodeURIComponent(u),w=M.observable(null),l=M.observable([]),ne=M.observable(!0),F=M.observable("");let se="inbox";try{se=Oe.includes(S.get(Z))?S.get(Z):"inbox"}catch{}const v=M.observable(se),oe=M.observable(""),j=M.observable(""),P=M.observable("");b.add(M.effect(()=>{const e=v.value;try{S.set(Z,e)}catch{}}));const $=M.computed(()=>qe(w.value||{})),B=M.observable([]);b.add(M.effect(()=>{const e=Date.now(),t=Ue(l.value,{filter:v.value,query:oe.value,from:j.value,to:P.value,now:e}),n=$.value;B.value=t.map(a=>{const s=L(a,n,e);return{...s,when:s.at?D(s.at).fromNow():"",whenFull:te(s.at)}})}));const he=M.computed(()=>Fe(l.value)),be=M.computed(()=>{const e=B.value.length,t=l.value.length;return t?e===t?U(t,"submission"):`${e} of ${U(t,"submission")}`:""}),ye=M.computed(()=>l.value.length?v.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),we=M.computed(()=>w.value?`What people sent through "${w.value.title||u}".`:"What people sent through this form."),R=e=>l.peek().find(t=>String(t.id)===String(e));async function Ee(){try{const[e,t]=await Le(r.querySelector(".dm-loading-slot"),Promise.all([A(`/forms/${k}`),A(`/forms/${k}/submissions`)]),De.rows);if(!y())return;w.value=e||null,l.value=Array.isArray(t)?t:[],F.value="",ve(e?.title||u)}catch(e){if(!y())return;F.value=`Could not load the submissions: ${e.message||e}`}finally{ne.value=!1}}function ve(e){const t=i.get(0).querySelector(".dm-plugin-banner-name"),n=t?.lastChild;n&&n.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(n.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}const K=(e,t)=>ce(e.id,()=>xe(e,t)),W=e=>ce(e.id,()=>ge(e));async function xe(e,t){try{const n=await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return l.value=l.peek().map(a=>String(a.id)===String(e.id)?{...a,data:n?.entry?.data||{...a.data,spam:t}}:a),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not changed: ${n.message||n}`,{type:"error"}),!1}}async function ge(e){const t=L(e,$.peek()).title;if(!await E.confirm(`Delete the submission from "${ee(t)}"? This cannot be undone.`,{confirmText:"Delete"}))return!1;try{return await A(`/forms/${k}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),l.value=l.peek().filter(n=>String(n.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),Q("#/forms"),!0}catch(n){return E.toast(`Not deleted: ${n.message||n}`,{type:"error"}),!1}}let _=!1;async function Ce(){if(!_){_=!0;try{await ke()}finally{_=!1}}}async function ke(){const e=l.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=w.peek()?.title||u;if(await E.confirm(`Delete all ${U(e,"submission")} to "${ee(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await A(`/forms/${k}/submissions`,{method:"DELETE"}),l.value=[],E.toast("All submissions cleared.",{type:"success"}),Q("#/forms")}catch(n){E.toast(`Not cleared: ${n.message||n}`,{type:"error"})}}const ae=async e=>{const t=je(e,$.peek(),{formTitle:w.peek()?.title||u,when:te(e.meta?.createdAt)});E.toast(await fe(t)?"Copied.":"Copy failed.",{type:"info"})},Se=async e=>{E.toast(await fe(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ie=e=>{const t=L(e,$.peek()).email;t&&Be({to:[t],subject:`Re: ${w.peek()?.title||u}`})};async function $e(e,t){try{const n=await fetch(`/api/forms/${k}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);const a=await n.blob(),s=document.createElement("a");return s.href=URL.createObjectURL(a),s.download=t,document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(s.href),1e3),!0}catch(n){return E.toast(`Export failed: ${n.message||n}`,{type:"error"}),!1}}const H=new Set,O=new Set;function re(e,t,n,a="md"){if(O.has(e))return null;O.add(e);let s;try{s=E.slideover({title:ee(t),size:a,position:"right"}),s.element.appendChild(n);const m=me(n);return H.add(s),Re(s,()=>{O.delete(e),H.delete(s),m()}),I.scan(n),s.open(),s}catch(m){O.delete(e);try{s?.destroy?.(),s?.element?.remove()}catch{}return E.toast(`Could not open it: ${m.message||m}`,{type:"error"}),null}}const J=new Set;async function ce(e,t){const n=String(e);if(J.has(n))return!1;J.add(n);try{return await t()}finally{J.delete(n)}}function Ne(){const e=l.peek().length,t=document.createElement("div");t.className="fsx-export";const n=document.createElement("p");n.className="fsx-note",n.textContent=e?`All ${U(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(n);let a;const s=(m,x,z,G,N)=>{const c=document.createElement("button");c.type="button",c.className="btn btn-ghost";const g=document.createElement("span");g.setAttribute("data-icon",x),c.append(g,document.createTextNode(` ${m}`));const T=document.createElement("span");T.setAttribute("data-help",N),c.addEventListener("click",async()=>{if(!c.disabled){c.disabled=!0;try{await $e(z,`${u}-submissions.${G}`)&&a?.close()}finally{c.disabled=!1}}});const o=document.createElement("div");o.append(c,T),t.appendChild(o)};s("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),s("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),a=re("export","Export submissions",t,"sm")}function V(e){if(!e)return;const t=$.peek(),n=L(e,t),a=document.createElement("div");a.className="fsx-detail";const s=document.createElement("div");s.className="fsx-facts";const m=o=>{const d=document.createElement("span");d.textContent=o,s.appendChild(d)};e.meta?.createdAt&&m(`Sent ${te(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&m(`IP ${e.meta.ip}`),n.spam&&m("Marked as spam"),a.appendChild(s);const x=document.createElement("dl");x.className="fsx-answers";const z=t.filter(o=>q(e.data?.[o.name])!=="");for(const o of z){const d=document.createElement("div");d.className="fsx-answer";const h=document.createElement("dt");h.textContent=o.label;const C=document.createElement("dd");C.textContent=q(e.data[o.name]),d.append(h,C),x.appendChild(d)}const G=new Set(t.map(o=>o.name));for(const[o,d]of Object.entries(e.data||{})){if(G.has(o)||o==="spam"||q(d)==="")continue;const h=document.createElement("div");h.className="fsx-answer";const C=document.createElement("dt");C.textContent=`${o} (no longer on the form)`;const p=document.createElement("dd");p.textContent=q(d),h.append(C,p),x.appendChild(h)}if(x.children.length)a.appendChild(x);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",a.appendChild(o)}const N=document.createElement("div");N.className="fsx-actions";const c=(o,d,h,C)=>{const p=document.createElement("button");p.type="button",p.className=`btn btn-sm ${h}`;const X=document.createElement("span");return X.setAttribute("data-icon",d),X.setAttribute("data-icon-size","13"),p.append(X,document.createTextNode(` ${o}`)),p.addEventListener("click",C),N.appendChild(p),p};let g;c("Copy","copy","btn-ghost",()=>ae(e)),n.email&&c("Email them","mail","btn-ghost",()=>ie(e)),c(n.spam?"Not spam":"Mark as spam",n.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await K(e,!n.spam)&&g?.close()});const T=document.createElement("span");T.className="fsx-spacer",N.appendChild(T),c("Delete","trash","btn-ghost text-danger",async()=>{await W(e)&&g?.close()}),c("Close","x","btn-ghost",()=>g?.close()),a.appendChild(N),g=re(`sub:${e.id}`,n.title,a)}function Te(e){const t=R(e);if(!t)return[];const n=L(t,$.peek());return[{header:n.title},{label:"Open\u2026",icon:"eye",action:()=>V(t)},{label:"Copy submission",icon:"copy",action:()=>ae(t)},{type:"divider"},{label:n.email?`Copy ${n.email}`:"Copy email",icon:"at-sign",disabled:!n.email,action:()=>Se(n.email)},{label:"Email them\u2026",icon:"mail",disabled:!n.email,action:()=>ie(t)},{type:"divider"},n.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>K(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>K(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>W(t)}]}const f=r.querySelector(".fsx-list");f.innerHTML=Ke;const Me=M.applyBindings({loading:ne,loadError:F,filter:v,query:oe,from:j,to:P,rows:B,counts:he,summary:be,emptyText:ye,intro:we},r,{methods:{showInbox:()=>{v.value="inbox"},showNew:()=>{v.value="new"},showSpam:()=>{v.value="spam"},clearDates:()=>{j.value="",P.value=""}}});b.add(()=>Me.dispose()),b.add(me(r,{position:"bottom"}));const Ae=E.contextMenu(f,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});b.add(()=>Ae?.destroy?.());const le=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const n=e.target.closest(".fsx-kebab");if(n){pe(n);return}V(R(t.dataset.id))},de=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),pe(t);else if(e.key==="Enter")V(R(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const n=R(t.dataset.id);n&&W(n)}}};f.addEventListener("click",le),f.addEventListener("keydown",de),b.add(()=>{f.removeEventListener("click",le),f.removeEventListener("keydown",de)}),Y(i,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),Y(i,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:Ne}),Y(i,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:Ce}),b.add(()=>{[...H].forEach(e=>{try{e.close()}catch{}})}),I.scan(r),await Ee(),y()&&requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}};
|
|
11
|
+
</div>`,te=a=>String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"),j=(a,i,p=`${i}s`)=>`${a} ${a===1?i:p}`,ne=a=>a?Ke(a):"";async function fe(a){try{return await navigator.clipboard.writeText(a),!0}catch{}try{const i=document.createElement("textarea");i.value=a,i.style.cssText="position:fixed;opacity:0;",document.body.appendChild(i),i.select();const p=document.execCommand("copy");return i.remove(),p}catch{return!1}}function he(a,i){const p=je(i);if(!p.length||p.length!==(Array.isArray(i)?i.length:1)){a.textContent=F(i);return}const m=document.createElement("ul");m.className="fsx-files";for(const d of p){const u=document.createElement("li"),c=ze(d.url),y=document.createElement(c?"a":"span");if(c&&(y.href=c,y.target="_blank",y.rel="noopener"),y.textContent=F(d),u.appendChild(y),Number(d.size)>0){const N=document.createElement("small");N.textContent=` ${Number(d.size)<1024?`${d.size} B`:`${Math.round(Number(d.size)/1024)} KB`}`,u.appendChild(N)}m.appendChild(u)}a.appendChild(m)}const x=Ie(ue);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(a){x.reset();const i=a.get(0).querySelector(".fsx")||a.get(0),p=()=>ue.test(location.hash)&&i.isConnected,m=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!m){E.toast("No form selected.",{type:"error"});return}const d=encodeURIComponent(m),u=M.observable(null),c=M.observable([]),y=M.observable(!0),N=M.observable("");let se="inbox";try{se=qe.includes(S.get(ee))?S.get(ee):"inbox"}catch{}const v=M.observable(se),oe=M.observable(""),z=M.observable(""),B=M.observable("");x.add(M.effect(()=>{const e=v.value;try{S.set(ee,e)}catch{}}));const T=M.computed(()=>Ue(u.value||{})),P=M.observable([]);x.add(M.effect(()=>{const e=Date.now(),t=Fe(c.value,{filter:v.value,query:oe.value,from:z.value,to:B.value,now:e}),n=T.value;P.value=t.map(r=>{const s=O(r,n,e);return{...s,when:s.at?D(s.at).fromNow():"",whenFull:ne(s.at)}})}));const be=M.computed(()=>Be(c.value)),ye=M.computed(()=>{const e=P.value.length,t=c.value.length;return t?e===t?j(t,"submission"):`${e} of ${j(t,"submission")}`:""}),Ee=M.computed(()=>c.value.length?v.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),we=M.computed(()=>u.value?`What people sent through "${u.value.title||m}".`:"What people sent through this form."),q=e=>c.peek().find(t=>String(t.id)===String(e));async function ge(){try{const[e,t]=await Re(i.querySelector(".dm-loading-slot"),Promise.all([R(`/forms/${d}`),R(`/forms/${d}/submissions`)]),Le.rows);if(!p())return;u.value=e||null,c.value=Array.isArray(t)?t:[],N.value="",xe(e?.title||m)}catch(e){if(!p())return;N.value=`Could not load the submissions: ${e.message||e}`}finally{y.value=!1}}function xe(e){const t=a.get(0).querySelector(".dm-plugin-banner-name"),n=t?.lastChild;n&&n.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(n.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}const K=(e,t)=>ce(e.id,()=>ve(e,t)),_=e=>ce(e.id,()=>Ce(e));async function ve(e,t){try{const n=await R(`/forms/${d}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return c.value=c.peek().map(r=>String(r.id)===String(e.id)?{...r,data:n?.entry?.data||{...r.data,spam:t}}:r),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),Z("#/forms"),!0}catch(n){return E.toast(`Not changed: ${n.message||n}`,{type:"error"}),!1}}async function Ce(e){const t=O(e,T.peek()).title;if(!await E.confirm(`Delete the submission from "${te(t)}"? This cannot be undone.`,{confirmText:"Delete"}))return!1;try{return await R(`/forms/${d}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),c.value=c.peek().filter(n=>String(n.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),Z("#/forms"),!0}catch(n){return E.toast(`Not deleted: ${n.message||n}`,{type:"error"}),!1}}let H=!1;async function ke(){if(!H){H=!0;try{await Se()}finally{H=!1}}}async function Se(){const e=c.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=u.peek()?.title||m;if(await E.confirm(`Delete all ${j(e,"submission")} to "${te(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await R(`/forms/${d}/submissions`,{method:"DELETE"}),c.value=[],E.toast("All submissions cleared.",{type:"success"}),Z("#/forms")}catch(n){E.toast(`Not cleared: ${n.message||n}`,{type:"error"})}}const ae=async e=>{const t=Pe(e,T.peek(),{formTitle:u.peek()?.title||m,when:ne(e.meta?.createdAt)});E.toast(await fe(t)?"Copied.":"Copy failed.",{type:"info"})},$e=async e=>{E.toast(await fe(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ie=e=>{const t=O(e,T.peek()).email;t&&_e({to:[t],subject:`Re: ${u.peek()?.title||m}`})};async function Ne(e,t){try{const n=await fetch(`/api/forms/${d}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!n.ok)throw new Error(`${n.status} ${n.statusText}`);const r=await n.blob(),s=document.createElement("a");return s.href=URL.createObjectURL(r),s.download=t,document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(s.href),1e3),!0}catch(n){return E.toast(`Export failed: ${n.message||n}`,{type:"error"}),!1}}const W=new Set,U=new Set;function re(e,t,n,r="md"){if(U.has(e))return null;U.add(e);let s;try{s=E.slideover({title:te(t),size:r,position:"right"}),s.element.appendChild(n);const h=me(n);return W.add(s),Oe(s,()=>{U.delete(e),W.delete(s),h()}),I.scan(n),s.open(),s}catch(h){U.delete(e);try{s?.destroy?.(),s?.element?.remove()}catch{}return E.toast(`Could not open it: ${h.message||h}`,{type:"error"}),null}}const J=new Set;async function ce(e,t){const n=String(e);if(J.has(n))return!1;J.add(n);try{return await t()}finally{J.delete(n)}}function Te(){const e=c.peek().length,t=document.createElement("div");t.className="fsx-export";const n=document.createElement("p");n.className="fsx-note",n.textContent=e?`All ${j(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(n);let r;const s=(h,C,G,X,A)=>{const l=document.createElement("button");l.type="button",l.className="btn btn-ghost";const k=document.createElement("span");k.setAttribute("data-icon",C),l.append(k,document.createTextNode(` ${h}`));const L=document.createElement("span");L.setAttribute("data-help",A),l.addEventListener("click",async()=>{if(!l.disabled){l.disabled=!0;try{await Ne(G,`${m}-submissions.${X}`)&&r?.close()}finally{l.disabled=!1}}});const o=document.createElement("div");o.append(l,L),t.appendChild(o)};s("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),s("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),r=re("export","Export submissions",t,"sm")}function V(e){if(!e)return;const t=T.peek(),n=O(e,t),r=document.createElement("div");r.className="fsx-detail";const s=document.createElement("div");s.className="fsx-facts";const h=o=>{const f=document.createElement("span");f.textContent=o,s.appendChild(f)};e.meta?.createdAt&&h(`Sent ${ne(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&h(`IP ${e.meta.ip}`),n.spam&&h("Marked as spam"),r.appendChild(s);const C=document.createElement("dl");C.className="fsx-answers";const G=t.filter(o=>F(e.data?.[o.name])!=="");for(const o of G){const f=document.createElement("div");f.className="fsx-answer";const g=document.createElement("dt");g.textContent=o.label;const $=document.createElement("dd");he($,e.data[o.name]),f.append(g,$),C.appendChild(f)}const X=new Set(t.map(o=>o.name));for(const[o,f]of Object.entries(e.data||{})){if(X.has(o)||o==="spam"||F(f)==="")continue;const g=document.createElement("div");g.className="fsx-answer";const $=document.createElement("dt");$.textContent=`${o} (no longer on the form)`;const b=document.createElement("dd");he(b,f),g.append($,b),C.appendChild(g)}if(C.children.length)r.appendChild(C);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",r.appendChild(o)}const A=document.createElement("div");A.className="fsx-actions";const l=(o,f,g,$)=>{const b=document.createElement("button");b.type="button",b.className=`btn btn-sm ${g}`;const Y=document.createElement("span");return Y.setAttribute("data-icon",f),Y.setAttribute("data-icon-size","13"),b.append(Y,document.createTextNode(` ${o}`)),b.addEventListener("click",$),A.appendChild(b),b};let k;l("Copy","copy","btn-ghost",()=>ae(e)),n.email&&l("Email them","mail","btn-ghost",()=>ie(e)),l(n.spam?"Not spam":"Mark as spam",n.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await K(e,!n.spam)&&k?.close()});const L=document.createElement("span");L.className="fsx-spacer",A.appendChild(L),l("Delete","trash","btn-ghost text-danger",async()=>{await _(e)&&k?.close()}),l("Close","x","btn-ghost",()=>k?.close()),r.appendChild(A),k=re(`sub:${e.id}`,n.title,r)}function Me(e){const t=q(e);if(!t)return[];const n=O(t,T.peek());return[{header:n.title},{label:"Open\u2026",icon:"eye",action:()=>V(t)},{label:"Copy submission",icon:"copy",action:()=>ae(t)},{type:"divider"},{label:n.email?`Copy ${n.email}`:"Copy email",icon:"at-sign",disabled:!n.email,action:()=>$e(n.email)},{label:"Email them\u2026",icon:"mail",disabled:!n.email,action:()=>ie(t)},{type:"divider"},n.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>K(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>K(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>_(t)}]}const w=i.querySelector(".fsx-list");w.innerHTML=He;const Ae=M.applyBindings({loading:y,loadError:N,filter:v,query:oe,from:z,to:B,rows:P,counts:be,summary:ye,emptyText:Ee,intro:we},i,{methods:{showInbox:()=>{v.value="inbox"},showNew:()=>{v.value="new"},showSpam:()=>{v.value="spam"},clearDates:()=>{z.value="",B.value=""}}});x.add(()=>Ae.dispose()),x.add(me(i,{position:"bottom"}));const De=E.contextMenu(w,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>Me(e.dataset.id)});x.add(()=>De?.destroy?.());const le=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const n=e.target.closest(".fsx-kebab");if(n){pe(n);return}V(q(t.dataset.id))},de=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),pe(t);else if(e.key==="Enter")V(q(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const n=q(t.dataset.id);n&&_(n)}}};w.addEventListener("click",le),w.addEventListener("keydown",de),x.add(()=>{w.removeEventListener("click",le),w.removeEventListener("keydown",de)}),Q(a,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),Q(a,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:Te}),Q(a,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:ke}),x.add(()=>{[...W].forEach(e=>{try{e.close()}catch{}})}),I.scan(i),await ge(),p()&&requestAnimationFrame(()=>{w.isConnected&&I.scan(w)})}};
|
package/admin/js/views/media.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as L}from"../api.js";import{catchFiles as qe,mediaUploader as Ne}from"../lib/media-upload.js";import{addBannerAction as ze}from"../lib/plugin-chrome.js";import{SHAPES as Ie,whileLoading as Re}from"../lib/loading.js";import{helpPopovers as be}from"../lib/help-popover.js";import{onceClosed as Ue,openMenuAt as ve,settingsStore as Be,sidebarCount as j,viewLifetime as Fe}from"../lib/tool-kit.js";import{openImageEditor as He}from"../lib/image-editor.js";import{KIND_LABEL as ye,arrangeMedia as Pe,formatBytes as he,isEditable as G,isPreviewable as Ke,kindCounts as Oe,kindOf as _e,markdownFor as J,mediaTile as Ve,normalisePrefs as Ye,safeName as T}from"../lib/media-arrange.js";import{fmtDateTime as je}from"../lib/dates.js";const ge=/^#\/media\/?(\?.*)?$/,Q="#/media",q="media.draft",we=Be("media.prefs",Ye),Ge=`
|
|
2
2
|
<div class="mdx-tile" data-name="{{name}}" tabindex="0" title="{{name}}">
|
|
3
3
|
<div class="mdx-thumb">
|
|
4
4
|
<img data-bind-hidden="!preview" data-bind-src="thumb" alt="" loading="lazy" decoding="async">
|
|
@@ -7,7 +7,7 @@ import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-
|
|
|
7
7
|
<span class="mdx-chip">{{kindLabel}}</span>
|
|
8
8
|
<button type="button" class="mdx-kebab" aria-label="Actions for {{name}}" title="Actions">\u22EE</button>
|
|
9
9
|
<div class="mdx-info"><span class="mdx-name">{{name}}</span><span class="mdx-meta">{{meta}}</span></div>
|
|
10
|
-
</div>`,
|
|
10
|
+
</div>`,N=c=>String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Je(c){const s=document.createElement("template");return s.innerHTML=c.trim(),s.content.firstElementChild}const l=Fe(ge);let W=null;async function z(c,s){try{await navigator.clipboard.writeText(c),E.toast(`${s} 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(c){l.reset();const s=c.get(0).querySelector(".mdx")||c.get(0),u=()=>ge.test(location.hash)&&s.isConnected,X=we.read(),p=M.observable([]),Z=M.observable(!0),R=M.observable(""),b=M.observable(X.kind),U=M.observable(X.sort),ee=M.observable(""),te=M.observable(!1),ae=M.observable("");let B=String(Date.now());l.add(M.effect(()=>we.write({kind:b.value,sort:U.value})));const C=M.observable([]);l.add(M.effect(()=>{const e=Date.now();C.value=Pe(p.value,{kind:b.value,query:ee.value,sort:U.value}).map(a=>Ve(a,{now:e,stamp:B}))}));const ne=M.computed(()=>Oe(p.value));l.add(M.effect(()=>{b.value!=="all"&&!ne.value[b.value]&&p.value.length&&(b.value="all")}));const ke=M.computed(()=>{const e=p.value,a=C.value.length,i=he(e.reduce((n,m)=>n+(Number(m.size)||0),0));return a===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${i}`:`${a} of ${e.length} \xB7 ${i} in all`}),xe=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),h=e=>p.peek().find(a=>a.name===e);async function g(){try{const e=await Re(s.querySelector(".dm-loading-slot"),L.media.list(),Ie.tiles);if(!u())return;B=String(Date.now()),p.value=Array.isArray(e)?e:[],R.value=""}catch(e){R.value=`Could not load the media: ${e.message||e}`}finally{Z.value=!1}}async function F(e){if(!await E.confirm(`Delete "${N(e.name)}"? Pages that show it will show a broken image or link. This cannot be undone.`))return!1;try{return await L.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),j(Q),u()&&await g(),!0}catch(a){return E.toast(`Not deleted: ${a.message||a}`,{type:"error"}),!1}}async function oe(e){const a=await He(e);return a&&u()&&(j(Q),await g()),a}const H=e=>window.location.origin+e.url;let v=null,se=!1;const P=new Set;function w(e,{focusRename:a=!1,draftName:i=null}={}){const n=h(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const m=M.observable(i??n.name),D=M.observable(!1),de=M.computed(()=>{const t=m.value.trim();if(!t)return"A file needs a name.";const o=T(t);return o!==n.name&&h(o)?`"${o}" is already here.`:""}),Ae=M.computed(()=>{const t=m.value.trim(),o=T(t);return t&&o!==t?`Saved as "${o}" - only letters, numbers, dots, hyphens and underscores are kept.`:""}),ce=()=>m.peek().trim()!==n.name,me={newName:m,saving:D,renameErr:de,renameHint:Ae,cannotRename:M.computed(()=>D.value||!!de.value||T(m.value.trim())===n.name),editable:G(n.name)},d=E.slideover({title:N(n.name),size:"md",position:"right"}),r=Je(`
|
|
11
11
|
<div class="mdx-panel">
|
|
12
12
|
<div class="mdx-preview"></div>
|
|
13
13
|
<dl class="mdx-facts"></dl>
|
|
@@ -37,4 +37,4 @@ import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-
|
|
|
37
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
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
39
|
</div>
|
|
40
|
-
</div>`),
|
|
40
|
+
</div>`),O=r.querySelector(".mdx-preview"),x=_e(n.name);if(Ke(n.name)){const t=document.createElement("img");t.src=`${n.url}?t=${encodeURIComponent(B)}`,t.alt=n.name,O.appendChild(t)}else if(x==="video"||x==="audio"){const t=document.createElement(x);t.src=n.url,t.controls=!0,t.preload="metadata",O.appendChild(t)}else{const t=document.createElement("span");t.setAttribute("data-icon",x==="document"?"file-text":"paperclip"),t.setAttribute("data-icon-size","40"),O.appendChild(t)}const Ce=r.querySelector(".mdx-facts"),$=(t,o)=>{const fe=document.createElement("dt");fe.textContent=t;const Y=document.createElement("dd");return Y.textContent=o,Ce.append(fe,Y),Y};if($("Type",`${ye[x]==="Images"?"Image":ye[x].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),$("Size",he(n.size)),$("Uploaded",n.createdAt?je(n.createdAt):"-"),G(n.name)){const t=$("Dimensions","\u2026");L.media.info(n.name).then(o=>{t.textContent=o?.width?`${o.width} \xD7 ${o.height} px`:"-"}).catch(()=>{t.textContent="-"})}r.querySelector(".mdx-url").value=H(n),r.querySelector(".mdx-md").value=J(n),r.querySelector(".mdx-open").href=n.url;let A=!1;async function ue(){if(!me.cannotRename.peek()){D.value=!0;try{const t=await L.media.rename(n.name,m.peek().trim());E.toast(`Renamed to "${t?.name||T(m.peek().trim())}".`,{type:"success"}),A=!0,d.close(),u()&&await g()}catch(t){E.toast(`Not renamed: ${t.message||t}`,{type:"error"})}finally{D.value=!1}}}d.element.appendChild(r);const De=be(r),$e=M.applyBindings(me,r,{methods:{rename:ue,copyUrl:()=>z(H(n),"Address"),copyMd:()=>z(J(n),"Markdown"),async del(){await F(n)&&(A=!0,d.close())},async edit(){A=!0,d.close(),await oe(n)}}}),_=r.querySelector("#mdx-rename"),Le=t=>{t.key==="Enter"&&t.target===_&&(t.preventDefault(),ue())};r.addEventListener("keydown",Le);const pe={name:n.name,dirty:ce,newName:()=>m.peek()};v=pe,P.add(d),Ue(d,()=>{$e.dispose(),De(),r.querySelectorAll("video, audio").forEach(t=>{try{t.pause()}catch{}}),P.delete(d),v===pe&&(v=null)});const Te=d.close.bind(d);let V=!1;d.close=async t=>{if(!A&&!se&&t!==!0&&ce()){if(V)return;V=!0;const o=await E.confirm("Discard the new name you typed?");if(V=!1,!o)return}return A=!0,Te()},I.scan(r),d.open(),(a||i)&&(_.focus(),_.select())}function Ee(e){const a=h(e);return a?[{header:a.name},{label:"Details\u2026",icon:"info",action:()=>w(a.name)},{label:"Copy address",icon:"link",action:()=>z(H(a),"Address")},{label:"Copy for a page",icon:"copy",action:()=>z(J(a),"Markdown")},{label:"Open in a new tab",icon:"external-link",action:()=>window.open(a.url,"_blank","noopener")},{type:"divider"},{label:"Rename\u2026",icon:"type",action:()=>w(a.name,{focusRename:!0})},...G(a.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>oe(a)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>F(a)}]:[]}const f=s.querySelector(".mdx-grid");f.innerHTML=Ge;const k=e=>()=>{b.value=e},Me=M.applyBindings({files:p,loading:Z,loadError:R,kind:b,sort:U,query:ee,tiles:C,counts:ne,totalText:ke,emptyText:xe,hintText:ae,dragging:te},s,{methods:{kindAll:k("all"),kindImage:k("image"),kindVideo:k("video"),kindAudio:k("audio"),kindDocument:k("document"),kindOther:k("other")}});l.add(()=>Me.dispose()),l.add(be(s,{position:"bottom"})),l.add(M.effect(()=>{C.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const Se=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>Ee(e.dataset.name)});l.add(()=>Se?.destroy?.());const ie=e=>{const a=e.target.closest(".mdx-tile");if(!a)return;const i=e.target.closest(".mdx-kebab");if(i){ve(i);return}w(a.dataset.name)},le=e=>{const a=e.target.closest(".mdx-tile");if(!a||e.target!==a)return;const i=h(a.dataset.name);i&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ve(a)):e.key==="Enter"?w(i.name):e.key==="F2"?(e.preventDefault(),w(i.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),F(i)))};f.addEventListener("click",ie),f.addEventListener("keydown",le),l.add(()=>{f.removeEventListener("click",ie),f.removeEventListener("keydown",le)});const K=Ne(s.querySelector(".mdx-zone"),{input:s.querySelector(".mdx-file-input"),exists:e=>!!h(e),showing:u,onHint:e=>{ae.value=e},onUploaded(){j(Q),u()?g():W?.()}});W=()=>{u()&&g()},l.add(()=>{W=null,K.release()}),ze(c,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>K.browse()}),l.add(qe(c.get(0),K.addFiles,{onDragging:e=>{te.value=e}}));const re=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",re),l.add(()=>window.removeEventListener("beforeunload",re)),l.add(()=>{if(v?.dirty())try{S.set(q,{name:v.name,newName:v.newName(),at:new Date().toISOString()})}catch{}se=!0,[...P].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(s),await g(),!u())return;let y=null;try{y=S.get(q)}catch{}if(y?.name){try{S.remove?S.remove(q):S.set(q,null)}catch{}h(y.name)&&await E.confirm(`You were renaming "${N(y.name)}" to "${N(y.newName)}" and left before saving. Pick up where you left off?`)&&u()&&w(y.name,{draftName:y.newName})}}};
|
package/docs/DommaFeatures.md
CHANGED
|
@@ -461,7 +461,7 @@ Any other properties stored on a field, such as `validation`, `logic`, `referenc
|
|
|
461
461
|
|
|
462
462
|
**Types the server and displays understand, but that can only be set in `schema.json`, recipes or plugins:**
|
|
463
463
|
- `reference`. A relational field (see below). The server validates it and the display, form and API code resolve it.
|
|
464
|
-
- `file`. An uploaded-file reference `{url, name, size, mime}` with a `file: {accept, maxSize}` config. Displays show an image thumbnail for image MIME types and a download link for anything else.
|
|
464
|
+
- `file`. An uploaded-file reference `{url, name, size, mime}` (a list of them with `multiple: true`) with a `file: {accept, maxSize, maxFiles}` config. The collection editor offers it as "Files (uploaded with a form)"; the entry editor shows the files and keeps them. Displays show an image thumbnail for image MIME types and a download link for anything else.
|
|
465
465
|
- `boolean`, `datetime`, `multiselect` / `multi-select`, `tags`, `chooser`, `json`, `object`, `array`, `markdown`, `text`. These are used by presets, plugins and recipes. The Collection Browser builds a filter control from `text`/`textarea`/`email`, `number`, `date`/`datetime`, `select`/`chooser`, `multiselect`/`tags`/`tag` and `boolean`/`checkbox`.
|
|
466
466
|
|
|
467
467
|
Examples of where they appear: `projects` uses `boolean`, `notifications` uses `datetime` and `array`, `api-endpoints` uses `object`, the calendar plugin uses `markdown`, the onboarding recipe uses `multiselect` and `file`.
|
|
@@ -494,8 +494,9 @@ Examples of where they appear: `projects` uses `boolean`, `notifications` uses `
|
|
|
494
494
|
|
|
495
495
|
- **File uploads**. A form `file` field switches the submission to `multipart/form-data`.
|
|
496
496
|
- The server checks each file against the field's `file.accept` (MIME list, wildcards such as `image/*` allowed) and `file.maxSize` (default 5 MB).
|
|
497
|
-
-
|
|
498
|
-
- The
|
|
497
|
+
- `multiple: true` takes several files, up to `file.maxFiles` (default 5, at most 20; the form editor's Several files / Most files). One bad file or one too many refuses the submission.
|
|
498
|
+
- The filename is cleaned and given a uuid prefix, and the file is saved to `content/media/` - only after every other check has passed.
|
|
499
|
+
- The entry stores `{url, name, size, mime}`, or a list of them for a `multiple` field.
|
|
499
500
|
- Files are publicly reachable under `/media/`.
|
|
500
501
|
Where: `parseMultipartForm()` in `server/routes/api/forms.js`; `docs/configuration.md`. Since: 0.20.0.
|
|
501
502
|
|
|
@@ -2827,7 +2828,7 @@ entries are genuine code issues.
|
|
|
2827
2828
|
- The collection editor offers only 13 types. It cannot create `reference`, `file`, `boolean`, `datetime`, `multiselect` and others, which need hand-edited JSON even though the display, filter and API code supports them.
|
|
2828
2829
|
- `docs/configuration.md` lists `text`, `boolean`, `multiselect`, `chooser` and `tags` as schema types but not the editor's own `string`/`tel`/`radio`/`checkbox-group`/`time`/`url`/`hidden`.
|
|
2829
2830
|
- Collection-to-form mapping turns every unknown type into a plain text input.
|
|
2830
|
-
- The form editor has no UI for a file field's `accept`/`maxSize
|
|
2831
|
+
- The form editor has no UI for a file field's `accept`/`maxSize` (it has Several files / Most files).
|
|
2831
2832
|
- **Collection editor offers role names that do not exist by default.** It hard-codes `subscriber`, `editor` and `manager` for API and export access, while the base roles are `super-admin`, `admin` and `user`. (fixed in 0.92.1)
|
|
2832
2833
|
- For the API, an unknown role resolves to level Infinity, so any signed-in user passes.
|
|
2833
2834
|
- For export, an unknown role fails closed.
|
package/docs/api-reference.md
CHANGED
|
@@ -1313,7 +1313,12 @@ Order of checks:
|
|
|
1313
1313
|
`redirect`) run after the store; their failures are logged and notified, never returned.
|
|
1314
1314
|
|
|
1315
1315
|
File parts are checked against the field's `file.maxSize` (default 5 MB) and `file.accept` (MIME list,
|
|
1316
|
-
`image/*` wildcards), saved to the media library and stored as `{url, name, size, mime}
|
|
1316
|
+
`image/*` wildcards), saved to the media library and stored as `{url, name, size, mime}` - a list of them for
|
|
1317
|
+
a `multiple: true` field, which takes up to `file.maxFiles` (default 5, at most 20; one more is a `400`). All the files of
|
|
1318
|
+
one submission together may hold at most server.json `uploads.maxSubmissionBytes` (default 50 MB, or `uploads.maxFileSize`
|
|
1319
|
+
if larger); past it the
|
|
1320
|
+
submission is a `400` and nothing is written. A
|
|
1321
|
+
file field's value only comes from file parts. There is no CAPTCHA.
|
|
1317
1322
|
`{{entryId}}` in `settings.successRedirect` (or a trigger redirect, which wins) is replaced with the new id.
|
|
1318
1323
|
|
|
1319
1324
|
---
|
package/docs/configuration.md
CHANGED
|
@@ -29,6 +29,7 @@ Controls the HTTP server.
|
|
|
29
29
|
| `csp.connectHosts` | string[] | `[]` | Other sites a public page may read from in the browser (`fetch`), added to the Content-Security-Policy's `connect-src` as `https://<host>`. The site itself and cdn.jsdelivr.net are always allowed. Bare hostnames only (`dcm.dommajs.org`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
|
|
30
30
|
| `csp.imgHosts` | string[] | `[]` | Other sites a public page may show images from (an `<img>` hosted elsewhere, such as a badge or partner logo), added to the Content-Security-Policy's `img-src` as `https://<host>`. The site itself, `data:` and `blob:` are always allowed; without this an image from another site is blocked - upload it to Media instead if you can. Bare hostnames only (`statics.startupbase.io`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
|
|
31
31
|
| `uploads.maxFileSize` | number | `10485760` (10 MB) | Maximum allowed upload size in bytes |
|
|
32
|
+
| `uploads.maxSubmissionBytes` | number | 50 MB, or `uploads.maxFileSize` when that is larger | The most a public form submission's files may add up to, all file fields together. The server holds a submission's files in memory until every check has passed, so it stops reading and refuses the submission (`400`, nothing written) the moment the running total passes this. Read fresh on every submission |
|
|
32
33
|
| `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
|
|
33
34
|
| `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
|
|
34
35
|
| `requireSignedPlugins` | boolean | `false` | When `true`, the site runs only plugin code the Marketplace signed: an unsigned `.dcmsplugin` (e.g. one exported from another site) is refused even if the installer confirms it, New plugin is refused, a plugin's files cannot be changed in the code editor (it opens read only), licensed source cannot be read even by a Super Admin, and MongoDB connection options that start a program or read a file (`autoEncryption`, `tls…File`, …) cannot be saved. A bundle with a signature that does not verify is always refused, whatever this is set to. Read fresh on every check, and also as it was at boot, so changing the file on a running site can switch it on but not off. `DOMMA_SIGNED_ONLY=1` in the environment turns it on whatever the file says - Domma CMS Manager sets it on the sites it hosts. Nothing in the admin writes `server.json`. See `server/services/codePolicy.js` |
|
|
@@ -1680,8 +1681,9 @@ options when a reference field is in `filterable="…"`.
|
|
|
1680
1681
|
|
|
1681
1682
|
### `type: "file"` - uploaded file field
|
|
1682
1683
|
|
|
1683
|
-
Stores an uploaded file's metadata: `{ url, name, size, mime }
|
|
1684
|
-
|
|
1684
|
+
Stores an uploaded file's metadata: `{ url, name, size, mime }` - or, for a field
|
|
1685
|
+
with `multiple: true`, a list of them (always a list, even of one file). An empty
|
|
1686
|
+
field stores `""`. The actual bytes live in `content/media/` (under a uuid-prefixed filename so concurrent
|
|
1685
1687
|
uploads of the same name don't collide).
|
|
1686
1688
|
|
|
1687
1689
|
```json
|
|
@@ -1700,25 +1702,59 @@ uploads of the same name don't collide).
|
|
|
1700
1702
|
| Sub-field | Required | Description |
|
|
1701
1703
|
|-----------|----------|--------------------------------------------------------------------------------------|
|
|
1702
1704
|
| `accept` | no | Comma-separated mime types or wildcards (`image/*`, `application/pdf,image/jpeg`) |
|
|
1703
|
-
| `maxSize` | no | Maximum bytes (default 5 MB)
|
|
1705
|
+
| `maxSize` | no | Maximum bytes per file (default 5 MB) |
|
|
1706
|
+
| `maxFiles`| no | With `multiple: true`: how many files the field takes (default 5, never more than 20) |
|
|
1704
1707
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
+
`multiple: true` (on the field, beside `file`) lets the visitor add several files;
|
|
1709
|
+
the form editor's **Several files** and **Most files** set it and `maxFiles`.
|
|
1710
|
+
|
|
1711
|
+
**Form rendering** - a drop area (`E.dropzone`) over the field's file input: drop,
|
|
1712
|
+
browse (click, Enter or Space) or paste, with image previews. `accept`, `maxSize`
|
|
1713
|
+
and the count (one file, or up to `maxFiles` with `multiple: true`) are checked as
|
|
1714
|
+
files are added, and a refused file is named under the area and announced. The
|
|
1715
|
+
rules are `public/js/form-files.mjs`, which the server imports for its own check,
|
|
1716
|
+
so both say the same words. In a multi-step form, going back to a step keeps every
|
|
1717
|
+
file chosen there.
|
|
1708
1718
|
|
|
1709
1719
|
**Submission** - the form switches automatically to `multipart/form-data`
|
|
1710
1720
|
when any file input has a selected file. The server-side handler:
|
|
1711
1721
|
|
|
1712
|
-
1. Validates each file against its field's `accept` and `maxSize`
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1722
|
+
1. Validates each file against its field's `accept` and `maxSize` (and refuses web
|
|
1723
|
+
pages, SVG, XML and script whatever `accept` says). One refused file, or more
|
|
1724
|
+
files than `maxFiles`, refuses the whole submission with a `400`; a second file
|
|
1725
|
+
sent for a single field is ignored. All of a submission's files together may
|
|
1726
|
+
hold at most `uploads.maxSubmissionBytes` (server.json; by default 50 MB, or
|
|
1727
|
+
`uploads.maxFileSize` when that is larger) - past that the submission is
|
|
1728
|
+
refused as soon as the total is reached
|
|
1729
|
+
2. Holds the files in memory until the honeypot, timing, trigger, validation and
|
|
1730
|
+
rate-limit checks pass - nothing a refused or bot submission sent is written.
|
|
1731
|
+
If a file cannot be written, or the entry is then refused (a collection rule),
|
|
1732
|
+
the files already written for it are removed again
|
|
1733
|
+
3. Sanitises each filename (lowercases, strips path chars, prepends a uuid) and
|
|
1734
|
+
saves it to `content/media/<uuid>-<safe-name>`
|
|
1735
|
+
4. Stores the reference object (a list of them for a `multiple` field) as the
|
|
1736
|
+
field's value. A file field's value only ever comes from uploaded files: a
|
|
1737
|
+
JSON body claiming one holds a reference is stored as empty
|
|
1738
|
+
|
|
1739
|
+
**Where the value goes** - the submissions screen and the collection's entries
|
|
1740
|
+
screen list the files as links (the entry editor shows them and keeps them as they
|
|
1741
|
+
are); the CSV export writes `name (url)`, several joined with `; `; the JSON
|
|
1742
|
+
export, the webhook body (`{form, data}`), the `form:submitted` hook's `data` and
|
|
1743
|
+
Actions' `{{entry.data.<field>}}` carry the stored value itself (so a `multiple`
|
|
1744
|
+
field is an array - `{{entry.data.photos.0.url}}` is the first file's address);
|
|
1745
|
+
the notification email links each file (absolute: `site.json` `baseUrl`, else an
|
|
1746
|
+
address an admin has signed in at). CSV cells that start with `=`, `+`, `-`, `@`,
|
|
1747
|
+
TAB or CR are prefixed with `'` so a spreadsheet does not run them as formulas.
|
|
1748
|
+
A collection's `file` field is never `required` there (a form made collection
|
|
1749
|
+
leaves it off): the form enforces it, and the entry editor, which cannot upload,
|
|
1750
|
+
keeps the stored files as they are.
|
|
1716
1751
|
|
|
1717
1752
|
**Display** - `[collection]`, the Collection Browser, and the public read API
|
|
1718
1753
|
all render file references appropriately:
|
|
1719
1754
|
|
|
1720
1755
|
- Image mimes (`image/png`, `image/jpeg`, etc.) → clickable `<img>` thumbnail
|
|
1721
1756
|
- Everything else → `<a href download>filename</a>`
|
|
1757
|
+
- A `multiple` field's list → one of the above per file (a table cell lists the names)
|
|
1722
1758
|
|
|
1723
1759
|
Stored reference shape:
|
|
1724
1760
|
|
|
@@ -1731,6 +1767,17 @@ Stored reference shape:
|
|
|
1731
1767
|
}
|
|
1732
1768
|
```
|
|
1733
1769
|
|
|
1770
|
+
and for `"multiple": true`:
|
|
1771
|
+
|
|
1772
|
+
```json
|
|
1773
|
+
[
|
|
1774
|
+
{"url": "/media/1f2e3d4c-front.jpg", "name": "front.jpg", "size": 81234, "mime": "image/jpeg"},
|
|
1775
|
+
{"url": "/media/9a8b7c6d-back.jpg", "name": "back.jpg", "size": 79012, "mime": "image/jpeg"}
|
|
1776
|
+
]
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
`fileRefs(value)` in `public/js/form-files.mjs` reads either shape as a list.
|
|
1780
|
+
|
|
1734
1781
|
**Security note** - the `/media/` URL is publicly served (the file is in
|
|
1735
1782
|
`content/media/` which the CMS exposes statically). If your use case needs
|
|
1736
1783
|
gated downloads (e.g. CVs only visible to the candidate + recruiters),
|
|
@@ -60,7 +60,7 @@ Everything the single-page form does is applied to each page as the visitor reac
|
|
|
60
60
|
| **Date fields** | Render as native date pickers on every page |
|
|
61
61
|
| **Icon / image fields**| Keep their Browse buttons and previews on every page |
|
|
62
62
|
| **Spacers** | Rendered in place on the page they belong to |
|
|
63
|
-
| **File uploads** |
|
|
63
|
+
| **File uploads** | Files chosen on page one (all of them, for a multiple field) are still attached when page three submits |
|
|
64
64
|
| **Conditional logic** | A rule on page two can test an answer given on page one |
|
|
65
65
|
| **Spam protection** | The honeypot and timing check cover the submission, not just page one |
|
|
66
66
|
|
|
@@ -1115,8 +1115,9 @@ all entries inline.
|
|
|
1115
1115
|
Subsequent pages append rather than replace; filter/sort changes reset to page 1 normally.
|
|
1116
1116
|
- **Server-mode debounce + abort** - in-flight fetches are cancelled when a new state change supersedes them, so
|
|
1117
1117
|
fast typing in the search box doesn't fight for response order.
|
|
1118
|
-
- **Polished controls** - multiselect renders as toggleable chips with counts; numeric ranges add a
|
|
1119
|
-
slider
|
|
1118
|
+
- **Polished controls** - multiselect renders as toggleable chips with counts; numeric ranges add a two-thumb
|
|
1119
|
+
slider (Domma's `E.rangeSlider`: arrow keys, Page Up/Down, Home/End, read out by screen readers, the field's
|
|
1120
|
+
prefix/suffix text in its labels) under the min/max inputs, in client mode when the loaded entries give the bounds.
|
|
1120
1121
|
|
|
1121
1122
|
##### State-machine transitions
|
|
1122
1123
|
|
|
@@ -409,6 +409,9 @@ options.hooks.on('content:pageCreated', ({ page, urlPath }) => {
|
|
|
409
409
|
|------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------------|
|
|
410
410
|
| `form:submitted` | `{ slug, entryId, data, formTitle }` | A public form submission passes validation and is stored. `entryId` is `null` if collection storage is disabled. |
|
|
411
411
|
|
|
412
|
+
In `data`, a file field is `{url, name, size, mime}`, a list of them when the field is `multiple`, or `''`;
|
|
413
|
+
`fileRefs(value)` from `public/js/form-files.mjs` reads any of the three as a list.
|
|
414
|
+
|
|
412
415
|
#### Auth events
|
|
413
416
|
|
|
414
417
|
| Event | Payload | Fired when |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.17",
|
|
4
4
|
"description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/server.js",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"bcryptjs": "^3.0.3",
|
|
87
87
|
"css-select": "^7.0.0",
|
|
88
88
|
"dom-serializer": "^3.1.1",
|
|
89
|
-
"domma-js": "0.58.
|
|
89
|
+
"domma-js": "0.58.1",
|
|
90
90
|
"dotenv": "^17.2.3",
|
|
91
91
|
"fastify": "5.12.7",
|
|
92
92
|
"gray-matter": "^4.0.3",
|
package/public/css/forms.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.fb-form-wrapper{max-width:600px;margin:2rem auto}.fb-form-honeypot{position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;opacity:0;z-index:-1}.fb-form-success{padding:1rem 1.25rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-success) 12%,transparent);border:1px solid color-mix(in srgb,var(--dm-success) 30%,transparent);color:var(--dm-success);font-size:.95rem;margin-top:.5rem}.fb-form-error{padding:.75rem 1rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--dm-danger) 25%,transparent);color:var(--dm-danger);font-size:.875rem;margin-top:.75rem}.fb-form-loading{opacity:.6;pointer-events:none}.wizard-progress{height:4px;background:var(--dm-border);border-radius:2px;margin-bottom:1rem}.wizard-progress-bar{height:100%;background:var(--dm-primary);border-radius:2px;transition:width .3s ease}.wizard-step-title{font-size:1.1rem;font-weight:600;margin-bottom:.25rem}.wizard-step-description{font-size:.875rem;color:var(--dm-text-muted);margin-bottom:1rem}.wizard-nav{display:flex;justify-content:space-between;gap:.5rem;margin-top:1rem}.fb-field-hidden{display:none!important}.fb-validation-error{color:var(--dm-danger);font-size:.8rem;margin-top:.25rem}.fb-field-trigger-hidden{display:none!important}.fb-field-disabled{opacity:.55;pointer-events:none}.fb-trigger-banner{display:flex;align-items:flex-start;gap:.75rem;margin:.75rem 0;padding:.7rem .9rem;border-radius:var(--dm-radius-md);border-left:3px solid var(--dm-info);background:var(--dm-surface-raised);color:var(--dm-text);font-size:.9rem}.fb-trigger-banner--success{border-left-color:var(--dm-success)}.fb-trigger-banner--warning{border-left-color:var(--dm-warning)}.fb-trigger-banner--error{border-left-color:var(--dm-danger)}.fb-trigger-banner-text{flex:1 1 auto}.fb-trigger-banner-close{flex:0 0 auto;background:none;border:0;color:var(--dm-text-muted);font-size:1.1rem;line-height:1;cursor:pointer;padding:0 .15rem}.fb-trigger-banner-close:hover{color:var(--dm-text)}.fb-trigger-block-note{color:var(--dm-danger);font-size:.85rem;margin:.5rem 0 0}.fb-field-trigger-masked{display:none!important}.fb-trigger-end{margin:1rem 0;padding:1rem 1.1rem;border-radius:var(--dm-radius-md);border:1px solid var(--dm-border);border-left:3px solid var(--dm-warning);background:var(--dm-surface-raised);color:var(--dm-text)}.fb-trigger-end-text{margin:0}.fb-form-wrapper .grid>.fb-trigger-banner,.cms-form-embed .grid>.fb-trigger-banner,.fb-form-wrapper .grid>.fb-trigger-end,.cms-form-embed .grid>.fb-trigger-end{grid-column:1 / -1}.fb-form-wrapper .domma-form-field{margin-bottom:2rem}.fb-form-wrapper .form-group:not(.form-buttons){margin-bottom:2rem}.fb-spacer{height:2rem}.fb-field-required .picker-label:after,.fb-field-required>.form-label:after{content:" *";color:var(--dm-danger)}.fb-field-required .picker-label .picker-required{display:none}.fb-field-invalid>.form-label,.fb-field-invalid .picker-label{color:var(--dm-danger)}.fb-field-invalid .form-input,.fb-field-invalid .form-select,.fb-field-invalid textarea,.fb-field-invalid .picker-options{border-color:var(--dm-danger)}@media(max-width:700px){.fb-form-wrapper form.grid,[data-form-inline] form.grid,.fb-form-wrapper .wizard form.grid,[data-form-inline] .wizard form.grid{grid-template-columns:1fr!important}.fb-form-wrapper form.grid>[class*=col-span-],[data-form-inline] form.grid>[class*=col-span-]{grid-column:span 1 / span 1!important}}.wizard-nav{gap:1rem;flex-wrap:wrap}.wizard-nav .btn{min-width:7.5rem}@media(max-width:480px){.wizard-nav{flex-direction:column-reverse}.wizard-nav .btn{width:100%}}.fb-form-wrapper .form-buttons,[data-form-inline] .form-buttons,#fb-preview-form .form-buttons{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1.25rem}.form-buttons.fb-submit{display:flex;gap:1rem;flex-wrap:wrap}.form-buttons.fb-submit--left{justify-content:flex-start;text-align:left}.form-buttons.fb-submit--center{justify-content:center;text-align:center}.form-buttons.fb-submit--right{justify-content:flex-end;text-align:right}.form-buttons.fb-submit--full>button[type=submit],.form-buttons.fb-submit--full>input[type=submit]{flex:1 1 100%;width:100%}.form-buttons.fb-submit>.fb-trigger-block-note{flex-basis:100%}.btn.fb-btn{gap:.45em}.btn.fb-btn>.fb-btn-icon{flex-shrink:0}.form-buttons.fb-submit--pair{display:flex;gap:1rem;flex-wrap:wrap;align-items:center}.form-buttons.fb-submit--full.fb-submit--pair:not(.fb-submit--stack)>.btn{flex:1 1 0;width:auto}.form-buttons.fb-submit--stack{flex-direction:column;flex-wrap:nowrap;align-items:flex-start;gap:.75rem}.form-buttons.fb-submit--stack.fb-submit--center{align-items:center}.form-buttons.fb-submit--stack.fb-submit--right{align-items:flex-end}.form-buttons.fb-submit--stack.fb-submit--full{align-items:stretch}.form-buttons.fb-submit--stack>.btn,.form-buttons.fb-submit--stack>button[type=submit]{flex:0 0 auto}.form-buttons.fb-submit--stack>.fb-trigger-block-note{flex-basis:auto;align-self:stretch}a.btn.fb-secondary-btn.btn-primary:not(:hover){color:var(--dm-primary-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-secondary:not(:hover){color:var(--dm-secondary-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-success:not(:hover){color:var(--dm-success-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-danger:not(:hover){color:var(--dm-danger-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-warning:not(:hover){color:var(--dm-warning-text)}a.btn.fb-secondary-btn.btn-info:not(:hover){color:var(--dm-info-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-outline:not(:hover){color:var(--dm-text)}a.btn.fb-secondary-btn.btn-outline-success:not(:hover){color:var(--dm-success)}a.btn.fb-secondary-btn.btn-outline-danger:not(:hover){color:var(--dm-danger)}a.btn.fb-secondary-btn.btn-outline-warning:not(:hover){color:var(--dm-warning-text)}a.btn.fb-secondary-btn.btn-outline-info:not(:hover){color:var(--dm-info)}a.btn.fb-secondary-btn.btn-ghost:not(:hover){color:var(--dm-text-secondary)}a.btn.fb-secondary-btn:hover{text-decoration:none}
|
|
1
|
+
.fb-form-wrapper{max-width:600px;margin:2rem auto}.fb-form-honeypot{position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;opacity:0;z-index:-1}.fb-form-success{padding:1rem 1.25rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-success) 12%,transparent);border:1px solid color-mix(in srgb,var(--dm-success) 30%,transparent);color:var(--dm-success);font-size:.95rem;margin-top:.5rem}.fb-form-error{padding:.75rem 1rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--dm-danger) 25%,transparent);color:var(--dm-danger);font-size:.875rem;margin-top:.75rem}.fb-form-loading{opacity:.6;pointer-events:none}.wizard-progress{height:4px;background:var(--dm-border);border-radius:2px;margin-bottom:1rem}.wizard-progress-bar{height:100%;background:var(--dm-primary);border-radius:2px;transition:width .3s ease}.wizard-step-title{font-size:1.1rem;font-weight:600;margin-bottom:.25rem}.wizard-step-description{font-size:.875rem;color:var(--dm-text-muted);margin-bottom:1rem}.wizard-nav{display:flex;justify-content:space-between;gap:.5rem;margin-top:1rem}.fb-field-hidden{display:none!important}.fb-validation-error{color:var(--dm-danger);font-size:.8rem;margin-top:.25rem}.fb-field-trigger-hidden{display:none!important}.fb-field-disabled{opacity:.55;pointer-events:none}.fb-trigger-banner{display:flex;align-items:flex-start;gap:.75rem;margin:.75rem 0;padding:.7rem .9rem;border-radius:var(--dm-radius-md);border-left:3px solid var(--dm-info);background:var(--dm-surface-raised);color:var(--dm-text);font-size:.9rem}.fb-trigger-banner--success{border-left-color:var(--dm-success)}.fb-trigger-banner--warning{border-left-color:var(--dm-warning)}.fb-trigger-banner--error{border-left-color:var(--dm-danger)}.fb-trigger-banner-text{flex:1 1 auto}.fb-trigger-banner-close{flex:0 0 auto;background:none;border:0;color:var(--dm-text-muted);font-size:1.1rem;line-height:1;cursor:pointer;padding:0 .15rem}.fb-trigger-banner-close:hover{color:var(--dm-text)}.fb-trigger-block-note{color:var(--dm-danger);font-size:.85rem;margin:.5rem 0 0}.fb-field-trigger-masked{display:none!important}.fb-trigger-end{margin:1rem 0;padding:1rem 1.1rem;border-radius:var(--dm-radius-md);border:1px solid var(--dm-border);border-left:3px solid var(--dm-warning);background:var(--dm-surface-raised);color:var(--dm-text)}.fb-trigger-end-text{margin:0}.fb-form-wrapper .grid>.fb-trigger-banner,.cms-form-embed .grid>.fb-trigger-banner,.fb-form-wrapper .grid>.fb-trigger-end,.cms-form-embed .grid>.fb-trigger-end{grid-column:1 / -1}.fb-form-wrapper .domma-form-field{margin-bottom:2rem}.fb-form-wrapper .form-group:not(.form-buttons){margin-bottom:2rem}.fb-spacer{height:2rem}.fb-field-required .picker-label:after,.fb-field-required>.form-label:after{content:" *";color:var(--dm-danger)}.fb-field-required .picker-label .picker-required{display:none}.fb-field-invalid>.form-label,.fb-field-invalid .picker-label{color:var(--dm-danger)}.fb-field-invalid .form-input,.fb-field-invalid .form-select,.fb-field-invalid textarea,.fb-field-invalid .picker-options{border-color:var(--dm-danger)}.fb-file-refusal{margin:.4rem 0 0;font-size:.875rem;color:var(--dm-danger)}.fb-file-refusal[hidden]{display:none}.fb-field-invalid .dm-dropzone{border-color:var(--dm-danger)}.page-body ul.dm-dropzone-files{margin:var(--dm-space-3, .75rem) 0;padding-left:0}@media(max-width:700px){.fb-form-wrapper form.grid,[data-form-inline] form.grid,.fb-form-wrapper .wizard form.grid,[data-form-inline] .wizard form.grid{grid-template-columns:1fr!important}.fb-form-wrapper form.grid>[class*=col-span-],[data-form-inline] form.grid>[class*=col-span-]{grid-column:span 1 / span 1!important}}.wizard-nav{gap:1rem;flex-wrap:wrap}.wizard-nav .btn{min-width:7.5rem}@media(max-width:480px){.wizard-nav{flex-direction:column-reverse}.wizard-nav .btn{width:100%}}.fb-form-wrapper .form-buttons,[data-form-inline] .form-buttons,#fb-preview-form .form-buttons{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1.25rem}.form-buttons.fb-submit{display:flex;gap:1rem;flex-wrap:wrap}.form-buttons.fb-submit--left{justify-content:flex-start;text-align:left}.form-buttons.fb-submit--center{justify-content:center;text-align:center}.form-buttons.fb-submit--right{justify-content:flex-end;text-align:right}.form-buttons.fb-submit--full>button[type=submit],.form-buttons.fb-submit--full>input[type=submit]{flex:1 1 100%;width:100%}.form-buttons.fb-submit>.fb-trigger-block-note{flex-basis:100%}.btn.fb-btn{gap:.45em}.btn.fb-btn>.fb-btn-icon{flex-shrink:0}.form-buttons.fb-submit--pair{display:flex;gap:1rem;flex-wrap:wrap;align-items:center}.form-buttons.fb-submit--full.fb-submit--pair:not(.fb-submit--stack)>.btn{flex:1 1 0;width:auto}.form-buttons.fb-submit--stack{flex-direction:column;flex-wrap:nowrap;align-items:flex-start;gap:.75rem}.form-buttons.fb-submit--stack.fb-submit--center{align-items:center}.form-buttons.fb-submit--stack.fb-submit--right{align-items:flex-end}.form-buttons.fb-submit--stack.fb-submit--full{align-items:stretch}.form-buttons.fb-submit--stack>.btn,.form-buttons.fb-submit--stack>button[type=submit]{flex:0 0 auto}.form-buttons.fb-submit--stack>.fb-trigger-block-note{flex-basis:auto;align-self:stretch}a.btn.fb-secondary-btn.btn-primary:not(:hover){color:var(--dm-primary-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-secondary:not(:hover){color:var(--dm-secondary-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-success:not(:hover){color:var(--dm-success-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-danger:not(:hover){color:var(--dm-danger-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-warning:not(:hover){color:var(--dm-warning-text)}a.btn.fb-secondary-btn.btn-info:not(:hover){color:var(--dm-info-text, var(--dm-white))}a.btn.fb-secondary-btn.btn-outline:not(:hover){color:var(--dm-text)}a.btn.fb-secondary-btn.btn-outline-success:not(:hover){color:var(--dm-success)}a.btn.fb-secondary-btn.btn-outline-danger:not(:hover){color:var(--dm-danger)}a.btn.fb-secondary-btn.btn-outline-warning:not(:hover){color:var(--dm-warning-text)}a.btn.fb-secondary-btn.btn-outline-info:not(:hover){color:var(--dm-info)}a.btn.fb-secondary-btn.btn-ghost:not(:hover){color:var(--dm-text-secondary)}a.btn.fb-secondary-btn:hover{text-decoration:none}
|