domma-cms 0.91.1 → 0.92.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.
Files changed (38) hide show
  1. package/CLAUDE.md +18 -9
  2. package/admin/js/lib/collection-editor-arrange.js +2 -2
  3. package/admin/js/lib/view-editor-arrange.js +1 -1
  4. package/admin/js/templates/collection-editor.html +3 -1
  5. package/admin/js/templates/docs/api-users.html +8 -8
  6. package/admin/js/templates/docs/api-views.html +5 -4
  7. package/admin/js/templates/docs/usage-users-roles.html +28 -48
  8. package/admin/js/templates/docs/usage-views.html +6 -4
  9. package/admin/js/views/collection-editor.js +4 -4
  10. package/admin/js/views/view-editor.js +1 -1
  11. package/package.json +1 -1
  12. package/plugins/feedback/CLAUDE.md +96 -0
  13. package/plugins/feedback/admin/css/index.css +80 -0
  14. package/plugins/feedback/admin/lib/kit.js +105 -0
  15. package/plugins/feedback/admin/templates/feedback.html +1 -0
  16. package/plugins/feedback/admin/views/feedback.js +63 -0
  17. package/plugins/feedback/admin/views/inbox.js +325 -0
  18. package/plugins/feedback/admin/views/send.js +223 -0
  19. package/plugins/feedback/collections/feedback-reports.json +24 -0
  20. package/plugins/feedback/config.js +15 -0
  21. package/plugins/feedback/lib/receiver.js +121 -0
  22. package/plugins/feedback/lib/sender.js +161 -0
  23. package/plugins/feedback/lib/shape.js +211 -0
  24. package/plugins/feedback/plugin.js +276 -0
  25. package/plugins/feedback/plugin.json +48 -0
  26. package/plugins/feedback/tests/api.test.js +219 -0
  27. package/plugins/feedback/tests/shape.test.js +87 -0
  28. package/plugins/free-tier.lock.json +22 -1
  29. package/server/routes/api/collections.js +8 -1
  30. package/server/routes/api/search.js +27 -6
  31. package/server/routes/api/views.js +19 -20
  32. package/server/routes/public.js +9 -0
  33. package/server/services/content.js +6 -4
  34. package/server/services/markdown.js +66 -33
  35. package/server/services/rowAccess.js +49 -0
  36. package/server/services/search.js +145 -17
  37. package/server/services/viewerSession.js +2 -2
  38. package/server/services/views.js +67 -5
package/CLAUDE.md CHANGED
@@ -61,15 +61,24 @@ editing shortcode markup; the nesting rules below are the part the doc does not
61
61
 
62
62
  ### Shortcode Nesting Rules
63
63
 
64
- Processing order (innermost first):
65
-
66
- 1. `[dconfig]` - always first
67
- 2. `[grid]` / `[row]` / `[col]`
68
- 3. `[card]` (and `[box]` - a headerless card, handled at the top of `processCardBlocks`)
69
- 4. `[tabs]`, `[accordion]`, `[carousel]`, `[hero]`, `[table]`, `[badge]`, `[countdown]`, `[timeline]`, `[spacer]`,
70
- `[center]`, `[icon]`, `[cta]`, `[block]`, `[view]`, `[collection]`, `[text]`, `[button]`, `[link]`,
71
- `[listgroup]`, `[form]`, `[banner]`, `[celebrate]`
72
- 5. `[slideover]` - always last (can contain anything above)
64
+ Processing order (`parseMarkdown()` in `server/services/markdown.js`; earlier stages are already HTML when later ones run):
65
+
66
+ 1. `markdown:beforeParse` transforms, then `[component]` / `<dm-*>` tags
67
+ 2. `[collection]`, `[view]`, `[block]` + block short tags, `[menu]`
68
+ 3. `[dconfig]`
69
+ 4. Effects shortcodes, then plugin shortcodes
70
+ 5. `[tabs]`, `[accordion]`, `[carousel]`, `[countdown]`, `[timeline]`, `[listgroup]`, `[spacer]`, `[center]`, `[icon]`,
71
+ `[demo]`, `[embed]`, `[form]`, `[hero]`, `[table]`
72
+ 6. Inline tags: `[badge]`, `[text]`, `[button]`, `[link]`, `[cta]`
73
+ 7. `[grid]` / `[row]` / `[col]`
74
+ 8. `[card]` (and `[box]` - a headerless card, handled at the top of `processCardBlocks`)
75
+ 9. `[banner]`
76
+ 10. `[slideover]` - always last before Markdown (can contain anything above)
77
+ 11. marked → sanitise → `markdown:afterParse` transforms
78
+
79
+ Containers that render their own body (tabs, accordion, carousel, timeline, hero, grid, card, slideover, effect
80
+ wrappers) run the inline tags plus `[grid]`/`[card]` over it first - keep that set in step with
81
+ `processInlineShortcodes()` when adding an inline tag. The full order is in `docs/markdown-shortcodes.md`.
73
82
 
74
83
  Safe nesting: `[card]` inside `[slideover]`, `[grid]` inside `[slideover]`, `[col]` inside `[grid]`, `[card]` inside
75
84
  `[box]` and `[box]` inside `[card]`.
