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.
Files changed (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/index.html +2 -2
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/lib/collection-editor-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/form-submissions-arrange.js +1 -1
  10. package/admin/js/lib/hotkeys-ui.js +7 -16
  11. package/admin/js/lib/hotkeys.js +1 -1
  12. package/admin/js/lib/media-arrange.js +1 -1
  13. package/admin/js/lib/media-picker.js +17 -5
  14. package/admin/js/lib/media-upload.js +1 -0
  15. package/admin/js/lib/palette-commands.js +1 -0
  16. package/admin/js/templates/collection-entries.html +4 -0
  17. package/admin/js/templates/form-submissions.html +3 -0
  18. package/admin/js/templates/media.html +19 -10
  19. package/admin/js/views/collection-entries.js +7 -7
  20. package/admin/js/views/form-editor.js +6 -6
  21. package/admin/js/views/form-submissions.js +2 -2
  22. package/admin/js/views/media.js +3 -3
  23. package/docs/DommaFeatures.md +5 -4
  24. package/docs/api-reference.md +6 -1
  25. package/docs/configuration.md +57 -10
  26. package/docs/guides/multi-page-forms.md +1 -1
  27. package/docs/markdown-shortcodes.md +3 -2
  28. package/docs/plugin-development.md +3 -0
  29. package/package.json +2 -2
  30. package/public/css/forms.css +1 -1
  31. package/public/css/site.css +1 -1
  32. package/public/js/collection-browser.js +2 -2
  33. package/public/js/collection-range.mjs +1 -0
  34. package/public/js/form-files.mjs +1 -0
  35. package/public/js/form-logic-engine.js +1 -1
  36. package/public/js/forms.js +2 -2
  37. package/public/js/site.js +1 -1
  38. package/server/routes/api/forms.js +211 -60
  39. package/server/routes/api/media.js +25 -2
  40. package/server/server.js +8 -2
  41. package/server/services/collections.js +3 -0
  42. package/server/services/email.js +15 -4
  43. package/server/services/forms.js +11 -3
  44. package/server/services/markdown.js +20 -9
  45. package/server/services/mediaTypes.js +22 -0
  46. package/server/templates/page.html +8 -6
@@ -1,4 +1,4 @@
1
- import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as Y}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as De,whileLoading as Le}from"/admin/js/lib/loading.js";import{helpPopovers as me}from"/admin/js/lib/help-popover.js";import{onceClosed as Re,openMenuAt as pe,sidebarCount as Q,viewLifetime as Ie}from"/admin/js/lib/tool-kit.js";import{FILTERS as Oe,answerFields as qe,answerText as q,arrangeSubmissions as Ue,submissionCounts as Fe,submissionRow as L,submissionText as je}from"/admin/js/lib/form-submissions-arrange.js";import{fmtDateTime as Pe}from"../lib/dates.js";import{openEmail as Be}from"../lib/mail-compose.js";const ue=/^#\/forms\/[^/?#]+\/submissions\/?(\?.*)?$/,Z="form-submissions.filter",Ke=`
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"),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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"),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)})}};
@@ -1,4 +1,4 @@
1
- import{api as z}from"../api.js";import{addBannerAction as Be}from"../lib/plugin-chrome.js";import{SHAPES as He,whileLoading as Oe}from"../lib/loading.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{onceClosed as Ke,openMenuAt as Me,settingsStore as _e,sidebarCount as ee,viewLifetime as je}from"../lib/tool-kit.js";import{openImageEditor as Ve}from"../lib/image-editor.js";import{KIND_LABEL as De,arrangeMedia as Ye,clashes as Ge,formatBytes as Le,isEditable as te,isPreviewable as Je,kindCounts as Qe,kindOf as We,markdownFor as ae,mediaTile as Xe,normalisePrefs as Ze,safeName as B}from"../lib/media-arrange.js";import{fmtDateTime as et}from"../lib/dates.js";const Se=/^#\/media\/?(\?.*)?$/,ne="#/media",H="media.draft",Ae=_e("media.prefs",Ze),tt=`
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>`,q=u=>String(u??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function at(u){const d=document.createElement("template");return d.innerHTML=u.trim(),d.content.firstElementChild}const l=je(Se);async function O(u,d){try{await navigator.clipboard.writeText(u),E.toast(`${d} 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 d=u.get(0).querySelector(".mdx")||u.get(0),b=()=>Se.test(location.hash)&&d.isConnected,oe=Ae.read(),p=M.observable([]),se=M.observable(!0),K=M.observable(""),y=M.observable(oe.kind),_=M.observable(oe.sort),ie=M.observable(""),D=M.observable(""),R=M.observable(!1);let j=String(Date.now());l.add(M.effect(()=>Ae.write({kind:y.value,sort:_.value})));const U=M.observable([]);l.add(M.effect(()=>{const e=Date.now();U.value=Ye(p.value,{kind:y.value,query:ie.value,sort:_.value}).map(t=>Xe(t,{now:e,stamp:j}))}));const re=M.computed(()=>Qe(p.value));l.add(M.effect(()=>{y.value!=="all"&&!re.value[y.value]&&p.value.length&&(y.value="all")}));const Ce=M.computed(()=>{const e=p.value,t=U.value.length,o=Le(e.reduce((n,i)=>n+(Number(i.size)||0),0));return t===e.length?`${e.length} file${e.length===1?"":"s"} \xB7 ${o}`:`${t} of ${e.length} \xB7 ${o} in all`}),Te=M.computed(()=>p.value.length?"No files match.":"Nothing uploaded yet - drop files here, or Upload in the banner."),L=e=>p.peek().find(t=>t.name===e);async function A(){try{const e=await Oe(d.querySelector(".dm-loading-slot"),z.media.list(),He.tiles);if(!b())return;j=String(Date.now()),p.value=Array.isArray(e)?e:[],K.value=""}catch(e){K.value=`Could not load the media: ${e.message||e}`}finally{se.value=!1}}async function le(e){const t=[...e||[]].filter(s=>s&&s.size!==void 0);if(!t.length)return;if(D.peek()){E.toast("An upload is still running.",{type:"warning"});return}const o=Ge(t.map(s=>s.name),p.peek().map(s=>s.name));if(o.length){const s=o.slice(0,5).map(w=>`"${q(w)}"`).join(", ")+(o.length>5?` and ${o.length-5} more`:"");if(!await E.confirm(`${o.length===1?"A file":`${o.length} files`} with the same name ${o.length===1?"is":"are"} already here: ${s}. Replace ${o.length===1?"it":"them"}?`))return}const n=[];let i=0;try{for(const s of t){D.value=`Uploading ${i+1} of ${t.length} - ${s.name}`;const w=new FormData;w.append("file",s);try{await z.media.upload(w),i++}catch(J){n.push(`${s.name}: ${J.message||"failed"}`)}}}finally{D.value=""}i&&E.toast(`Uploaded ${i} file${i===1?"":"s"}.`,{type:"success"}),n.length&&E.toast(`Not uploaded - ${n.join("; ")}`,{type:"error",duration:9e3}),ee(ne),b()&&await A()}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 z.media.delete(e.name),E.toast(`Deleted "${e.name}".`,{type:"success"}),ee(ne),b()&&await A(),!0}catch(t){return E.toast(`Not deleted: ${t.message||t}`,{type:"error"}),!1}}async function de(e){const t=await Ve(e);return t&&b()&&(ee(ne),await A()),t}const Y=e=>window.location.origin+e.url;let h=null,ce=!1;const G=new Set;function k(e,{focusRename:t=!1,draftName:o=null}={}){const n=L(e);if(!n){E.toast(`"${e}" is not here any more.`,{type:"warning"});return}const i=M.observable(o??n.name),s=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&&L(r)?`"${r}" is already here.`:""}),J=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.`:""}),ge=()=>i.peek().trim()!==n.name,we={newName:i,saving:s,renameErr:w,renameHint:J,cannotRename:M.computed(()=>s.value||!!w.value||B(i.value.trim())===n.name),editable:te(n.name)},m=E.slideover({title:q(n.name),size:"md",position:"right"}),c=at(`
10
+ </div>`,N=c=>String(c??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");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>`),Q=c.querySelector(".mdx-preview"),$=We(n.name);if(Je(n.name)){const a=document.createElement("img");a.src=`${n.url}?t=${encodeURIComponent(j)}`,a.alt=n.name,Q.appendChild(a)}else if($==="video"||$==="audio"){const a=document.createElement($);a.src=n.url,a.controls=!0,a.preload="metadata",Q.appendChild(a)}else{const a=document.createElement("span");a.setAttribute("data-icon",$==="document"?"file-text":"paperclip"),a.setAttribute("data-icon-size","40"),Q.appendChild(a)}const Re=c.querySelector(".mdx-facts"),P=(a,r)=>{const xe=document.createElement("dt");xe.textContent=a;const Z=document.createElement("dd");return Z.textContent=r,Re.append(xe,Z),Z};if(P("Type",`${De[$]==="Images"?"Image":De[$].replace(/s$/,"")} (${(n.name.split(".").pop()||"").toUpperCase()})`),P("Size",Le(n.size)),P("Uploaded",n.createdAt?et(n.createdAt):"-"),te(n.name)){const a=P("Dimensions","\u2026");z.media.info(n.name).then(r=>{a.textContent=r?.width?`${r.width} \xD7 ${r.height} px`:"-"}).catch(()=>{a.textContent="-"})}c.querySelector(".mdx-url").value=Y(n),c.querySelector(".mdx-md").value=ae(n),c.querySelector(".mdx-open").href=n.url;let N=!1;async function ke(){if(!we.cannotRename.peek()){s.value=!0;try{const a=await z.media.rename(n.name,i.peek().trim());E.toast(`Renamed to "${a?.name||B(i.peek().trim())}".`,{type:"success"}),N=!0,m.close(),b()&&await A()}catch(a){E.toast(`Not renamed: ${a.message||a}`,{type:"error"})}finally{s.value=!1}}}m.element.appendChild(c);const Ie=$e(c),Ue=M.applyBindings(we,c,{methods:{rename:ke,copyUrl:()=>O(Y(n),"Address"),copyMd:()=>O(ae(n),"Markdown"),async del(){await V(n)&&(N=!0,m.close())},async edit(){N=!0,m.close(),await de(n)}}}),W=c.querySelector("#mdx-rename"),Fe=a=>{a.key==="Enter"&&a.target===W&&(a.preventDefault(),ke())};c.addEventListener("keydown",Fe);const Ee={name:n.name,dirty:ge,newName:()=>i.peek()};h=Ee,G.add(m),Ke(m,()=>{Ue.dispose(),Ie(),c.querySelectorAll("video, audio").forEach(a=>{try{a.pause()}catch{}}),G.delete(m),h===Ee&&(h=null)});const Pe=m.close.bind(m);let X=!1;m.close=async a=>{if(!N&&!ce&&a!==!0&&ge()){if(X)return;X=!0;const r=await E.confirm("Discard the new name you typed?");if(X=!1,!r)return}return N=!0,Pe()},I.scan(c),m.open(),(t||o)&&(W.focus(),W.select())}function Ne(e){const t=L(e);return t?[{header:t.name},{label:"Details\u2026",icon:"info",action:()=>k(t.name)},{label:"Copy address",icon:"link",action:()=>O(Y(t),"Address")},{label:"Copy for a page",icon:"copy",action:()=>O(ae(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})},...te(t.name)?[{label:"Edit image\u2026",icon:"edit",action:()=>de(t)}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>V(t)}]:[]}const f=d.querySelector(".mdx-grid");f.innerHTML=tt;const x=e=>()=>{y.value=e},ze=M.applyBindings({files:p,loading:se,loadError:K,kind:y,sort:_,query:ie,tiles:U,counts:re,totalText:Ce,emptyText:Te,busyText:D,dragging:R},d,{methods:{kindAll:x("all"),kindImage:x("image"),kindVideo:x("video"),kindAudio:x("audio"),kindDocument:x("document"),kindOther:x("other")}});l.add(()=>ze.dispose()),l.add($e(d,{position:"bottom"})),l.add(M.effect(()=>{U.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const qe=E.contextMenu(f,{cursor:"glyph",match:".mdx-tile",inherit:!1,density:"compact",items:e=>Ne(e.dataset.name)});l.add(()=>qe?.destroy?.());const me=e=>{const t=e.target.closest(".mdx-tile");if(!t)return;const o=e.target.closest(".mdx-kebab");if(o){Me(o);return}k(t.dataset.name)},ue=e=>{const t=e.target.closest(".mdx-tile");if(!t||e.target!==t)return;const o=L(t.dataset.name);o&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Me(t)):e.key==="Enter"?k(o.name):e.key==="F2"?(e.preventDefault(),k(o.name,{focusRename:!0})):e.key==="Delete"&&(e.preventDefault(),V(o)))};f.addEventListener("click",me),f.addEventListener("keydown",ue),l.add(()=>{f.removeEventListener("click",me),f.removeEventListener("keydown",ue)});const C=d.querySelector(".mdx-file-input"),pe=()=>{const e=[...C.files];C.value="",le(e)};C.addEventListener("change",pe),l.add(()=>C.removeEventListener("change",pe)),Be(u,{id:"media-upload",icon:"upload",title:"Upload files",onClick:()=>C.click()});const F=e=>[...e.dataTransfer?.types||[]].includes("Files");let T=0;const fe=e=>{F(e)&&(e.preventDefault(),T++,R.value=!0)},ve=e=>{F(e)&&(e.preventDefault(),e.dataTransfer.dropEffect="copy")},be=e=>{F(e)&&(T=Math.max(0,T-1),T||(R.value=!1))},ye=e=>{F(e)&&(e.preventDefault(),T=0,R.value=!1,le(e.dataTransfer.files))},v=u.get(0);v.addEventListener("dragenter",fe),v.addEventListener("dragover",ve),v.addEventListener("dragleave",be),v.addEventListener("drop",ye),l.add(()=>{v.removeEventListener("dragenter",fe),v.removeEventListener("dragover",ve),v.removeEventListener("dragleave",be),v.removeEventListener("drop",ye)});const he=e=>{(D.peek()||h?.dirty())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",he),l.add(()=>window.removeEventListener("beforeunload",he)),l.add(()=>{if(h?.dirty())try{S.set(H,{name:h.name,newName:h.newName(),at:new Date().toISOString()})}catch{}ce=!0,[...G].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(d),await A(),!b())return;let g=null;try{g=S.get(H)}catch{}if(g?.name){try{S.remove?S.remove(H):S.set(H,null)}catch{}L(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})}}};
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})}}};
@@ -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
- - The filename is cleaned and given a uuid prefix, and the file is saved to `content/media/`.
498
- - The entry stores `{url, name, size, mime}`.
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.
@@ -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}`. There is no CAPTCHA.
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
  ---
@@ -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 }`. The actual
1684
- bytes live in `content/media/` (under a uuid-prefixed filename so concurrent
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
- **Form rendering** - `<input type="file">` with the `accept` hint. Setting
1706
- `multiple: true` on the field renders a multi-file picker (one file per entry
1707
- update; the form takes the last selection).
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
- 2. Sanitises the filename (lowercases, strips path chars, prepends a uuid)
1714
- 3. Saves to `content/media/<uuid>-<safe-name>`
1715
- 4. Replaces the entry's field value with the reference object
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** | A file chosen on page one is still attached when page three submits |
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 dual-handle
1119
- slider above the min/max inputs (when bounds can be derived from the dataset).
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.15",
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.0",
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",
@@ -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}