domma-cms 0.99.0 → 0.100.1

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.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.46.1
2
+ * Domma Tools CSS v0.48.0
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-27T20:43:29.160Z
6
- * Commit: 92f2ee4
5
+ * Built: 2026-09-27T22:15:04.657Z
6
+ * Commit: cf6d8de
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.46.1
2
+ * Domma Tools v0.48.0
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-09-27T20:43:15.062Z
6
- * Commit: 92f2ee4
5
+ * Built: 2026-09-27T22:14:53.527Z
6
+ * Commit: cf6d8de
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/admin/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  <link rel="stylesheet" href="/admin/css/dashboard.css">
22
22
 
23
23
  <!-- Forms CSS - needed for conditional logic preview (.fb-field-hidden, etc.) -->
24
- <link rel="stylesheet" href="/public/css/forms.css?v=20260926-end-panel">
24
+ <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
25
25
 
26
26
  <!-- Menu item hover / current-page effects - the admin sidebar is a menu
27
27
  like any other, so it honours the same `highlight` block. -->
@@ -80,7 +80,7 @@
80
80
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
81
81
 
82
82
  <!-- Form Logic Engine - needed for conditional logic preview -->
83
- <script src="/public/js/form-logic-engine.js?v=20260927-field-tokens"></script>
83
+ <script src="/public/js/form-logic-engine.js?v=20260927-submit-btn"></script>
84
84
 
85
85
  <!-- Admin App (ES module entry) -->
86
86
  <script type="module" src="/admin/js/app.js"></script>
