webspresso 0.0.36 → 0.0.37
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 +169 -25
|
@@ -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();
|
|
@@ -1454,34 +1454,50 @@ function loadRecords(modelName, page = 1, filters = null) {
|
|
|
1454
1454
|
});
|
|
1455
1455
|
}
|
|
1456
1456
|
|
|
1457
|
+
// Initialize model data
|
|
1458
|
+
function initializeModelView(modelName) {
|
|
1459
|
+
state.records = [];
|
|
1460
|
+
state.currentModelMeta = null;
|
|
1461
|
+
state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
|
|
1462
|
+
state.filterPanelOpen = false;
|
|
1463
|
+
state.filterDrawerOpen = false;
|
|
1464
|
+
state.selectedRecords = new Set(); // Bulk selection
|
|
1465
|
+
state.bulkActionInProgress = false;
|
|
1466
|
+
state._currentModelName = modelName;
|
|
1467
|
+
|
|
1468
|
+
// Parse filters from URL query string
|
|
1469
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1470
|
+
const page = parseInt(urlParams.get('page')) || 1;
|
|
1471
|
+
state.filters = parseFilterQuery(window.location.search);
|
|
1472
|
+
|
|
1473
|
+
// Load model metadata first, then records
|
|
1474
|
+
state.loading = true;
|
|
1475
|
+
api.get('/models/' + modelName)
|
|
1476
|
+
.then(modelMeta => {
|
|
1477
|
+
state.currentModelMeta = modelMeta;
|
|
1478
|
+
state.currentModel = modelMeta;
|
|
1479
|
+
return loadRecords(modelName, page, state.filters);
|
|
1480
|
+
})
|
|
1481
|
+
.catch(err => {
|
|
1482
|
+
state.error = err.message;
|
|
1483
|
+
state.loading = false;
|
|
1484
|
+
m.redraw();
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1457
1488
|
// Record List Component - displays records with dynamic columns
|
|
1458
1489
|
const RecordList = {
|
|
1459
1490
|
oninit: () => {
|
|
1460
1491
|
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
|
-
});
|
|
1492
|
+
initializeModelView(modelName);
|
|
1493
|
+
},
|
|
1494
|
+
onbeforeupdate: () => {
|
|
1495
|
+
// Check if model changed (navigation between different models)
|
|
1496
|
+
const modelName = m.route.param('model');
|
|
1497
|
+
if (state._currentModelName !== modelName) {
|
|
1498
|
+
initializeModelView(modelName);
|
|
1499
|
+
}
|
|
1500
|
+
return true;
|
|
1485
1501
|
},
|
|
1486
1502
|
view: () => {
|
|
1487
1503
|
const modelName = m.route.param('model');
|
|
@@ -1601,12 +1617,123 @@ const RecordList = {
|
|
|
1601
1617
|
m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
|
|
1602
1618
|
])
|
|
1603
1619
|
: m('.bg-white.rounded-lg.shadow-sm.border.border-gray-200.overflow-hidden', [
|
|
1620
|
+
// Bulk Actions Toolbar (shown when items selected)
|
|
1621
|
+
state.selectedRecords && state.selectedRecords.size > 0 && m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
|
|
1622
|
+
m('span.text-sm.text-indigo-700.font-medium',
|
|
1623
|
+
state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
|
|
1624
|
+
),
|
|
1625
|
+
m('.flex.items-center.gap-2', [
|
|
1626
|
+
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', {
|
|
1627
|
+
disabled: state.bulkActionInProgress,
|
|
1628
|
+
onclick: async () => {
|
|
1629
|
+
if (!confirm('Are you sure you want to delete ' + state.selectedRecords.size + ' records? This action cannot be undone.')) return;
|
|
1630
|
+
state.bulkActionInProgress = true;
|
|
1631
|
+
m.redraw();
|
|
1632
|
+
try {
|
|
1633
|
+
const ids = Array.from(state.selectedRecords);
|
|
1634
|
+
await api.post('/extensions/bulk-actions/bulk-delete/execute?model=' + modelName, { ids });
|
|
1635
|
+
state.selectedRecords = new Set();
|
|
1636
|
+
loadRecords(modelName, state.pagination.page);
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
alert('Error: ' + err.message);
|
|
1639
|
+
} finally {
|
|
1640
|
+
state.bulkActionInProgress = false;
|
|
1641
|
+
m.redraw();
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
}, [
|
|
1645
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1646
|
+
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' })
|
|
1647
|
+
),
|
|
1648
|
+
'Delete',
|
|
1649
|
+
]),
|
|
1650
|
+
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', {
|
|
1651
|
+
disabled: state.bulkActionInProgress,
|
|
1652
|
+
onclick: async () => {
|
|
1653
|
+
state.bulkActionInProgress = true;
|
|
1654
|
+
m.redraw();
|
|
1655
|
+
try {
|
|
1656
|
+
const ids = Array.from(state.selectedRecords);
|
|
1657
|
+
const response = await api.post('/extensions/export?model=' + modelName + '&format=json', { ids });
|
|
1658
|
+
// Download as file
|
|
1659
|
+
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
|
1660
|
+
const url = URL.createObjectURL(blob);
|
|
1661
|
+
const a = document.createElement('a');
|
|
1662
|
+
a.href = url;
|
|
1663
|
+
a.download = modelName + '-export.json';
|
|
1664
|
+
a.click();
|
|
1665
|
+
URL.revokeObjectURL(url);
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
alert('Error: ' + err.message);
|
|
1668
|
+
} finally {
|
|
1669
|
+
state.bulkActionInProgress = false;
|
|
1670
|
+
m.redraw();
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
}, [
|
|
1674
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1675
|
+
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' })
|
|
1676
|
+
),
|
|
1677
|
+
'Export JSON',
|
|
1678
|
+
]),
|
|
1679
|
+
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', {
|
|
1680
|
+
disabled: state.bulkActionInProgress,
|
|
1681
|
+
onclick: async () => {
|
|
1682
|
+
state.bulkActionInProgress = true;
|
|
1683
|
+
m.redraw();
|
|
1684
|
+
try {
|
|
1685
|
+
const ids = Array.from(state.selectedRecords);
|
|
1686
|
+
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', { ids });
|
|
1687
|
+
// Download as file
|
|
1688
|
+
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
1689
|
+
const url = URL.createObjectURL(blob);
|
|
1690
|
+
const a = document.createElement('a');
|
|
1691
|
+
a.href = url;
|
|
1692
|
+
a.download = modelName + '-export.csv';
|
|
1693
|
+
a.click();
|
|
1694
|
+
URL.revokeObjectURL(url);
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
alert('Error: ' + err.message);
|
|
1697
|
+
} finally {
|
|
1698
|
+
state.bulkActionInProgress = false;
|
|
1699
|
+
m.redraw();
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
}, [
|
|
1703
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
1704
|
+
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' })
|
|
1705
|
+
),
|
|
1706
|
+
'Export CSV',
|
|
1707
|
+
]),
|
|
1708
|
+
m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
|
|
1709
|
+
onclick: () => {
|
|
1710
|
+
state.selectedRecords = new Set();
|
|
1711
|
+
m.redraw();
|
|
1712
|
+
},
|
|
1713
|
+
}, 'Clear'),
|
|
1714
|
+
]),
|
|
1715
|
+
]),
|
|
1604
1716
|
// Table container with sticky header and actions
|
|
1605
1717
|
m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
|
|
1606
1718
|
m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
|
|
1607
1719
|
// Sticky header
|
|
1608
1720
|
m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 10;' }, [
|
|
1609
1721
|
m('tr', [
|
|
1722
|
+
// Checkbox column header
|
|
1723
|
+
m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px;' }, [
|
|
1724
|
+
m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
|
|
1725
|
+
checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
|
|
1726
|
+
indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
|
|
1727
|
+
onchange: (e) => {
|
|
1728
|
+
if (e.target.checked) {
|
|
1729
|
+
state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
|
|
1730
|
+
} else {
|
|
1731
|
+
state.selectedRecords = new Set();
|
|
1732
|
+
}
|
|
1733
|
+
m.redraw();
|
|
1734
|
+
},
|
|
1735
|
+
}),
|
|
1736
|
+
]),
|
|
1610
1737
|
// Dynamic column headers
|
|
1611
1738
|
...displayColumns.map(col =>
|
|
1612
1739
|
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 +1747,24 @@ const RecordList = {
|
|
|
1620
1747
|
]),
|
|
1621
1748
|
]),
|
|
1622
1749
|
m('tbody.divide-y.divide-gray-100', state.records.map(record =>
|
|
1623
|
-
m('tr.hover:bg-gray-50.transition-colors',
|
|
1750
|
+
m('tr.hover:bg-gray-50.transition-colors', {
|
|
1751
|
+
class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50' : '',
|
|
1752
|
+
}, [
|
|
1753
|
+
// Checkbox cell
|
|
1754
|
+
m('td.px-4.py-3', [
|
|
1755
|
+
m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
|
|
1756
|
+
checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
|
|
1757
|
+
onchange: (e) => {
|
|
1758
|
+
if (!state.selectedRecords) state.selectedRecords = new Set();
|
|
1759
|
+
if (e.target.checked) {
|
|
1760
|
+
state.selectedRecords.add(record[primaryKey]);
|
|
1761
|
+
} else {
|
|
1762
|
+
state.selectedRecords.delete(record[primaryKey]);
|
|
1763
|
+
}
|
|
1764
|
+
m.redraw();
|
|
1765
|
+
},
|
|
1766
|
+
}),
|
|
1767
|
+
]),
|
|
1624
1768
|
// Dynamic cell values
|
|
1625
1769
|
...displayColumns.map(col =>
|
|
1626
1770
|
m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700',
|