@@ -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"]),ACCESS_LEVELS=["public","token","subscriber","editor","manager","admin"],EXPORT_ACCESS=ACCESS_LEVELS.filter(e=>e!=="token"),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"]),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(n=>n.value===e)?.label||e||"";export function optionsText(e){return(Array.isArray(e)?e:[]).map(n=>typeof n=="string"?`${n}: ${n}`:`${n?.value??""}: ${n?.label??n?.value??""}`).join(`
2
2
  `)}export function parseOptions(e){return String(e??"").split(`
3
- `).filter(r=>r.trim()).map(r=>{const[t,...n]=r.split(":");return{value:t.trim(),label:n.join(":").trim()||t.trim()}})}const d=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),f=()=>({enabled:!0,access:"subscriber",formats:["csv","json"]});let m=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(m++).toString(36)}`;export function formFromCollection(e=null,{project:r=""}={}){const t=e||{},n={...d(),...t.api||{}},l={...f(),...t.export||{}},s={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(a=>({...a,_key:fieldKey()})),readFields:Array.isArray(n.read?.fields)?n.read.fields.join(", "):"",exportEnabled:l.enabled!==!1,exportAccess:EXPORT_ACCESS.includes(l.access)?l.access:"subscriber",exportCsv:(Array.isArray(l.formats)?l.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(l.formats)?l.formats:EXPORT_FORMATS).includes("json")};for(const a of OPERATIONS)s[`${a}Enabled`]=n[a]?.enabled===!0,s[`${a}Access`]=ACCESS_LEVELS.includes(n[a]?.access)?n[a].access:"admin";return s}export function cleanField(e){const{_key:r,...t}=e||{};return t}export function payloadFromForm(e,{isNew:r=!1,meta:t={}}={}){const n=e||{},l={};for(const o of OPERATIONS)l[o]={enabled:n[`${o}Enabled`]===!0,access:n[`${o}Access`]||"admin"};const s=String(n.readFields??"").split(",").map(o=>o.trim()).filter(Boolean);s.length&&(l.read.fields=s);const a=Math.min(6,Math.max(1,Number.parseInt(n.columns,10)||2));return{title:String(n.title??"").trim(),...r&&{slug:slugify(n.slug||n.title)},description:String(n.description??"").trim(),layout:n.layout==="grid"?"grid":"stacked",columns:a,fields:(n.fields||[]).map(cleanField),api:l,export:{enabled:n.exportEnabled!==!1,access:n.exportAccess||"subscriber",formats:EXPORT_FORMATS.filter(o=>o==="csv"?n.exportCsv:n.exportJson)},storage:n.storageAdapter==="mongodb"?{adapter:"mongodb",connection:n.storageConnection||"default"}:{adapter:"file"},meta:{...t||{},project:String(n.project??"")||null},bundled:n.bundled===!0}}export function snapshot(e){const{fields:r=[],...t}=e||{};return JSON.stringify({...t,fields:r.map(cleanField)})}export function checkForm(e,{isNew:r=!1,taken:t=[],original:n={}}={}){const l=e||{},s={};if(String(l.title??"").trim()||(s.title="A collection needs a title."),r){const i=slugify(l.slug||l.title);i?t.includes(i)&&(s.slug=`"${i}" is already a collection.`):s.slug="The slug needs a letter or a number."}const a=Number.parseInt(l.columns,10);l.layout==="grid"&&!(a>=1&&a<=6)&&(s.columns="Between 1 and 6 columns.");const o={},c=new Map;return(l.fields||[]).forEach(i=>{const p=fieldProblem(i,n[i._key]);p&&(o[i._key]=p);const u=String(i.name??"").trim();u&&(c.has(u)?o[i._key]=o[i._key]||`"${u}" is used by another field.`:c.set(u,i._key))}),Object.keys(o).length&&(s.fields=o),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(s.export="Pick at least one export format, or turn export off."),s}export function fieldProblem(e,r){const t=String(e?.name??"").trim();return t?t!==r&&!FIELD_NAME_RE.test(t)?"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:t=!1}={}){const n=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&n.push(`${(e.options||[]).length} options`),t&&n.push(e.fullWidth?"full width":`span ${e.span>1?e.span:1}`),{key:e._key,label:e.label||"(Untitled field)",meta:n.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,t){const n=[...e];if(r<0||r>=n.length||t<0||t>=n.length||r===t)return n;const[l]=n.splice(r,1);return n.splice(t,0,l),n}export function duplicateField(e,r){const t=e[r];if(!t)return[...e];const n=new Set(e.map(c=>c.name)),l=`${t.name||"field"}_copy`;let s=l;for(let c=2;n.has(s);c++)s=`${l}_${c}`;const a={...JSON.parse(JSON.stringify(cleanField(t))),name:s,label:`${t.label||"Field"} (copy)`,_key:fieldKey()};delete a.id;const o=[...e];return o.splice(r+1,0,a),o}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}}export function fieldFromPanel(e,r){const t={...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()};if(OPTION_TYPES.has(t.type)&&(t.options=parseOptions(r.options)),r.fullWidth)t.fullWidth=!0,delete t.span;else{delete t.fullWidth;const n=Number.parseInt(r.span,10);n>1?t.span=Math.min(6,n):delete t.span}return t}export function storageChange(e,r){const t=e||"file",n=r||"file";return t===n?"":`${t} \u2192 ${n}`}export function draftOf(e){const{fields:r=[],...t}=e||{};return{...t,fields:r.map(cleanField)}}export function formFromDraft(e,r){const t=e||{};return{...r,...t,fields:(Array.isArray(t.fields)?t.fields:r.fields).map(n=>({...cleanField(n),_key:fieldKey()}))}}
3
+ `).filter(n=>n.trim()).map(n=>{const[r,...t]=n.split(":");return{value:r.trim(),label:t.join(":").trim()||r.trim()}})}const d=()=>({create:{enabled:!1,access:"admin"},read:{enabled:!0,access:"public"},update:{enabled:!1,access:"admin"},delete:{enabled:!1,access:"admin"}}),m=e=>({enabled:!0,access:e||"",formats:["csv","json"]});export function roleLadder(e){return(Array.isArray(e)?e:e?.entries||[]).map(r=>({name:r?.data?.name,label:r?.data?.label||r?.data?.name,level:Number(r?.data?.level)})).filter(r=>typeof r.name=="string"&&r.name&&Number.isFinite(r.level)).sort((r,t)=>r.level-t.level||r.name.localeCompare(t.name))}export function accessOptions(e,n,{token:r=!0}={}){const t=[{value:"public",label:"Public - anyone"}];r&&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 n=="string"&&n&&!t.some(l=>l.value===n)&&t.push(e?.length?{value:n,label:`${n} (role not found)`,missing:!0}:{value:n,label:n}),t}let f=0;export const fieldKey=()=>`f${Date.now().toString(36)}${(f++).toString(36)}`;export function formFromCollection(e=null,{project:n="",defaultRole:r=""}={}){const t=e||{},l={...d(),...t.api||{}},a={...m(r),...t.export||{}},s={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??n??""),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(o=>({...o,_key:fieldKey()})),readFields:Array.isArray(l.read?.fields)?l.read.fields.join(", "):"",exportEnabled:a.enabled!==!1,exportAccess:typeof a.access=="string"&&a.access?a.access:String(r||""),exportCsv:(Array.isArray(a.formats)?a.formats:EXPORT_FORMATS).includes("csv"),exportJson:(Array.isArray(a.formats)?a.formats:EXPORT_FORMATS).includes("json")};for(const o of OPERATIONS){s[`${o}Enabled`]=l[o]?.enabled===!0;const i=l[o]?.access;s[`${o}Access`]=typeof i=="string"&&i?i:"admin"}return s}export function cleanField(e){const{_key:n,...r}=e||{};return r}export function payloadFromForm(e,{isNew:n=!1,meta:r={}}={}){const t=e||{},l={};for(const o of OPERATIONS)l[o]={enabled:t[`${o}Enabled`]===!0,access:t[`${o}Access`]||"admin"};const a=String(t.readFields??"").split(",").map(o=>o.trim()).filter(Boolean);a.length&&(l.read.fields=a);const s=Math.min(6,Math.max(1,Number.parseInt(t.columns,10)||2));return{title:String(t.title??"").trim(),...n&&{slug:slugify(t.slug||t.title)},description:String(t.description??"").trim(),layout:t.layout==="grid"?"grid":"stacked",columns:s,fields:(t.fields||[]).map(cleanField),api:l,export:{enabled:t.exportEnabled!==!1,...t.exportAccess&&{access:t.exportAccess},formats:EXPORT_FORMATS.filter(o=>o==="csv"?t.exportCsv:t.exportJson)},storage:t.storageAdapter==="mongodb"?{adapter:"mongodb",connection:t.storageConnection||"default"}:{adapter:"file"},meta:{...r||{},project:String(t.project??"")||null},bundled:t.bundled===!0}}export function snapshot(e){const{fields:n=[],...r}=e||{};return JSON.stringify({...r,fields:n.map(cleanField)})}export function checkForm(e,{isNew:n=!1,taken:r=[],original:t={}}={}){const l=e||{},a={};if(String(l.title??"").trim()||(a.title="A collection needs a title."),n){const c=slugify(l.slug||l.title);c?r.includes(c)&&(a.slug=`"${c}" is already a collection.`):a.slug="The slug needs a letter or a number."}const s=Number.parseInt(l.columns,10);l.layout==="grid"&&!(s>=1&&s<=6)&&(a.columns="Between 1 and 6 columns.");const o={},i=new Map;return(l.fields||[]).forEach(c=>{const p=fieldProblem(c,t[c._key]);p&&(o[c._key]=p);const u=String(c.name??"").trim();u&&(i.has(u)?o[c._key]=o[c._key]||`"${u}" is used by another field.`:i.set(u,c._key))}),Object.keys(o).length&&(a.fields=o),l.exportEnabled&&!l.exportCsv&&!l.exportJson&&(a.export="Pick at least one export format, or turn export off."),a}export function fieldProblem(e,n){const r=String(e?.name??"").trim();return r?r!==n&&!FIELD_NAME_RE.test(r)?"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(n=>n!=="fields").length+Object.keys(e?.fields||{}).length;export function fieldRow(e,n={},{grid:r=!1}={}){const t=[e.name||"(no name)",typeLabel(e.type)];return OPTION_TYPES.has(e.type)&&t.push(`${(e.options||[]).length} options`),r&&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:n[e._key]||"",rowCls:n[e._key]?"is-bad":""}}export function moveField(e,n,r){const t=[...e];if(n<0||n>=t.length||r<0||r>=t.length||n===r)return t;const[l]=t.splice(n,1);return t.splice(r,0,l),t}export function duplicateField(e,n){const r=e[n];if(!r)return[...e];const t=new Set(e.map(i=>i.name)),l=`${r.name||"field"}_copy`;let a=l;for(let i=2;t.has(a);i++)a=`${l}_${i}`;const s={...JSON.parse(JSON.stringify(cleanField(r))),name:a,label:`${r.label||"Field"} (copy)`,_key:fieldKey()};delete s.id;const o=[...e];return o.splice(n+1,0,s),o}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}}export function fieldFromPanel(e,n){const r={...e,label:String(n.label??"").trim(),name:String(n.name??"").trim(),type:n.type||"string",required:n.required===!0,placeholder:String(n.placeholder??"").trim(),helper:String(n.helper??"").trim()};if(OPTION_TYPES.has(r.type)&&(r.options=parseOptions(n.options)),n.fullWidth)r.fullWidth=!0,delete r.span;else{delete r.fullWidth;const t=Number.parseInt(n.span,10);t>1?r.span=Math.min(6,t):delete r.span}return r}export function storageChange(e,n){const r=e||"file",t=n||"file";return r===t?"":`${r} \u2192 ${t}`}export function draftOf(e){const{fields:n=[],...r}=e||{};return{...r,fields:n.map(cleanField)}}export function formFromDraft(e,n){const r=e||{};return{...n,...r,fields:(Array.isArray(r.fields)?r.fields:n.fields).map(t=>({...cleanField(t),_key:fieldKey()}))}}
@@ -11,4 +11,4 @@ export const STAGE_TYPES=[{type:"$match",label:"Filter rows",guided:!0},{type:"$
11
11
  }`},{type:"$group",label:"Group and total",example:`{
12
12
  "_id": "$data.category",
13
13
  "count": {"$sum": 1}
14
- }`},{type:"$limit",label:"Keep the first N",example:"10"},{type:"$skip",label:"Skip the first N",example:"10"},{type:"$count",label:"Count the rows",example:'"total"'}];const f=Object.fromEntries(STAGE_TYPES.map(r=>[r.type,r]));export const isGuided=r=>f[r]?.guided===!0,OPERATORS=[{value:"eq",label:"= equals"},{value:"ne",label:"\u2260 not equals"},{value:"gt",label:"> greater than"},{value:"lt",label:"< less than"},{value:"gte",label:"\u2265 greater or equal"},{value:"lte",label:"\u2264 less or equal"},{value:"contains",label:"~ contains"},{value:"in",label:"\u2208 one of (comma list)"}];const g={eq:"=",ne:"\u2260",gt:">",lt:"<",gte:"\u2265",lte:"\u2264",contains:"~",in:"\u2208"};export const slugify=r=>String(r??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");export function matchToRows(r){const n=r?.$match||r||{},e=Array.isArray(n.$or),t=[],o={$eq:"eq",$ne:"ne",$gt:"gt",$lt:"lt",$gte:"gte",$lte:"lte",$in:"in"},s=a=>{for(const[i,l]of Object.entries(a||{}))if(l&&typeof l=="object"&&!Array.isArray(l)){if("$regex"in l){t.push({field:i,op:"contains",val:String(l.$regex??"")});continue}for(const[p,c]of Object.entries(l))o[p]&&t.push({field:i,op:o[p],val:Array.isArray(c)?c.join(", "):String(c)})}else t.push({field:i,op:"eq",val:String(l??"")})};return e?n.$or.forEach(s):s(n),{logic:e?"or":"and",rows:t}}const u=r=>String(r).startsWith("__")?String(r).slice(2):String(r),d=r=>r!==""&&!Number.isNaN(Number(r))?Number(r):r;export function rowsToMatch(r,n){const e=[];for(const t of n||[]){const o=String(t?.field??"").trim();if(!o)continue;const s=u(o),a=String(t.val??"").trim(),i={};switch(t.op){case"ne":i[s]={$ne:a};break;case"gt":case"lt":case"gte":case"lte":i[s]={[`$${t.op}`]:d(a)};break;case"contains":i[s]={$regex:a,$options:"i"};break;case"in":i[s]={$in:a.split(",").map(l=>l.trim()).filter(Boolean)};break;default:i[s]=a}e.push(i)}return e.length?r==="or"?{$or:e}:Object.assign({},...e):{}}export const sortToRows=r=>Object.entries(r?.$sort||r||{}).map(([n,e])=>({field:n,dir:Number(e)===-1?-1:1}));export function rowsToSort(r){const n={};for(const e of r||[]){const t=String(e?.field??"").trim();t&&(n[u(t)]=Number(e.dir)===-1?-1:1)}return n}export function parseStageJson(r,n){const e=String(n??"").trim();if(r==="$limit"||r==="$skip"){const t=Number(e.replace(/^"|"$/g,""));return Number.isInteger(t)&&t>=0?{config:t,error:""}:{config:null,error:`${r} takes a whole number, e.g. 10`}}if(r==="$count"){const t=e.replace(/^"|"$/g,"");return/^[A-Za-z_][\w]*$/.test(t)?{config:t,error:""}:{config:null,error:'$count takes a field name, e.g. "total"'}}if(!e)return{config:{},error:r==="$unwind"?'$unwind needs the array to flatten, e.g. "$data.tags"':""};try{const t=JSON.parse(e);return r==="$unwind"&&(typeof t=="string"||t&&typeof t=="object")?{config:t,error:""}:!t||typeof t!="object"||Array.isArray(t)?{config:null,error:`${r} takes an object - {\u2026}`}:{config:t,error:""}}catch(t){return{config:null,error:`Not valid JSON: ${t.message}`}}}export function stageText(r,n){return n==null||typeof n=="object"&&!Array.isArray(n)&&!Object.keys(n).length?"":typeof n=="number"?String(n):JSON.stringify(n,null,2)}export function stageSummary(r){const{type:n,config:e}=r||{};if(n==="$match"){const{logic:t,rows:o}=matchToRows(e);return o.length?o.slice(0,2).map(a=>`${u(a.field).replace(/^data\./,"")} ${g[a.op]||"="} ${a.val}`).join(t==="or"?" or ":" and ")+(o.length>2?` \u2026 (${o.length} conditions)`:""):"every row"}if(n==="$sort"){const t=sortToRows(e);return t.length?t.map(o=>`${o.field.replace(/^data\./,"")} ${o.dir===-1?"\u2193":"\u2191"}`).join(", "):"no order set"}if(n==="$lookup")return e?.from?`join ${e.from}${e.as?` as ${e.as}`:""}`:"join\u2026";if(n==="$limit")return`first ${e}`;if(n==="$skip")return`skip ${e}`;if(n==="$count")return`count as ${e}`;if(n==="$unwind")return`flatten ${typeof e=="string"?e:e?.path||"\u2026"}`;if(e&&typeof e=="object"){const t=Object.keys(e);return t.length?`${t.slice(0,3).join(", ")}${t.length>3?", \u2026":""}`:"empty"}return f[n]?.label||""}export function move(r,n,e){const t=n+e;if(n<0||n>=r.length||t<0||t>=r.length)return r.slice();const o=r.slice(),[s]=o.splice(n,1);return o.splice(t,0,s),o}export function checkView(r,{stageErrors:n=[],isNew:e=!1,taken:t=[]}={}){const o=r||{},s={source:[],pipeline:[],display:[],access:[]};if(String(o.title??"").trim()||s.source.push("A view needs a title."),o.pipeline?.source||s.source.push("Choose the collection it reads from."),e){const i=o.slug?slugify(o.slug):slugify(o.title);i?t.includes(i)&&s.source.push(`"${i}" is already a view - choose another slug.`):s.source.push("The title (or slug) needs at least one letter or number.")}for(const i of n)s.pipeline.push(`Stage ${i.index+1} (${i.type}): ${i.error}`);const a=Number(o.display?.pageSize);return(!Number.isInteger(a)||a<1||a>200)&&s.display.push("Page size is 1 to 200."),o.display?.mode==="block"&&!o.display?.block&&s.display.push("Block mode needs a block template."),o.access?.rowLevel?.mode==="field"&&!String(o.access.rowLevel.field??"").trim()&&s.access.push("Field Match needs the field to compare."),!o.access?.public&&!(o.access?.roles||[]).length&&s.access.push("Pick at least one role, or make it public."),s}export const errorCount=r=>Object.values(r||{}).reduce((n,e)=>n+e.length,0);export function snapshot(r){const n=e=>Array.isArray(e)?e.map(n):e&&typeof e=="object"?Object.fromEntries(Object.keys(e).sort().map(t=>[t,n(e[t])])):e;return JSON.stringify(n(r??null))}export const draftKey=r=>`view-editor.draft.${r||"new"}`;export function flattenDoc(r,n="",e={}){for(const[t,o]of Object.entries(r||{})){const s=n?`${n}.${t}`:t;o&&typeof o=="object"&&!Array.isArray(o)&&Object.keys(o).length<8?flattenDoc(o,s,e):e[s]=o}return e}export function resultColumns(r,n=[]){return Array.isArray(n)&&n.length?n.map(e=>({key:e.key,label:e.label||e.key})):Object.keys(flattenDoc(r?.[0]||{})).slice(0,6).map(e=>({key:e,label:e}))}export function cellText(r,n,e=80){let t=r;for(const s of String(n).split("."))t=t?.[s];const o=t==null?"":Array.isArray(t)?t.map(s=>s&&typeof s=="object"?JSON.stringify(s):String(s)).join(", "):typeof t=="object"?JSON.stringify(t):String(t);return o.length>e?`${o.slice(0,e)}\u2026`:o}
14
+ }`},{type:"$limit",label:"Keep the first N",example:"10"},{type:"$skip",label:"Skip the first N",example:"10"},{type:"$count",label:"Count the rows",example:'"total"'}];const f=Object.fromEntries(STAGE_TYPES.map(n=>[n.type,n]));export const isGuided=n=>f[n]?.guided===!0,OPERATORS=[{value:"eq",label:"= equals"},{value:"ne",label:"\u2260 not equals"},{value:"gt",label:"> greater than"},{value:"lt",label:"< less than"},{value:"gte",label:"\u2265 greater or equal"},{value:"lte",label:"\u2264 less or equal"},{value:"contains",label:"~ contains"},{value:"in",label:"\u2208 one of (comma list)"}];const g={eq:"=",ne:"\u2260",gt:">",lt:"<",gte:"\u2265",lte:"\u2264",contains:"~",in:"\u2208"};export const slugify=n=>String(n??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");export function matchToRows(n){const r=n?.$match||n||{},e=Array.isArray(r.$or),t=[],o={$eq:"eq",$ne:"ne",$gt:"gt",$lt:"lt",$gte:"gte",$lte:"lte",$in:"in"},s=l=>{for(const[i,a]of Object.entries(l||{}))if(a&&typeof a=="object"&&!Array.isArray(a)){if("$regex"in a){t.push({field:i,op:"contains",val:String(a.$regex??"")});continue}for(const[p,c]of Object.entries(a))o[p]&&t.push({field:i,op:o[p],val:Array.isArray(c)?c.join(", "):String(c)})}else t.push({field:i,op:"eq",val:String(a??"")})};return e?r.$or.forEach(s):s(r),{logic:e?"or":"and",rows:t}}const u=n=>String(n).startsWith("__")?String(n).slice(2):String(n),d=n=>n!==""&&!Number.isNaN(Number(n))?Number(n):n;export function rowsToMatch(n,r){const e=[];for(const t of r||[]){const o=String(t?.field??"").trim();if(!o)continue;const s=u(o),l=String(t.val??"").trim(),i={};switch(t.op){case"ne":i[s]={$ne:l};break;case"gt":case"lt":case"gte":case"lte":i[s]={[`$${t.op}`]:d(l)};break;case"contains":i[s]={$regex:l,$options:"i"};break;case"in":i[s]={$in:l.split(",").map(a=>a.trim()).filter(Boolean)};break;default:i[s]=l}e.push(i)}return e.length?n==="or"?{$or:e}:Object.assign({},...e):{}}export const sortToRows=n=>Object.entries(n?.$sort||n||{}).map(([r,e])=>({field:r,dir:Number(e)===-1?-1:1}));export function rowsToSort(n){const r={};for(const e of n||[]){const t=String(e?.field??"").trim();t&&(r[u(t)]=Number(e.dir)===-1?-1:1)}return r}export function parseStageJson(n,r){const e=String(r??"").trim();if(n==="$limit"||n==="$skip"){const t=Number(e.replace(/^"|"$/g,""));return Number.isInteger(t)&&t>=0?{config:t,error:""}:{config:null,error:`${n} takes a whole number, e.g. 10`}}if(n==="$count"){const t=e.replace(/^"|"$/g,"");return/^[A-Za-z_][\w]*$/.test(t)?{config:t,error:""}:{config:null,error:'$count takes a field name, e.g. "total"'}}if(!e)return{config:{},error:n==="$unwind"?'$unwind needs the array to flatten, e.g. "$data.tags"':""};try{const t=JSON.parse(e);return n==="$unwind"&&(typeof t=="string"||t&&typeof t=="object")?{config:t,error:""}:!t||typeof t!="object"||Array.isArray(t)?{config:null,error:`${n} takes an object - {\u2026}`}:{config:t,error:""}}catch(t){return{config:null,error:`Not valid JSON: ${t.message}`}}}export function stageText(n,r){return r==null||typeof r=="object"&&!Array.isArray(r)&&!Object.keys(r).length?"":typeof r=="number"?String(r):JSON.stringify(r,null,2)}export function stageSummary(n){const{type:r,config:e}=n||{};if(r==="$match"){const{logic:t,rows:o}=matchToRows(e);return o.length?o.slice(0,2).map(l=>`${u(l.field).replace(/^data\./,"")} ${g[l.op]||"="} ${l.val}`).join(t==="or"?" or ":" and ")+(o.length>2?` \u2026 (${o.length} conditions)`:""):"every row"}if(r==="$sort"){const t=sortToRows(e);return t.length?t.map(o=>`${o.field.replace(/^data\./,"")} ${o.dir===-1?"\u2193":"\u2191"}`).join(", "):"no order set"}if(r==="$lookup")return e?.from?`join ${e.from}${e.as?` as ${e.as}`:""}`:"join\u2026";if(r==="$limit")return`first ${e}`;if(r==="$skip")return`skip ${e}`;if(r==="$count")return`count as ${e}`;if(r==="$unwind")return`flatten ${typeof e=="string"?e:e?.path||"\u2026"}`;if(e&&typeof e=="object"){const t=Object.keys(e);return t.length?`${t.slice(0,3).join(", ")}${t.length>3?", \u2026":""}`:"empty"}return f[r]?.label||""}export function move(n,r,e){const t=r+e;if(r<0||r>=n.length||t<0||t>=n.length)return n.slice();const o=n.slice(),[s]=o.splice(r,1);return o.splice(t,0,s),o}export function checkView(n,{stageErrors:r=[],isNew:e=!1,taken:t=[]}={}){const o=n||{},s={source:[],pipeline:[],display:[],access:[]};if(String(o.title??"").trim()||s.source.push("A view needs a title."),o.pipeline?.source||s.source.push("Choose the collection it reads from."),e){const i=o.slug?slugify(o.slug):slugify(o.title);i?t.includes(i)&&s.source.push(`"${i}" is already a view - choose another slug.`):s.source.push("The title (or slug) needs at least one letter or number.")}for(const i of r)s.pipeline.push(`Stage ${i.index+1} (${i.type}): ${i.error}`);const l=Number(o.display?.pageSize);return(!Number.isInteger(l)||l<1||l>200)&&s.display.push("Page size is 1 to 200."),o.display?.mode==="block"&&!o.display?.block&&s.display.push("Block mode needs a block template."),o.access?.rowLevel?.mode==="field"&&!String(o.access.rowLevel.field??"").trim()&&s.access.push("Field Match needs the field to compare."),!o.access?.public&&!(o.access?.roles||[]).length&&s.access.push("Pick at least one role, or make it public."),s}export const adminTierRoles=n=>(n||[]).filter(r=>r?.name&&Number.isFinite(r.level)&&r.level<=1).map(r=>r.name),errorCount=n=>Object.values(n||{}).reduce((r,e)=>r+e.length,0);export function snapshot(n){const r=e=>Array.isArray(e)?e.map(r):e&&typeof e=="object"?Object.fromEntries(Object.keys(e).sort().map(t=>[t,r(e[t])])):e;return JSON.stringify(r(n??null))}export const draftKey=n=>`view-editor.draft.${n||"new"}`;export function flattenDoc(n,r="",e={}){for(const[t,o]of Object.entries(n||{})){const s=r?`${r}.${t}`:t;o&&typeof o=="object"&&!Array.isArray(o)&&Object.keys(o).length<8?flattenDoc(o,s,e):e[s]=o}return e}export function resultColumns(n,r=[]){return Array.isArray(r)&&r.length?r.map(e=>({key:e.key,label:e.label||e.key})):Object.keys(flattenDoc(n?.[0]||{})).slice(0,6).map(e=>({key:e,label:e}))}export function cellText(n,r,e=80){let t=n;for(const s of String(r).split("."))t=t?.[s];const o=t==null?"":Array.isArray(t)?t.map(s=>s&&typeof s=="object"?JSON.stringify(s):String(s)).join(", "):typeof t=="object"?JSON.stringify(t):String(t);return o.length>e?`${o.slice(0,e)}\u2026`:o}
@@ -117,6 +117,7 @@
117
117
  <label class="cex-check"><input type="checkbox" data-model="deleteEnabled.value"> Allowed</label>
118
118
  <select class="form-input" aria-label="Who may delete" data-model="deleteAccess.value" data-bind-disabled="!deleteEnabled.value"></select></div>
119
119
  </div>
120
+ <small class="cex-err" data-bind-hidden="!missingApiRoles.value" data-bind-text="missingApiRoles.value"></small>
120
121
  </div>
121
122
 
122
123
  <div class="cex-card">
@@ -125,7 +126,8 @@
125
126
  <div class="cex-ops">
126
127
  <div class="cex-op"><strong>Export</strong>
127
128
  <label class="cex-check"><input id="export-enabled" type="checkbox" data-model="exportEnabled.value"> Allowed</label>
128
- <select id="export-access" class="form-input" aria-label="Who may export" data-model="exportAccess.value" data-bind-disabled="!exportEnabled.value"></select></div>
129
+ <select id="export-access" class="form-input" aria-label="Who may export" data-model="exportAccess.value" data-bind-disabled="!exportEnabled.value"></select>
130
+ <small class="cex-err cex-wide" data-bind-hidden="!missingExportRole.value" data-bind-text="missingExportRole.value"></small></div>
129
131
  <div class="cex-op cex-op-sub"><span>Formats</span>
130
132
  <span class="cex-formats">
131
133
  <label class="cex-check"><input id="export-format-csv" type="checkbox" data-model="exportCsv.value" data-bind-disabled="!exportEnabled.value"> CSV</label>
@@ -69,12 +69,12 @@
69
69
  <div class="col-12">
70
70
  <div class="docs-body">
71
71
 
72
- <p>Role hierarchy governs which users can manage other users. A manager cannot create, edit, or delete
73
- an admin.
74
- Self-deletion is always blocked.</p>
72
+ <p>Role hierarchy governs which users can manage other users: you can only create, edit, delete or reset
73
+ the password of a user whose role is less senior (a higher level number) than your own - an
74
+ Admin cannot manage a Super Admin. Self-deletion is always blocked.</p>
75
75
 
76
76
  <h3><span class="method-badge method-get">GET</span><span class="endpoint-path">/api/users</span></h3>
77
- <p class="auth-note">Requires Bearer token + <code>users</code> permission (admin or manager).</p>
77
+ <p class="auth-note">Requires Bearer token + <code>users</code> read permission (Super Admin and Admin by default).</p>
78
78
  <p>Return all users. Passwords are stripped from the response.</p>
79
79
  <pre class="code-block"><code>// Response 200
80
80
  [ { "id": "uuid", "name": "Alice", "email": "alice@example.com", "role": "admin", "isActive": true } ]</code></pre>
@@ -121,12 +121,12 @@
121
121
  <tr>
122
122
  <td><code>role</code></td>
123
123
  <td>string</td>
124
- <td>Optional. Defaults to <code>editor</code>.</td>
124
+ <td>Optional. Defaults to <code>user</code>.</td>
125
125
  </tr>
126
126
  </tbody>
127
127
  </table>
128
128
  <pre class="code-block"><code>// Response 201
129
- { "id": "uuid", "name": "Bob", "email": "bob@example.com", "role": "editor", "isActive": true }
129
+ { "id": "uuid", "name": "Bob", "email": "bob@example.com", "role": "user", "isActive": true }
130
130
 
131
131
  // Error 409
132
132
  { "error": "Email already in use" }</code></pre>
@@ -134,8 +134,8 @@
134
134
  <h3><span class="method-badge method-put">PUT</span><span class="endpoint-path">/api/users/:id</span>
135
135
  </h3>
136
136
  <p class="auth-note">Requires Bearer token + <code>users</code> permission.</p>
137
- <p>Update a user's details. Managers cannot edit admins. Role escalation beyond the actor's own level
138
- is blocked.</p>
137
+ <p>Update a user's details. You cannot edit a user as senior as or more senior than yourself (other
138
+ than yourself). Role escalation beyond the actor's own level is blocked.</p>
139
139
  <table class="table table-sm">