@@ -1,3 +1,3 @@
1
- export const FIELD_TYPES=[{value:"string",label:"Text (single line)"},{value:"email",label:"Email"},{value:"tel",label:"Phone"},{value:"number",label:"Number"},{value:"textarea",label:"Textarea (multi-line)"},{value:"select",label:"Dropdown (select)"},{value:"radio",label:"Radio buttons"},{value:"checkbox",label:"Single checkbox"},{value:"checkbox-group",label:"Checkbox group"},{value:"date",label:"Date"},{value:"time",label:"Time"},{value:"url",label:"URL"},{value:"hidden",label:"Hidden field"}],OPTION_TYPES=new Set(["select","radio","checkbox-group"]),ADDON_TYPES=new Set(["string","email","tel","number","textarea","select","date","time","url"]);const f=/^[a-z0-9-]+$/i,m=24;function p(e){return e?typeof e=="string"?{icon:"",text:e}:{icon:e.icon||"",text:e.icon?"":String(e.text??"")}:{icon:"",text:""}}export function addonFromBoxes(e,r){const n=String(e??"").trim(),t=String(r??"").trim();return n&&f.test(n)?{icon:n}:t?{text:t.slice(0,m)}:null}export const OPERATIONS=["create","read","update","delete"],EXPORT_FORMATS=["csv","json"],FIELD_NAME_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;export function slugify(e){return String(e??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export const nameFromLabel=e=>slugify(e).replace(/-/g,"_"),typeLabel=e=>FIELD_TYPES.find(r=>r.value===e)?.label||e||"";export function optionsText(e){return(Array.isArray(e)?e:[]).map(r=>typeof r=="string"?`${r}: ${r}`:`${r?.value??""}: ${r?.label??r?.value??""}`).join(`
1
+ export const FIELD_TYPES=[{value:"string",label:"Text (single line)"},{value:"email",label:"Email"},{value:"tel",label:"Phone"},{value:"number",label:"Number"},{value:"textarea",label:"Textarea (multi-line)"},{value:"select",label:"Dropdown (select)"},{value:"radio",label:"Radio buttons"},{value:"checkbox",label:"Single checkbox"},{value:"checkbox-group",label:"Checkbox group"},{value:"date",label:"Date"},{value:"time",label:"Time"},{value:"url",label:"URL"},{value:"hidden",label:"Hidden field"}],OPTION_TYPES=new Set(["select","radio","checkbox-group"]),ADDON_TYPES=new Set(["string","email","tel","number","textarea","select","date","time","url"]);const f=/^[a-z0-9-]+$/i;export const EXTRA_TYPES={clear:new Set(["string","email","tel","url","textarea"]),stepper:new Set(["number"]),counter:new Set(["string","textarea"])};const m=24;function p(e){return e?typeof e=="string"?{icon:"",text:e}:{icon:e.icon||"",text:e.icon?"":String(e.text??"")}:{icon:"",text:""}}export function addonFromBoxes(e,r){const n=String(e??"").trim(),t=String(r??"").trim();return n&&f.test(n)?{icon:n}:t?{text:t.slice(0,m)}:null}export const OPERATIONS=["create","read","update","delete"],EXPORT_FORMATS=["csv","json"],FIELD_NAME_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;export function slugify(e){return String(e??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}export const nameFromLabel=e=>slugify(e).replace(/-/g,"_"),typeLabel=e=>FIELD_TYPES.find(r=>r.value===e)?.label||e||"";export function optionsText(e){return(Array.isArray(e)?e:[]).map(r=>typeof r=="string"?`${r}: ${r}`:`${r?.value??""}: ${r?.label??r?.value??""}`).join(`
2
2
  `)}export function parseOptions(e){return String(e??"").split(`
3
- `).filter(r=>r.trim()).map(r=>{const[n,...t]=r.split(":");return{value:n.trim(),label:t.join(":").trim()||n.trim()}})}const g=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),b=e=>({enabled:!0,access:e||"",formats:["csv","json"]});export function roleLadder(e){return(Array.isArray(e)?e:e?.entries||[]).map(n=>({name:n?.data?.name,label:n?.data?.label||n?.data?.name,level:Number(n?.data?.level)})).filter(n=>typeof n.name=="string"&&n.name&&Number.isFinite(n.level)).sort((n,t)=>n.level-t.level||n.name.localeCompare(t.name))}export function accessOptions(e,r,{token:n=!0}={}){const t=[{value:"public",label:"Public - anyone"}];n&&t.push({value:"token",label:"API token"});for(const l of[...e||[]].reverse())t.push({value:l.name,label:l.level===0?`${l.label} only`:`${l.label} and above`});return typeof r=="string"&&r&&!t.some(l=>l.value===r)&&t.push(e?.length?{value:r,label:`${r} (role not found)`,missing:!0}:{value:r,label:r}),t}let x=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(x++).toString(36)}`;export function formFromCollection(e=null,{project:r="",defaultRole:n=""}={}){const t=e||{},l={...g(),...t.api||{}},o={...b(n),...t.export||{}},a={title:String(t.title??""),slug:String(t.slug??""),description:String(t.description??""),layout:t.layout==="grid"?"grid":"stacked",columns:String(Number.parseInt(t.columns,10)||2),project:String(t.meta?.project??r??""),bundled:t.bundled===!0,storageAdapter:t.storage?.adapter==="mongodb"?"mongodb":"file",storageConnection:String(t.storage?.connection||"default"),fields:(Array.isArray(t.fields)?t.fields:[]).map(i=>({...i,_key:fieldKey()})),readFields:Array.isArray(l.read?.fields)?l.read.fields.join(", "):"",exportEnabled:o.enabled!==!1,exportAccess:typeof o.access=="string"&&o.access?o.access:String(n||""),exportCsv:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("json")};for(const i of OPERATIONS){a[`${i}Enabled`]=l[i]?.enabled===!0;const s=l[i]?.access;a[`${i}Access`]=typeof s=="string"&&s?s:"admin"}return a}export function cleanField(e){const{_key:r,...n}=e||{};return n}export function payloadFromForm(e,{isNew:r=!1,meta:n={}}={}){const t=e||{},l={};for(const i of OPERATIONS)l[i]={enabled:t[`${i}Enabled`]===!0,access:t[`${i}Access`]||"admin"};const o=String(t.readFields??"").split(",").map(i=>i.trim()).filter(Boolean);o.length&&(l.read.fields=o);const a=Math.min(6,Math.max(1,Number.parseInt(t.columns,10)||2));return{title:String(t.title??"").trim(),...r&&{slug:slugify(t.slug||t.title)},description:String(t.description??"").trim(),layout:t.layout==="grid"?"grid":"stacked",columns:a,fields:(t.fields||[]).map(cleanField),api:l,export:{enabled:t.exportEnabled!==!1,...t.exportAccess&&{access:t.exportAccess},formats:EXPORT_FORMATS.filter(i=>i==="csv"?t.exportCsv:t.exportJson)},storage:t.storageAdapter==="mongodb"?{adapter:"mongodb",connection:t.storageConnection||"default"}:{adapter:"file"},meta:{...n||{},project:String(t.project??"")||null},bundled:t.bundled===!0}}export function snapshot(e){const{fields:r=[],...n}=e||{};return JSON.stringify({...n,fields:r.map(cleanField)})}export function checkForm(e,{isNew:r=!1,taken:n=[],original:t={}}={}){const l=e||{},o={};if(String(l.title??"").trim()||(o.title="A collection needs a title."),r){const c=slugify(l.slug||l.title);c?n.includes(c)&&(o.slug=`"${c}" is already a collection.`):o.slug="The slug needs a letter or a number."}const a=Number.parseInt(l.columns,10);l.layout==="grid"&&!(a>=1&&a<=6)&&(o.columns="Between 1 and 6 columns.");const i={},s=new Map;return(l.fields||[]).forEach(c=>{const d=fieldProblem(c,t[c._key]);d&&(i[c._key]=d);const u=String(c.name??"").trim();u&&(s.has(u)?i[c._key]=i[c._key]||`"${u}" is used by another field.`:s.set(u,c._key))}),Object.keys(i).length&&(o.fields=i),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(o.export="Pick at least one export format, or turn export off."),o}export function fieldProblem(e,r){const n=String(e?.name??"").trim();return n?n!==r&&!FIELD_NAME_RE.test(n)?"Names start with a letter or _, then letters, digits, _ or -.":OPTION_TYPES.has(e.type)&&!(Array.isArray(e.options)&&e.options.length)?`A ${typeLabel(e.type).toLowerCase()} needs at least one option.`:"":"A field needs a name - it is what each entry is keyed by."}export const errorCount=e=>Object.keys(e||{}).filter(r=>r!=="fields").length+Object.keys(e?.fields||{}).length;export function fieldRow(e,r={},{grid:n=!1}={}){const t=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&t.push(`${(e.options||[]).length} options`),n&&t.push(e.fullWidth?"full width":`span ${e.span>1?e.span:1}`),{key:e._key,label:e.label||"(Untitled field)",meta:t.join(" \xB7 "),required:e.required===!0,hidden:e.type==="hidden",problem:r[e._key]||"",rowCls:r[e._key]?"is-bad":""}}export function moveField(e,r,n){const t=[...e];if(r<0||r>=t.length||n<0||n>=t.length||r===n)return t;const[l]=t.splice(r,1);return t.splice(n,0,l),t}export function duplicateField(e,r){const n=e[r];if(!n)return[...e];const t=new Set(e.map(s=>s.name)),l=`${n.name||"field"}_copy`;let o=l;for(let s=2;t.has(o);s++)o=`${l}_${s}`;const a={...JSON.parse(JSON.stringify(cleanField(n))),name:o,label:`${n.label||"Field"} (copy)`,_key:fieldKey()};delete a.id;const i=[...e];return i.splice(r+1,0,a),i}export const newField=()=>({id:`field-${Date.now()}`,name:"",label:"",type:"string",required:!1,placeholder:"",helper:"",options:[],validation:[],logic:null,_key:fieldKey()});export function panelForm(e){return{label:String(e.label??""),name:String(e.name??""),type:e.type||"string",required:e.required===!0,placeholder:String(e.placeholder??""),helper:String(e.helper??""),options:optionsText(e.options),span:String(e.span>1?e.span:1),fullWidth:e.fullWidth===!0,prefixIcon:p(e.prefix).icon,prefixText:p(e.prefix).text,suffixIcon:p(e.suffix).icon,suffixText:p(e.suffix).text}}export function fieldFromPanel(e,r){const n={...e,label:String(r.label??"").trim(),name:String(r.name??"").trim(),type:r.type||"string",required:r.required===!0,placeholder:String(r.placeholder??"").trim(),helper:String(r.helper??"").trim()};OPTION_TYPES.has(n.type)&&(n.options=parseOptions(r.options));for(const t of["prefix","suffix"]){const l=`${t}Icon`in r||`${t}Text`in r;if(!ADDON_TYPES.has(n.type)){delete n[t];continue}if(!l)continue;const o=addonFromBoxes(r[`${t}Icon`],r[`${t}Text`]);o?n[t]=o:delete n[t]}if(r.fullWidth)n.fullWidth=!0,delete n.span;else{delete n.fullWidth;const t=Number.parseInt(r.span,10);t>1?n.span=Math.min(6,t):delete n.span}return n}export function storageChange(e,r){const n=e||"file",t=r||"file";return n===t?"":`${n} \u2192 ${t}`}export function draftOf(e){const{fields:r=[],...n}=e||{};return{...n,fields:r.map(cleanField)}}export function formFromDraft(e,r){const n=e||{};return{...r,...n,fields:(Array.isArray(n.fields)?n.fields:r.fields).map(t=>({...cleanField(t),_key:fieldKey()}))}}
3
+ `).filter(r=>r.trim()).map(r=>{const[n,...t]=r.split(":");return{value:n.trim(),label:t.join(":").trim()||n.trim()}})}const g=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),b=e=>({enabled:!0,access:e||"",formats:["csv","json"]});export function roleLadder(e){return(Array.isArray(e)?e:e?.entries||[]).map(n=>({name:n?.data?.name,label:n?.data?.label||n?.data?.name,level:Number(n?.data?.level)})).filter(n=>typeof n.name=="string"&&n.name&&Number.isFinite(n.level)).sort((n,t)=>n.level-t.level||n.name.localeCompare(t.name))}export function accessOptions(e,r,{token:n=!0}={}){const t=[{value:"public",label:"Public - anyone"}];n&&t.push({value:"token",label:"API token"});for(const l of[...e||[]].reverse())t.push({value:l.name,label:l.level===0?`${l.label} only`:`${l.label} and above`});return typeof r=="string"&&r&&!t.some(l=>l.value===r)&&t.push(e?.length?{value:r,label:`${r} (role not found)`,missing:!0}:{value:r,label:r}),t}let x=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(x++).toString(36)}`;export function formFromCollection(e=null,{project:r="",defaultRole:n=""}={}){const t=e||{},l={...g(),...t.api||{}},o={...b(n),...t.export||{}},a={title:String(t.title??""),slug:String(t.slug??""),description:String(t.description??""),layout:t.layout==="grid"?"grid":"stacked",columns:String(Number.parseInt(t.columns,10)||2),project:String(t.meta?.project??r??""),bundled:t.bundled===!0,storageAdapter:t.storage?.adapter==="mongodb"?"mongodb":"file",storageConnection:String(t.storage?.connection||"default"),fields:(Array.isArray(t.fields)?t.fields:[]).map(i=>({...i,_key:fieldKey()})),readFields:Array.isArray(l.read?.fields)?l.read.fields.join(", "):"",exportEnabled:o.enabled!==!1,exportAccess:typeof o.access=="string"&&o.access?o.access:String(n||""),exportCsv:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(o.formats)?o.formats:EXPORT_FORMATS).includes("json")};for(const i of OPERATIONS){a[`${i}Enabled`]=l[i]?.enabled===!0;const s=l[i]?.access;a[`${i}Access`]=typeof s=="string"&&s?s:"admin"}return a}export function cleanField(e){const{_key:r,...n}=e||{};return n}export function payloadFromForm(e,{isNew:r=!1,meta:n={}}={}){const t=e||{},l={};for(const i of OPERATIONS)l[i]={enabled:t[`${i}Enabled`]===!0,access:t[`${i}Access`]||"admin"};const o=String(t.readFields??"").split(",").map(i=>i.trim()).filter(Boolean);o.length&&(l.read.fields=o);const a=Math.min(6,Math.max(1,Number.parseInt(t.columns,10)||2));return{title:String(t.title??"").trim(),...r&&{slug:slugify(t.slug||t.title)},description:String(t.description??"").trim(),layout:t.layout==="grid"?"grid":"stacked",columns:a,fields:(t.fields||[]).map(cleanField),api:l,export:{enabled:t.exportEnabled!==!1,...t.exportAccess&&{access:t.exportAccess},formats:EXPORT_FORMATS.filter(i=>i==="csv"?t.exportCsv:t.exportJson)},storage:t.storageAdapter==="mongodb"?{adapter:"mongodb",connection:t.storageConnection||"default"}:{adapter:"file"},meta:{...n||{},project:String(t.project??"")||null},bundled:t.bundled===!0}}export function snapshot(e){const{fields:r=[],...n}=e||{};return JSON.stringify({...n,fields:r.map(cleanField)})}export function checkForm(e,{isNew:r=!1,taken:n=[],original:t={}}={}){const l=e||{},o={};if(String(l.title??"").trim()||(o.title="A collection needs a title."),r){const c=slugify(l.slug||l.title);c?n.includes(c)&&(o.slug=`"${c}" is already a collection.`):o.slug="The slug needs a letter or a number."}const a=Number.parseInt(l.columns,10);l.layout==="grid"&&!(a>=1&&a<=6)&&(o.columns="Between 1 and 6 columns.");const i={},s=new Map;return(l.fields||[]).forEach(c=>{const d=fieldProblem(c,t[c._key]);d&&(i[c._key]=d);const u=String(c.name??"").trim();u&&(s.has(u)?i[c._key]=i[c._key]||`"${u}" is used by another field.`:s.set(u,c._key))}),Object.keys(i).length&&(o.fields=i),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(o.export="Pick at least one export format, or turn export off."),o}export function fieldProblem(e,r){const n=String(e?.name??"").trim();return n?n!==r&&!FIELD_NAME_RE.test(n)?"Names start with a letter or _, then letters, digits, _ or -.":OPTION_TYPES.has(e.type)&&!(Array.isArray(e.options)&&e.options.length)?`A ${typeLabel(e.type).toLowerCase()} needs at least one option.`:"":"A field needs a name - it is what each entry is keyed by."}export const errorCount=e=>Object.keys(e||{}).filter(r=>r!=="fields").length+Object.keys(e?.fields||{}).length;export function fieldRow(e,r={},{grid:n=!1}={}){const t=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&t.push(`${(e.options||[]).length} options`),n&&t.push(e.fullWidth?"full width":`span ${e.span>1?e.span:1}`),{key:e._key,label:e.label||"(Untitled field)",meta:t.join(" \xB7 "),required:e.required===!0,hidden:e.type==="hidden",problem:r[e._key]||"",rowCls:r[e._key]?"is-bad":""}}export function moveField(e,r,n){const t=[...e];if(r<0||r>=t.length||n<0||n>=t.length||r===n)return t;const[l]=t.splice(r,1);return t.splice(n,0,l),t}export function duplicateField(e,r){const n=e[r];if(!n)return[...e];const t=new Set(e.map(s=>s.name)),l=`${n.name||"field"}_copy`;let o=l;for(let s=2;t.has(o);s++)o=`${l}_${s}`;const a={...JSON.parse(JSON.stringify(cleanField(n))),name:o,label:`${n.label||"Field"} (copy)`,_key:fieldKey()};delete a.id;const i=[...e];return i.splice(r+1,0,a),i}export const newField=()=>({id:`field-${Date.now()}`,name:"",label:"",type:"string",required:!1,placeholder:"",helper:"",options:[],validation:[],logic:null,_key:fieldKey()});export function panelForm(e){return{label:String(e.label??""),name:String(e.name??""),type:e.type||"string",required:e.required===!0,placeholder:String(e.placeholder??""),helper:String(e.helper??""),options:optionsText(e.options),span:String(e.span>1?e.span:1),fullWidth:e.fullWidth===!0,prefixIcon:p(e.prefix).icon,prefixText:p(e.prefix).text,suffixIcon:p(e.suffix).icon,suffixText:p(e.suffix).text,clear:e.clear===!0,stepper:e.stepper===!0,counter:e.counter===!0}}export function fieldFromPanel(e,r){const n={...e,label:String(r.label??"").trim(),name:String(r.name??"").trim(),type:r.type||"string",required:r.required===!0,placeholder:String(r.placeholder??"").trim(),helper:String(r.helper??"").trim()};OPTION_TYPES.has(n.type)&&(n.options=parseOptions(r.options));for(const[t,l]of Object.entries(EXTRA_TYPES)){if(!l.has(n.type)){delete n[t];continue}t in r&&(r[t]===!0?n[t]=!0:delete n[t])}for(const t of["prefix","suffix"]){const l=`${t}Icon`in r||`${t}Text`in r;if(!ADDON_TYPES.has(n.type)){delete n[t];continue}if(!l)continue;const o=addonFromBoxes(r[`${t}Icon`],r[`${t}Text`]);o?n[t]=o:delete n[t]}if(r.fullWidth)n.fullWidth=!0,delete n.span;else{delete n.fullWidth;const t=Number.parseInt(r.span,10);t>1?n.span=Math.min(6,t):delete n.span}return n}export function storageChange(e,r){const n=e||"file",t=r||"file";return n===t?"":`${n} \u2192 ${t}`}export function draftOf(e){const{fields:r=[],...n}=e||{};return{...n,fields:r.map(cleanField)}}export function formFromDraft(e,r){const n=e||{};return{...r,...n,fields:(Array.isArray(n.fields)?n.fields:r.fields).map(t=>({...cleanField(t),_key:fieldKey()}))}}
@@ -1,4 +1,4 @@
1
- import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o,where:"",text:a}:{code:o,where:a,text:n},e=(o,a,n="")=>({title:o,code:a,note:n||""}),t=(o,{intro:a="",head:n=null,rows:s=[],block:l="",examples:i=[],outro:r=""}={})=>({title:o,intro:a,head:(n||[]).map((d,c)=>({i:c,text:d})),rows:s.map(m),block:l,examples:i,outro:r}),u=[t("Markdown Basics",{head:["Syntax","Result"],rows:[["**bold**","bold (rendered bold)"],["_italic_","italic (rendered italic)"],["## Heading","Heading (h2)"],["[text](url)","Link"],["![alt](url)","Image"],["- item","Bullet list"],["`code`","Inline code"],["---","Divider"]]}),t("Keyboard Shortcuts",{rows:[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]]}),t("Tips",{rows:[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]],outro:"Most shortcodes accept a class attribute for custom styling."})],g=[t("Grid & Columns",{rows:[['cols="N"',"[grid] only","Number of columns (1-12)"],['gap="N"',"[grid], [row]","Gap between columns/rows (1-6)"],['span="N"',"[col] only","How many columns this cell spans"],['fullwidth="true"',"[grid] only","Break out of page container to span full viewport"],['class="x"',"all","Add extra CSS classes"]],examples:[e("Equal columns",`[grid cols="3" gap="4"]
1
+ import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o,where:"",text:a}:{code:o,where:a,text:n},e=(o,a,n="")=>({title:o,code:a,note:n||""}),t=(o,{intro:a="",head:n=null,rows:s=[],block:l="",examples:i=[],outro:r=""}={})=>({title:o,intro:a,head:(n||[]).map((d,c)=>({i:c,text:d})),rows:s.map(m),block:l,examples:i,outro:r}),u=[t("Markdown Basics",{head:["Syntax","Result"],rows:[["**bold**","bold (rendered bold)"],["_italic_","italic (rendered italic)"],["## Heading","Heading (h2)"],["[text](url)","Link"],["![alt](url)","Image"],["- item","Bullet list"],["`code`","Inline code"],["---","Divider"]]}),t("Keyboard Shortcuts",{rows:[["Ctrl + B","Bold selected text"],["Ctrl + I","Italic selected text"],["Ctrl + K","Insert link dialog"],["Tab","Indent 2 spaces at cursor"],["Shift + Tab","Dedent 2 spaces on current line"],["Ctrl + X","Cut current line (no selection) or cut selection"],["Ctrl + C","Copy current line (no selection) or copy selection"],["Ctrl + V","Paste from clipboard"],["Ctrl + Z","Undo"],["Ctrl + Y","Redo"]]}),t("Tips",{rows:[['class="text-center"',"Centre text and inline elements on any shortcode"],['class="mx-auto"',"Centre a block element horizontally"],["[center]...[/center]","Shorthand wrapper for centred content"]],outro:"Most shortcodes accept a class attribute for custom styling."})],h=[t("Grid & Columns",{rows:[['cols="N"',"[grid] only","Number of columns (1-12)"],['gap="N"',"[grid], [row]","Gap between columns/rows (1-6)"],['span="N"',"[col] only","How many columns this cell spans"],['fullwidth="true"',"[grid] only","Break out of page container to span full viewport"],['class="x"',"all","Add extra CSS classes"]],examples:[e("Equal columns",`[grid cols="3" gap="4"]
2
2
  [col]One[/col]
3
3
  [col]Two[/col]
4
4
  [col]Three[/col]
@@ -22,13 +22,15 @@ import{onceClosed as p}from"./tool-kit.js";const m=([o,a,n])=>n===void 0?{code:o
22
22
  [col]
23
23
  [card title="Three"]Content[/card]
24
24
  [/col]
25
- [/grid]`)]}),t("Box",{intro:"A card with no header - a plain container. No shadow and a 2px corner radius unless you say otherwise.",rows:[['shadow="none|md|lg"',"Shadow (default: none)"],['rounded="xs|none|sm|lg|full"',"Corners (default: xs = 2px)"],['padding="compact|spacious"',"Inner padding"],["borderless","Flag: no border"],['footer="..."',"Footer strip text"],['class="..."',"Extra CSS classes (the box also carries dm-box)"]],examples:[e("Basic box",`[box]
25
+ [/grid]`)]}),t("Box",{intro:"A card with no header - a plain container. No shadow and a 2px corner radius unless you say otherwise.",rows:[['shadow="none|md|lg"',"Shadow (default: none)"],['rounded="xs|none|sm|lg|full"',"Corners (default: xs = 2px)"],['padding="compact|spacious"',"Inner padding"],["borderless","Flag: no border"],['color="primary|secondary|success|info|warning|danger"',"Tint from the theme colour (light or dark with the theme)"],['color="#0f766e"',"Your own colour, kept on every theme, with black or white text picked for it"],['footer="..."',"Footer strip text"],['class="..."',"Extra CSS classes (the box also carries dm-box)"]],examples:[e("Basic box",`[box]
26
26
  Any content - text, buttons, a grid or a card.
27
+ [/box]`),e("Tinted from the theme",`[box color="warning"]
28
+ **Heads up:** the office is closed on Friday.
27
29
  [/box]`),e("With a shadow and rounder corners",`[box shadow="md" rounded="lg"]
28
30
  Lifted off the page.
29
31
  [/box]`,"Title, icon and image are ignored - a box never has a header. A [card] can go inside a [box], and a [box] inside a [card].")]}),t("Spacer",{intro:"Self-closing shortcode that inserts a fixed-height blank gap.",rows:[['size="N"',"Height in px (default: 8)"],['class="..."',"Extra CSS class on the spacer div"]],examples:[e("32px gap",'[spacer size="32" /]')]}),t("Center",{intro:"Wraps content in a centred div. Works with any content including cards, grids, and text.",examples:[e("Example","[center]Centred content here[/center]",'Accepts an optional class="..." attribute for extra styling.')]}),t("Hero",{intro:"Full-width hero sections - no plugin required. Uses Domma's built-in Hero CSS component.",rows:[['title="..."',"Heading text"],['tagline="..."',"Subtitle text"],['size="sm|lg|full"',"Height variant (default: normal)"],['variant="dark|primary|gradient-blue|gradient-purple|gradient-sunset|gradient-ocean"',"Colour / gradient preset"],['image="url"',"Background image URL (adds cover mode)"],['overlay="light|dark|darker|gradient|gradient-reverse"',"Image overlay style"],['align="center|left"',"Content alignment (default: center)"],['fullwidth="true"',"Break out of the page container to span full viewport width"],['bg="..."',"Background colour CSS value"],["twinkle","Flag: adds particle overlay (requires Effects plugin)"],['twinkle-count="N"',"Number of particles"],['twinkle-colour="..."',"Particle colour CSS value"],["blobs","Flag: adds ambient blob background"],['blobs-type="..."',"Blob animation type (default: float-blobs)"],['class="..."',"Extra CSS classes"],['id="..."',"Element id"]],examples:[e("Basic hero",'[hero title="Welcome" tagline="Build something great"][/hero]'),e("Gradient with body content",`[hero title="Get Started" tagline="Everything you need." size="lg" variant="gradient-blue" align="center"]
30
32
  Some introductory **Markdown** content here.
31
- [/hero]`),e("Background image with overlay",'[hero title="Our Story" image="/media/hero.jpg" overlay="dark" size="full"][/hero]',"Combine image + overlay for text legibility over photos.")]})],h=[t("Cards",{examples:[e("Basic card",`[card title="Optional Title"]
33
+ [/hero]`),e("Background image with overlay",'[hero title="Our Story" image="/media/hero.jpg" overlay="dark" size="full"][/hero]',"Combine image + overlay for text legibility over photos.")]})],g=[t("Cards",{examples:[e("Basic card",`[card title="Optional Title"]
32
34
  Markdown **works** here.
33
35
  [/card]`,"Omit title for a card with no header."),e("Icon - inline (default)",`[card title="Feature" icon="star"]
34
36
  Icon sits left of the title in a flex row.
@@ -88,7 +90,7 @@ Markdown content here.
88
90
  [undo all="true" /]
89
91
  [/scribe]`,'Use [render]...[/render] to type text, [wait]Ms[/wait] to pause, and [undo /] to delete. loop="true" replays the sequence. Only [render], [wait], and [undo] shortcodes are parsed - plain text between actions is ignored.'),e("Christmas",'[christmas intensity="medium" snowmen="off" trees="off" wreaths="off" northStars="off" sleigh="off" train="off" elf="off" robin="off" firework="off" /]',"intensity: light | medium | heavy. Count-based (off disables entirely): snowmen, trees, wreaths, northStars. Probability-based specials (off prevents spawning): sleigh, train, elf, robin, firework. Snowflakes always fall."),e("Halloween",'[halloween intensity="medium" gravestones="off" jackOLanterns="off" spiders="off" scarecrows="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): gravestones, jackOLanterns, spiders, scarecrows, twinklingStars. Particles: bats, ghosts, pumpkins (always on). Fixed decorations: haunted-house, cauldron (always present)."),e("St Patrick's Day",'[st-patricks intensity="medium" pots="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): pots (pots of gold), twinklingStars. Particles: shamrocks, clover petals, gold coins, sparkles (always on). Fixed decorations: rainbow, harp, moon. Leprechaun appears randomly - more likely at higher intensity."),e("Valentine's Day",'[valentines intensity="medium" garlands="off" butterflies="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): garlands (heart garlands), butterflies. Particles: hearts, rose petals, sparkles, lips (always on). Fixed decorations: envelopes (always present). Cupid appears randomly - more likely at higher intensity."),e("Guy Fawkes Night",'[guy-fawkes intensity="medium" bonfires="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): bonfires. Particles: embers, fireworks, sparks, rockets, bursts, trails (always on). Fixed decorations: guy-effigy, catherine-wheel, roman-candle, sparkler-bundle, moon. Starts at 25% particle density and builds up gradually."),e("St Andrew's Day",'[st-andrews intensity="medium" thistles="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): thistles, twinklingStars. Particles: heather petals, saltire sparkles (always on). Fixed decorations: bagpiper, saltire-flag, tartan-pattern, highland-scene (always present). Bagpiper probability increases with intensity."),e("St David's Day",'[st-davids intensity="medium" daffodilFields="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): daffodilFields, twinklingStars. Particles: daffodil petals, spring sparkles (always on). Fixed decorations: welsh-dragon, harp, leek-bundle, flag (always present). Flying dragon appears randomly - more likely at higher intensity."),e("St George's Day",'[st-georges intensity="medium" roses="off" twinklingStars="off" /]',"intensity: light | medium | heavy. Decoration toggles (off disables): roses (English roses), twinklingStars. Particles: rose petals, Tudor roses, oak leaves, sparkles (always on). Fixed decorations: st-georges-cross, dragon, shield, castle (always present). Knight appears randomly - more likely at higher intensity.")],outro:"Enable the Domma Effects plugin to activate these shortcodes on your site."}),t("DConfig - Declarative Behaviour",{intro:"Define click handlers and class toggles without writing JavaScript. Use the DConfig section above or embed inline with [dconfig]...[/dconfig] in the content body. Inline shortcodes win on selector conflict.",examples:[e("Toggle a class on click",'{ "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }','Selector keys use standard CSS selectors. "target" is optional - defaults to the element itself.'),e("Inline shortcode syntax",`[dconfig]
90
92
  { "#my-btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }
91
- [/dconfig]`)]}),t("CSS Editor",{intro:"The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page - one Save, one toast.",rows:[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]]}),t("Colour Picker",{intro:"Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss."})];export const REFERENCE=[{id:"basics",label:"Basics",sections:u},{id:"layout",label:"Layout",sections:g},{id:"components",label:"Components",sections:h},{id:"data",label:"Data",sections:b},{id:"advanced",label:"Advanced",sections:f}];const y=`
93
+ [/dconfig]`)]}),t("CSS Editor",{intro:"The toolbar's </> button (left of the Split View icon) swaps the left pane between Markdown and Custom CSS. Edits are saved together with the page - one Save, one toast.",rows:[["</> button","Toggle between Markdown editor and Custom CSS editor"],["Split / Write / Preview","Standard view mode buttons to the right of </>"],["Save","Saves both page content and CSS in a single operation"]]}),t("Colour Picker",{intro:"Insert \u2192 Colour Picker opens a centred colour picker. Pick a colour, then use Copy to copy the hex code to the clipboard or Insert to drop it at the cursor. Click outside the panel to dismiss."})];export const REFERENCE=[{id:"basics",label:"Basics",sections:u},{id:"layout",label:"Layout",sections:h},{id:"components",label:"Components",sections:g},{id:"data",label:"Data",sections:b},{id:"advanced",label:"Advanced",sections:f}];const y=`
92
94
  <div class="per">
93
95
  <div class="per-tabs" role="tablist" data-each="tabs key=id">
94
96
  <button type="button" class="per-tab" role="tab" data-on-click="$root.show(id)"
@@ -2,7 +2,7 @@ export const SHORTCODES=[{name:"card",label:"Card",icon:"card",category:"Layout"
2
2
  Content
3
3
  [/card]`,custom:"card",attrs:[{name:"title",type:"text",label:"Title"},{name:"subtitle",type:"text",label:"Subtitle"},{name:"icon",type:"icon",label:"Icon"},{name:"icon-layout",type:"select",label:"Icon layout",options:[["inline","Inline"],["stacked","Stacked"]]},{name:"footer",type:"text",label:"Footer"},{name:"variant",type:"select",label:"Variant",options:[["","Default"],["primary","Primary"]]},{name:"collapsible",type:"checkbox",label:"Collapsible"},{name:"hover",type:"checkbox",label:"Hover highlight"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"box",label:"Box",icon:"square",category:"Layout",selfClosing:!1,container:!0,wrappable:!0,template:`[box]
4
4
  Content
5
- [/box]`,attrs:[{name:"shadow",type:"select",label:"Shadow",options:[["","None (default)"],["md","Medium"],["lg","Large"]]},{name:"rounded",type:"select",label:"Corners",options:[["","2px (default)"],["none","Square"],["sm","Small"],["lg","Large"],["full","Pill"]]},{name:"padding",type:"select",label:"Padding",options:[["","Normal"],["compact","Compact"],["spacious","Spacious"]]},{name:"borderless",type:"checkbox",label:"No border"},{name:"footer",type:"text",label:"Footer"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"grid",label:"Grid",icon:"columns",category:"Layout",selfClosing:!1,container:!0,wrappable:!1,template:`[grid cols="2" gap="3"]
5
+ [/box]`,attrs:[{name:"shadow",type:"select",label:"Shadow",options:[["","None (default)"],["md","Medium"],["lg","Large"]]},{name:"rounded",type:"select",label:"Corners",options:[["","2px (default)"],["none","Square"],["sm","Small"],["lg","Large"],["full","Pill"]]},{name:"padding",type:"select",label:"Padding",options:[["","Normal"],["compact","Compact"],["spacious","Spacious"]]},{name:"borderless",type:"checkbox",label:"No border"},{name:"color",type:"select",label:"Colour (from the theme)",options:[["","None"],["primary","Primary"],["secondary","Secondary"],["success","Success"],["info","Info"],["warning","Warning"],["danger","Danger"]]},{name:"footer",type:"text",label:"Footer"},{name:"class",type:"text",label:"CSS class"},{name:"id",type:"text",label:"ID"}]},{name:"grid",label:"Grid",icon:"columns",category:"Layout",selfClosing:!1,container:!0,wrappable:!1,template:`[grid cols="2" gap="3"]
6
6
  [col]
7
7
  Left
8
8
  [/col]
@@ -112,6 +112,10 @@
112
112
  "successRedirect": "/thanks?id=&#123;&#123;entryId&#125;&#125;",
113
113
  "layout": "grid",
114
114
  "columns": 2,
115
+ "submitAlign": "",
116
+ "submitButton": { "style": "success", "size": "lg", "iconStart": "", "iconEnd": "send" },
117
+ "secondaryButton": { "kind": "link", "label": "Cancel", "url": "/", "style": "ghost",
118
+ "iconStart": "", "iconEnd": "", "first": false, "stack": false },
115
119
  "honeypot": true,
116
120
  "rateLimitPerMinute": 3,
117
121
  "actionSlug": ""
@@ -122,6 +126,23 @@
122
126
  "collection": { "enabled": true, "slug": "contact" }
123
127
  }
124
128
  }</code></pre>
129
+ <p><code>submitAlign</code> places the submit button: <code>""</code> (the default - straight after the last
130
+ field), <code>left</code>, <code>center</code>, <code>right</code> or <code>full</code> (stretched across the
131
+ form). Any of the four gives the button a row of its own. A multi-step form's Previous / Next / Finish footer is
132
+ not affected. The older <code>submitSpan: "full"</code> is still read, as <code>left</code>.</p>
133
+ <p><code>submitButton</code> (optional) styles the button: <code>style</code> is one of <code>primary</code> (the
134
+ default), <code>secondary</code>, <code>success</code>, <code>danger</code>, <code>warning</code>,
135
+ <code>info</code>, <code>outline</code>, <code>outline-success</code>, <code>outline-danger</code>,
136
+ <code>outline-warning</code>, <code>outline-info</code> or <code>ghost</code>; <code>size</code> is
137
+ <code>""</code>, <code>sm</code> or <code>lg</code>; <code>iconStart</code> / <code>iconEnd</code> are icon names.
138
+ On a multi-step form they apply to Next and Finish (icons to Finish only).</p>
139
+ <p><code>secondaryButton</code> (optional) adds a button beside Submit: <code>kind</code> <code>reset</code> (Clear
140
+ form) or <code>link</code> (Cancel, to <code>url</code>), with its own <code>label</code>, <code>style</code> and
141
+ icons; <code>first: true</code> puts it before Submit and <code>stack: true</code> stacks the two. It shares the
142
+ submit's size and <code>submitAlign</code> places the pair. Not shown on multi-step forms. A <code>link</code>
143
+ <code>url</code> must be a path, <code>#anchor</code>, <code>?query</code> or an <code>http(s):</code>,
144
+ <code>mailto:</code> or <code>tel:</code> address - create and update answer <code>400</code> otherwise. Leave
145
+ either key out for the default button.</p>
125
146
  <p>The create and update responses carry a <code>warnings</code> array when the form and its target collection
126
147
  disagree - the collection is missing, requires a field the form does not collect, or lacks a field the form
127
148
  sends. Saving still succeeds.</p>
@@ -24,7 +24,8 @@
24
24
  <code>schema.json</code>. Click a field to edit it; under <strong>Joined to the
25
25
  input</strong> a text, number, dropdown, date, time, URL or textarea field can have an icon
26
26
  or a short text (such as <code>£</code> or <code>kg</code>) joined to the start or end of
27
- its input in the entry form.</li>
27
+ its input in the entry form. The same panel can add a clear button, - / + buttons on a
28
+ number, or a character counter.</li>
28
29
  <li><strong>API &amp; Export</strong> - what the public API may do (read, create, update,
29
30
  delete) and who may do it, which fields a read returns, and whether visitors may export
30
31
  what a <code>[collection]</code> display shows.</li>
@@ -103,7 +103,14 @@ Any content - text, a button, a [grid] or a [card].
103
103
  [/box]
104
104
  [box shadow="md" rounded="lg" padding="spacious"]
105
105
  Lifted off the page, with rounder corners.
106
+ [/box]
107
+ [box color="success"]
108
+ Tinted with the theme's success colour.
106
109
  [/box]</code></pre>
110
+ <p>A box follows the site's theme: its background, border, text and links come from the theme's
111
+ colours, so it reads properly on every theme, light or dark, and when a visitor switches
112
+ theme. <code>variant="dark"</code> stays a dark panel with light text on it whatever the
113
+ theme.</p>
107
114
  <table class="table table-sm">
108
115
  <thead>
109
116
  <tr>
@@ -124,6 +131,15 @@ Lifted off the page, with rounder corners.
124
131
  <code>lg</code> &middot; <code>full</code></td>
125
132
  <td>Corner radius</td>
126
133
  </tr>
134
+ <tr>
135
+ <td><code>color</code> (or <code>colour</code>)</td>
136
+ <td><code>primary</code> &middot; <code>secondary</code> &middot; <code>success</code> &middot;
137
+ <code>info</code> &middot; <code>warning</code> &middot; <code>danger</code> &middot; a hex
138
+ colour such as <code>#0f766e</code></td>
139
+ <td>A theme colour tints the box from the site's theme, light or dark. A hex colour is kept
140
+ as you gave it, with black or white text - whichever reads better. Replaces
141
+ <code>variant</code>.</td>
142
+ </tr>
127
143
  <tr>
128
144
  <td><code>padding</code>, <code>borderless</code>, <code>variant</code>, <code>footer</code>,
129
145
  <code>class</code>, <code>id</code></td>
@@ -120,10 +120,6 @@
120
120
  <div class="card mb-4">
121
121
  <div class="card-header"><h2>Settings</h2></div>
122
122
  <div class="card-body">
123
- <div class="mb-3">
124
- <label class="form-label">Submit Button Text</label>
125
- <input id="setting-submit-text" type="text" class="form-input" placeholder="Submit" value="Submit">
126
- </div>
127
123
  <div class="mb-3">
128
124
  <label class="form-label">Success Message</label>
129
125
  <textarea id="setting-success-message" class="form-input" rows="3"
@@ -153,18 +149,136 @@
153
149
  </select>
154
150
  <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">A grid form lays out in two
155
151
  or three columns. Use a field's Column Span to make one field wider than the rest.</p>
156
- <div class="mt-3">
157
- <label class="form-label">Submit Button Span</label>
158
- <select id="setting-submit-span" class="form-select">
159
- <option value="">Auto (1 column)</option>
160
- <option value="full">Full width</option>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- The submit button: text, position, look, and an optional second
157
+ button (settings.submitText / submitAlign / submitButton /
158
+ secondaryButton - public/js/form-submit.mjs draws all of it). -->
159
+ <div class="card mb-4" id="submit-button-card">
160
+ <div class="card-header"><h2>Submit Button</h2></div>
161
+ <div class="card-body">
162
+ <div class="row">
163
+ <div class="col-6 mb-3">
164
+ <label class="form-label">Text</label>
165
+ <input id="setting-submit-text" type="text" class="form-input" placeholder="Submit" value="Submit">
166
+ </div>
167
+ <div class="col-6 mb-3">
168
+ <label class="form-label">Position <span data-help-title="Submit Button Position" data-help="Where the submit button sits. Default keeps it straight after the last field - in a grid, in the next free column. Left, Centre and Right give it a row of its own at that side of the form; Full width stretches it across the form. With a second button, this places the pair. A multi-step form keeps Previous and Next / Finish in its footer whatever this says."></span></label>
169
+ <select id="setting-submit-align" class="form-select">
170
+ <option value="">Default (after the last field)</option>
171
+ <option value="left">Left</option>
172
+ <option value="center">Centre</option>
173
+ <option value="right">Right</option>
174
+ <option value="full">Full width</option>
175
+ </select>
176
+ <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">Left, Centre, Right and Full
177
+ width put the button on a row of its own below the fields.</p>
178
+ </div>
179
+ </div>
180
+ <div class="row">
181
+ <div class="col-6 mb-3">
182
+ <label class="form-label">Style <span data-help-title="Button style" data-help="The button's colour, from the site's theme. Outline draws a border with the page showing through; Ghost is text only until hovered. A multi-step form's Next and Finish take the same style."></span></label>
183
+ <select id="setting-submit-style" class="form-select">
184
+ <option value="primary">Primary</option>
185
+ <option value="secondary">Secondary</option>
186
+ <option value="success">Success</option>
187
+ <option value="danger">Danger</option>
188
+ <option value="warning">Warning</option>
189
+ <option value="info">Info</option>
190
+ <option value="outline">Outline</option>
191
+ <option value="outline-success">Outline success</option>
192
+ <option value="outline-danger">Outline danger</option>
193
+ <option value="outline-warning">Outline warning</option>
194
+ <option value="outline-info">Outline info</option>
195
+ <option value="ghost">Ghost</option>
196
+ </select>
197
+ </div>
198
+ <div class="col-6 mb-3">
199
+ <label class="form-label">Size</label>
200
+ <select id="setting-submit-size" class="form-select">
201
+ <option value="sm">Small</option>
202
+ <option value="" selected>Normal</option>
203
+ <option value="lg">Large</option>
204
+ </select>
205
+ </div>
206
+ </div>
207
+ <div class="row">
208
+ <div class="col-6 mb-3">
209
+ <label class="form-label">Icon before the text <span data-help-title="Button icons" data-help="An icon either side of the button's text - a paper plane after Send, say. Type an icon's name or browse for one; leave both empty for text only. On a multi-step form the icons go on the Finish button, not on Next. If a trigger ends the form early, its Finish keeps them."></span></label>
210
+ <div id="setting-submit-icon-start-slot"></div>
211
+ </div>
212
+ <div class="col-6 mb-3">
213
+ <label class="form-label">Icon after the text</label>
214
+ <div id="setting-submit-icon-end-slot"></div>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="mb-3" style="border-top:1px solid var(--dm-border);padding-top:1rem;">
219
+ <label class="form-label">Second button <span data-help-title="Second button" data-help="A button beside Submit. Clear form empties every answer and puts the form back as it started (the visitor is asked first if they have typed anything). Cancel link takes the visitor to a page - back to the home page, say. Not shown on a multi-step form, whose footer already has Previous."></span></label>
220
+ <select id="setting-second-kind" class="form-select">
221
+ <option value="">None</option>
222
+ <option value="reset">Clear form</option>
223
+ <option value="link">Cancel link</option>
224
+ </select>
225
+ </div>
226
+ <div id="second-button-group" style="display:none;">
227
+ <div class="row">
228
+ <div class="col-6 mb-3">
229
+ <label class="form-label">Text</label>
230
+ <input id="setting-second-label" type="text" class="form-input" placeholder="Clear form">
231
+ </div>
232
+ <div class="col-6 mb-3" id="second-url-group">
233
+ <label class="form-label">Goes to <span data-help-title="Cancel link" data-help="Where Cancel takes the visitor: a page on this site (/contact) or a full address (https://..., mailto:, tel:). Anything else is refused when you save."></span></label>
234
+ <input id="setting-second-url" type="text" class="form-input" placeholder="/">
235
+ </div>
236
+ </div>
237
+ <div class="row">
238
+ <div class="col-6 mb-3">
239
+ <label class="form-label">Style</label>
240
+ <select id="setting-second-style" class="form-select">
241
+ <option value="primary">Primary</option>
242
+ <option value="secondary" selected>Secondary</option>
243
+ <option value="success">Success</option>
244
+ <option value="danger">Danger</option>
245
+ <option value="warning">Warning</option>
246
+ <option value="info">Info</option>
247
+ <option value="outline">Outline</option>
248
+ <option value="outline-success">Outline success</option>
249
+ <option value="outline-danger">Outline danger</option>
250
+ <option value="outline-warning">Outline warning</option>
251
+ <option value="outline-info">Outline info</option>
252
+ <option value="ghost">Ghost</option>
161
253
  </select>
162
- <p class="form-hint text-muted" style="margin-top:.3rem;font-size:.8rem;">Controls how wide the submit
163
- button is in grid layout.</p>
164
254
  </div>
165
- </div>
255
+ <div class="col-6 mb-3">
256
+ <label class="form-label">Arrangement <span data-help-title="Arrangement" data-help="Which button comes first, and whether the two sit side by side or one above the other. Submit Button Position lines the pair up; with Full width, side by side buttons share the row equally."></span></label>
257
+ <div style="display:flex;gap:.5rem;">
258
+ <select id="setting-second-order" class="form-select">
259
+ <option value="after">Submit first</option>
260
+ <option value="first">This button first</option>
261
+ </select>
262
+ <select id="setting-second-arrange" class="form-select">
263
+ <option value="row">Side by side</option>
264
+ <option value="stack">Stacked</option>
265
+ </select>
166
266
  </div>
267
+ </div>
268
+ </div>
269
+ <div class="row">
270
+ <div class="col-6 mb-3">
271
+ <label class="form-label">Icon before the text</label>
272
+ <div id="setting-second-icon-start-slot"></div>
273
+ </div>
274
+ <div class="col-6 mb-3">
275
+ <label class="form-label">Icon after the text</label>
276
+ <div id="setting-second-icon-end-slot"></div>
277
+ </div>
167
278
  </div>
279
+ </div>
280
+ </div>
281
+ </div>
168
282
  </div>
169
283
 
170
284
  <!-- Tab 4: Actions -->
@@ -1,11 +1,11 @@
1
- import{api as g}from"../api.js";import{addBannerAction as de}from"../lib/plugin-chrome.js";import{helpPopovers as Ie}from"../lib/help-popover.js";import{onceClosed as st,openMenuAt as Re}from"../lib/tool-kit.js";import{ADDON_TYPES as it,FIELD_TYPES as rt,OPERATIONS as ct,OPTION_TYPES as dt,accessOptions as pt,checkForm as ut,draftOf as ft,duplicateField as mt,errorCount as q,fieldFromPanel as Te,fieldProblem as ht,fieldRow as vt,formFromCollection as Fe,formFromDraft as bt,moveField as De,nameFromLabel as gt,newField as yt,panelForm as xt,payloadFromForm as wt,roleLadder as kt,slugify as X,snapshot as _,storageChange as Ne}from"../lib/collection-editor-arrange.js";import{fmtDateTime as Et}from"../lib/dates.js";import{rowPlaceKeeper as St}from"../lib/menu-drag.js";import{openIconPicker as Pe}from"../lib/card-builder.js";const Mt=/^\/collections\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,$t=s=>`collection-editor.draft.${s||"new"}`,Ct=`
1
+ import{api as g}from"../api.js";import{addBannerAction as de}from"../lib/plugin-chrome.js";import{helpPopovers as Re}from"../lib/help-popover.js";import{onceClosed as it,openMenuAt as Ie}from"../lib/tool-kit.js";import{ADDON_TYPES as rt,EXTRA_TYPES as pe,FIELD_TYPES as ct,OPERATIONS as dt,OPTION_TYPES as pt,accessOptions as ut,checkForm as ft,draftOf as mt,duplicateField as ht,errorCount as q,fieldFromPanel as Fe,fieldProblem as vt,fieldRow as bt,formFromCollection as Pe,formFromDraft as gt,moveField as De,nameFromLabel as yt,newField as xt,panelForm as wt,payloadFromForm as kt,roleLadder as Et,slugify as Q,snapshot as _,storageChange as Ne}from"../lib/collection-editor-arrange.js";import{fmtDateTime as St}from"../lib/dates.js";import{rowPlaceKeeper as Mt}from"../lib/menu-drag.js";import{openIconPicker as Be}from"../lib/card-builder.js";const Ct=/^\/collections\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,$t=i=>`collection-editor.draft.${i||"new"}`,At=`
2
2
  <div class="cex-row" data-key="{{key}}" tabindex="0" data-bind-class="rowCls">
3
3
  <span class="cex-grip" title="Drag to reorder" aria-hidden="true"></span>
4
4
  <span class="cex-main"><strong data-bind-text="label"></strong><small data-bind-text="meta"></small><small class="cex-problem" data-bind-hidden="!problem" data-bind-text="problem"></small></span>
5
5
  <span class="cex-chip is-ok" data-bind-hidden="!required">Required</span>
6
6
  <span class="cex-chip" data-bind-hidden="!hidden">Hidden</span>
7
7
  <button type="button" class="cex-kebab" aria-label="Actions for this field" title="Actions">\u22EE</button>
8
- </div>`,Z=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ot(s){const p=document.createElement("template");return p.innerHTML=s.trim(),p.content.firstElementChild}function Be(s,p){const i=s.querySelector(".dm-plugin-banner-name");if(!i)return;const C=document.createElement("span");C.className="dm-plugin-banner-parent",C.textContent="Collections";const x=document.createElement("span");x.className="dm-plugin-banner-sep",x.setAttribute("aria-hidden","true"),x.textContent="\u203A",i.replaceChildren(C,x,document.createTextNode(p))}let y=null,Le=!1;const ee=s=>String(s||"").split("?")[0];function jt(){Le||!window.R?.use||(Le=!0,R.use((s,p,i)=>{if(!(y&&Mt.test(p?.path||"")&&ee(s?.path)!==ee(p?.path))||!y.dirty())return i();const x=`#${p.path}`;return E.confirm("This collection has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?i():history.replaceState(null,"",x)})}),M.subscribe("router:afterChange",({to:s}={})=>{if(y&&ee(s?.path)!==y.path){const p=y;y=null,p.dispose()}}))}export const collectionEditorView={templateUrl:"/admin/js/templates/collection-editor.html",async onMount(s){if(jt(),y){const e=y;y=null,e.dispose()}const p=s.get(0),i=p.querySelector(".cex")||p,C=ee(location.hash.replace(/^#/,""))||"/collections/new",x=C.match(/^\/collections\/edit\/([^/?#]+)/),d=x?decodeURIComponent(x[1]):null,D=!d,pe=[],v=e=>(pe.push(e),e);let ue=!1;const te=()=>!ue&&i.isConnected,fe=E.loader(p,{type:"dots"});let O=null,ne=[],N=null,me=[],u=0,P=[];try{[O,ne,N,me,P]=await Promise.all([d?g.collections.get(d):Promise.resolve(null),g.projects.list().catch(()=>[]),g.collections.proStatus().catch(()=>null),D?g.collections.list().then(e=>(e||[]).map(t=>t.slug)).catch(()=>[]):Promise.resolve([]),g.collections.listEntries("roles",{limit:1e3}).then(kt).catch(()=>[])]),d&&(u=(await g.collections.listEntries(d,{limit:1}).catch(()=>null))?.total??0)}catch(e){fe.destroy(),E.toast(e?.status===404?"Collection not found.":`Could not load the collection: ${e.message||e}`,{type:"error"}),R.navigate("/collections");return}if(fe.destroy(),!i.isConnected)return;if(d&&!O){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const he=new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",m=Fe(O,{project:he,defaultRole:P.at(-1)?.name||""}),w=Object.fromEntries(m.fields.map(e=>[e._key,e.name])),qe=O?.meta||{};let K=m.storageAdapter;const _e=new Set(["public","token",...P.map(e=>e.name)]),ve=(e,t)=>{if(!P.length)return"";const o=[...new Set(e.filter(l=>l&&!_e.has(l)))];return o.length?`Not a role on this site: ${o.join(", ")}. Until you pick another, ${t}.`:""},n={};for(const[e,t]of Object.entries(m))n[e]=M.observable(t);const k=M.observable(D),U=M.observable(!1),H=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),be=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),F=M.observable(_(m)),B=M.computed(()=>_(be())!==F.value);let W=!D;v(M.effect(()=>{const e=n.title.value;W||(n.slug.value=X(e))}));const h=M.computed(()=>ut(be(),{isNew:k.value,taken:me,original:w})),ge=M.observable([]);v(M.effect(()=>{const e=h.value.fields||{},t=n.layout.value==="grid";ge.value=n.fields.value.map(o=>vt(o,e,{grid:t}))}));const Ke=M.computed(()=>new Set(n.fields.value.map(e=>e.name))),Ue={...n,isNew:k,saving:U,dirty:B,rows:ge,errTitle:M.computed(()=>h.value.title||""),errSlug:M.computed(()=>h.value.slug||""),errColumns:M.computed(()=>h.value.columns||""),errExport:M.computed(()=>h.value.export||""),problemCount:M.computed(()=>q(h.value)),problemText:M.computed(()=>{const e=q(h.value);return e?`${e} thing${e===1?"":"s"} to fix before it can be saved`:""}),settingsBad:M.computed(()=>!!(h.value.title||h.value.slug||h.value.columns)),fieldsBad:M.computed(()=>!!h.value.fields),exportBad:M.computed(()=>!!h.value.export),fieldCount:M.computed(()=>n.fields.value.length),slugHint:M.computed(()=>k.value?`Will be created as "${X(n.slug.value||n.title.value)||"\u2026"}".`:"Cannot be changed once the collection exists."),apiPath:M.computed(()=>`/api/collections/${X(n.slug.value||n.title.value)||"<slug>"}/public`),unknownReadFields:M.computed(()=>{const e=String(n.readFields.value||"").split(",").map(t=>t.trim()).filter(t=>t&&!Ke.value.has(t));return e.length?`Not a field of this collection: ${e.join(", ")}`:""}),missingApiRoles:M.computed(()=>ve(ct.filter(e=>n[`${e}Enabled`].value).map(e=>n[`${e}Access`].value),"only the level-0 role passes")),missingExportRole:M.computed(()=>ve(n.exportEnabled.value?[n.exportAccess.value]:[],"only the level-0 role is offered export")),showStorage:M.observable(!!N?.pro&&d!=="roles"),storageNote:M.computed(()=>{const e=Ne(K,n.storageAdapter.value);return!e||k.value?"":u?`Switching ${e}: Save asks whether to move the ${u} existing ${u===1?"entry":"entries"} across. Export them first if they matter.`:`Switching ${e}. There are no entries to move.`})},ye=i.querySelector("#collection-project");for(const e of ne){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,ye.appendChild(t)}if(m.project&&!ne.some(e=>e.slug===m.project)){const e=document.createElement("option");e.value=m.project,e.textContent=`${m.project} (not found)`,ye.appendChild(e)}const He=[...i.querySelectorAll('.cex-op select[data-model$="Access.value"]')],xe=e=>{for(const t of He){const o=t.getAttribute("data-model").split(".")[0],l=e[o];t.replaceChildren(...pt(P,l,{token:t.id!=="export-access"}).map(r=>new Option(r.label,r.value))),t.value=l}};xe(m);const We=i.querySelector("#storage-connection"),Ye=Array.isArray(N?.connections)&&N.connections.length?N.connections:["default"];for(const e of new Set([...Ye,m.storageConnection])){const t=document.createElement("option");t.value=e,t.textContent=e,We.appendChild(t)}const Y=e=>n.fields.peek().findIndex(t=>t._key===e),j=e=>{n.fields.value=e};function z(e,t){const o=Y(e);o<0||(j(De(n.fields.peek(),o,o+t)),requestAnimationFrame(()=>i.querySelector(`.cex-row[data-key="${e}"]`)?.focus()))}async function oe(e){const t=Y(e);if(t<0)return;const o=n.fields.peek()[t];!k.peek()&&u&&w[e]&&!await E.confirm(`Remove the field "${Z(o.label||o.name)}"? The ${u} existing ${u===1?"entry keeps its":"entries keep their"} value for it, but it is no longer shown or edited. Nothing is lost until you Save - Discard puts it back.`)||j(n.fields.peek().filter(l=>l._key!==e))}function G(e,{isAdded:t=!1}={}){const o=Y(e);if(o<0)return;const l=n.fields.peek()[o],r=xt(l),c={};for(const[a,$]of Object.entries(r))c[a]=M.observable($);const f=JSON.stringify(r),V=()=>Object.fromEntries(Object.keys(c).map(a=>[a,c[a].peek()])),$e=()=>JSON.stringify(V())!==f;let Ce=!!l.name;const Ze=M.effect(()=>{const a=c.label.value;Ce||(c.name.value=gt(a))}),et=new Set(n.fields.peek().filter(a=>a._key!==e).map(a=>a.name)),Oe=M.computed(()=>{const a=Te(l,Object.fromEntries(Object.keys(c).map($=>[$,c[$].value])));return a.name&&et.has(a.name)?`"${a.name}" is used by another field.`:ht(a,w[e])}),tt=M.computed(()=>!k.peek()&&u&&w[e]&&c.name.value.trim()!==w[e]?`Entries keep their value under "${w[e]}" - renaming the field does not move it.`:""),nt={...c,problem:Oe,renamed:tt,isOptions:M.computed(()=>dt.has(c.type.value)),canAddon:M.computed(()=>it.has(c.type.value)),isGrid:M.computed(()=>n.layout.value==="grid")},b=E.slideover({title:t?"New field":`Field: ${Z(l.label||l.name||"Untitled")}`,size:"md",position:"right"}),T=Ot(`
8
+ </div>`,Z=i=>String(i??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function Ot(i){const p=document.createElement("template");return p.innerHTML=i.trim(),p.content.firstElementChild}function Le(i,p){const r=i.querySelector(".dm-plugin-banner-name");if(!r)return;const $=document.createElement("span");$.className="dm-plugin-banner-parent",$.textContent="Collections";const x=document.createElement("span");x.className="dm-plugin-banner-sep",x.setAttribute("aria-hidden","true"),x.textContent="\u203A",r.replaceChildren($,x,document.createTextNode(p))}let y=null,qe=!1;const ee=i=>String(i||"").split("?")[0];function jt(){qe||!window.R?.use||(qe=!0,R.use((i,p,r)=>{if(!(y&&Ct.test(p?.path||"")&&ee(i?.path)!==ee(p?.path))||!y.dirty())return r();const x=`#${p.path}`;return E.confirm("This collection has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(d=>{d?r():history.replaceState(null,"",x)})}),M.subscribe("router:afterChange",({to:i}={})=>{if(y&&ee(i?.path)!==y.path){const p=y;y=null,p.dispose()}}))}export const collectionEditorView={templateUrl:"/admin/js/templates/collection-editor.html",async onMount(i){if(jt(),y){const e=y;y=null,e.dispose()}const p=i.get(0),r=p.querySelector(".cex")||p,$=ee(location.hash.replace(/^#/,""))||"/collections/new",x=$.match(/^\/collections\/edit\/([^/?#]+)/),d=x?decodeURIComponent(x[1]):null,P=!d,ue=[],v=e=>(ue.push(e),e);let fe=!1;const te=()=>!fe&&r.isConnected,me=E.loader(p,{type:"dots"});let A=null,ae=[],D=null,he=[],u=0,N=[];try{[A,ae,D,he,N]=await Promise.all([d?g.collections.get(d):Promise.resolve(null),g.projects.list().catch(()=>[]),g.collections.proStatus().catch(()=>null),P?g.collections.list().then(e=>(e||[]).map(t=>t.slug)).catch(()=>[]):Promise.resolve([]),g.collections.listEntries("roles",{limit:1e3}).then(Et).catch(()=>[])]),d&&(u=(await g.collections.listEntries(d,{limit:1}).catch(()=>null))?.total??0)}catch(e){me.destroy(),E.toast(e?.status===404?"Collection not found.":`Could not load the collection: ${e.message||e}`,{type:"error"}),R.navigate("/collections");return}if(me.destroy(),!r.isConnected)return;if(d&&!A){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const ve=new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",m=Pe(A,{project:ve,defaultRole:N.at(-1)?.name||""}),w=Object.fromEntries(m.fields.map(e=>[e._key,e.name])),_e=A?.meta||{};let K=m.storageAdapter;const Ke=new Set(["public","token",...N.map(e=>e.name)]),be=(e,t)=>{if(!N.length)return"";const n=[...new Set(e.filter(l=>l&&!Ke.has(l)))];return n.length?`Not a role on this site: ${n.join(", ")}. Until you pick another, ${t}.`:""},a={};for(const[e,t]of Object.entries(m))a[e]=M.observable(t);const k=M.observable(P),U=M.observable(!1),H=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].peek()])),ge=()=>Object.fromEntries(Object.keys(a).map(e=>[e,a[e].value])),F=M.observable(_(m)),B=M.computed(()=>_(ge())!==F.value);let Y=!P;v(M.effect(()=>{const e=a.title.value;Y||(a.slug.value=Q(e))}));const h=M.computed(()=>ft(ge(),{isNew:k.value,taken:he,original:w})),ye=M.observable([]);v(M.effect(()=>{const e=h.value.fields||{},t=a.layout.value==="grid";ye.value=a.fields.value.map(n=>bt(n,e,{grid:t}))}));const Ue=M.computed(()=>new Set(a.fields.value.map(e=>e.name))),He={...a,isNew:k,saving:U,dirty:B,rows:ye,errTitle:M.computed(()=>h.value.title||""),errSlug:M.computed(()=>h.value.slug||""),errColumns:M.computed(()=>h.value.columns||""),errExport:M.computed(()=>h.value.export||""),problemCount:M.computed(()=>q(h.value)),problemText:M.computed(()=>{const e=q(h.value);return e?`${e} thing${e===1?"":"s"} to fix before it can be saved`:""}),settingsBad:M.computed(()=>!!(h.value.title||h.value.slug||h.value.columns)),fieldsBad:M.computed(()=>!!h.value.fields),exportBad:M.computed(()=>!!h.value.export),fieldCount:M.computed(()=>a.fields.value.length),slugHint:M.computed(()=>k.value?`Will be created as "${Q(a.slug.value||a.title.value)||"\u2026"}".`:"Cannot be changed once the collection exists."),apiPath:M.computed(()=>`/api/collections/${Q(a.slug.value||a.title.value)||"<slug>"}/public`),unknownReadFields:M.computed(()=>{const e=String(a.readFields.value||"").split(",").map(t=>t.trim()).filter(t=>t&&!Ue.value.has(t));return e.length?`Not a field of this collection: ${e.join(", ")}`:""}),missingApiRoles:M.computed(()=>be(dt.filter(e=>a[`${e}Enabled`].value).map(e=>a[`${e}Access`].value),"only the level-0 role passes")),missingExportRole:M.computed(()=>be(a.exportEnabled.value?[a.exportAccess.value]:[],"only the level-0 role is offered export")),showStorage:M.observable(!!D?.pro&&d!=="roles"),storageNote:M.computed(()=>{const e=Ne(K,a.storageAdapter.value);return!e||k.value?"":u?`Switching ${e}: Save asks whether to move the ${u} existing ${u===1?"entry":"entries"} across. Export them first if they matter.`:`Switching ${e}. There are no entries to move.`})},xe=r.querySelector("#collection-project");for(const e of ae){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,xe.appendChild(t)}if(m.project&&!ae.some(e=>e.slug===m.project)){const e=document.createElement("option");e.value=m.project,e.textContent=`${m.project} (not found)`,xe.appendChild(e)}const Ye=[...r.querySelectorAll('.cex-op select[data-model$="Access.value"]')],we=e=>{for(const t of Ye){const n=t.getAttribute("data-model").split(".")[0],l=e[n];t.replaceChildren(...ut(N,l,{token:t.id!=="export-access"}).map(c=>new Option(c.label,c.value))),t.value=l}};we(m);const We=r.querySelector("#storage-connection"),ze=Array.isArray(D?.connections)&&D.connections.length?D.connections:["default"];for(const e of new Set([...ze,m.storageConnection])){const t=document.createElement("option");t.value=e,t.textContent=e,We.appendChild(t)}const W=e=>a.fields.peek().findIndex(t=>t._key===e),O=e=>{a.fields.value=e};function z(e,t){const n=W(e);n<0||(O(De(a.fields.peek(),n,n+t)),requestAnimationFrame(()=>r.querySelector(`.cex-row[data-key="${e}"]`)?.focus()))}async function ne(e){const t=W(e);if(t<0)return;const n=a.fields.peek()[t];!k.peek()&&u&&w[e]&&!await E.confirm(`Remove the field "${Z(n.label||n.name)}"? The ${u} existing ${u===1?"entry keeps its":"entries keep their"} value for it, but it is no longer shown or edited. Nothing is lost until you Save - Discard puts it back.`)||O(a.fields.peek().filter(l=>l._key!==e))}function G(e,{isAdded:t=!1}={}){const n=W(e);if(n<0)return;const l=a.fields.peek()[n],c=wt(l),s={};for(const[o,C]of Object.entries(c))s[o]=M.observable(C);const f=JSON.stringify(c),V=()=>Object.fromEntries(Object.keys(s).map(o=>[o,s[o].peek()])),$e=()=>JSON.stringify(V())!==f;let Ae=!!l.name;const et=M.effect(()=>{const o=s.label.value;Ae||(s.name.value=yt(o))}),tt=new Set(a.fields.peek().filter(o=>o._key!==e).map(o=>o.name)),Oe=M.computed(()=>{const o=Fe(l,Object.fromEntries(Object.keys(s).map(C=>[C,s[C].value])));return o.name&&tt.has(o.name)?`"${o.name}" is used by another field.`:vt(o,w[e])}),at=M.computed(()=>!k.peek()&&u&&w[e]&&s.name.value.trim()!==w[e]?`Entries keep their value under "${w[e]}" - renaming the field does not move it.`:""),nt={...s,problem:Oe,renamed:at,isOptions:M.computed(()=>pt.has(s.type.value)),canAddon:M.computed(()=>rt.has(s.type.value)),canClear:M.computed(()=>pe.clear.has(s.type.value)),canStepper:M.computed(()=>pe.stepper.has(s.type.value)),canCounter:M.computed(()=>pe.counter.has(s.type.value)),isGrid:M.computed(()=>a.layout.value==="grid")},b=E.slideover({title:t?"New field":`Field: ${Z(l.label||l.name||"Untitled")}`,size:"md",position:"right"}),T=Ot(`
9
9
  <div class="cex-panel">
10
10
  <div class="cex-grid">
11
11
  <div>
@@ -20,7 +20,7 @@ import{api as g}from"../api.js";import{addBannerAction as de}from"../lib/plugin-
20
20
  <div>
21
21
  <label class="form-label" for="cex-f-type">Type</label>
22
22
  <select id="cex-f-type" class="form-input" data-model="type.value">
23
- ${rt.map(a=>`<option value="${a.value}">${a.label}</option>`).join("")}
23
+ ${ct.map(o=>`<option value="${o.value}">${o.label}</option>`).join("")}
24
24
  </select>
25
25
  </div>
26
26
  <div class="cex-setting">
@@ -58,6 +58,21 @@ import{api as g}from"../api.js";import{addBannerAction as de}from"../lib/plugin-
58
58
  <input type="text" class="form-input" maxlength="24" placeholder="or text, e.g. kg" aria-label="Text after the input" data-model="suffixText.value">
59
59
  </div>
60
60
  </div>
61
+ <div class="cex-setting" data-bind-hidden="!canClear.value">
62
+ <label class="form-label" for="cex-f-clear">Clear button
63
+ <span data-help="An x at the end of the input that empties it."></span></label>
64
+ <input id="cex-f-clear" type="checkbox" class="form-switch" data-model="clear.value">
65
+ </div>
66
+ <div class="cex-setting" data-bind-hidden="!canStepper.value">
67
+ <label class="form-label" for="cex-f-stepper">- / + buttons
68
+ <span data-help="Buttons either side of the number that step it up and down."></span></label>
69
+ <input id="cex-f-stepper" type="checkbox" class="form-switch" data-model="stepper.value">
70
+ </div>
71
+ <div class="cex-setting" data-bind-hidden="!canCounter.value">
72
+ <label class="form-label" for="cex-f-counter">Character counter
73
+ <span data-help="Shows how many characters have been typed under the input."></span></label>
74
+ <input id="cex-f-counter" type="checkbox" class="form-switch" data-model="counter.value">
75
+ </div>
61
76
  <div data-bind-hidden="!isGrid.value">
62
77
  <label class="form-label" for="cex-f-span">Column span
63
78
  <span data-help="How many of the grid's columns the field takes, in the entry form."></span></label>
@@ -77,4 +92,4 @@ import{api as g}from"../api.js";import{addBannerAction as de}from"../lib/plugin-
77
92
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
78
93
  <button type="button" class="btn btn-sm btn-primary" data-on-click="done" data-bind-disabled="problem.value"><span data-icon="check" data-icon-size="13"></span> Done</button>
79
94
  </div>
80
- </div>`);let Q=!1,re=!1;const ot=b.close.bind(b);function je(){if(Oe.peek())return;const a=Te(l,V());j(n.fields.peek().map($=>$._key===e?a:$)),Q=!0,b.close()}b.element.appendChild(T);const at=Ie(T),lt=M.applyBindings(nt,T,{methods:{done:je,cancel:()=>b.close(),pickPrefix:()=>Pe(a=>{c.prefixIcon.value=a,c.prefixText.value=""}),pickSuffix:()=>Pe(a=>{c.suffixIcon.value=a,c.suffixText.value=""}),remove:async()=>{Q=!0,b.close(),await oe(e)}}}),Ae=T.querySelector("#cex-f-name");Ae.addEventListener("input",()=>{Ce=!!Ae.value}),T.addEventListener("keydown",a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),je())}),st(b,()=>{Ze(),lt.dispose(),at(),se.delete(b)}),b.close=async a=>{if(!Q&&a!==!0&&$e()){if(re)return;re=!0;const ce=await E.confirm("Discard the changes to this field?");if(re=!1,!ce)return}Q=!0;const $=ot();return t&&!$e()&&!l.name&&j(n.fields.peek().filter(ce=>ce._key!==e)),$},se.add(b),I.scan(T),b.open(),T.querySelector("#cex-f-label")?.focus()}function ze(){const e=yt();j([...n.fields.peek(),e]),Ve.activate?.(1),G(e._key,{isAdded:!0})}function Ge(e){const t=Y(e);if(t<0)return[];const o=n.fields.peek()[t],l=n.fields.peek().length;return[{header:o.label||o.name||"Field"},{label:"Edit\u2026",icon:"edit",action:()=>G(e)},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>z(e,-1)},{label:"Move down",icon:"arrow-down",disabled:t===l-1,action:()=>z(e,1)},{label:"Duplicate",icon:"copy",action:()=>j(mt(n.fields.peek(),t))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:()=>oe(e)}]}async function ae(){if(U.peek())return;const e=h.peek();if(q(e)){E.toast(`Not saved - ${q(e)} thing${q(e)===1?"":"s"} to fix first (marked on the tabs).`,{type:"warning"});return}const t=wt(H(),{isNew:k.peek(),meta:qe});U.value=!0;try{if(k.peek()){const l=await g.collections.create(t);F.value=_(H()),ie(),E.toast(`Created "${t.title}".`,{type:"success"}),R.navigate(`/collections/edit/${encodeURIComponent(l.slug)}`);return}const o=Ne(K,t.storage.adapter);if(o&&u){if(await E.confirm(`You changed the storage (${o}). Move the ${u} existing ${u===1?"entry":"entries"} to the new storage?`)){const r=await g.collections.migrateStorage(d,t.storage);E.toast(`Moved ${r.migrated} of ${r.total} entries.`,{type:"success"})}else if(!await E.confirm(`Switch without moving them? The ${u} ${u===1?"entry stays":"entries stay"} in ${Z(K)} storage and this collection will show none until they are moved.`))return}if(await g.collections.update(d,t),!te())return;K=t.storage.adapter,o&&(u=(await g.collections.listEntries(d,{limit:1}).catch(()=>null))?.total??u);for(const l of n.fields.peek())w[l._key]=l.name;F.value=_(H()),ie(),Be(p,t.title),E.toast("Collection saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{U.value=!1}}async function Je(){if(!B.peek()||!await E.confirm("Discard the changes you have not saved?"))return;const e=Fe(O,{project:he});for(const t of e.fields)w[t._key]=t.name;for(const[t,o]of Object.entries(e))n[t].value=o;F.value=_(e),W=!k.peek()}const L=i.querySelector(".cex-list");L.innerHTML=Ct;const Ve=E.tabs(i.querySelector(".cex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings(Ue,i,{methods:{save:()=>ae(),discard:()=>Je(),addField:()=>ze()}});v(()=>Qe.dispose()),v(Ie(i,{position:"bottom"}));const le=i.querySelector("#field-slug"),we=()=>{W=!!le.value};le.addEventListener("input",we),v(()=>le.removeEventListener("input",we));const se=new Set;v(()=>[...se].forEach(e=>{try{e.close(!0)}catch{}}));const Xe=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:e=>Ge(e.dataset.key)});v(()=>Xe?.destroy?.());const ke={click:e=>{const t=e.target.closest(".cex-row");if(!t)return;const o=e.target.closest(".cex-kebab");if(o){Re(o);return}G(t.dataset.key)},keydown:e=>{const t=e.target.closest(".cex-row");!t||e.target!==t||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Re(t)):e.key==="Enter"?G(t.dataset.key):e.altKey&&e.key==="ArrowUp"?(e.preventDefault(),z(t.dataset.key,-1)):e.altKey&&e.key==="ArrowDown"?(e.preventDefault(),z(t.dataset.key,1)):e.key==="Delete"&&(e.preventDefault(),oe(t.dataset.key)))}};for(const[e,t]of Object.entries(ke))L.addEventListener(e,t);if(v(()=>{for(const[e,t]of Object.entries(ke))L.removeEventListener(e,t)}),typeof E.sortable=="function"){const e=St(),t=E.sortable(L,{items:".cex-row",key:"data-key",keyboard:!1,onStart:e.start,onEnd:({item:o})=>e.restore(o),onSort:({item:o,from:l,to:r})=>{e.restore(o),j(De(n.fields.peek(),l,r))}});v(()=>t.destroy())}Be(p,O?.title||"New collection"),de(s,{id:"collection-editor-back",icon:"arrow-left",title:"All collections",onClick:()=>R.navigate("/collections")}),d&&de(s,{id:"collection-editor-entries",icon:"list",title:"Open entries",onClick:()=>R.navigate(`/collections/${encodeURIComponent(d)}/entries`)}),de(s,{id:"collection-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>ae()});const Ee=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!te()||(e.preventDefault(),e.stopImmediatePropagation(),ae())};document.addEventListener("keydown",Ee,!0),v(()=>document.removeEventListener("keydown",Ee,!0));const J=$t(d);function ie(){try{S.remove?S.remove(J):S.set(J,null)}catch{}}const Se=()=>{if(B.peek())try{S.set(J,{form:ft(H()),base:F.peek(),at:new Date().toISOString()})}catch{}},Me=e=>{B.peek()&&(Se(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Me),v(()=>window.removeEventListener("beforeunload",Me)),y={path:C,dirty:()=>B.peek(),dispose(){ue=!0,Se(),pe.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let A=null;try{A=S.get(J)}catch{}if(A?.form){ie();const e=A.base&&A.base!==F.peek(),t=A.at?` (${Et(A.at)})`:"",o=d?`changes to "${Z(O?.title||d)}"`:"a new collection",l=e?`You left unsaved ${o}${t}, but the saved collection has changed since. Restore your version anyway?`:`You left unsaved ${o}${t}. Restore them?`;if(await E.confirm(l)&&te()){const r=bt(A.form,m);D||(r.slug=m.slug);const c=Object.fromEntries(m.fields.filter(f=>f.id).map(f=>[f.id,f.name]));for(const f of r.fields)f.id&&c[f.id]!==void 0&&(w[f._key]=c[f.id]);xe(r);for(const[f,V]of Object.entries(r))n[f]&&(n[f].value=V);D&&(W=!!r.slug&&r.slug!==X(r.title))}}}};
95
+ </div>`);let X=!1,re=!1;const ot=b.close.bind(b);function je(){if(Oe.peek())return;const o=Fe(l,V());O(a.fields.peek().map(C=>C._key===e?o:C)),X=!0,b.close()}b.element.appendChild(T);const lt=Re(T),st=M.applyBindings(nt,T,{methods:{done:je,cancel:()=>b.close(),pickPrefix:()=>Be(o=>{s.prefixIcon.value=o,s.prefixText.value=""}),pickSuffix:()=>Be(o=>{s.suffixIcon.value=o,s.suffixText.value=""}),remove:async()=>{X=!0,b.close(),await ne(e)}}}),Te=T.querySelector("#cex-f-name");Te.addEventListener("input",()=>{Ae=!!Te.value}),T.addEventListener("keydown",o=>{(o.ctrlKey||o.metaKey)&&o.key==="Enter"&&(o.preventDefault(),je())}),it(b,()=>{et(),st.dispose(),lt(),se.delete(b)}),b.close=async o=>{if(!X&&o!==!0&&$e()){if(re)return;re=!0;const ce=await E.confirm("Discard the changes to this field?");if(re=!1,!ce)return}X=!0;const C=ot();return t&&!$e()&&!l.name&&O(a.fields.peek().filter(ce=>ce._key!==e)),C},se.add(b),I.scan(T),b.open(),T.querySelector("#cex-f-label")?.focus()}function Ge(){const e=xt();O([...a.fields.peek(),e]),Xe.activate?.(1),G(e._key,{isAdded:!0})}function Je(e){const t=W(e);if(t<0)return[];const n=a.fields.peek()[t],l=a.fields.peek().length;return[{header:n.label||n.name||"Field"},{label:"Edit\u2026",icon:"edit",action:()=>G(e)},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>z(e,-1)},{label:"Move down",icon:"arrow-down",disabled:t===l-1,action:()=>z(e,1)},{label:"Duplicate",icon:"copy",action:()=>O(ht(a.fields.peek(),t))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:()=>ne(e)}]}async function oe(){if(U.peek())return;const e=h.peek();if(q(e)){E.toast(`Not saved - ${q(e)} thing${q(e)===1?"":"s"} to fix first (marked on the tabs).`,{type:"warning"});return}const t=kt(H(),{isNew:k.peek(),meta:_e});U.value=!0;try{if(k.peek()){const l=await g.collections.create(t);F.value=_(H()),ie(),E.toast(`Created "${t.title}".`,{type:"success"}),R.navigate(`/collections/edit/${encodeURIComponent(l.slug)}`);return}const n=Ne(K,t.storage.adapter);if(n&&u){if(await E.confirm(`You changed the storage (${n}). Move the ${u} existing ${u===1?"entry":"entries"} to the new storage?`)){const c=await g.collections.migrateStorage(d,t.storage);E.toast(`Moved ${c.migrated} of ${c.total} entries.`,{type:"success"})}else if(!await E.confirm(`Switch without moving them? The ${u} ${u===1?"entry stays":"entries stay"} in ${Z(K)} storage and this collection will show none until they are moved.`))return}if(await g.collections.update(d,t),!te())return;K=t.storage.adapter,n&&(u=(await g.collections.listEntries(d,{limit:1}).catch(()=>null))?.total??u);for(const l of a.fields.peek())w[l._key]=l.name;F.value=_(H()),ie(),Le(p,t.title),E.toast("Collection saved.",{type:"success"})}catch(n){E.toast(`Not saved: ${n.message||n}`,{type:"error"})}finally{U.value=!1}}async function Ve(){if(!B.peek()||!await E.confirm("Discard the changes you have not saved?"))return;const e=Pe(A,{project:ve});for(const t of e.fields)w[t._key]=t.name;for(const[t,n]of Object.entries(e))a[t].value=n;F.value=_(e),Y=!k.peek()}const L=r.querySelector(".cex-list");L.innerHTML=At;const Xe=E.tabs(r.querySelector(".cex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Qe=M.applyBindings(He,r,{methods:{save:()=>oe(),discard:()=>Ve(),addField:()=>Ge()}});v(()=>Qe.dispose()),v(Re(r,{position:"bottom"}));const le=r.querySelector("#field-slug"),ke=()=>{Y=!!le.value};le.addEventListener("input",ke),v(()=>le.removeEventListener("input",ke));const se=new Set;v(()=>[...se].forEach(e=>{try{e.close(!0)}catch{}}));const Ze=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:e=>Je(e.dataset.key)});v(()=>Ze?.destroy?.());const Ee={click:e=>{const t=e.target.closest(".cex-row");if(!t)return;const n=e.target.closest(".cex-kebab");if(n){Ie(n);return}G(t.dataset.key)},keydown:e=>{const t=e.target.closest(".cex-row");!t||e.target!==t||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ie(t)):e.key==="Enter"?G(t.dataset.key):e.altKey&&e.key==="ArrowUp"?(e.preventDefault(),z(t.dataset.key,-1)):e.altKey&&e.key==="ArrowDown"?(e.preventDefault(),z(t.dataset.key,1)):e.key==="Delete"&&(e.preventDefault(),ne(t.dataset.key)))}};for(const[e,t]of Object.entries(Ee))L.addEventListener(e,t);if(v(()=>{for(const[e,t]of Object.entries(Ee))L.removeEventListener(e,t)}),typeof E.sortable=="function"){const e=Mt(),t=E.sortable(L,{items:".cex-row",key:"data-key",keyboard:!1,onStart:e.start,onEnd:({item:n})=>e.restore(n),onSort:({item:n,from:l,to:c})=>{e.restore(n),O(De(a.fields.peek(),l,c))}});v(()=>t.destroy())}Le(p,A?.title||"New collection"),de(i,{id:"collection-editor-back",icon:"arrow-left",title:"All collections",onClick:()=>R.navigate("/collections")}),d&&de(i,{id:"collection-editor-entries",icon:"list",title:"Open entries",onClick:()=>R.navigate(`/collections/${encodeURIComponent(d)}/entries`)}),de(i,{id:"collection-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>oe()});const Se=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!te()||(e.preventDefault(),e.stopImmediatePropagation(),oe())};document.addEventListener("keydown",Se,!0),v(()=>document.removeEventListener("keydown",Se,!0));const J=$t(d);function ie(){try{S.remove?S.remove(J):S.set(J,null)}catch{}}const Me=()=>{if(B.peek())try{S.set(J,{form:mt(H()),base:F.peek(),at:new Date().toISOString()})}catch{}},Ce=e=>{B.peek()&&(Me(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ce),v(()=>window.removeEventListener("beforeunload",Ce)),y={path:$,dirty:()=>B.peek(),dispose(){fe=!0,Me(),ue.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(r);let j=null;try{j=S.get(J)}catch{}if(j?.form){ie();const e=j.base&&j.base!==F.peek(),t=j.at?` (${St(j.at)})`:"",n=d?`changes to "${Z(A?.title||d)}"`:"a new collection",l=e?`You left unsaved ${n}${t}, but the saved collection has changed since. Restore your version anyway?`:`You left unsaved ${n}${t}. Restore them?`;if(await E.confirm(l)&&te()){const c=gt(j.form,m);P||(c.slug=m.slug);const s=Object.fromEntries(m.fields.filter(f=>f.id).map(f=>[f.id,f.name]));for(const f of c.fields)f.id&&s[f.id]!==void 0&&(w[f._key]=s[f.id]);we(c);for(const[f,V]of Object.entries(c))a[f]&&(a[f].value=V);P&&(Y=!!c.slug&&c.slug!==Q(c.title))}}}};