webspresso 0.0.67 → 0.0.68

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/README.md CHANGED
@@ -129,6 +129,17 @@ If you select a database:
129
129
  - `models/` directory is created
130
130
  - `DATABASE_URL` is added to `.env.example` with a template
131
131
 
132
+ **Scaffold: `config/` and environment files**
133
+
134
+ New projects include:
135
+
136
+ - **`config/load-env.js`** — loads, in order, `.env`, `.env.local`, `.env.${NODE_ENV}`, and `.env.${NODE_ENV}.local` (each file overrides keys from earlier ones).
137
+ - **`config/env.schema.js`** — validates `process.env` with **Zod** before the app starts (`PORT`, `NODE_ENV`, i18n vars, `BASE_URL`, optional `DATABASE_URL`).
138
+ - **`config/app.js`** — returns `createApp()` options (paths; if `webspresso.db.js` exists, also **`createDatabase`** as `db`).
139
+ - **`server.js`** — calls `loadEnv()`, then `createApp(getCreateAppOptions())`, then `listen` using the parsed `PORT`.
140
+
141
+ Copy **`.env.example`** to **`.env`** (and use `.env.local` for machine-specific overrides). Patterns such as `.env.development.local` are gitignored via `.env.*.local`.
142
+
132
143
  **Seed Data Generation:**
133
144
  After selecting a database, you'll be asked if you want to generate seed data:
134
145
  - If yes, `@faker-js/faker` is added to dependencies