140
140
  <thead>
141
141
  <tr>
@@ -69,9 +69,9 @@
69
69
  <div class="col-12">
70
70
  <div class="docs-body">
71
71
 
72
- <p>Views require a MongoDB connection. All admin endpoints require authentication and the
73
- <code>views</code> permission. View configs are stored in the <code>cms__views</code> MongoDB
74
- collection.</p>
72
+ <p>Views work on every storage adapter - no MongoDB connection is needed. All admin endpoints require
73
+ authentication and the <code>views</code> permission. View configs are stored as JSON files in
74
+ <code>content/views/&lt;slug&gt;.json</code>.</p>
75
75
 
76
76
  <h3 style="margin-top:16px;font-size:15px;text-transform:uppercase;letter-spacing:.5px;opacity:.6">
77
77
  Admin Endpoints</h3>
@@ -135,7 +135,8 @@
135
135
  </h3>
136
136
  <p class="auth-note">Access level: per view <code>access</code> config</p>
137
137
  <p>Execute the view publicly. If <code>access.public</code> is <code>false</code>, a valid JWT and a
138
- role listed in <code>access.roles</code> is required.</p>
138
+ role listed in <code>access.roles</code> (or a more privileged one) is required; with no roles
139
+ listed, only the admin tier (role levels 0 and 1) may read it.</p>
139
140
 
