thatcher 1.0.63 → 1.0.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.63",
3
+ "version": "1.0.64",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -94,10 +94,12 @@ export function getInitialState(spec) {
94
94
  state[key] = field.default;
95
95
  } else if (field.type === 'bool') {
96
96
  state[key] = false;
97
- } else if (field.type === 'int' || field.type === 'decimal') {
97
+ } else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
98
98
  state[key] = 0;
99
- } else if (field.type === 'json') {
99
+ } else if (field.type === 'json' || field.type === 'multiselect') {
100
100
  state[key] = [];
101
+ } else if (field.type === 'file' || field.type === 'attachment') {
102
+ state[key] = null;
101
103
  } else if (field.type === 'date' || field.type === 'timestamp') {
102
104
  state[key] = null;
103
105
  } else {
@@ -215,10 +215,14 @@ function coerceFieldValue(value, type) {
215
215
  switch (type) {
216
216
  case 'int':
217
217
  case 'decimal':
218
+ case 'currency':
218
219
  return Number(value);
219
220
  case 'bool':
220
221
  return Boolean(value);
221
222
  case 'json':
223
+ case 'multiselect':
224
+ case 'file':
225
+ case 'attachment':
222
226
  return typeof value === 'string' ? JSON.parse(value) : value;
223
227
  case 'date':
224
228
  case 'timestamp':
@@ -4,11 +4,15 @@ export function coerceFieldValue(value, type) {
4
4
  switch (type) {
5
5
  case 'int':
6
6
  case 'decimal':
7
+ case 'currency':
7
8
  return Number(value);
8
9
  case 'bool':
9
10
  case 'boolean':
10
11
  return value === true || value === 'true' || value === 1;
11
12
  case 'json':
13
+ case 'multiselect':
14
+ case 'file':
15
+ case 'attachment':
12
16
  return typeof value === 'string' ? JSON.parse(value) : value;
13
17
  case 'date':
14
18
  case 'timestamp': {
@@ -31,10 +35,13 @@ export function deserializeField(value, type) {
31
35
 
32
36
  switch (type) {
33
37
  case 'json':
38
+ case 'multiselect':
39
+ case 'file':
40
+ case 'attachment':
34
41
  try {
35
42
  return typeof value === 'string' ? JSON.parse(value) : value;
36
43
  } catch {
37
- return {};
44
+ return type === 'multiselect' ? [] : {};
38
45
  }
39
46
  case 'bool':
40
47
  return Boolean(value);
@@ -45,6 +45,18 @@ export async function validateField(fieldDef, value, options = {}) {
45
45
  }
46
46
  }
47
47
 
48
+ // Multi-select: value validated as array by validateType above; membership checked here (needs entityName for option-list resolution, same as enum)
49
+ if (fieldDef.type === 'multiselect' && fieldDef.options) {
50
+ const allowed = resolveEnumOptions(fieldDef, entityName);
51
+ const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
52
+ if (allowed.length > 0 && arr.some(v => !allowed.includes(v))) {
53
+ return {
54
+ valid: false,
55
+ error: `Invalid value(s) for '${fieldName}'. Expected values from: ${allowed.join(', ')}`,
56
+ };
57
+ }
58
+ }
59
+
48
60
  // Reference validation
49
61
  if (fieldDef.type === 'ref' && fieldDef.ref) {
50
62
  if (existingValue !== undefined && value === existingValue) {
@@ -72,14 +84,24 @@ function validateType(fieldDef, value, fieldName) {
72
84
 
73
85
  if (type === 'string' || type === 'text') {
74
86
  if (typeof value !== 'string') return `Field '${fieldName}' must be a string`;
75
- } else if (type === 'number' || type === 'int' || type === 'decimal') {
87
+ } else if (type === 'number' || type === 'int' || type === 'decimal' || type === 'currency') {
76
88
  if (typeof value !== 'number' || isNaN(value)) return `Field '${fieldName}' must be a number`;
89
+ if (type === 'currency' && !Number.isInteger(value)) return `Field '${fieldName}' must be an integer number of cents`;
90
+ if (fieldDef.step && (value % fieldDef.step !== 0)) return `Field '${fieldName}' must be a multiple of ${fieldDef.step}`;
77
91
  if (min !== undefined && value < min) return `Field '${fieldName}' must be at least ${min}`;
78
92
  if (max !== undefined && value > max) return `Field '${fieldName}' must be at most ${max}`;
79
93
  } else if (type === 'boolean' || type === 'bool') {
80
94
  if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
81
95
  } else if (type === 'timestamp' || type === 'date') {
82
96
  if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
97
+ } else if (type === 'multiselect') {
98
+ const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
99
+ if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
100
+ } else if (type === 'file' || type === 'attachment') {
101
+ const f = typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : value;
102
+ if (!f || typeof f !== 'object' || typeof f.stored_name !== 'string' || typeof f.url !== 'string') {
103
+ return `Field '${fieldName}' must be file metadata with stored_name and url`;
104
+ }
83
105
  } else if (type === 'json') {
84
106
  if (typeof value === 'string') {
85
107
  try { JSON.parse(value); } catch { return `Field '${fieldName}' must be valid JSON`; }
@@ -1,6 +1,7 @@
1
1
  import http from 'http';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
+ import crypto from 'crypto';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { createLogger } from '../lib/logger.js';
6
7
  import { getLucia } from '../engine.server.js';
@@ -146,6 +147,10 @@ export function createServer(options) {
146
147
  return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
147
148
  }
148
149
 
150
+ if (req.method === 'POST' && entity === 'upload' && !id) {
151
+ return await handleFileUpload(req, res, thatcher, configEngine);
152
+ }
153
+
149
154
  // Check if user has custom route for this
150
155
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
151
156
  const routeExists = await fileExists(userRoutePath);
@@ -206,6 +211,25 @@ async function serveStaticFile(pathname, req, res) {
206
211
  // Could serve SPA
207
212
  return false;
208
213
  }
214
+ if (pathname.startsWith('/uploads/')) {
215
+ // basename strips any traversal the URL decoder let through; uploaded
216
+ // files are served read-only, by their sanitized stored name only.
217
+ const name = path.basename(pathname.slice('/uploads/'.length));
218
+ const filePath = path.join(process.cwd(), 'uploads', name);
219
+ try {
220
+ if (await fileExists(filePath) && path.dirname(filePath) === path.join(process.cwd(), 'uploads')) {
221
+ const content = await fs.promises.readFile(filePath);
222
+ res.setHeader('Content-Type', 'application/octet-stream');
223
+ res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
224
+ res.writeHead(200);
225
+ res.end(content);
226
+ return true;
227
+ }
228
+ } catch (err) {
229
+ if (err.code !== 'ENOENT') staticLog.error(`${filePath} ${err.code}`, { message: err.message });
230
+ }
231
+ return false;
232
+ }
209
233
  // Try to serve from public/ or static/
210
234
  const filePath = path.join(process.cwd(), 'public', pathname);
211
235
  try {
@@ -935,6 +959,119 @@ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
935
959
  }
936
960
  }
937
961
 
962
+ const UPLOAD_ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'application/json']);
963
+ const UPLOAD_MAX_SIZE = 10 * 1024 * 1024;
964
+ const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
965
+
966
+ // Sanitize to a flat, extension-preserving, path-traversal-safe name: strip
967
+ // any directory component, keep only [a-zA-Z0-9._-], cap length. The stored
968
+ // filename is never the client-supplied one directly -- a random prefix
969
+ // prevents overwrite/collision and the sanitization prevents "../../etc" or
970
+ // null-byte tricks reaching fs.writeFile.
971
+ function sanitizeUploadFilename(name) {
972
+ const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'file';
973
+ const prefix = crypto.randomBytes(8).toString('hex');
974
+ return `${prefix}_${base}`;
975
+ }
976
+
977
+ async function readMultipartFile(req) {
978
+ const ct = req.headers['content-type'] || '';
979
+ const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
980
+ if (!boundaryMatch) throw new Error('Multipart boundary not found');
981
+ const boundary = '--' + (boundaryMatch[1] || boundaryMatch[2]).trim();
982
+
983
+ const chunks = [];
984
+ let size = 0;
985
+ await new Promise((resolve, reject) => {
986
+ const timeout = setTimeout(() => { req.destroy(); reject(new Error('Request timeout')); }, 30000);
987
+ req.on('data', chunk => {
988
+ size += chunk.length;
989
+ if (size > UPLOAD_MAX_SIZE) { clearTimeout(timeout); req.destroy(); reject(new Error('File too large')); return; }
990
+ chunks.push(chunk);
991
+ });
992
+ req.on('end', () => { clearTimeout(timeout); resolve(); });
993
+ req.on('error', (err) => { clearTimeout(timeout); reject(err); });
994
+ });
995
+
996
+ const buf = Buffer.concat(chunks);
997
+ const boundaryBuf = Buffer.from(boundary);
998
+ const parts = [];
999
+ let start = buf.indexOf(boundaryBuf);
1000
+ while (start !== -1) {
1001
+ const next = buf.indexOf(boundaryBuf, start + boundaryBuf.length);
1002
+ if (next === -1) break;
1003
+ parts.push(buf.slice(start + boundaryBuf.length, next));
1004
+ start = next;
1005
+ }
1006
+
1007
+ for (const part of parts) {
1008
+ const headerEnd = part.indexOf('\r\n\r\n');
1009
+ if (headerEnd === -1) continue;
1010
+ const headerText = part.slice(0, headerEnd).toString('utf-8');
1011
+ if (!/name="file"/i.test(headerText)) continue;
1012
+ const filenameMatch = headerText.match(/filename="([^"]*)"/i);
1013
+ if (!filenameMatch || !filenameMatch[1]) continue;
1014
+ const typeMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
1015
+ const contentType = (typeMatch ? typeMatch[1] : 'application/octet-stream').trim();
1016
+ let body = part.slice(headerEnd + 4);
1017
+ if (body.slice(-2).toString() === '\r\n') body = body.slice(0, -2);
1018
+ return { filename: filenameMatch[1], contentType, buffer: body };
1019
+ }
1020
+ throw new Error('No file field found in upload');
1021
+ }
1022
+
1023
+ async function handleFileUpload(req, res, thatcher, configEngineArg) {
1024
+ const user = await resolveRequestUser(req);
1025
+ if (!user) {
1026
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1027
+ res.end(JSON.stringify({ error: 'Authentication required' }));
1028
+ return;
1029
+ }
1030
+
1031
+ let file;
1032
+ try {
1033
+ file = await readMultipartFile(req);
1034
+ } catch (e) {
1035
+ res.writeHead(400);
1036
+ res.end(JSON.stringify({ error: e.message }));
1037
+ return;
1038
+ }
1039
+
1040
+ if (!UPLOAD_ALLOWED_TYPES.has(file.contentType)) {
1041
+ res.writeHead(400);
1042
+ res.end(JSON.stringify({ error: `Unsupported file type: ${file.contentType}` }));
1043
+ return;
1044
+ }
1045
+ if (file.buffer.length === 0) {
1046
+ res.writeHead(400);
1047
+ res.end(JSON.stringify({ error: 'Empty file' }));
1048
+ return;
1049
+ }
1050
+
1051
+ try {
1052
+ await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
1053
+ const storedName = sanitizeUploadFilename(file.filename);
1054
+ const destPath = path.join(UPLOAD_DIR, storedName);
1055
+ // Belt-and-suspenders: confirm the resolved path is still inside UPLOAD_DIR
1056
+ // even though sanitizeUploadFilename already strips traversal sequences.
1057
+ if (path.dirname(destPath) !== UPLOAD_DIR) throw new Error('Invalid upload path');
1058
+ await fs.promises.writeFile(destPath, file.buffer);
1059
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1060
+ res.end(JSON.stringify({
1061
+ filename: file.filename,
1062
+ stored_name: storedName,
1063
+ content_type: file.contentType,
1064
+ size: file.buffer.length,
1065
+ url: `/uploads/${storedName}`,
1066
+ uploaded_by: user.id,
1067
+ }));
1068
+ } catch (err) {
1069
+ apiLog.error(err.message);
1070
+ res.writeHead(500);
1071
+ res.end(JSON.stringify({ error: err.message }));
1072
+ }
1073
+ }
1074
+
938
1075
  async function readBody(req) {
939
1076
  return new Promise((resolve, reject) => {
940
1077
  let data = '';
@@ -63,7 +63,7 @@ function roleLabel(r) {
63
63
  return KNOWN_ROLE_LABELS[key] || (key.length > 8 ? 'Staff' : (key.charAt(0).toUpperCase() + key.slice(1)))
64
64
  }
65
65
 
66
- function formatFieldValue(k, v, entityName) {
66
+ function formatFieldValue(k, v, entityName, f) {
67
67
  if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
68
68
  if (entityName === 'user' && k === 'status') {
69
69
  const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
@@ -71,6 +71,15 @@ function formatFieldValue(k, v, entityName) {
71
71
  }
72
72
  if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
73
73
  if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
74
+ if (f?.type === 'currency' && typeof v === 'number') return esc((f.currency_symbol || '$') + (v / 100).toFixed(2))
75
+ if (f?.type === 'multiselect') {
76
+ const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
77
+ return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
78
+ }
79
+ if (f?.type === 'file' || f?.type === 'attachment') {
80
+ const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
81
+ return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
82
+ }
74
83
  return fmtVal(v, k)
75
84
  }
76
85
 
@@ -85,7 +94,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
85
94
  const fieldRows = visibleFields.map(([k, f]) =>
86
95
  `<div class="detail-row">
87
96
  <span class="detail-row-label">${esc(f.label || k)}</span>
88
- <span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
97
+ <span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
89
98
  </div>`
90
99
  ).join('')
91
100
 
@@ -159,6 +168,21 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
159
168
  const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${esc(ov)}" ${val===ov?'selected':''}>${esc(ol)}</option>` }).join('')
160
169
  return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
161
170
  }
171
+ if (f.type === 'multiselect' && f.options) {
172
+ const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
173
+ const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
174
+ return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
175
+ }
176
+ if (f.type === 'currency') {
177
+ const symbol = esc(f.currency_symbol || '$')
178
+ const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
179
+ return `<div class="form-field">${lbl(k,f,f.required)}<div style="display:flex;align-items:center;gap:6px"><span>${symbol}</span><input type="number" step="0.01" id="field-${k}" name="${k}" value="${esc(decimalVal)}" class="form-input" data-currency="${k}" ${req} placeholder="0.00"/></div></div>`
180
+ }
181
+ if (f.type === 'file' || f.type === 'attachment') {
182
+ const existing = val && typeof val === 'object' ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return null } })() : null)
183
+ const existingNote = existing?.filename ? `<div style="font-size:0.8rem;color:var(--color-text-muted)" data-existing-file="${k}">Current: ${esc(existing.filename)}</div>` : ''
184
+ return `<div class="form-field">${lbl(k,f,f.required)}<input type="file" id="field-${k}" data-attachment="${k}" class="form-input" ${existing ? '' : req}/>${existingNote}<input type="hidden" id="field-${k}-value" name="${k}" value="${existing ? esc(JSON.stringify(existing)) : ''}"/></div>`
185
+ }
162
186
  return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
163
187
  }).join('\n')
164
188
 
@@ -176,7 +200,7 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
176
200
  <div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${templatePicker}${formFields}${pwField}
177
201
  <div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
178
202
  <a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
179
- const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
203
+ const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;try{const fileInputs=[...form.querySelectorAll('input[type=file][data-attachment]')];for(const fi of fileInputs){const f=fi.files&&fi.files[0];if(!f)continue;const uf=new FormData();uf.append('file',f);const ures=await fetch('/api/upload',{method:'POST',body:uf});const ud=await ures.json();if(!ures.ok)throw new Error(ud.error||'File upload failed');const hidden=document.getElementById('field-'+fi.dataset.attachment+'-value');hidden.value=JSON.stringify(ud)}const fd=new FormData(form);const data={};for(const[k,v]of fd.entries()){if(k.endsWith('[]'))continue;data[k]=v}form.querySelectorAll('input[type=checkbox]:not([name$="[]"])').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('[data-multiselect]').forEach(ms=>{const name=ms.dataset.multiselect;data[name]=[...ms.querySelectorAll('input[type=checkbox]:checked')].map(cb=>cb.value)});form.querySelectorAll('input[type=number]:not([data-currency])').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});form.querySelectorAll('input[data-currency]').forEach(inp=>{const name=inp.dataset.currency;if(inp.value!=='')data[name]=Math.round(Number(inp.value)*100)});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
180
204
  return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script])
181
205
  }
182
206