webspresso 0.0.36 → 0.0.38
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/bin/commands/admin-password.js +251 -0
- package/bin/webspresso.js +2 -0
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/components.js +334 -25
- package/plugins/admin-panel/core/api-extensions.js +151 -8
- package/plugins/admin-panel/index.js +5 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Password Command
|
|
3
|
+
* Reset admin user password via CLI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
|
|
10
|
+
function registerCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('admin:password')
|
|
13
|
+
.description('Reset admin user password')
|
|
14
|
+
.option('-e, --email <email>', 'Admin user email')
|
|
15
|
+
.option('-p, --password <password>', 'New password (not recommended, use interactive mode)')
|
|
16
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
try {
|
|
19
|
+
// Find project root and load database
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
|
|
22
|
+
// Try to find and load the database config
|
|
23
|
+
let dbConfig = null;
|
|
24
|
+
const configPaths = [
|
|
25
|
+
options.config,
|
|
26
|
+
path.join(cwd, 'webspresso.config.js'),
|
|
27
|
+
path.join(cwd, 'database.config.js'),
|
|
28
|
+
path.join(cwd, 'db.config.js'),
|
|
29
|
+
].filter(Boolean);
|
|
30
|
+
|
|
31
|
+
for (const configPath of configPaths) {
|
|
32
|
+
if (fs.existsSync(configPath)) {
|
|
33
|
+
const config = require(configPath);
|
|
34
|
+
dbConfig = config.database || config;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!dbConfig) {
|
|
40
|
+
console.error('❌ Error: Could not find database configuration.');
|
|
41
|
+
console.error(' Please run this command from your project root.');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Lazy load dependencies
|
|
46
|
+
const bcrypt = require('bcrypt');
|
|
47
|
+
const knex = require('knex');
|
|
48
|
+
|
|
49
|
+
// Initialize database connection
|
|
50
|
+
const db = knex(dbConfig);
|
|
51
|
+
|
|
52
|
+
// Check if admin_users table exists
|
|
53
|
+
const hasTable = await db.schema.hasTable('admin_users');
|
|
54
|
+
if (!hasTable) {
|
|
55
|
+
console.error('❌ Error: admin_users table does not exist.');
|
|
56
|
+
console.error(' Run "webspresso admin:setup" and "webspresso db:migrate" first.');
|
|
57
|
+
await db.destroy();
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Get email (interactive if not provided)
|
|
62
|
+
let email = options.email;
|
|
63
|
+
if (!email) {
|
|
64
|
+
const rl = readline.createInterface({
|
|
65
|
+
input: process.stdin,
|
|
66
|
+
output: process.stdout,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
email = await new Promise((resolve) => {
|
|
70
|
+
rl.question('Enter admin email: ', (answer) => {
|
|
71
|
+
rl.close();
|
|
72
|
+
resolve(answer.trim());
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!email) {
|
|
78
|
+
console.error('❌ Error: Email is required.');
|
|
79
|
+
await db.destroy();
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if user exists
|
|
84
|
+
const user = await db('admin_users').where({ email }).first();
|
|
85
|
+
if (!user) {
|
|
86
|
+
console.error(`❌ Error: Admin user with email "${email}" not found.`);
|
|
87
|
+
|
|
88
|
+
// Show available users
|
|
89
|
+
const users = await db('admin_users').select('id', 'email', 'name');
|
|
90
|
+
if (users.length > 0) {
|
|
91
|
+
console.log('\nAvailable admin users:');
|
|
92
|
+
users.forEach(u => console.log(` - ${u.email} (${u.name || 'No name'})`));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await db.destroy();
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get new password (interactive if not provided)
|
|
100
|
+
let password = options.password;
|
|
101
|
+
if (!password) {
|
|
102
|
+
const rl = readline.createInterface({
|
|
103
|
+
input: process.stdin,
|
|
104
|
+
output: process.stdout,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Disable echo for password input
|
|
108
|
+
if (process.stdin.isTTY) {
|
|
109
|
+
process.stdout.write('Enter new password: ');
|
|
110
|
+
password = await new Promise((resolve) => {
|
|
111
|
+
let pwd = '';
|
|
112
|
+
process.stdin.setRawMode(true);
|
|
113
|
+
process.stdin.resume();
|
|
114
|
+
process.stdin.on('data', (char) => {
|
|
115
|
+
char = char.toString();
|
|
116
|
+
if (char === '\n' || char === '\r') {
|
|
117
|
+
process.stdin.setRawMode(false);
|
|
118
|
+
process.stdin.pause();
|
|
119
|
+
console.log(); // New line after password
|
|
120
|
+
resolve(pwd);
|
|
121
|
+
} else if (char === '\u0003') {
|
|
122
|
+
// Ctrl+C
|
|
123
|
+
process.exit();
|
|
124
|
+
} else if (char === '\u007F') {
|
|
125
|
+
// Backspace
|
|
126
|
+
if (pwd.length > 0) {
|
|
127
|
+
pwd = pwd.slice(0, -1);
|
|
128
|
+
process.stdout.write('\b \b');
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
pwd += char;
|
|
132
|
+
process.stdout.write('*');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
rl.close();
|
|
137
|
+
} else {
|
|
138
|
+
password = await new Promise((resolve) => {
|
|
139
|
+
rl.question('Enter new password: ', (answer) => {
|
|
140
|
+
rl.close();
|
|
141
|
+
resolve(answer);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!password || password.length < 6) {
|
|
148
|
+
console.error('❌ Error: Password must be at least 6 characters.');
|
|
149
|
+
await db.destroy();
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Hash the password
|
|
154
|
+
const hashedPassword = await bcrypt.hash(password, 10);
|
|
155
|
+
|
|
156
|
+
// Update the password
|
|
157
|
+
await db('admin_users')
|
|
158
|
+
.where({ email })
|
|
159
|
+
.update({
|
|
160
|
+
password: hashedPassword,
|
|
161
|
+
updated_at: new Date(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
console.log(`\n✅ Password updated successfully for: ${email}\n`);
|
|
165
|
+
|
|
166
|
+
await db.destroy();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error('❌ Error:', err.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Also add a command to list admin users
|
|
174
|
+
program
|
|
175
|
+
.command('admin:list')
|
|
176
|
+
.description('List all admin users')
|
|
177
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
178
|
+
.action(async (options) => {
|
|
179
|
+
try {
|
|
180
|
+
const cwd = process.cwd();
|
|
181
|
+
|
|
182
|
+
// Try to find and load the database config
|
|
183
|
+
let dbConfig = null;
|
|
184
|
+
const configPaths = [
|
|
185
|
+
options.config,
|
|
186
|
+
path.join(cwd, 'webspresso.config.js'),
|
|
187
|
+
path.join(cwd, 'database.config.js'),
|
|
188
|
+
path.join(cwd, 'db.config.js'),
|
|
189
|
+
].filter(Boolean);
|
|
190
|
+
|
|
191
|
+
for (const configPath of configPaths) {
|
|
192
|
+
if (fs.existsSync(configPath)) {
|
|
193
|
+
const config = require(configPath);
|
|
194
|
+
dbConfig = config.database || config;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!dbConfig) {
|
|
200
|
+
console.error('❌ Error: Could not find database configuration.');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const knex = require('knex');
|
|
205
|
+
const db = knex(dbConfig);
|
|
206
|
+
|
|
207
|
+
// Check if admin_users table exists
|
|
208
|
+
const hasTable = await db.schema.hasTable('admin_users');
|
|
209
|
+
if (!hasTable) {
|
|
210
|
+
console.log('ℹ️ admin_users table does not exist yet.');
|
|
211
|
+
console.log(' Run "webspresso admin:setup" and "webspresso db:migrate" first.');
|
|
212
|
+
await db.destroy();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Get all admin users
|
|
217
|
+
const users = await db('admin_users')
|
|
218
|
+
.select('id', 'email', 'name', 'role', 'active', 'created_at')
|
|
219
|
+
.orderBy('id');
|
|
220
|
+
|
|
221
|
+
if (users.length === 0) {
|
|
222
|
+
console.log('\nNo admin users found.\n');
|
|
223
|
+
console.log('Create the first admin user via the admin panel setup page.');
|
|
224
|
+
} else {
|
|
225
|
+
console.log(`\n📋 Admin Users (${users.length}):\n`);
|
|
226
|
+
console.log(' ID | Email | Name | Role | Active | Created');
|
|
227
|
+
console.log(' ' + '-'.repeat(90));
|
|
228
|
+
|
|
229
|
+
users.forEach(user => {
|
|
230
|
+
const email = (user.email || '').padEnd(30).slice(0, 30);
|
|
231
|
+
const name = (user.name || '-').padEnd(14).slice(0, 14);
|
|
232
|
+
const role = (user.role || 'admin').padEnd(7).slice(0, 7);
|
|
233
|
+
const active = user.active ? ' ✓ ' : ' ✗ ';
|
|
234
|
+
const created = user.created_at
|
|
235
|
+
? new Date(user.created_at).toLocaleDateString()
|
|
236
|
+
: '-';
|
|
237
|
+
|
|
238
|
+
console.log(` ${String(user.id).padStart(3)} | ${email} | ${name} | ${role} | ${active} | ${created}`);
|
|
239
|
+
});
|
|
240
|
+
console.log();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await db.destroy();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('❌ Error:', err.message);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { registerCommand };
|
package/bin/webspresso.js
CHANGED
|
@@ -25,6 +25,7 @@ const { registerCommand: registerDbStatus } = require('./commands/db-status');
|
|
|
25
25
|
const { registerCommand: registerDbMake } = require('./commands/db-make');
|
|
26
26
|
const { registerCommand: registerSeed } = require('./commands/seed');
|
|
27
27
|
const { registerCommand: registerAdminSetup } = require('./commands/admin-setup');
|
|
28
|
+
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
28
29
|
|
|
29
30
|
registerNew(program);
|
|
30
31
|
registerPage(program);
|
|
@@ -38,6 +39,7 @@ registerDbStatus(program);
|
|
|
38
39
|
registerDbMake(program);
|
|
39
40
|
registerSeed(program);
|
|
40
41
|
registerAdminSetup(program);
|
|
42
|
+
registerAdminPassword(program);
|
|
41
43
|
|
|
42
44
|
// Parse arguments
|
|
43
45
|
program.parse();
|
package/package.json
CHANGED
|
@@ -75,6 +75,16 @@ m.route(document.getElementById('app'), '/', {
|
|
|
75
75
|
return SetupForm;
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
|
+
'/settings': {
|
|
79
|
+
onmatch: async () => {
|
|
80
|
+
const isAuth = await checkAuth();
|
|
81
|
+
if (!isAuth) {
|
|
82
|
+
m.route.set('/login');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
return SettingsPage;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
78
88
|
'/models/:model': {
|
|
79
89
|
onmatch: async () => {
|
|
80
90
|
const isAuth = await checkAuth();
|
|
@@ -68,6 +68,9 @@ const state = {
|
|
|
68
68
|
filters: {}, // Active filters { column: { op, value, from, to } }
|
|
69
69
|
filterPanelOpen: false, // Filter panel visibility (deprecated)
|
|
70
70
|
filterDrawerOpen: false, // Filter drawer visibility
|
|
71
|
+
bulkFields: [], // Bulk-updatable fields (enum/boolean)
|
|
72
|
+
bulkFieldDropdownOpen: false, // Bulk field dropdown visibility
|
|
73
|
+
selectedBulkField: null, // Currently selected bulk field for update
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
// Breadcrumb Component
|
|
@@ -1303,6 +1306,152 @@ function formatCellValue(value, col) {
|
|
|
1303
1306
|
}
|
|
1304
1307
|
}
|
|
1305
1308
|
|
|
1309
|
+
// Load bulk-updatable fields for a model
|
|
1310
|
+
async function loadBulkFields(modelName) {
|
|
1311
|
+
try {
|
|
1312
|
+
const response = await api.get('/extensions/bulk-fields/' + modelName);
|
|
1313
|
+
state.bulkFields = response.fields || [];
|
|
1314
|
+
m.redraw();
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
console.error('Failed to load bulk fields:', err);
|
|
1317
|
+
state.bulkFields = [];
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Execute bulk field update
|
|
1322
|
+
async function executeBulkFieldUpdate(modelName, field, value, ids) {
|
|
1323
|
+
try {
|
|
1324
|
+
const response = await api.post('/extensions/bulk-update/' + modelName, {
|
|
1325
|
+
ids: ids,
|
|
1326
|
+
field: field,
|
|
1327
|
+
value: value,
|
|
1328
|
+
});
|
|
1329
|
+
return response;
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
throw err;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Bulk Field Update Dropdown Component
|
|
1336
|
+
const BulkFieldUpdateDropdown = {
|
|
1337
|
+
view: (vnode) => {
|
|
1338
|
+
const { modelName, selectedIds, onComplete } = vnode.attrs;
|
|
1339
|
+
|
|
1340
|
+
if (!state.bulkFields || state.bulkFields.length === 0) {
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
return m('.relative.inline-block', [
|
|
1345
|
+
// Dropdown trigger
|
|
1346
|
+
m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-purple-600.bg-white.border.border-purple-200.rounded.hover:bg-purple-50.transition-colors', {
|
|
1347
|
+
disabled: state.bulkActionInProgress,
|
|
1348
|
+
onclick: (e) => {
|
|
1349
|
+
e.stopPropagation();
|
|
1350
|
+
state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
|
|
1351
|
+
state.selectedBulkField = null;
|
|
1352
|
+
m.redraw();
|
|
1353
|
+
},
|
|
1354
|
+
}, [
|
|
1355
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1356
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' })
|
|
1357
|
+
),
|
|
1358
|
+
'Set Field',
|
|
1359
|
+
m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1360
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
|
|
1361
|
+
),
|
|
1362
|
+
]),
|
|
1363
|
+
|
|
1364
|
+
// Dropdown menu
|
|
1365
|
+
state.bulkFieldDropdownOpen && m('.absolute.z-50.mt-1.w-64.bg-white.rounded-lg.shadow-lg.border.border-gray-200.overflow-hidden', {
|
|
1366
|
+
style: 'left: 0; top: 100%;',
|
|
1367
|
+
onclick: (e) => e.stopPropagation(),
|
|
1368
|
+
}, [
|
|
1369
|
+
// Close button area click handler
|
|
1370
|
+
m('.fixed.inset-0.z-40', {
|
|
1371
|
+
onclick: () => {
|
|
1372
|
+
state.bulkFieldDropdownOpen = false;
|
|
1373
|
+
state.selectedBulkField = null;
|
|
1374
|
+
m.redraw();
|
|
1375
|
+
},
|
|
1376
|
+
}),
|
|
1377
|
+
|
|
1378
|
+
// Dropdown content
|
|
1379
|
+
m('.relative.z-50.bg-white', [
|
|
1380
|
+
// Header
|
|
1381
|
+
m('.px-3.py-2.bg-gray-50.border-b.border-gray-200', [
|
|
1382
|
+
m('span.text-xs.font-medium.text-gray-500.uppercase.tracking-wider',
|
|
1383
|
+
state.selectedBulkField ? 'Select Value' : 'Select Field'
|
|
1384
|
+
),
|
|
1385
|
+
]),
|
|
1386
|
+
|
|
1387
|
+
// Field list or value list
|
|
1388
|
+
m('.max-h-64.overflow-y-auto', [
|
|
1389
|
+
state.selectedBulkField
|
|
1390
|
+
// Show values for selected field
|
|
1391
|
+
? state.selectedBulkField.options.map(option =>
|
|
1392
|
+
m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
|
|
1393
|
+
onclick: async () => {
|
|
1394
|
+
state.bulkActionInProgress = true;
|
|
1395
|
+
state.bulkFieldDropdownOpen = false;
|
|
1396
|
+
m.redraw();
|
|
1397
|
+
|
|
1398
|
+
try {
|
|
1399
|
+
await executeBulkFieldUpdate(modelName, state.selectedBulkField.name, option.value, selectedIds);
|
|
1400
|
+
state.selectedBulkField = null;
|
|
1401
|
+
if (onComplete) onComplete();
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
alert('Error: ' + err.message);
|
|
1404
|
+
} finally {
|
|
1405
|
+
state.bulkActionInProgress = false;
|
|
1406
|
+
m.redraw();
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
}, [
|
|
1410
|
+
m('span.text-gray-700', String(option.label)),
|
|
1411
|
+
state.selectedBulkField.type === 'boolean' && m('span.ml-2',
|
|
1412
|
+
option.value === true
|
|
1413
|
+
? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
|
|
1414
|
+
: m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-600', '✗')
|
|
1415
|
+
),
|
|
1416
|
+
])
|
|
1417
|
+
)
|
|
1418
|
+
// Show field list
|
|
1419
|
+
: state.bulkFields.map(field =>
|
|
1420
|
+
m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
|
|
1421
|
+
onclick: () => {
|
|
1422
|
+
state.selectedBulkField = field;
|
|
1423
|
+
m.redraw();
|
|
1424
|
+
},
|
|
1425
|
+
}, [
|
|
1426
|
+
m('.flex.items-center.gap-2', [
|
|
1427
|
+
m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
|
|
1428
|
+
m('span.text-xs.text-gray-400.uppercase', field.type),
|
|
1429
|
+
]),
|
|
1430
|
+
m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1431
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
|
|
1432
|
+
),
|
|
1433
|
+
])
|
|
1434
|
+
),
|
|
1435
|
+
|
|
1436
|
+
// Back button when viewing values
|
|
1437
|
+
state.selectedBulkField && m('button.w-full.px-3.py-2.text-left.text-sm.text-gray-500.hover:bg-gray-50.border-t.border-gray-100.flex.items-center.gap-1', {
|
|
1438
|
+
onclick: () => {
|
|
1439
|
+
state.selectedBulkField = null;
|
|
1440
|
+
m.redraw();
|
|
1441
|
+
},
|
|
1442
|
+
}, [
|
|
1443
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1444
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
|
|
1445
|
+
),
|
|
1446
|
+
'Back to fields',
|
|
1447
|
+
]),
|
|
1448
|
+
]),
|
|
1449
|
+
]),
|
|
1450
|
+
]),
|
|
1451
|
+
]);
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1306
1455
|
// Get columns to display in table (limit to reasonable number)
|
|
1307
1456
|
function getDisplayColumns(columns) {
|
|
1308
1457
|
if (!columns || columns.length === 0) return [];
|
|
@@ -1454,34 +1603,55 @@ function loadRecords(modelName, page = 1, filters = null) {
|
|
|
1454
1603
|
});
|
|
1455
1604
|
}
|
|
1456
1605
|
|
|
1606
|
+
// Initialize model data
|
|
1607
|
+
function initializeModelView(modelName) {
|
|
1608
|
+
state.records = [];
|
|
1609
|
+
state.currentModelMeta = null;
|
|
1610
|
+
state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
|
|
1611
|
+
state.filterPanelOpen = false;
|
|
1612
|
+
state.filterDrawerOpen = false;
|
|
1613
|
+
state.selectedRecords = new Set(); // Bulk selection
|
|
1614
|
+
state.bulkActionInProgress = false;
|
|
1615
|
+
state.bulkFields = []; // Reset bulk fields
|
|
1616
|
+
state.bulkFieldDropdownOpen = false;
|
|
1617
|
+
state.selectedBulkField = null;
|
|
1618
|
+
state._currentModelName = modelName;
|
|
1619
|
+
|
|
1620
|
+
// Parse filters from URL query string
|
|
1621
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1622
|
+
const page = parseInt(urlParams.get('page')) || 1;
|
|
1623
|
+
state.filters = parseFilterQuery(window.location.search);
|
|
1624
|
+
|
|
1625
|
+
// Load model metadata first, then records
|
|
1626
|
+
state.loading = true;
|
|
1627
|
+
api.get('/models/' + modelName)
|
|
1628
|
+
.then(modelMeta => {
|
|
1629
|
+
state.currentModelMeta = modelMeta;
|
|
1630
|
+
state.currentModel = modelMeta;
|
|
1631
|
+
// Load bulk-updatable fields for this model
|
|
1632
|
+
loadBulkFields(modelName);
|
|
1633
|
+
return loadRecords(modelName, page, state.filters);
|
|
1634
|
+
})
|
|
1635
|
+
.catch(err => {
|
|
1636
|
+
state.error = err.message;
|
|
1637
|
+
state.loading = false;
|
|
1638
|
+
m.redraw();
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1457
1642
|
// Record List Component - displays records with dynamic columns
|
|
1458
1643
|
const RecordList = {
|
|
1459
1644
|
oninit: () => {
|
|
1460
1645
|
const modelName = m.route.param('model');
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
state.filters = parseFilterQuery(window.location.search);
|
|
1471
|
-
|
|
1472
|
-
// Load model metadata first, then records
|
|
1473
|
-
state.loading = true;
|
|
1474
|
-
api.get('/models/' + modelName)
|
|
1475
|
-
.then(modelMeta => {
|
|
1476
|
-
state.currentModelMeta = modelMeta;
|
|
1477
|
-
state.currentModel = modelMeta;
|
|
1478
|
-
return loadRecords(modelName, page, state.filters);
|
|
1479
|
-
})
|
|
1480
|
-
.catch(err => {
|
|
1481
|
-
state.error = err.message;
|
|
1482
|
-
state.loading = false;
|
|
1483
|
-
m.redraw();
|
|
1484
|
-
});
|
|
1646
|
+
initializeModelView(modelName);
|
|
1647
|
+
},
|
|
1648
|
+
onbeforeupdate: () => {
|
|
1649
|
+
// Check if model changed (navigation between different models)
|
|
1650
|
+
const modelName = m.route.param('model');
|
|
1651
|
+
if (state._currentModelName !== modelName) {
|
|
1652
|
+
initializeModelView(modelName);
|
|
1653
|
+
}
|
|
1654
|
+
return true;
|
|
1485
1655
|
},
|
|
1486
1656
|
view: () => {
|
|
1487
1657
|
const modelName = m.route.param('model');
|
|
@@ -1601,12 +1771,134 @@ const RecordList = {
|
|
|
1601
1771
|
m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
|
|
1602
1772
|
])
|
|
1603
1773
|
: m('.bg-white.rounded-lg.shadow-sm.border.border-gray-200.overflow-hidden', [
|
|
1774
|
+
// Bulk Actions Toolbar (shown when items selected)
|
|
1775
|
+
state.selectedRecords && state.selectedRecords.size > 0 && m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
|
|
1776
|
+
m('span.text-sm.text-indigo-700.font-medium',
|
|
1777
|
+
state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
|
|
1778
|
+
),
|
|
1779
|
+
m('.flex.items-center.gap-2', [
|
|
1780
|
+
m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
|
|
1781
|
+
disabled: state.bulkActionInProgress,
|
|
1782
|
+
onclick: async () => {
|
|
1783
|
+
if (!confirm('Are you sure you want to delete ' + state.selectedRecords.size + ' records? This action cannot be undone.')) return;
|
|
1784
|
+
state.bulkActionInProgress = true;
|
|
1785
|
+
m.redraw();
|
|
1786
|
+
try {
|
|
1787
|
+
const ids = Array.from(state.selectedRecords);
|
|
1788
|
+
await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, { ids });
|
|
1789
|
+
state.selectedRecords = new Set();
|
|
1790
|
+
loadRecords(modelName, state.pagination.page);
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
alert('Error: ' + err.message);
|
|
1793
|
+
} finally {
|
|
1794
|
+
state.bulkActionInProgress = false;
|
|
1795
|
+
m.redraw();
|
|
1796
|
+
}
|
|
1797
|
+
},
|
|
1798
|
+
}, [
|
|
1799
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1800
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
|
|
1801
|
+
),
|
|
1802
|
+
'Delete',
|
|
1803
|
+
]),
|
|
1804
|
+
m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
|
|
1805
|
+
disabled: state.bulkActionInProgress,
|
|
1806
|
+
onclick: async () => {
|
|
1807
|
+
state.bulkActionInProgress = true;
|
|
1808
|
+
m.redraw();
|
|
1809
|
+
try {
|
|
1810
|
+
const ids = Array.from(state.selectedRecords);
|
|
1811
|
+
const response = await api.post('/extensions/export?model=' + modelName + '&format=json', { ids });
|
|
1812
|
+
// Download as file
|
|
1813
|
+
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
|
1814
|
+
const url = URL.createObjectURL(blob);
|
|
1815
|
+
const a = document.createElement('a');
|
|
1816
|
+
a.href = url;
|
|
1817
|
+
a.download = modelName + '-export.json';
|
|
1818
|
+
a.click();
|
|
1819
|
+
URL.revokeObjectURL(url);
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
alert('Error: ' + err.message);
|
|
1822
|
+
} finally {
|
|
1823
|
+
state.bulkActionInProgress = false;
|
|
1824
|
+
m.redraw();
|
|
1825
|
+
}
|
|
1826
|
+
},
|
|
1827
|
+
}, [
|
|
1828
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1829
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
|
|
1830
|
+
),
|
|
1831
|
+
'Export JSON',
|
|
1832
|
+
]),
|
|
1833
|
+
m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
|
|
1834
|
+
disabled: state.bulkActionInProgress,
|
|
1835
|
+
onclick: async () => {
|
|
1836
|
+
state.bulkActionInProgress = true;
|
|
1837
|
+
m.redraw();
|
|
1838
|
+
try {
|
|
1839
|
+
const ids = Array.from(state.selectedRecords);
|
|
1840
|
+
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', { ids });
|
|
1841
|
+
// Download as file
|
|
1842
|
+
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
1843
|
+
const url = URL.createObjectURL(blob);
|
|
1844
|
+
const a = document.createElement('a');
|
|
1845
|
+
a.href = url;
|
|
1846
|
+
a.download = modelName + '-export.csv';
|
|
1847
|
+
a.click();
|
|
1848
|
+
URL.revokeObjectURL(url);
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
alert('Error: ' + err.message);
|
|
1851
|
+
} finally {
|
|
1852
|
+
state.bulkActionInProgress = false;
|
|
1853
|
+
m.redraw();
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
}, [
|
|
1857
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1858
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
|
|
1859
|
+
),
|
|
1860
|
+
'Export CSV',
|
|
1861
|
+
]),
|
|
1862
|
+
// Bulk Field Update Dropdown
|
|
1863
|
+
m(BulkFieldUpdateDropdown, {
|
|
1864
|
+
modelName: modelName,
|
|
1865
|
+
selectedIds: Array.from(state.selectedRecords),
|
|
1866
|
+
onComplete: () => {
|
|
1867
|
+
state.selectedRecords = new Set();
|
|
1868
|
+
loadRecords(modelName, state.pagination.page);
|
|
1869
|
+
},
|
|
1870
|
+
}),
|
|
1871
|
+
m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
|
|
1872
|
+
onclick: () => {
|
|
1873
|
+
state.selectedRecords = new Set();
|
|
1874
|
+
state.bulkFieldDropdownOpen = false;
|
|
1875
|
+
state.selectedBulkField = null;
|
|
1876
|
+
m.redraw();
|
|
1877
|
+
},
|
|
1878
|
+
}, 'Clear'),
|
|
1879
|
+
]),
|
|
1880
|
+
]),
|
|
1604
1881
|
// Table container with sticky header and actions
|
|
1605
1882
|
m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
|
|
1606
1883
|
m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
|
|
1607
1884
|
// Sticky header
|
|
1608
1885
|
m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 10;' }, [
|
|
1609
1886
|
m('tr', [
|
|
1887
|
+
// Checkbox column header
|
|
1888
|
+
m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px;' }, [
|
|
1889
|
+
m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
|
|
1890
|
+
checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
|
|
1891
|
+
indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
|
|
1892
|
+
onchange: (e) => {
|
|
1893
|
+
if (e.target.checked) {
|
|
1894
|
+
state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
|
|
1895
|
+
} else {
|
|
1896
|
+
state.selectedRecords = new Set();
|
|
1897
|
+
}
|
|
1898
|
+
m.redraw();
|
|
1899
|
+
},
|
|
1900
|
+
}),
|
|
1901
|
+
]),
|
|
1610
1902
|
// Dynamic column headers
|
|
1611
1903
|
...displayColumns.map(col =>
|
|
1612
1904
|
m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.border-b.border-gray-200',
|
|
@@ -1620,7 +1912,24 @@ const RecordList = {
|
|
|
1620
1912
|
]),
|
|
1621
1913
|
]),
|
|
1622
1914
|
m('tbody.divide-y.divide-gray-100', state.records.map(record =>
|
|
1623
|
-
m('tr.hover:bg-gray-50.transition-colors',
|
|
1915
|
+
m('tr.hover:bg-gray-50.transition-colors', {
|
|
1916
|
+
class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50' : '',
|
|
1917
|
+
}, [
|
|
1918
|
+
// Checkbox cell
|
|
1919
|
+
m('td.px-4.py-3', [
|
|
1920
|
+
m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
|
|
1921
|
+
checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
|
|
1922
|
+
onchange: (e) => {
|
|
1923
|
+
if (!state.selectedRecords) state.selectedRecords = new Set();
|
|
1924
|
+
if (e.target.checked) {
|
|
1925
|
+
state.selectedRecords.add(record[primaryKey]);
|
|
1926
|
+
} else {
|
|
1927
|
+
state.selectedRecords.delete(record[primaryKey]);
|
|
1928
|
+
}
|
|
1929
|
+
m.redraw();
|
|
1930
|
+
},
|
|
1931
|
+
}),
|
|
1932
|
+
]),
|
|
1624
1933
|
// Dynamic cell values
|
|
1625
1934
|
...displayColumns.map(col =>
|
|
1626
1935
|
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700',
|
|
@@ -151,6 +151,131 @@ function createExtensionApiHandlers(options) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Bulk update field values (for enum/boolean fields)
|
|
156
|
+
*/
|
|
157
|
+
async function bulkUpdateFieldHandler(req, res) {
|
|
158
|
+
try {
|
|
159
|
+
const { model: modelName } = req.params;
|
|
160
|
+
const { ids, field, value } = req.body;
|
|
161
|
+
|
|
162
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
163
|
+
return res.status(400).json({ error: 'No records selected' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!field) {
|
|
167
|
+
return res.status(400).json({ error: 'Field name is required' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { getModel } = require('../../../core/orm/model');
|
|
171
|
+
const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
|
|
172
|
+
|
|
173
|
+
if (!model || !model.admin?.enabled) {
|
|
174
|
+
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Get field metadata
|
|
178
|
+
const column = model.columns.get(field);
|
|
179
|
+
if (!column) {
|
|
180
|
+
return res.status(400).json({ error: `Field "${field}" not found in model` });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate field type - only allow enum and boolean
|
|
184
|
+
const columnMeta = column._meta || {};
|
|
185
|
+
const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
|
|
186
|
+
const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
|
|
187
|
+
|
|
188
|
+
if (!isEnum && !isBoolean) {
|
|
189
|
+
return res.status(400).json({ error: `Field "${field}" is not an enum or boolean type` });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Validate value for enum fields
|
|
193
|
+
if (isEnum && !columnMeta.enum.includes(value)) {
|
|
194
|
+
return res.status(400).json({ error: `Invalid value "${value}" for enum field "${field}"` });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Coerce boolean value
|
|
198
|
+
let updateValue = value;
|
|
199
|
+
if (isBoolean) {
|
|
200
|
+
updateValue = value === true || value === 'true' || value === 1 || value === '1';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Perform bulk update
|
|
204
|
+
const repo = db.getRepository(modelName);
|
|
205
|
+
let updated = 0;
|
|
206
|
+
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
try {
|
|
209
|
+
await repo.update(id, { [field]: updateValue });
|
|
210
|
+
updated++;
|
|
211
|
+
} catch (e) {
|
|
212
|
+
console.error(`Failed to update record ${id}:`, e.message);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
res.json({
|
|
217
|
+
success: true,
|
|
218
|
+
result: {
|
|
219
|
+
message: `${updated} of ${ids.length} records updated`,
|
|
220
|
+
updated,
|
|
221
|
+
field,
|
|
222
|
+
value: updateValue,
|
|
223
|
+
},
|
|
224
|
+
affected: updated,
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('Bulk update field error:', error);
|
|
228
|
+
res.status(500).json({ error: error.message });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get bulk-updatable fields for a model (enum and boolean fields)
|
|
234
|
+
*/
|
|
235
|
+
function bulkFieldsHandler(req, res) {
|
|
236
|
+
try {
|
|
237
|
+
const { model: modelName } = req.params;
|
|
238
|
+
|
|
239
|
+
const { getModel } = require('../../../core/orm/model');
|
|
240
|
+
const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
|
|
241
|
+
|
|
242
|
+
if (!model || !model.admin?.enabled) {
|
|
243
|
+
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const bulkFields = [];
|
|
247
|
+
|
|
248
|
+
for (const [fieldName, column] of model.columns.entries()) {
|
|
249
|
+
const columnMeta = column._meta || {};
|
|
250
|
+
const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
|
|
251
|
+
const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
|
|
252
|
+
|
|
253
|
+
if (isEnum) {
|
|
254
|
+
bulkFields.push({
|
|
255
|
+
name: fieldName,
|
|
256
|
+
type: 'enum',
|
|
257
|
+
label: columnMeta.label || fieldName,
|
|
258
|
+
options: columnMeta.enum.map(v => ({ value: v, label: v })),
|
|
259
|
+
});
|
|
260
|
+
} else if (isBoolean) {
|
|
261
|
+
bulkFields.push({
|
|
262
|
+
name: fieldName,
|
|
263
|
+
type: 'boolean',
|
|
264
|
+
label: columnMeta.label || fieldName,
|
|
265
|
+
options: [
|
|
266
|
+
{ value: true, label: 'True' },
|
|
267
|
+
{ value: false, label: 'False' },
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
res.json({ fields: bulkFields });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
res.status(500).json({ error: error.message });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
154
279
|
/**
|
|
155
280
|
* Dashboard stats
|
|
156
281
|
*/
|
|
@@ -188,11 +313,25 @@ function createExtensionApiHandlers(options) {
|
|
|
188
313
|
|
|
189
314
|
/**
|
|
190
315
|
* Export records (CSV/JSON)
|
|
316
|
+
* Supports both GET (with ids in query) and POST (with ids in body)
|
|
191
317
|
*/
|
|
192
318
|
async function exportHandler(req, res) {
|
|
193
319
|
try {
|
|
194
|
-
|
|
195
|
-
const
|
|
320
|
+
// Support both path param and query param for model name
|
|
321
|
+
const modelName = req.params.model || req.query.model;
|
|
322
|
+
const format = req.query.format || 'json';
|
|
323
|
+
|
|
324
|
+
// Support IDs from query string (GET) or body (POST)
|
|
325
|
+
let idList = null;
|
|
326
|
+
if (req.body?.ids && Array.isArray(req.body.ids)) {
|
|
327
|
+
idList = req.body.ids;
|
|
328
|
+
} else if (req.query.ids) {
|
|
329
|
+
idList = req.query.ids.split(',');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!modelName) {
|
|
333
|
+
return res.status(400).json({ error: 'Model name is required' });
|
|
334
|
+
}
|
|
196
335
|
|
|
197
336
|
const { getModel } = require('../../../core/orm/model');
|
|
198
337
|
const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
|
|
@@ -205,8 +344,7 @@ function createExtensionApiHandlers(options) {
|
|
|
205
344
|
let records;
|
|
206
345
|
|
|
207
346
|
// If specific IDs provided, fetch those
|
|
208
|
-
if (
|
|
209
|
-
const idList = ids.split(',');
|
|
347
|
+
if (idList && idList.length > 0) {
|
|
210
348
|
records = [];
|
|
211
349
|
for (const id of idList) {
|
|
212
350
|
const record = await repo.findById(id);
|
|
@@ -224,23 +362,26 @@ function createExtensionApiHandlers(options) {
|
|
|
224
362
|
return columns.map(col => {
|
|
225
363
|
const val = record[col];
|
|
226
364
|
if (val === null || val === undefined) return '';
|
|
227
|
-
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
|
365
|
+
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
|
|
228
366
|
return `"${val.replace(/"/g, '""')}"`;
|
|
229
367
|
}
|
|
368
|
+
if (typeof val === 'object') {
|
|
369
|
+
return `"${JSON.stringify(val).replace(/"/g, '""')}"`;
|
|
370
|
+
}
|
|
230
371
|
return String(val);
|
|
231
372
|
}).join(',');
|
|
232
373
|
});
|
|
233
374
|
|
|
375
|
+
const csvContent = [header, ...rows].join('\n');
|
|
234
376
|
res.setHeader('Content-Type', 'text/csv');
|
|
235
377
|
res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.csv"`);
|
|
236
|
-
res.
|
|
378
|
+
res.json({ data: csvContent, format: 'csv' });
|
|
237
379
|
} else {
|
|
238
380
|
// JSON export
|
|
239
|
-
res.setHeader('Content-Type', 'application/json');
|
|
240
|
-
res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.json"`);
|
|
241
381
|
res.json({ data: records, model: modelName, exportedAt: new Date().toISOString() });
|
|
242
382
|
}
|
|
243
383
|
} catch (error) {
|
|
384
|
+
console.error('Export error:', error);
|
|
244
385
|
res.status(500).json({ error: error.message });
|
|
245
386
|
}
|
|
246
387
|
}
|
|
@@ -284,6 +425,8 @@ function createExtensionApiHandlers(options) {
|
|
|
284
425
|
widgetDataHandler,
|
|
285
426
|
actionHandler,
|
|
286
427
|
bulkActionHandler,
|
|
428
|
+
bulkUpdateFieldHandler,
|
|
429
|
+
bulkFieldsHandler,
|
|
287
430
|
dashboardStatsHandler,
|
|
288
431
|
exportHandler,
|
|
289
432
|
activityLogHandler,
|
|
@@ -177,7 +177,12 @@ function adminPanelPlugin(options = {}) {
|
|
|
177
177
|
ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
|
|
178
178
|
ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
|
|
179
179
|
ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
|
|
180
|
+
ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
|
|
181
|
+
ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
|
|
180
182
|
ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
|
|
183
|
+
ctx.addRoute('get', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
|
|
184
|
+
ctx.addRoute('post', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
|
|
185
|
+
ctx.addRoute('post', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
|
|
181
186
|
ctx.addRoute('get', `${adminPath}/api/extensions/activity`, requireAuth, extensionHandlers.activityLogHandler);
|
|
182
187
|
|
|
183
188
|
// Custom pages API routes
|