140
141
  </div>
141
142
  </div>
@@ -8,76 +8,56 @@
8
8
  <div class="docs-body">
9
9
 
10
10
  <p>Users are stored as individual JSON files in <code>content/users/</code>. Passwords are hashed with
11
- bcrypt
12
- and never returned by the API.</p>
11
+ bcrypt and never returned by the API. Manage them at <strong>System &gt; Users</strong>.</p>
13
12
 
14
13
  <h3>Roles</h3>
14
+ <p>Roles are data, stored in the <code>roles</code> preset collection and managed at
15
+ <strong>System &gt; Roles</strong>. Every site starts with three base roles, which cannot be
16
+ deleted:</p>
15
17
  <table class="table table-sm">
16
18
  <thead>
17
19
  <tr>
18
20
  <th>Role</th>
21
+ <th>Level</th>
19
22
  <th>Access</th>
20
23
  </tr>
21
24
  </thead>
22
25
  <tbody>
23
26
  <tr>
24
- <td><strong>Admin</strong></td>
25
- <td>Full access - users, plugins, settings, all content.</td>
27
+ <td><strong>Super Admin</strong> (<code>super-admin</code>)</td>
28
+ <td>0</td>
29
+ <td>Everything, including permissions added by plugins.</td>
26
30
  </tr>
