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.
- package/CLAUDE.md +18 -9
- package/admin/js/lib/collection-editor-arrange.js +2 -2
- package/admin/js/lib/view-editor-arrange.js +1 -1
- package/admin/js/templates/collection-editor.html +3 -1
- package/admin/js/templates/docs/api-users.html +8 -8
- package/admin/js/templates/docs/api-views.html +5 -4
- package/admin/js/templates/docs/usage-users-roles.html +28 -48
- package/admin/js/templates/docs/usage-views.html +6 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/plugins/feedback/CLAUDE.md +96 -0
- package/plugins/feedback/admin/css/index.css +80 -0
- package/plugins/feedback/admin/lib/kit.js +105 -0
- package/plugins/feedback/admin/templates/feedback.html +1 -0
- package/plugins/feedback/admin/views/feedback.js +63 -0
- package/plugins/feedback/admin/views/inbox.js +325 -0
- package/plugins/feedback/admin/views/send.js +223 -0
- package/plugins/feedback/collections/feedback-reports.json +24 -0
- package/plugins/feedback/config.js +15 -0
- package/plugins/feedback/lib/receiver.js +121 -0
- package/plugins/feedback/lib/sender.js +161 -0
- package/plugins/feedback/lib/shape.js +211 -0
- package/plugins/feedback/plugin.js +276 -0
- package/plugins/feedback/plugin.json +48 -0
- package/plugins/feedback/tests/api.test.js +219 -0
- package/plugins/feedback/tests/shape.test.js +87 -0
- package/plugins/free-tier.lock.json +22 -1
- package/server/routes/api/collections.js +8 -1
- package/server/routes/api/search.js +27 -6
- package/server/routes/api/views.js +19 -20
- package/server/routes/public.js +9 -0
- package/server/services/content.js +6 -4
- package/server/services/markdown.js +66 -33
- package/server/services/rowAccess.js +49 -0
- package/server/services/search.js +145 -17
- package/server/services/viewerSession.js +2 -2
- 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 (
|
|
65
|
-
|
|
66
|
-
1. `[
|
|
67
|
-
2. `[
|
|
68
|
-
3. `[
|
|
69
|
-
4.
|
|
70
|
-
|
|
71
|
-
`[
|
|
72
|
-
|
|
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"]),
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
73
|
-
an
|
|
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 (
|
|
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>
|
|
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": "
|
|
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.
|
|
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
|
|
73
|
-
<code>views</code> permission. View configs are stored
|
|
74
|
-
|
|
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/<slug>.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
|
|
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 > 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 > 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
|
|
25
|
-
<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>
|
|
29
|
-
<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>
|
|
33
|
-
<td>
|
|
34
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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 & Actions</h3>
|
|
48
|
-
<p>Views and Actions
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
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>`,
|
|
8
|
+
</div>`,te=s=>String(s??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");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
|
-
${
|
|
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
|
|
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))}}}};
|