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 +50 -0
- package/bin/commands/doctor.js +23 -0
- package/bin/commands/new.js +106 -15
- package/core/orm/migrations/scaffold.js +6 -0
- package/core/orm/schema-helpers.js +19 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +30 -0
- package/index.js +15 -1
- package/package.json +3 -1
- package/plugins/admin-panel/api.js +5 -5
- package/plugins/admin-panel/components.js +184 -0
- package/plugins/admin-panel/core/registry.js +1 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +135 -66
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +8 -0
- package/plugins/index.js +3 -0
- package/plugins/upload/index.js +188 -0
- package/plugins/upload/local-file-provider.js +122 -0
- package/templates/skills/webspresso-usage/SKILL.md +54 -22
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.
|
package/bin/commands/doctor.js
CHANGED
|
@@ -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
|
|
package/bin/commands/new.js
CHANGED
|
@@ -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
|
-
//
|
|
207
|
-
const
|
|
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 {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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 =
|
|
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 {
|
|
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.
|
|
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);
|