27
31
  <tr>
28
- <td><strong>Manager</strong></td>
29
- <td>Content + structure (navigation, layouts). No users or plugins.</td>
32
+ <td><strong>Admin</strong> (<code>admin</code>)</td>
33
+ <td>1</td>
34
+ <td>Content, structure, collections, views, actions, users, settings, theme, plugins,
35
+ API tokens and endpoints, notifications.</td>
30
36
  </tr>
31
37
  <tr>
32
- <td><strong>Editor</strong></td>
33
- <td>Pages and media only.</td>
34
- </tr>
35
- <tr>
36
- <td><strong>Subscriber</strong></td>
37
- <td>Read-only access to the API.</td>
38
+ <td><strong>User</strong> (<code>user</code>)</td>
39
+ <td>2</td>
40
+ <td>Notifications only - a signed-in account with no admin rights of its own.</td>
38
41
  </tr>
39
42
  </tbody>
40
43
  </table>
44
+ <p>You can add your own roles (New role on the Roles screen) and plugins can add theirs. Each role has
45
+ a level (lower = more senior) and a set of permissions, ticked in a matrix of resources and
46
+ actions (view, create, edit, delete). A user has one primary role plus optional additional
47
+ roles; a permission check passes if any of their roles grants it. You can only manage users
48
+ whose role is less senior than your own.</p>
41
49
 
42
50
  <h3>Authentication</h3>
43
- <p>The admin panel uses JWT Bearer tokens. Tokens are stored in the browser and automatically
44
- refreshed.
45
- Sessions expire according to <code>config/auth.json</code> - default is 24 hours.</p>
51
+ <p>The admin panel uses JWT Bearer tokens, stored in the browser and refreshed automatically. By
52
+ default an access token lasts 15 minutes and a refresh token 7 days (<code>config/auth.json</code>).
53
+ Each sign-in is a session kept on disk, so signing out holds even across a restart; changing a
54
+ password signs out that account's other sessions.</p>
46
55
 
47
56
  <h3>Views &amp; Actions</h3>
48
- <p>Views and Actions are pro-only features that require a MongoDB connection.
49
- Both roles - Admin and Manager - can create, edit, and run Views and Actions.</p>
50
- <table class="table table-sm">
51
- <thead>
52
- <tr>
53
- <th>Role</th>
54
- <th>Views</th>
55
- <th>Actions</th>
56
- </tr>
57
- </thead>
58
- <tbody>
59
- <tr>
60
- <td><strong>Admin</strong></td>
61
- <td>Full access</td>
62
- <td>Full access</td>
63
- </tr>
64
- <tr>
65
- <td><strong>Manager</strong></td>
66
- <td>Full access</td>
67
- <td>Full access</td>
68
- </tr>
69
- <tr>
70
- <td><strong>Editor</strong></td>
71
- <td>No access</td>
72
- <td>No access</td>
73
- </tr>
74
- <tr>
75
- <td><strong>Subscriber</strong></td>
76
- <td>No access</td>
77
- <td>No access</td>
78
- </tr>
79
- </tbody>
80
- </table>
57
+ <p>Access to Views and Actions is controlled by the <code>views</code> and <code>actions</code>
58
+ permissions. Super Admin and Admin hold both by default; the User role holds neither. Grant
59
+ them to a custom role in the role editor. Views work on every storage adapter; Actions need a
60
+ MongoDB connection.</p>
81
61
 
82
62
  </div>
83
63
  </div>
@@ -8,14 +8,16 @@
8
8
  <div class="docs-body">
9
9
 
10
10
  <p>Views are admin-designed display configurations that query one or more Collections via a stored
11
- MongoDB aggregation pipeline, then render the results in the admin panel.
12
- Views require a MongoDB connection configured under <strong>Collections → Options</strong>.</p>
11
+ aggregation-style pipeline, then render the results in the admin panel or on a page with
12
+ <code>[view]</code>. They work on every storage adapter: MongoDB-backed collections run native
13
+ aggregations, and file-backed collections run the built-in pipeline evaluator. No MongoDB
14
+ connection is required.</p>
13
15
 
14
16
  <h3>Creating a View</h3>
15
17
  <ol>
16
18
  <li>Navigate to <strong>Data → Views</strong> and click <strong>New View</strong>.</li>
17
- <li><strong>Source tab</strong> - enter a title, choose the primary source collection, and select
18
- the MongoDB connection.
19
+ <li><strong>Source tab</strong> - enter a title, choose the primary source collection and, if the
20
+ collection lives on MongoDB, the connection to use.
19
21
  </li>
20
22
  <li><strong>Pipeline tab</strong> - add aggregation stages in order. Each stage has a type and a
21
23
  JSON config object.
@@ -1,11 +1,11 @@
1
- import{api as C}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{onceClosed as ot,openMenuAt as Fe}from"../lib/tool-kit.js";import{ACCESS_LEVELS as at,EXPORT_ACCESS as lt,FIELD_TYPES as st,OPTION_TYPES as it,checkForm as rt,draftOf as ct,duplicateField as dt,errorCount as K,fieldFromPanel as Le,fieldProblem as ut,fieldRow as pt,formFromCollection as De,formFromDraft as ft,moveField as Re,nameFromLabel as mt,newField as vt,panelForm as ht,payloadFromForm as bt,slugify as Z,snapshot as H,storageChange as Ne}from"../lib/collection-editor-arrange.js";const gt=/^\/collections\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,yt=s=>`collection-editor.draft.${s||"new"}`,wt=`
1
+ import{api as g}from"../api.js";import{addBannerAction as pe}from"../lib/plugin-chrome.js";import{helpPopovers as De}from"../lib/help-popover.js";import{onceClosed as it,openMenuAt as Le}from"../lib/tool-kit.js";import{FIELD_TYPES as rt,OPERATIONS as ct,OPTION_TYPES as dt,accessOptions as ut,checkForm as pt,draftOf as ft,duplicateField as mt,errorCount as H,fieldFromPanel as Ne,fieldProblem as vt,fieldRow as ht,formFromCollection as qe,formFromDraft as bt,moveField as Te,nameFromLabel as gt,newField as yt,panelForm as wt,payloadFromForm as xt,roleLadder as kt,slugify as ee,snapshot as U,storageChange as Pe}from"../lib/collection-editor-arrange.js";const Et=/^\/collections\/(new|edit\/[^/?#]+)\/?(\?.*)?$/,St=s=>`collection-editor.draft.${s||"new"}`,Mt=`
2
2
  <div class="cex-row" data-key="{{key}}" tabindex="0" draggable="true" data-bind-class="rowCls">