@@ -2041,6 +2052,45 @@ const { app } = createApp({
2041
2052
 
2042
2053
  List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed` (`only` / `include` when the model uses soft delete), plus **equality filters** on real columns (unknown keys are ignored).
2043
2054
 
2055
+ ### File upload plugin
2056
+
2057
+ Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns a public URL path). Use **`mimeAllowlist`** / **`extensionAllowlist`** and **`maxBytes`** (default **10 MiB**) on the server; production apps should prefer an explicit MIME allowlist.
2058
+
2059
+ **Setup:**
2060
+
2061
+ ```javascript
2062
+ const { createApp, uploadPlugin, adminPanelPlugin } = require('webspresso');
2063
+
2064
+ const { app } = createApp({
2065
+ pagesDir: './pages',
2066
+ publicDir: './public',
2067
+ db,
2068
+ plugins: [
2069
+ uploadPlugin({
2070
+ path: '/api/upload',
2071
+ local: {
2072
+ destDir: './public/uploads',
2073
+ publicBasePath: '/uploads',
2074
+ },
2075
+ maxBytes: 10 * 1024 * 1024,
2076
+ mimeAllowlist: ['image/jpeg', 'image/png', 'application/pdf'],
2077
+ extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
2078
+ middleware: [], // optional Express handlers (e.g. session auth)
2079
+ fieldName: 'file',
2080
+ }),
2081
+ adminPanelPlugin({
2082
+ db,
2083
+ // uploadUrl optional: defaults to app.get('webspresso.uploadPath') set by uploadPlugin
2084
+ }),
2085
+ ],
2086
+ });
2087
+ ```
2088
+
2089
+ - **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
2090
+ - **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials.
2091
+ - **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
2092
+ - **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
2093
+
2044
2094
  ### Health check plugin
2045
2095
 
2046
2096
  Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
@@ -107,6 +107,29 @@ function registerCommand(program) {
107
107
  warnings += 1;
108
108
  }
109
109
 
110
+ console.log('\nEnvironment files');
111
+ console.log('-----------------');
112
+
113
+ const envExamplePath = path.join(cwd, '.env.example');
114
+ const envPath = path.join(cwd, '.env');
115
+ if (fs.existsSync(envExamplePath)) {
116
+ line('✓', '.env.example present');
117
+ } else {
118
+ line('○', 'No .env.example (optional; new scaffolds include one)');
119
+ }
120
+ if (fs.existsSync(envPath)) {
121
+ line('✓', '.env present');
122
+ } else if (fs.existsSync(envExamplePath)) {
123
+ line('⚠', 'No .env — copy .env.example to .env if the app expects env vars');
124
+ warnings += 1;
125
+ } else {
126
+ line('○', 'No .env (fine if you do not use dotenv in this project)');
127
+ }
128
+ const loadEnvPath = path.join(cwd, 'config', 'load-env.js');
129
+ if (fs.existsSync(loadEnvPath)) {
130
+ line('✓', 'config/load-env.js present (dotenv chain scaffold)');
131
+ }
132
+
110
133
  console.log('\nDatabase config');
111
134
  console.log('---------------');
112
135
 
@@ -159,6 +159,7 @@ function registerCommand(program) {
159
159
  fs.mkdirSync(path.join(projectPath, 'pages', 'locales'), { recursive: true });
160
160
  fs.mkdirSync(path.join(projectPath, 'views'), { recursive: true });
161
161
  fs.mkdirSync(path.join(projectPath, 'public'), { recursive: true });
162
+ fs.mkdirSync(path.join(projectPath, 'config'), { recursive: true });
162
163
 
163
164
  // Create models directory if database is selected
164
165
  if (useDatabase && databaseType) {
@@ -177,7 +178,8 @@ function registerCommand(program) {
177
178
  },
178
179
  dependencies: {
179
180
  webspresso: '*',
180
- dotenv: '^16.3.1'
181
+ dotenv: '^16.3.1',
182
+ zod: '^3.23.0'
181
183
  }
182
184
  };
183
185
 
@@ -203,28 +205,108 @@ function registerCommand(program) {
203
205
  JSON.stringify(packageJson, null, 2) + '\n'
204
206
  );
205
207
 
206
- // Create server.js
207
- const serverJs = `require('dotenv').config();
208
- const { createApp } = require('webspresso');
208
+ // config/load-env.js — dotenv chain (last file wins for each key)
209
+ const loadEnvJs = `const fs = require('fs');
209
210
  const path = require('path');
211
+ const dotenv = require('dotenv');
212
+
213
+ /**
214
+ * Load env files in order: .env, .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
215
+ * @param {string} [rootDir] Project root (default: parent of config/)
216
+ */
217
+ function loadEnv(rootDir) {
218
+ const root = rootDir || path.resolve(__dirname, '..');
219
+ const loadFile = (name) => {
220
+ const full = path.join(root, name);
221
+ if (fs.existsSync(full)) {
222
+ dotenv.config({ path: full, override: true });
223
+ }
224
+ };
225
+ loadFile('.env');
226
+ loadFile('.env.local');
227
+ const mode = process.env.NODE_ENV || 'development';
228
+ loadFile(\`.env.\${mode}\`);
229
+ loadFile(\`.env.\${mode}.local\`);
230
+ }
231
+
232
+ module.exports = { loadEnv };
233
+ `;
210
234
 
211
- const { app } = createApp({
212
- pagesDir: path.join(__dirname, 'pages'),
213
- viewsDir: path.join(__dirname, 'views'),
214
- publicDir: path.join(__dirname, 'public')
235
+ const envSchemaJs = `const { z } = require('zod');
236
+
237
+ const envSchema = z.object({
238
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
239
+ PORT: z.coerce.number().int().positive().default(3000),
240
+ DEFAULT_LOCALE: z.string().min(1).default('en'),
241
+ SUPPORTED_LOCALES: z.string().min(1).default('en,de'),
242
+ BASE_URL: z.string().url().default('http://localhost:3000'),
243
+ DATABASE_URL: z.string().optional(),
215
244
  });
216
245
 
217
- const PORT = process.env.PORT || 3000;
246
+ let _parsed = null;
247
+
248
+ function parseEnv() {
249
+ if (_parsed) return _parsed;
250
+ const result = envSchema.safeParse(process.env);
251
+ if (!result.success) {
252
+ console.error('Invalid environment variables:');
253
+ console.error(result.error.format());
254
+ process.exit(1);
255
+ }
256
+ _parsed = result.data;
257
+ return _parsed;
258
+ }
259
+
260
+ module.exports = { envSchema, parseEnv };
261
+ `;
262
+
263
+ const appConfigJs = `const path = require('path');
264
+ const fs = require('fs');
265
+ const { parseEnv } = require('./env.schema');
266
+
267
+ function getCreateAppOptions() {
268
+ parseEnv();
269
+ const rootDir = path.resolve(__dirname, '..');
270
+ const options = {
271
+ pagesDir: path.join(rootDir, 'pages'),
272
+ viewsDir: path.join(rootDir, 'views'),
273
+ publicDir: path.join(rootDir, 'public'),
274
+ };
275
+ const dbFile = path.join(rootDir, 'webspresso.db.js');
276
+ if (fs.existsSync(dbFile)) {
277
+ const { createDatabase } = require('webspresso');
278
+ const knexConfig = require(dbFile);
279
+ options.db = createDatabase(knexConfig);
280
+ }
281
+ return options;
282
+ }
283
+
284
+ module.exports = getCreateAppOptions;
285
+ `;
286
+
287
+ const serverJs = `const { loadEnv } = require('./config/load-env');
288
+ loadEnv();
218
289
 
219
- app.listen(PORT, () => {
220
- console.log(\`🚀 Server running at http://localhost:\${PORT}\`);
290
+ const { createApp } = require('webspresso');
291
+ const getCreateAppOptions = require('./config/app');
292
+ const { parseEnv } = require('./config/env.schema');
293
+
294
+ const env = parseEnv();
295
+ const { app } = createApp(getCreateAppOptions());
296
+
297
+ app.listen(env.PORT, () => {
298
+ console.log(\`🚀 Server running at http://localhost:\${env.PORT}\`);
221
299
  });
222
300
  `;
223
-
301
+
302
+ fs.writeFileSync(path.join(projectPath, 'config', 'load-env.js'), loadEnvJs);
303
+ fs.writeFileSync(path.join(projectPath, 'config', 'env.schema.js'), envSchemaJs);
304
+ fs.writeFileSync(path.join(projectPath, 'config', 'app.js'), appConfigJs);
224
305
  fs.writeFileSync(path.join(projectPath, 'server.js'), serverJs);
225
306
 
226
- // Create .env.example
227
- let envExample = `PORT=3000
307
+ // Create .env.example (see config/load-env.js for merge order)
308
+ let envExample = `# Copy to .env and adjust. Optional overrides: .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
309
+ PORT=3000
228
310
  NODE_ENV=development
229
311
  DEFAULT_LOCALE=en
230
312
  SUPPORTED_LOCALES=en,de
@@ -308,10 +390,11 @@ BASE_URL=http://localhost:3000
308
390
  fs.writeFileSync(path.join(projectPath, 'seeds', 'index.js'), seedIndex);
309
391
  }
310
392
 
311
- // Create .gitignore
393
+ // Create .gitignore (.env optional: teams often commit a non-secret .env for local defaults)
312
394
  const gitignore = `node_modules/
313
395
  .env
314
396
  .env.local
397
+ .env.*.local
315
398
  .DS_Store
316
399
  coverage/
317
400
  *.log
@@ -482,10 +565,18 @@ Webspresso project
482
565
 
483
566
  \`\`\`bash
484
567
  npm install
568
+ cp .env.example .env
485
569
  npm run dev
486
570
  \`\`\`
487
571
 
488
572
  Visit http://localhost:3000
573
+
574
+ ## Configuration
575
+
576
+ - **\`config/load-env.js\`** — loads \`.env\`, \`.env.local\`, then \`.env.$NODE_ENV\` and \`.env.$NODE_ENV.local\` (later files override keys).
577
+ - **\`config/env.schema.js\`** — [Zod](https://zod.dev) schema for \`process.env\`; fails fast on invalid values.
578
+ - **\`config/app.js\`** — builds options passed to \`createApp()\` (paths; adds \`db\` when \`webspresso.db.js\` exists).
579
+ - **\`server.js\`** — calls \`loadEnv()\`, then \`createApp(getCreateAppOptions())\`.
489
580
  `;
490
581
 
491
582
  fs.writeFileSync(path.join(projectPath, 'README.md'), readme);
@@ -112,6 +112,12 @@ function generateColumnLine(columnName, meta) {
112
112
  parts.push(`table.text('${columnName}')`);
113
113
  break;
114
114
 
115
+ case 'file': {
116
+ const fileLen = meta.maxLength || 2048;
117
+ parts.push(`table.string('${columnName}', ${fileLen})`);
118
+ break;
119
+ }
120
+
115
121
  case 'float':
116
122
  parts.push(`table.float('${columnName}')`);
117
123
  break;
@@ -420,6 +420,25 @@ function createSchemaHelpers(z) {
420
420
  }, z);
421
421
  },
422
422
 
423
+ /**
424
+ * File / URL column (stored as varchar; value is public URL or path from upload)
425
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
426
+ * @returns {SchemaBuilder}
427
+ */
428
+ file(options = {}) {
429
+ const { maxLength = 2048, nullable = false, ...rest } = options;
430
+ let schema = z.string().max(maxLength);
431
+ if (nullable) {
432
+ schema = schema.nullable().optional();
433
+ }
434
+ return createSchemaBuilder(schema, {
435
+ type: 'file',
436
+ maxLength,
437
+ nullable,
438
+ ...rest,
439
+ }, z);
440
+ },
441
+
423
442
  /**
424
443
  * Integer column
425
444
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'file'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
package/index.d.ts CHANGED
@@ -531,3 +531,33 @@ export interface RestResourcePluginOptions {
531
531
  export function restResourcePlugin(options?: RestResourcePluginOptions): WebspressoPlugin;
532
532
 
533
533
  export function ormCacheAdminPlugin(options: { db: DatabaseInstance }): WebspressoPlugin;
534
+
535
+ /** Multipart upload storage (e.g. local disk or S3). */
536
+ export interface UploadStorageProvider {
537
+ put(args: {
538
+ buffer?: Buffer;
539
+ stream?: NodeJS.ReadableStream;
540
+ originalName: string;
541
+ mimeType: string;
542
+ size: number;
543
+ req: Request;
544
+ }): Promise<{ publicUrl: string; key?: string }>;
545
+ }
546
+
547
+ export interface UploadPluginOptions {
548
+ path?: string;
549
+ provider?: UploadStorageProvider;
550
+ local?: { destDir?: string; publicBasePath?: string };
551
+ maxBytes?: number;
552
+ mimeAllowlist?: string[] | null;
553
+ extensionAllowlist?: string[] | null;
554
+ middleware?: RequestHandler | RequestHandler[];
555
+ fieldName?: string;
556
+ }
557
+
558
+ export function uploadPlugin(options?: UploadPluginOptions): WebspressoPlugin;
559
+
560
+ export function createLocalFileProvider(options?: {
561
+ destDir?: string;
562
+ publicBasePath?: string;
563
+ }): UploadStorageProvider;
package/index.js CHANGED
@@ -40,7 +40,19 @@ const {
40
40
  const orm = require('./core/orm');
41
41
 
42
42
  // Built-in plugins
43
- const { schemaExplorerPlugin, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin, swaggerPlugin, healthCheckPlugin, restResourcePlugin, ormCacheAdminPlugin } = require('./plugins');
43
+ const {
44
+ schemaExplorerPlugin,
45
+ adminPanelPlugin,
46
+ siteAnalyticsPlugin,
47
+ auditLogPlugin,
48
+ recaptchaPlugin,
49
+ swaggerPlugin,
50
+ healthCheckPlugin,
51
+ restResourcePlugin,
52
+ ormCacheAdminPlugin,
53
+ uploadPlugin,
54
+ createLocalFileProvider,
55
+ } = require('./plugins');
44
56
 
45
57
  module.exports = {
46
58
  // Main API
@@ -95,4 +107,6 @@ module.exports = {
95
107
  healthCheckPlugin,
96
108
  restResourcePlugin,
97
109
  ormCacheAdminPlugin,
110
+ uploadPlugin,
111
+ createLocalFileProvider,
98
112
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -59,6 +59,7 @@
59
59
  "helmet": "^7.2.0",
60
60
  "inquirer": "^8.2.6",
61
61
  "knex": "^3.1.0",
62
+ "multer": "^2.1.1",
62
63
  "nunjucks": "^3.2.4",
63
64
  "sharp": "^0.33.5",
64
65
  "swup": "^4.8.3",
@@ -107,6 +108,7 @@
107
108
  "node": ">=18.0.0"
108
109
  },
109
110
  "overrides": {
111
+ "basic-ftp": "^5.3.0",
110
112
  "tar": "^7.5.11",
111
113
  "undici": "^6.24.0"
112
114
  }
@@ -308,8 +308,8 @@ function createApiHandlers(options) {
308
308
  } else if (value !== undefined && value !== null && value !== '') {
309
309
  switch (op) {
310
310
  case 'contains':
311
- // Apply LIKE for string/text types, or if type is unknown
312
- if (colType === 'string' || colType === 'text' || !colMeta) {
311
+ // Apply LIKE for string/text/file types, or if type is unknown
312
+ if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
313
313
  query = query.where(colName, 'like', `%${value}%`);
314
314
  countQuery = countQuery.where(colName, 'like', `%${value}%`);
315
315
  }
@@ -319,13 +319,13 @@ function createApiHandlers(options) {
319
319
  countQuery = countQuery.where(colName, '=', value);
320
320
  break;
321
321
  case 'starts_with':
322
- if (colType === 'string' || colType === 'text' || !colMeta) {
322
+ if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
323
323
  query = query.where(colName, 'like', `${value}%`);
324
324
  countQuery = countQuery.where(colName, 'like', `${value}%`);
325
325
  }
326
326
  break;
327
327
  case 'ends_with':
328
- if (colType === 'string' || colType === 'text' || !colMeta) {
328
+ if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
329
329
  query = query.where(colName, 'like', `%${value}`);
330
330
  countQuery = countQuery.where(colName, 'like', `%${value}`);
331
331
  }
@@ -359,7 +359,7 @@ function createApiHandlers(options) {
359
359
  if (req.query.search) {
360
360
  const searchTerm = `%${req.query.search}%`;
361
361
  const stringColumns = Array.from(model.columns.entries())
362
- .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
362
+ .filter(([_, meta]) => meta.type === 'string' || meta.type === 'text' || meta.type === 'file')
363
363
  .map(([name]) => name);
364
364
 
365
365
  if (stringColumns.length > 0) {
@@ -495,6 +495,7 @@ const FilterDrawer = {
495
495
  if (col.primary || col.autoIncrement) return false;
496
496
  if (col.auto === 'create' || col.auto === 'update') return false;
497
497
  if (col.type === 'json') return false;
498
+ if (col.type === 'file') return false;
498
499
  return true;
499
500
  });
500
501
 
@@ -1049,6 +1050,155 @@ const RichTextField = {
1049
1050
  }
1050
1051
  };
1051
1052
 
1053
+ // File upload field (multipart POST to settings.uploadUrl; field name "file")
1054
+ const FileUploadField = {
1055
+ oncreate: (vnode) => {
1056
+ const col = vnode.attrs.col;
1057
+ const readonly = vnode.attrs.readonly;
1058
+ if (readonly) return;
1059
+ var cfg = window.__ADMIN_CONFIG__;
1060
+ var uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1061
+ if (!uploadUrl) return;
1062
+ const dropZoneId = 'drop-zone-' + col.name;
1063
+ const dropZone = document.getElementById(dropZoneId);
1064
+ if (!dropZone) return;
1065
+ const meta = col.ui || {};
1066
+ const onChange = vnode.attrs.onChange;
1067
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) {
1068
+ dropZone.addEventListener(eventName, function (e) {
1069
+ e.preventDefault();
1070
+ e.stopPropagation();
1071
+ });
1072
+ });
1073
+ ['dragenter', 'dragover'].forEach(function (eventName) {
1074
+ dropZone.addEventListener(eventName, function () {
1075
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1076
+ });
1077
+ });
1078
+ ['dragleave', 'drop'].forEach(function (eventName) {
1079
+ dropZone.addEventListener(eventName, function () {
1080
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
1081
+ });
1082
+ });
1083
+ dropZone.addEventListener('drop', function (e) {
1084
+ var files = e.dataTransfer.files;
1085
+ if (files.length > 0) {
1086
+ handleAdminFileUpload(files[0], onChange, meta);
1087
+ }
1088
+ });
1089
+ var fileInput = dropZone.querySelector('input[type=file]');
1090
+ if (fileInput) {
1091
+ fileInput.addEventListener('change', function (e) {
1092
+ if (e.target.files.length > 0) {
1093
+ handleAdminFileUpload(e.target.files[0], onChange, meta);
1094
+ }
1095
+ });
1096
+ }
1097
+ },
1098
+ view: (vnode) => {
1099
+ const col = vnode.attrs.col;
1100
+ const value = vnode.attrs.value || '';
1101
+ const onChange = vnode.attrs.onChange;
1102
+ const readonly = vnode.attrs.readonly || false;
1103
+ const meta = col.ui || {};
1104
+ const label = meta.label || formatColumnLabel(col.name);
1105
+ const hint = meta.hint || '';
1106
+ const required = !col.nullable && !readonly;
1107
+ var uploadUrl = '';
1108
+ try {
1109
+ var cfg2 = window.__ADMIN_CONFIG__;
1110
+ uploadUrl = (cfg2 && cfg2.settings && cfg2.settings.uploadUrl) ? String(cfg2.settings.uploadUrl) : '';
1111
+ } catch (e) { uploadUrl = ''; }
1112
+ var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1113
+ var accept = meta.accept || '*/*';
1114
+ var dropZoneId = 'drop-zone-' + col.name;
1115
+ if (readonly) {
1116
+ return m('.mb-4', [
1117
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1118
+ value ? m('a.text-indigo-600.break-all', { href: value, target: '_blank', rel: 'noopener noreferrer' }, value) : m('span.text-gray-400', '—'),
1119
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1120
+ ]);
1121
+ }
1122
+ if (!uploadUrl) {
1123
+ return m('.mb-4', [
1124
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', { for: col.name }, label, required ? m('span.text-red-500', ' *') : null),
1125
+ m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured. Enter a public URL or path manually.'),
1126
+ m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded.bg-white.dark:bg-slate-800', {
1127
+ type: 'text',
1128
+ id: col.name,
1129
+ name: col.name,
1130
+ value: value,
1131
+ placeholder: 'https://… or /uploads/…',
1132
+ required: required,
1133
+ oninput: function (e) { if (onChange) onChange(e.target.value); },
1134
+ }),
1135
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1136
+ ]);
1137
+ }
1138
+ return m('.mb-4', [
1139
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', label, required ? m('span.text-red-500', ' *') : null),
1140
+ m('div#' + dropZoneId + '.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-8.text-center', { style: 'cursor: pointer;' }, [
1141
+ m('input[type=file].hidden', {
1142
+ id: 'file-input-' + col.name,
1143
+ accept: accept,
1144
+ onchange: function (e) {
1145
+ if (e.target.files.length > 0) {
1146
+ handleAdminFileUpload(e.target.files[0], onChange, meta);
1147
+ }
1148
+ },
1149
+ }),
1150
+ m('div', [
1151
+ m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
1152
+ m('label.text-blue-600.hover:text-blue-800.dark:text-blue-400.cursor-pointer', { for: 'file-input-' + col.name }, 'browse'),
1153
+ ]),
1154
+ value ? m('.mt-4.text-left', [
1155
+ m('p.text-sm.text-gray-600.dark:text-slate-400.break-all', 'Current: ' + value),
1156
+ m('button.text-red-600.hover:text-red-800.text-sm.mt-2', {
1157
+ type: 'button',
1158
+ onclick: function () { if (onChange) onChange(''); },
1159
+ }, 'Remove'),
1160
+ ]) : null,
1161
+ ]),
1162
+ m('input[type=hidden]', { name: col.name, value: typeof value === 'string' ? value : '' }),
1163
+ m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'),
1164
+ hint ? m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', hint) : null,
1165
+ ]);
1166
+ },
1167
+ };
1168
+
1169
+ async function handleAdminFileUpload(file, onChange, meta) {
1170
+ var uploadUrl = '';
1171
+ try {
1172
+ var cfg = window.__ADMIN_CONFIG__;
1173
+ uploadUrl = (cfg && cfg.settings && cfg.settings.uploadUrl) ? String(cfg.settings.uploadUrl) : '';
1174
+ } catch (e) {}
1175
+ if (!uploadUrl) {
1176
+ alert('Upload URL is not configured.');
1177
+ return;
1178
+ }
1179
+ var maxSize = meta.maxSize || meta.maxBytes || (10 * 1024 * 1024);
1180
+ if (file.size > maxSize) {
1181
+ alert('File too large (max ' + Math.round(maxSize / 1024 / 1024) + ' MB).');
1182
+ return;
1183
+ }
1184
+ var fd = new FormData();
1185
+ fd.append('file', file);
1186
+ try {
1187
+ var res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
1188
+ var data = {};
1189
+ try { data = await res.json(); } catch (e2) { data = {}; }
1190
+ if (!res.ok) {
1191
+ alert(data.message || data.error || ('Upload failed (' + res.status + ')'));
1192
+ return;
1193
+ }
1194
+ var url = data.url || data.publicUrl || '';
1195
+ if (onChange) onChange(url);
1196
+ m.redraw();
1197
+ } catch (err) {
1198
+ alert(err.message || 'Upload failed');
1199
+ }
1200
+ }
1201
+
1052
1202
  // Get appropriate renderer for a column type
1053
1203
  function getFieldRenderer(col, modelMeta) {
1054
1204
  // Check for custom field first
@@ -1064,8 +1214,29 @@ function getFieldRenderer(col, modelMeta) {
1064
1214
  });
1065
1215
  };
1066
1216
  }
1217
+ if (col.customField.type === 'file-upload') {
1218
+ return (col, value, onChange, readonly) => {
1219
+ return m(FileUploadField, {
1220
+ col,
1221
+ value: value || '',
1222
+ onChange,
1223
+ readonly: readonly || false,
1224
+ });
1225
+ };
1226
+ }
1067
1227
  // Add other custom field types here if needed
1068
1228
  }
1229
+
1230
+ if (col.type === 'file') {
1231
+ return (col, value, onChange, readonly) => {
1232
+ return m(FileUploadField, {
1233
+ col,
1234
+ value: value || '',
1235
+ onChange,
1236
+ readonly: readonly || false,
1237
+ });
1238
+ };
1239
+ }
1069
1240
 
1070
1241
  // Fallback to standard type renderers
1071
1242
  const typeMap = {
@@ -1316,6 +1487,19 @@ function formatCellValue(value, col) {
1316
1487
  case 'text':
1317
1488
  const textStr = String(value);
1318
1489
  return textStr.length > 50 ? textStr.substring(0, 50) + '...' : textStr;
1490
+
1491
+ case 'file': {
1492
+ const s = String(value);
1493
+ const short = s.length > 72 ? s.substring(0, 72) + '…' : s;
1494
+ if (/^https?:\/\//.test(s) || s.startsWith('/')) {
1495
+ return m('a.text-indigo-600.dark:text-indigo-400.hover:underline.break-all', {
1496
+ href: s,
1497
+ target: '_blank',
1498
+ rel: 'noopener noreferrer',
1499
+ }, short);
1500
+ }
1501
+ return short || m('span.text-gray-400', '—');
1502
+ }
1319
1503
 
1320
1504
  default:
1321
1505
  const str = String(value);
@@ -67,6 +67,7 @@ class AdminRegistry {
67
67
  perPage: 20,
68
68
  dateFormat: 'YYYY-MM-DD',
69
69
  timeFormat: 'HH:mm',
70
+ uploadUrl: null,
70
71
  };
71
72
 
72
73
  // User management config