3
3
  <span class="cex-grip" title="Drag to reorder" aria-hidden="true">\u283F</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>`,ee=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"),xt=s=>s.charAt(0).toUpperCase()+s.slice(1);function kt(s){const c=document.createElement("template");return c.innerHTML=s.trim(),c.content.firstElementChild}function Te(s,c){const i=s.querySelector(".dm-plugin-banner-name");if(!i)return;const $=document.createElement("span");$.className="dm-plugin-banner-parent",$.textContent="Collections";const y=document.createElement("span");y.className="dm-plugin-banner-sep",y.setAttribute("aria-hidden","true"),y.textContent="\u203A",i.replaceChildren($,y,document.createTextNode(c))}let g=null,qe=!1;const te=s=>String(s||"").split("?")[0];function Et(){qe||!window.R?.use||(qe=!0,R.use((s,c,i)=>{if(!(g&&gt.test(c?.path||"")&&te(s?.path)!==te(c?.path))||!g.dirty())return i();const y=`#${c.path}`;return E.confirm("This collection has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(r=>{r?i():history.replaceState(null,"",y)})}),M.subscribe("router:afterChange",({to:s}={})=>{if(g&&te(s?.path)!==g.path){const c=g;g=null,c.dispose()}}))}export const collectionEditorView={templateUrl:"/admin/js/templates/collection-editor.html",async onMount(s){if(Et(),g){const e=g;g=null,e.dispose()}const c=s.get(0),i=c.querySelector(".cex")||c,$=te(location.hash.replace(/^#/,""))||"/collections/new",y=$.match(/^\/collections\/edit\/([^/?#]+)/),r=y?decodeURIComponent(y[1]):null,P=!r,pe=[],b=e=>(pe.push(e),e);let fe=!1;const ne=()=>!fe&&i.isConnected,me=E.loader(c,{type:"dots"});let O=null,oe=[],B=null,ve=[],d=0;try{[O,oe,B,ve]=await Promise.all([r?C.collections.get(r):Promise.resolve(null),C.projects.list().catch(()=>[]),C.collections.proStatus().catch(()=>null),P?C.collections.list().then(e=>(e||[]).map(t=>t.slug)).catch(()=>[]):Promise.resolve([])]),r&&(d=(await C.collections.listEntries(r,{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(),!i.isConnected)return;if(r&&!O){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const he=new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",m=De(O,{project:he}),w=Object.fromEntries(m.fields.map(e=>[e._key,e.name])),Pe=O?.meta||{};let U=m.storageAdapter;const n={};for(const[e,t]of Object.entries(m))n[e]=M.observable(t);const x=M.observable(P),W=M.observable(!1),Y=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),be=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),L=M.observable(H(m)),_=M.computed(()=>H(be())!==L.value);let V=!P;b(M.effect(()=>{const e=n.title.value;V||(n.slug.value=Z(e))}));const p=M.computed(()=>rt(be(),{isNew:x.value,taken:ve,original:w})),ge=M.observable([]);b(M.effect(()=>{const e=p.value.fields||{},t=n.layout.value==="grid";ge.value=n.fields.value.map(o=>pt(o,e,{grid:t}))}));const Ie=M.computed(()=>new Set(n.fields.value.map(e=>e.name))),Be={...n,isNew:x,saving:W,dirty:_,rows:ge,errTitle:M.computed(()=>p.value.title||""),errSlug:M.computed(()=>p.value.slug||""),errColumns:M.computed(()=>p.value.columns||""),errExport:M.computed(()=>p.value.export||""),problemCount:M.computed(()=>K(p.value)),problemText:M.computed(()=>{const e=K(p.value);return e?`${e} thing${e===1?"":"s"} to fix before it can be saved`:""}),settingsBad:M.computed(()=>!!(p.value.title||p.value.slug||p.value.columns)),fieldsBad:M.computed(()=>!!p.value.fields),exportBad:M.computed(()=>!!p.value.export),fieldCount:M.computed(()=>n.fields.value.length),slugHint:M.computed(()=>x.value?`Will be created as "${Z(n.slug.value||n.title.value)||"\u2026"}".`:"Cannot be changed once the collection exists."),apiPath:M.computed(()=>`/api/collections/${Z(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&&!Ie.value.has(t));return e.length?`Not a field of this collection: ${e.join(", ")}`:""}),showStorage:M.observable(!!B?.pro&&r!=="roles"),storageNote:M.computed(()=>{const e=Ne(U,n.storageAdapter.value);return!e||x.value?"":d?`Switching ${e}: Save asks whether to move the ${d} existing ${d===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 oe){const t=document.createElement("option");t.value=e.slug,t.textContent=e.name||e.slug,ye.appendChild(t)}if(m.project&&!oe.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 _e=(e,t)=>{for(const o of t){const l=document.createElement("option");l.value=o,l.textContent=xt(o),e.appendChild(l)}};i.querySelectorAll(".cex-op select").forEach(e=>_e(e,e.id==="export-access"?lt:at));const Ke=i.querySelector("#storage-connection"),He=Array.isArray(B?.connections)&&B.connections.length?B.connections:["default"];for(const e of new Set([...He,m.storageConnection])){const t=document.createElement("option");t.value=e,t.textContent=e,Ke.appendChild(t)}const N=e=>n.fields.peek().findIndex(t=>t._key===e),j=e=>{n.fields.value=e};function z(e,t){const o=N(e);o<0||(j(Re(n.fields.peek(),o,o+t)),requestAnimationFrame(()=>i.querySelector(`.cex-row[data-key="${e}"]`)?.focus()))}async function ae(e){const t=N(e);if(t<0)return;const o=n.fields.peek()[t];!x.peek()&&d&&w[e]&&!await E.confirm(`Remove the field "${ee(o.label||o.name)}"? The ${d} existing ${d===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=N(e);if(o<0)return;const l=n.fields.peek()[o],v=ht(l),f={};for(const[a,k]of Object.entries(v))f[a]=M.observable(k);const u=JSON.stringify(v),X=()=>Object.fromEntries(Object.keys(f).map(a=>[a,f[a].peek()])),Ce=()=>JSON.stringify(X())!==u;let Me=!!l.name;const Je=M.effect(()=>{const a=f.label.value;Me||(f.name.value=mt(a))}),Xe=new Set(n.fields.peek().filter(a=>a._key!==e).map(a=>a.name)),$e=M.computed(()=>{const a=Le(l,Object.fromEntries(Object.keys(f).map(k=>[k,f[k].value])));return a.name&&Xe.has(a.name)?`"${a.name}" is used by another field.`:ut(a,w[e])}),Qe=M.computed(()=>!x.peek()&&d&&w[e]&&f.name.value.trim()!==w[e]?`Entries keep their value under "${w[e]}" - renaming the field does not move it.`:""),Ze={...f,problem:$e,renamed:Qe,isOptions:M.computed(()=>it.has(f.type.value)),isGrid:M.computed(()=>n.layout.value==="grid")},h=E.slideover({title:t?"New field":`Field: ${ee(l.label||l.name||"Untitled")}`,size:"md",position:"right"}),F=kt(`
8
+ </div>`,te=s=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");function $t(s){const c=document.createElement("template");return c.innerHTML=s.trim(),c.content.firstElementChild}function Ie(s,c){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 w=document.createElement("span");w.className="dm-plugin-banner-sep",w.setAttribute("aria-hidden","true"),w.textContent="\u203A",i.replaceChildren(C,w,document.createTextNode(c))}let y=null,Be=!1;const ne=s=>String(s||"").split("?")[0];function Ct(){Be||!window.R?.use||(Be=!0,R.use((s,c,i)=>{if(!(y&&Et.test(c?.path||"")&&ne(s?.path)!==ne(c?.path))||!y.dirty())return i();const w=`#${c.path}`;return E.confirm("This collection has unsaved changes. Leave anyway? They are kept as a draft for your next visit.").then(r=>{r?i():history.replaceState(null,"",w)})}),M.subscribe("router:afterChange",({to:s}={})=>{if(y&&ne(s?.path)!==y.path){const c=y;y=null,c.dispose()}}))}export const collectionEditorView={templateUrl:"/admin/js/templates/collection-editor.html",async onMount(s){if(Ct(),y){const e=y;y=null,e.dispose()}const c=s.get(0),i=c.querySelector(".cex")||c,C=ne(location.hash.replace(/^#/,""))||"/collections/new",w=C.match(/^\/collections\/edit\/([^/?#]+)/),r=w?decodeURIComponent(w[1]):null,P=!r,fe=[],b=e=>(fe.push(e),e);let me=!1;const oe=()=>!me&&i.isConnected,ve=E.loader(c,{type:"dots"});let O=null,ae=[],B=null,he=[],u=0,_=[];try{[O,ae,B,he,_]=await Promise.all([r?g.collections.get(r):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(kt).catch(()=>[])]),r&&(u=(await g.collections.listEntries(r,{limit:1}).catch(()=>null))?.total??0)}catch(e){ve.destroy(),E.toast(e?.status===404?"Collection not found.":`Could not load the collection: ${e.message||e}`,{type:"error"}),R.navigate("/collections");return}if(ve.destroy(),!i.isConnected)return;if(r&&!O){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const be=new URLSearchParams(location.hash.split("?")[1]||"").get("project")||"",f=qe(O,{project:be,defaultRole:_.at(-1)?.name||""}),x=Object.fromEntries(f.fields.map(e=>[e._key,e.name])),_e=O?.meta||{};let W=f.storageAdapter;const Ke=new Set(["public","token",..._.map(e=>e.name)]),ge=(e,t)=>{if(!_.length)return"";const o=[...new Set(e.filter(a=>a&&!Ke.has(a)))];return o.length?`Not a role on this site: ${o.join(", ")}. Until you pick another, ${t}.`:""},n={};for(const[e,t]of Object.entries(f))n[e]=M.observable(t);const k=M.observable(P),Y=M.observable(!1),z=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),ye=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),L=M.observable(U(f)),K=M.computed(()=>U(ye())!==L.value);let G=!P;b(M.effect(()=>{const e=n.title.value;G||(n.slug.value=ee(e))}));const m=M.computed(()=>pt(ye(),{isNew:k.value,taken:he,original:x})),we=M.observable([]);b(M.effect(()=>{const e=m.value.fields||{},t=n.layout.value==="grid";we.value=n.fields.value.map(o=>ht(o,e,{grid:t}))}));const He=M.computed(()=>new Set(n.fields.value.map(e=>e.name))),Ue={...n,isNew:k,saving:Y,dirty:K,rows:we,errTitle:M.computed(()=>m.value.title||""),errSlug:M.computed(()=>m.value.slug||""),errColumns:M.computed(()=>m.value.columns||""),errExport:M.computed(()=>m.value.export||""),problemCount:M.computed(()=>H(m.value)),problemText:M.computed(()=>{const e=H(m.value);return e?`${e} thing${e===1?"":"s"} to fix before it can be saved`:""}),settingsBad:M.computed(()=>!!(m.value.title||m.value.slug||m.value.columns)),fieldsBad:M.computed(()=>!!m.value.fields),exportBad:M.computed(()=>!!m.value.export),fieldCount:M.computed(()=>n.fields.value.length),slugHint:M.computed(()=>k.value?`Will be created as "${ee(n.slug.value||n.title.value)||"\u2026"}".`:"Cannot be changed once the collection exists."),apiPath:M.computed(()=>`/api/collections/${ee(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&&!He.value.has(t));return e.length?`Not a field of this collection: ${e.join(", ")}`:""}),missingApiRoles:M.computed(()=>ge(ct.filter(e=>n[`${e}Enabled`].value).map(e=>n[`${e}Access`].value),"only the level-0 role passes")),missingExportRole:M.computed(()=>ge(n.exportEnabled.value?[n.exportAccess.value]:[],"only the level-0 role is offered export")),showStorage:M.observable(!!B?.pro&&r!=="roles"),storageNote:M.computed(()=>{const e=Pe(W,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.`})},xe=i.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(f.project&&!ae.some(e=>e.slug===f.project)){const e=document.createElement("option");e.value=f.project,e.textContent=`${f.project} (not found)`,xe.appendChild(e)}const We=[...i.querySelectorAll('.cex-op select[data-model$="Access.value"]')],ke=e=>{for(const t of We){const o=t.getAttribute("data-model").split(".")[0],a=e[o];t.replaceChildren(...ut(_,a,{token:t.id!=="export-access"}).map(d=>new Option(d.label,d.value))),t.value=a}};ke(f);const Ye=i.querySelector("#storage-connection"),ze=Array.isArray(B?.connections)&&B.connections.length?B.connections:["default"];for(const e of new Set([...ze,f.storageConnection])){const t=document.createElement("option");t.value=e,t.textContent=e,Ye.appendChild(t)}const N=e=>n.fields.peek().findIndex(t=>t._key===e),j=e=>{n.fields.value=e};function V(e,t){const o=N(e);o<0||(j(Te(n.fields.peek(),o,o+t)),requestAnimationFrame(()=>i.querySelector(`.cex-row[data-key="${e}"]`)?.focus()))}async function le(e){const t=N(e);if(t<0)return;const o=n.fields.peek()[t];!k.peek()&&u&&x[e]&&!await E.confirm(`Remove the field "${te(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(a=>a._key!==e))}function J(e,{isAdded:t=!1}={}){const o=N(e);if(o<0)return;const a=n.fields.peek()[o],d=wt(a),v={};for(const[l,$]of Object.entries(d))v[l]=M.observable($);const p=JSON.stringify(d),X=()=>Object.fromEntries(Object.keys(v).map(l=>[l,v[l].peek()])),Oe=()=>JSON.stringify(X())!==p;let je=!!a.name;const et=M.effect(()=>{const l=v.label.value;je||(v.name.value=gt(l))}),tt=new Set(n.fields.peek().filter(l=>l._key!==e).map(l=>l.name)),Ae=M.computed(()=>{const l=Ne(a,Object.fromEntries(Object.keys(v).map($=>[$,v[$].value])));return l.name&&tt.has(l.name)?`"${l.name}" is used by another field.`:vt(l,x[e])}),nt=M.computed(()=>!k.peek()&&u&&x[e]&&v.name.value.trim()!==x[e]?`Entries keep their value under "${x[e]}" - renaming the field does not move it.`:""),ot={...v,problem:Ae,renamed:nt,isOptions:M.computed(()=>dt.has(v.type.value)),isGrid:M.computed(()=>n.layout.value==="grid")},h=E.slideover({title:t?"New field":`Field: ${te(a.label||a.name||"Untitled")}`,size:"md",position:"right"}),F=$t(`
9
9
  <div class="cex-panel">
10
10
  <div class="cex-grid">
11
11
  <div>
@@ -20,7 +20,7 @@ import{api as C}from"../api.js";import{addBannerAction as ue}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
- ${st.map(a=>`<option value="${a.value}">${a.label}</option>`).join("")}
23
+ ${rt.map(l=>`<option value="${l.value}">${l.label}</option>`).join("")}
24
24
  </select>
25
25
  </div>
26
26
  <div class="cex-setting">
@@ -61,4 +61,4 @@ import{api as C}from"../api.js";import{addBannerAction as ue}from"../lib/plugin-
61
61
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
62
62
  <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>
63
63
  </div>
64
- </div>`);let Q=!1,ce=!1;const et=h.close.bind(h);function Oe(){if($e.peek())return;const a=Le(l,X());j(n.fields.peek().map(k=>k._key===e?a:k)),Q=!0,h.close()}h.element.appendChild(F);const tt=Ae(F),nt=M.applyBindings(Ze,F,{methods:{done:Oe,cancel:()=>h.close(),remove:async()=>{Q=!0,h.close(),await ae(e)}}}),je=F.querySelector("#cex-f-name");je.addEventListener("input",()=>{Me=!!je.value}),F.addEventListener("keydown",a=>{(a.ctrlKey||a.metaKey)&&a.key==="Enter"&&(a.preventDefault(),Oe())}),ot(h,()=>{Je(),nt.dispose(),tt(),ie.delete(h)}),h.close=async a=>{if(!Q&&a!==!0&&Ce()){if(ce)return;ce=!0;const de=await E.confirm("Discard the changes to this field?");if(ce=!1,!de)return}Q=!0;const k=et();return t&&!Ce()&&!l.name&&j(n.fields.peek().filter(de=>de._key!==e)),k},ie.add(h),I.scan(F),h.open(),F.querySelector("#cex-f-label")?.focus()}function Ue(){const e=vt();j([...n.fields.peek(),e]),Ve.activate?.(1),G(e._key,{isAdded:!0})}function We(e){const t=N(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(dt(n.fields.peek(),t))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:()=>ae(e)}]}async function le(){if(W.peek())return;const e=p.peek();if(K(e)){E.toast(`Not saved - ${K(e)} thing${K(e)===1?"":"s"} to fix first (marked on the tabs).`,{type:"warning"});return}const t=bt(Y(),{isNew:x.peek(),meta:Pe});W.value=!0;try{if(x.peek()){const l=await C.collections.create(t);L.value=H(Y()),re(),E.toast(`Created "${t.title}".`,{type:"success"}),R.navigate(`/collections/edit/${encodeURIComponent(l.slug)}`);return}const o=Ne(U,t.storage.adapter);if(o&&d){if(await E.confirm(`You changed the storage (${o}). Move the ${d} existing ${d===1?"entry":"entries"} to the new storage?`)){const v=await C.collections.migrateStorage(r,t.storage);E.toast(`Moved ${v.migrated} of ${v.total} entries.`,{type:"success"})}else if(!await E.confirm(`Switch without moving them? The ${d} ${d===1?"entry stays":"entries stay"} in ${ee(U)} storage and this collection will show none until they are moved.`))return}if(await C.collections.update(r,t),!ne())return;U=t.storage.adapter,o&&(d=(await C.collections.listEntries(r,{limit:1}).catch(()=>null))?.total??d);for(const l of n.fields.peek())w[l._key]=l.name;L.value=H(Y()),re(),Te(c,t.title),E.toast("Collection saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{W.value=!1}}async function Ye(){if(!_.peek()||!await E.confirm("Discard the changes you have not saved?"))return;const e=De(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;L.value=H(e),V=!x.peek()}const T=i.querySelector(".cex-list");T.innerHTML=wt;const Ve=E.tabs(i.querySelector(".cex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),ze=M.applyBindings(Be,i,{methods:{save:()=>le(),discard:()=>Ye(),addField:()=>Ue()}});b(()=>ze.dispose()),b(Ae(i,{position:"bottom"}));const se=i.querySelector("#field-slug"),we=()=>{V=!!se.value};se.addEventListener("input",we),b(()=>se.removeEventListener("input",we));const ie=new Set;b(()=>[...ie].forEach(e=>{try{e.close(!0)}catch{}}));const Ge=E.contextMenu(T,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:e=>We(e.dataset.key)});b(()=>Ge?.destroy?.());let q=null;const xe={click:e=>{const t=e.target.closest(".cex-row");if(!t)return;const o=e.target.closest(".cex-kebab");if(o){Fe(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(),Fe(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(),ae(t.dataset.key)))},dragstart:e=>{const t=e.target.closest(".cex-row");t&&(q=t.dataset.key,t.classList.add("is-dragging"),e.dataTransfer.effectAllowed="move")},dragover:e=>{const t=e.target.closest(".cex-row");!t||!q||(e.preventDefault(),T.querySelectorAll(".is-drag-over").forEach(o=>o.classList.remove("is-drag-over")),t.classList.add("is-drag-over"))},drop:e=>{const t=e.target.closest(".cex-row");if(!t||!q)return;e.preventDefault();const o=N(q),l=N(t.dataset.key);q=null,j(Re(n.fields.peek(),o,l))},dragend:()=>{q=null,T.querySelectorAll(".is-drag-over, .is-dragging").forEach(e=>e.classList.remove("is-drag-over","is-dragging"))}};for(const[e,t]of Object.entries(xe))T.addEventListener(e,t);b(()=>{for(const[e,t]of Object.entries(xe))T.removeEventListener(e,t)}),Te(c,O?.title||"New collection"),ue(s,{id:"collection-editor-back",icon:"arrow-left",title:"All collections",onClick:()=>R.navigate("/collections")}),r&&ue(s,{id:"collection-editor-entries",icon:"list",title:"Open entries",onClick:()=>R.navigate(`/collections/${encodeURIComponent(r)}/entries`)}),ue(s,{id:"collection-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>le()});const ke=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!ne()||(e.preventDefault(),e.stopImmediatePropagation(),le())};document.addEventListener("keydown",ke,!0),b(()=>document.removeEventListener("keydown",ke,!0));const J=yt(r);function re(){try{S.remove?S.remove(J):S.set(J,null)}catch{}}const Ee=()=>{if(_.peek())try{S.set(J,{form:ct(Y()),base:L.peek(),at:new Date().toISOString()})}catch{}},Se=e=>{_.peek()&&(Ee(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Se),b(()=>window.removeEventListener("beforeunload",Se)),g={path:$,dirty:()=>_.peek(),dispose(){fe=!0,Ee(),pe.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let A=null;try{A=S.get(J)}catch{}if(A?.form){re();const e=A.base&&A.base!==L.peek(),t=A.at?` (${D(A.at).format("D MMM HH:mm")})`:"",o=r?`changes to "${ee(O?.title||r)}"`:"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)&&ne()){const v=ft(A.form,m);P||(v.slug=m.slug);const f=Object.fromEntries(m.fields.filter(u=>u.id).map(u=>[u.id,u.name]));for(const u of v.fields)u.id&&f[u.id]!==void 0&&(w[u._key]=f[u.id]);for(const[u,X]of Object.entries(v))n[u]&&(n[u].value=X);P&&(V=!!v.slug&&v.slug!==Z(v.title))}}}};
64
+ </div>`);let Z=!1,de=!1;const at=h.close.bind(h);function Re(){if(Ae.peek())return;const l=Ne(a,X());j(n.fields.peek().map($=>$._key===e?l:$)),Z=!0,h.close()}h.element.appendChild(F);const lt=De(F),st=M.applyBindings(ot,F,{methods:{done:Re,cancel:()=>h.close(),remove:async()=>{Z=!0,h.close(),await le(e)}}}),Fe=F.querySelector("#cex-f-name");Fe.addEventListener("input",()=>{je=!!Fe.value}),F.addEventListener("keydown",l=>{(l.ctrlKey||l.metaKey)&&l.key==="Enter"&&(l.preventDefault(),Re())}),it(h,()=>{et(),st.dispose(),lt(),re.delete(h)}),h.close=async l=>{if(!Z&&l!==!0&&Oe()){if(de)return;de=!0;const ue=await E.confirm("Discard the changes to this field?");if(de=!1,!ue)return}Z=!0;const $=at();return t&&!Oe()&&!a.name&&j(n.fields.peek().filter(ue=>ue._key!==e)),$},re.add(h),I.scan(F),h.open(),F.querySelector("#cex-f-label")?.focus()}function Ge(){const e=yt();j([...n.fields.peek(),e]),Qe.activate?.(1),J(e._key,{isAdded:!0})}function Ve(e){const t=N(e);if(t<0)return[];const o=n.fields.peek()[t],a=n.fields.peek().length;return[{header:o.label||o.name||"Field"},{label:"Edit\u2026",icon:"edit",action:()=>J(e)},{label:"Move up",icon:"arrow-up",disabled:t===0,action:()=>V(e,-1)},{label:"Move down",icon:"arrow-down",disabled:t===a-1,action:()=>V(e,1)},{label:"Duplicate",icon:"copy",action:()=>j(mt(n.fields.peek(),t))},{type:"divider"},{label:"Remove\u2026",icon:"trash",danger:!0,action:()=>le(e)}]}async function se(){if(Y.peek())return;const e=m.peek();if(H(e)){E.toast(`Not saved - ${H(e)} thing${H(e)===1?"":"s"} to fix first (marked on the tabs).`,{type:"warning"});return}const t=xt(z(),{isNew:k.peek(),meta:_e});Y.value=!0;try{if(k.peek()){const a=await g.collections.create(t);L.value=U(z()),ce(),E.toast(`Created "${t.title}".`,{type:"success"}),R.navigate(`/collections/edit/${encodeURIComponent(a.slug)}`);return}const o=Pe(W,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 d=await g.collections.migrateStorage(r,t.storage);E.toast(`Moved ${d.migrated} of ${d.total} entries.`,{type:"success"})}else if(!await E.confirm(`Switch without moving them? The ${u} ${u===1?"entry stays":"entries stay"} in ${te(W)} storage and this collection will show none until they are moved.`))return}if(await g.collections.update(r,t),!oe())return;W=t.storage.adapter,o&&(u=(await g.collections.listEntries(r,{limit:1}).catch(()=>null))?.total??u);for(const a of n.fields.peek())x[a._key]=a.name;L.value=U(z()),ce(),Ie(c,t.title),E.toast("Collection saved.",{type:"success"})}catch(o){E.toast(`Not saved: ${o.message||o}`,{type:"error"})}finally{Y.value=!1}}async function Je(){if(!K.peek()||!await E.confirm("Discard the changes you have not saved?"))return;const e=qe(O,{project:be});for(const t of e.fields)x[t._key]=t.name;for(const[t,o]of Object.entries(e))n[t].value=o;L.value=U(e),G=!k.peek()}const q=i.querySelector(".cex-list");q.innerHTML=Mt;const Qe=E.tabs(i.querySelector(".cex-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Xe=M.applyBindings(Ue,i,{methods:{save:()=>se(),discard:()=>Je(),addField:()=>Ge()}});b(()=>Xe.dispose()),b(De(i,{position:"bottom"}));const ie=i.querySelector("#field-slug"),Ee=()=>{G=!!ie.value};ie.addEventListener("input",Ee),b(()=>ie.removeEventListener("input",Ee));const re=new Set;b(()=>[...re].forEach(e=>{try{e.close(!0)}catch{}}));const Ze=E.contextMenu(q,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:e=>Ve(e.dataset.key)});b(()=>Ze?.destroy?.());let T=null;const Se={click:e=>{const t=e.target.closest(".cex-row");if(!t)return;const o=e.target.closest(".cex-kebab");if(o){Le(o);return}J(t.dataset.key)},keydown:e=>{const t=e.target.closest(".cex-row");!t||e.target!==t||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Le(t)):e.key==="Enter"?J(t.dataset.key):e.altKey&&e.key==="ArrowUp"?(e.preventDefault(),V(t.dataset.key,-1)):e.altKey&&e.key==="ArrowDown"?(e.preventDefault(),V(t.dataset.key,1)):e.key==="Delete"&&(e.preventDefault(),le(t.dataset.key)))},dragstart:e=>{const t=e.target.closest(".cex-row");t&&(T=t.dataset.key,t.classList.add("is-dragging"),e.dataTransfer.effectAllowed="move")},dragover:e=>{const t=e.target.closest(".cex-row");!t||!T||(e.preventDefault(),q.querySelectorAll(".is-drag-over").forEach(o=>o.classList.remove("is-drag-over")),t.classList.add("is-drag-over"))},drop:e=>{const t=e.target.closest(".cex-row");if(!t||!T)return;e.preventDefault();const o=N(T),a=N(t.dataset.key);T=null,j(Te(n.fields.peek(),o,a))},dragend:()=>{T=null,q.querySelectorAll(".is-drag-over, .is-dragging").forEach(e=>e.classList.remove("is-drag-over","is-dragging"))}};for(const[e,t]of Object.entries(Se))q.addEventListener(e,t);b(()=>{for(const[e,t]of Object.entries(Se))q.removeEventListener(e,t)}),Ie(c,O?.title||"New collection"),pe(s,{id:"collection-editor-back",icon:"arrow-left",title:"All collections",onClick:()=>R.navigate("/collections")}),r&&pe(s,{id:"collection-editor-entries",icon:"list",title:"Open entries",onClick:()=>R.navigate(`/collections/${encodeURIComponent(r)}/entries`)}),pe(s,{id:"collection-editor-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>se()});const Me=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||!oe()||(e.preventDefault(),e.stopImmediatePropagation(),se())};document.addEventListener("keydown",Me,!0),b(()=>document.removeEventListener("keydown",Me,!0));const Q=St(r);function ce(){try{S.remove?S.remove(Q):S.set(Q,null)}catch{}}const $e=()=>{if(K.peek())try{S.set(Q,{form:ft(z()),base:L.peek(),at:new Date().toISOString()})}catch{}},Ce=e=>{K.peek()&&($e(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",Ce),b(()=>window.removeEventListener("beforeunload",Ce)),y={path:C,dirty:()=>K.peek(),dispose(){me=!0,$e(),fe.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(i);let A=null;try{A=S.get(Q)}catch{}if(A?.form){ce();const e=A.base&&A.base!==L.peek(),t=A.at?` (${D(A.at).format("D MMM HH:mm")})`:"",o=r?`changes to "${te(O?.title||r)}"`:"a new collection",a=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(a)&&oe()){const d=bt(A.form,f);P||(d.slug=f.slug);const v=Object.fromEntries(f.fields.filter(p=>p.id).map(p=>[p.id,p.name]));for(const p of d.fields)p.id&&v[p.id]!==void 0&&(x[p._key]=v[p.id]);ke(d);for(const[p,X]of Object.entries(d))n[p]&&(n[p].value=X);P&&(G=!!d.slug&&d.slug!==ee(d.title))}}}};