millas 0.2.13 → 0.2.14

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.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -0,0 +1,351 @@
1
+ 'use strict';
2
+
3
+ const ActivityLog = require('../ActivityLog');
4
+ const AdminAuth = require('../AdminAuth');
5
+ const { ViewContext } = require('../ViewContext');
6
+
7
+ /**
8
+ * PageHandler
9
+ *
10
+ * Handles all resource page routes:
11
+ * GET /admin/ → dashboard
12
+ * GET /admin/:resource → list
13
+ * GET /admin/:resource/create → create form
14
+ * POST /admin/:resource → store
15
+ * GET /admin/:resource/:id/edit → edit form
16
+ * POST /admin/:resource/:id → update
17
+ * POST /admin/:resource/:id/delete → destroy
18
+ * GET /admin/:resource/:id → detail (readonly)
19
+ * GET /admin/search → global search
20
+ */
21
+ class PageHandler {
22
+ constructor(admin) {
23
+ this._admin = admin;
24
+ }
25
+
26
+ async dashboard(req, res) {
27
+ const admin = this._admin;
28
+ try {
29
+ const resourceData = await Promise.all(
30
+ admin.resources().map(async (R) => {
31
+ let count = 0;
32
+ let recent = [];
33
+ let recentCount = 0;
34
+ try {
35
+ count = await R.model.count();
36
+ const result = await R.fetchList({ page: 1, perPage: 5 });
37
+ recent = result.data.map(r => r.toJSON ? r.toJSON() : r);
38
+ try {
39
+ const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
40
+ recentCount = await R.model.where('created_at__gte', since).count();
41
+ } catch { /* model may not have created_at */ }
42
+ } catch {}
43
+ return {
44
+ slug: R.slug,
45
+ label: R._getLabel(),
46
+ singular: R._getLabelSingular(),
47
+ icon: R.icon,
48
+ count,
49
+ recentCount,
50
+ recent,
51
+ listFields: R.fields()
52
+ .filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
53
+ .slice(0, 4)
54
+ .map(f => f.toJSON()),
55
+ };
56
+ })
57
+ );
58
+
59
+ const [activityData, activityTotals] = await Promise.all([
60
+ ActivityLog.recent(25),
61
+ ActivityLog.totals(),
62
+ ]);
63
+
64
+ return admin._render(req, res, 'pages/dashboard.njk', admin._ctxWithFlash(req, res, {
65
+ pageTitle: 'Dashboard',
66
+ activePage: 'dashboard',
67
+ resources: resourceData,
68
+ activity: activityData,
69
+ activityTotals,
70
+ }));
71
+ } catch (err) {
72
+ admin._error(req, res, err);
73
+ }
74
+ }
75
+
76
+ async list(req, res) {
77
+ const admin = this._admin;
78
+ try {
79
+ const R = admin._resolve(req.params.resource, res);
80
+ if (!R) return;
81
+
82
+ const query = {
83
+ page: Number(req.query.page) || 1,
84
+ search: req.query.search || '',
85
+ sort: req.query.sort || 'id',
86
+ order: req.query.order || 'desc',
87
+ perPage: Number(req.query.perPage) || R.perPage,
88
+ year: req.query.year || null,
89
+ month: req.query.month || null,
90
+ };
91
+
92
+ const activeFilters = {};
93
+ if (req.query.filter) {
94
+ for (const [k, v] of Object.entries(req.query.filter)) {
95
+ if (v !== '') activeFilters[k] = v;
96
+ }
97
+ }
98
+
99
+ const result = await R.fetchList({ ...query, filters: activeFilters });
100
+ const rows = result.data.map(r => r.toJSON ? r.toJSON() : r);
101
+
102
+ const perms = {
103
+ canCreate: admin._perm(R, 'add', req.adminUser),
104
+ canEdit: admin._perm(R, 'change', req.adminUser),
105
+ canDelete: admin._perm(R, 'delete', req.adminUser),
106
+ canView: admin._perm(R, 'view', req.adminUser),
107
+ };
108
+
109
+ return admin._render(req, res, 'pages/list.njk',
110
+ ViewContext.list(R, {
111
+ rows, result, query, activeFilters, perms,
112
+ baseCtx: admin._ctxWithFlash(req, res, {}),
113
+ }), R);
114
+ } catch (err) {
115
+ admin._error(req, res, err);
116
+ }
117
+ }
118
+
119
+ async create(req, res) {
120
+ const admin = this._admin;
121
+ try {
122
+ const R = admin._resolve(req.params.resource, res);
123
+ if (!R) return;
124
+ if (!admin._perm(R, 'add', req.adminUser)) {
125
+ return res.status(403).send(`You do not have permission to add ${R._getLabelSingular()} records.`);
126
+ }
127
+
128
+ return admin._render(req, res, 'pages/form.njk',
129
+ ViewContext.create(R, {
130
+ adminPrefix: admin._config.prefix,
131
+ baseCtx: admin._ctxWithFlash(req, res, {}),
132
+ }), R);
133
+ } catch (err) {
134
+ admin._error(req, res, err);
135
+ }
136
+ }
137
+
138
+ async store(req, res) {
139
+ const admin = this._admin;
140
+ try {
141
+ const R = admin._resolve(req.params.resource, res);
142
+ if (!R) return;
143
+ if (!admin._perm(R, 'add', req.adminUser)) {
144
+ return res.status(403).send(`You do not have permission to add ${R._getLabelSingular()} records.`);
145
+ }
146
+ if (!admin._verifyCsrf(req, res)) return;
147
+
148
+ const record = await R.create(req.body, { user: req.adminUser, resource: R });
149
+ ActivityLog.record('create', R.slug, record?.id, `New ${R._getLabelSingular()}`, req.adminUser);
150
+
151
+ const submit = req.body._submit || 'save';
152
+ if (submit === 'continue' && record?.id) {
153
+ AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. You may continue editing.`);
154
+ return res.redirect(`${admin._config.prefix}/${R.slug}/${record.id}/edit`);
155
+ }
156
+ if (submit === 'add_another') {
157
+ AdminAuth.setFlash(res, 'success', `${R._getLabelSingular()} created. Add another below.`);
158
+ return res.redirect(`${admin._config.prefix}/${R.slug}/create`);
159
+ }
160
+
161
+ admin._flash(req, 'success', `${R._getLabelSingular()} created successfully`);
162
+ admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
163
+ } catch (err) {
164
+ if (err.status === 422) {
165
+ const R = admin._resources.get(req.params.resource);
166
+ return admin._render(req, res, 'pages/form.njk',
167
+ ViewContext.create(R, {
168
+ adminPrefix: admin._config.prefix,
169
+ record: req.body,
170
+ errors: err.errors || {},
171
+ baseCtx: admin._ctxWithFlash(req, res, {}),
172
+ }), R);
173
+ }
174
+ admin._error(req, res, err);
175
+ }
176
+ }
177
+
178
+ async edit(req, res) {
179
+ const admin = this._admin;
180
+ try {
181
+ const R = admin._resolve(req.params.resource, res);
182
+ if (!R) return;
183
+ if (!admin._perm(R, 'change', req.adminUser)) {
184
+ return res.status(403).send(`You do not have permission to change ${R._getLabelSingular()} records.`);
185
+ }
186
+
187
+ const record = await R.fetchOne(req.params.id);
188
+ const data = record.toJSON ? record.toJSON() : record;
189
+
190
+ return admin._render(req, res, 'pages/form.njk',
191
+ ViewContext.edit(R, {
192
+ adminPrefix: admin._config.prefix,
193
+ id: req.params.id,
194
+ record: data,
195
+ canDelete: admin._perm(R, 'delete', req.adminUser),
196
+ baseCtx: admin._ctxWithFlash(req, res, {}),
197
+ }), R);
198
+ } catch (err) {
199
+ admin._error(req, res, err);
200
+ }
201
+ }
202
+
203
+ async update(req, res) {
204
+ const admin = this._admin;
205
+ try {
206
+ const R = admin._resolve(req.params.resource, res);
207
+ if (!R) return;
208
+ if (!admin._perm(R, 'change', req.adminUser)) {
209
+ return res.status(403).send(`You do not have permission to change ${R._getLabelSingular()} records.`);
210
+ }
211
+ if (!admin._verifyCsrf(req, res)) return;
212
+
213
+ const method = req.body._method || 'POST';
214
+ if (method === 'PUT' || method === 'POST') {
215
+ await R.update(req.params.id, req.body, { user: req.adminUser, resource: R });
216
+ ActivityLog.record('update', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
217
+
218
+ const submit = req.body._submit || 'save';
219
+ if (submit === 'continue') {
220
+ AdminAuth.setFlash(res, 'success', 'Changes saved. You may continue editing.');
221
+ return res.redirect(`${admin._config.prefix}/${R.slug}/${req.params.id}/edit`);
222
+ }
223
+
224
+ admin._flash(req, 'success', `${R._getLabelSingular()} updated successfully`);
225
+ admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
226
+ }
227
+ } catch (err) {
228
+ if (err.status === 422) {
229
+ const R = admin._resources.get(req.params.resource);
230
+ return admin._render(req, res, 'pages/form.njk',
231
+ ViewContext.edit(R, {
232
+ adminPrefix: admin._config.prefix,
233
+ id: req.params.id,
234
+ record: { id: req.params.id, ...req.body },
235
+ canDelete: admin._perm(R, 'delete', req.adminUser),
236
+ errors: err.errors || {},
237
+ baseCtx: admin._ctxWithFlash(req, res, {}),
238
+ }), R);
239
+ }
240
+ admin._error(req, res, err);
241
+ }
242
+ }
243
+
244
+ async destroy(req, res) {
245
+ const admin = this._admin;
246
+ try {
247
+ const R = admin._resolve(req.params.resource, res);
248
+ if (!R) return;
249
+ if (!admin._perm(R, 'delete', req.adminUser)) {
250
+ return res.status(403).send(`You do not have permission to delete ${R._getLabelSingular()} records.`);
251
+ }
252
+ if (!admin._verifyCsrf(req, res)) return;
253
+
254
+ await R.destroy(req.params.id, { user: req.adminUser, resource: R });
255
+ ActivityLog.record('delete', R.slug, req.params.id, `${R._getLabelSingular()} #${req.params.id}`, req.adminUser);
256
+ admin._flash(req, 'success', `${R._getLabelSingular()} deleted`);
257
+ admin._redirectWithFlash(res, `${admin._config.prefix}/${R.slug}`, req._flashType, req._flashMessage);
258
+ } catch (err) {
259
+ admin._error(req, res, err);
260
+ }
261
+ }
262
+
263
+ async detail(req, res) {
264
+ const admin = this._admin;
265
+ try {
266
+ const R = admin._resolve(req.params.resource, res);
267
+ if (!R) return;
268
+ if (!admin._perm(R, 'view', req.adminUser)) {
269
+ if (admin._perm(R, 'change', req.adminUser)) {
270
+ return res.redirect(`${admin._config.prefix}/${R.slug}/${req.params.id}/edit`);
271
+ }
272
+ return res.status(403).send(`You do not have permission to view ${R._getLabelSingular()} records.`);
273
+ }
274
+
275
+ const record = await R.fetchOne(req.params.id);
276
+ const data = record.toJSON ? record.toJSON() : record;
277
+
278
+ const inlineData = await Promise.all(
279
+ (R.inlines || []).map(async (inline, idx) => {
280
+ const rows = await inline.fetchRows(data[R.model.primaryKey || 'id']);
281
+ return { ...inline.toJSON(), rows, inlineIndex: idx };
282
+ })
283
+ );
284
+
285
+ return admin._render(req, res, 'pages/detail.njk',
286
+ ViewContext.detail(R, {
287
+ id: req.params.id,
288
+ record: data,
289
+ inlineData,
290
+ perms: {
291
+ canEdit: admin._perm(R, 'change', req.adminUser),
292
+ canDelete: admin._perm(R, 'delete', req.adminUser),
293
+ canCreate: admin._perm(R, 'add', req.adminUser),
294
+ },
295
+ baseCtx: admin._ctxWithFlash(req, res, {}),
296
+ }), R);
297
+ } catch (err) {
298
+ admin._error(req, res, err);
299
+ }
300
+ }
301
+
302
+ async search(req, res) {
303
+ const admin = this._admin;
304
+ try {
305
+ const q = (req.query.q || '').trim();
306
+
307
+ if (!q) {
308
+ return admin._render(req, res, 'pages/search.njk',
309
+ ViewContext.search({
310
+ query: '', results: [], total: 0,
311
+ baseCtx: admin._ctxWithFlash(req, res, { activePage: 'search' }),
312
+ }));
313
+ }
314
+
315
+ const results = await Promise.all(
316
+ admin.resources().map(async (R) => {
317
+ if (!R.searchable || !R.searchable.length) return null;
318
+ try {
319
+ const result = await R.fetchList({ page: 1, perPage: 8, search: q });
320
+ if (!result.data.length) return null;
321
+ return {
322
+ slug: R.slug,
323
+ label: R._getLabel(),
324
+ singular: R._getLabelSingular(),
325
+ icon: R.icon,
326
+ total: result.total,
327
+ rows: result.data.map(r => r.toJSON ? r.toJSON() : r),
328
+ listFields: R.fields()
329
+ .filter(f => f._type !== 'tab' && !f._hidden && !f._detailOnly)
330
+ .slice(0, 4)
331
+ .map(f => f.toJSON()),
332
+ };
333
+ } catch { return null; }
334
+ })
335
+ );
336
+
337
+ const filtered = results.filter(Boolean);
338
+ const total = filtered.reduce((s, r) => s + r.total, 0);
339
+
340
+ return admin._render(req, res, 'pages/search.njk',
341
+ ViewContext.search({
342
+ query: q, results: filtered, total,
343
+ baseCtx: admin._ctxWithFlash(req, res, { activePage: 'search' }),
344
+ }));
345
+ } catch (err) {
346
+ admin._error(req, res, err);
347
+ }
348
+ }
349
+ }
350
+
351
+ module.exports = PageHandler;
@@ -52,9 +52,21 @@ class AdminResource {
52
52
  /** Singular label */
53
53
  static labelSingular = null;
54
54
 
55
- /** SVG icon id (without ic- prefix) */
55
+ /** Bootstrap Icons name (e.g. 'house', 'people', 'credit-card') */
56
56
  static icon = 'table';
57
57
 
58
+ /**
59
+ * Sidebar group label. Resources with the same group are rendered under
60
+ * the same collapsible section in the admin sidebar.
61
+ *
62
+ * static group = 'Property & Units';
63
+ * static group = 'Payments';
64
+ * static group = 'KYC';
65
+ *
66
+ * Resources with no group are placed under a default 'Resources' section.
67
+ */
68
+ static group = null;
69
+
58
70
  /** Records per page default */
59
71
  static perPage = 20;
60
72
 
@@ -431,6 +443,15 @@ class AdminResource {
431
443
  // Never write id — already deleted above
432
444
  delete clean[key];
433
445
  break;
446
+ case 'password':
447
+ // Blank password on an edit form means "keep the current hash" —
448
+ // remove the key entirely so it is never written to the DB and
449
+ // never passed to before_save. Non-blank values pass through as-is;
450
+ // the developer hashes them in before_save using Hash.make().
451
+ if (raw === '' || raw === null || raw === undefined) {
452
+ delete clean[key];
453
+ }
454
+ break;
434
455
  default:
435
456
  // string-like fields: leave as-is
436
457
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * SelectFilter2.js — Millas Admin M2M Dual-List Widget
3
+ *
4
+ * Moves options between "available" and "chosen" select lists.
5
+ * Also ensures all chosen options are selected before form submit
6
+ * so their values are included in the POST body.
7
+ *
8
+ * Requires: jQuery
9
+ */
10
+ (function ($) {
11
+ 'use strict';
12
+
13
+ // ── Move options between lists ───────────────────────────────────────────────
14
+ window.m2mMove = function (fieldName, fromId, toId) {
15
+ $('#m2m-' + fromId + '-' + fieldName + ' option:selected')
16
+ .appendTo('#m2m-' + toId + '-' + fieldName);
17
+ };
18
+
19
+ // ── Wire move buttons via data attributes ────────────────────────────────────
20
+ $(document).on('click', '[data-m2m-move]', function () {
21
+ var fieldName = $(this).data('m2m-field');
22
+ var from = $(this).data('m2m-from');
23
+ var to = $(this).data('m2m-to');
24
+ if (fieldName && from && to) {
25
+ m2mMove(fieldName, from, to);
26
+ }
27
+ });
28
+
29
+ // ── Select all chosen before form submit ─────────────────────────────────────
30
+ $(document).on('submit', 'form', function () {
31
+ $('[id^="m2m-chosen-"] option').prop('selected', true);
32
+ });
33
+
34
+ }(jQuery));
@@ -0,0 +1,201 @@
1
+ /**
2
+ * actions.js — Millas Admin List Page
3
+ *
4
+ * Bulk selection, bulk delete, bulk actions, filter toggle,
5
+ * per-row action dropdowns and context menus.
6
+ *
7
+ * Requires: ui.js, core.js, jQuery
8
+ */
9
+ (function ($) {
10
+ 'use strict';
11
+
12
+ $(function () {
13
+ var PREFIX = (window.MILLAS_ADMIN_PREFIX || '/admin').replace(/\/+$/, '');
14
+ var SLUG = (window.MILLAS_RESOURCE_SLUG || '').replace(/\/+$/, '');
15
+
16
+ // ── Filter panel ─────────────────────────────────────────────────────────
17
+ if (window.MILLAS_HAS_ACTIVE_FILTERS) {
18
+ $('#filter-panel').show();
19
+ }
20
+
21
+ $(document).on('click', '#filter-toggle', function () {
22
+ $('#filter-panel').toggle();
23
+ });
24
+
25
+ // ── Live search on Enter ──────────────────────────────────────────────────
26
+ $('#search-form input[name="search"]').on('keydown', function (e) {
27
+ if (e.key === 'Enter') $('#search-form').submit();
28
+ });
29
+
30
+ // ── Bulk selection ────────────────────────────────────────────────────────
31
+ function updateBulkBar() {
32
+ var $checked = $('.item-check:checked');
33
+ var n = $checked.length;
34
+ var total = $('.item-check').length;
35
+ $('#bulk-bar').toggleClass('visible', n > 0);
36
+ $('#bulk-count').text(n + ' selected');
37
+ $('#check-all')
38
+ .prop('indeterminate', n > 0 && n < total)
39
+ .prop('checked', n > 0 && n === total);
40
+ }
41
+
42
+ $(document).on('click', '#check-all', function () {
43
+ $('.item-check').prop('checked', this.checked);
44
+ updateBulkBar();
45
+ });
46
+
47
+ $(document).on('change', '.item-check', updateBulkBar);
48
+
49
+ $(document).on('click', '#clear-selection', function () {
50
+ $('.item-check, #check-all').prop('checked', false).prop('indeterminate', false);
51
+ updateBulkBar();
52
+ });
53
+
54
+ // ── Bulk delete ───────────────────────────────────────────────────────────
55
+ $(document).on('click', '#bulk-delete-btn', function () {
56
+ var ids = $('.item-check:checked').map(function () { return this.value; }).get();
57
+ if (!ids.length) return;
58
+ var label = ids.length + ' record' + (ids.length > 1 ? 's' : '');
59
+ UI.Confirm.show({
60
+ title: 'Delete ' + label,
61
+ message: 'Delete <strong>' + label + '</strong>? This cannot be undone.',
62
+ confirm: 'Delete',
63
+ danger: true,
64
+ }).then(function (ok) {
65
+ if (!ok) return;
66
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
67
+ var $form = $('<form method="POST">').attr('action', PREFIX + '/' + SLUG + '/bulk-delete');
68
+ $form.append('<input name="_csrf" value="' + csrf + '">');
69
+ $.each(ids, function (_, id) {
70
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
71
+ });
72
+ $form.appendTo('body').submit();
73
+ });
74
+ });
75
+
76
+ // ── Bulk action ───────────────────────────────────────────────────────────
77
+ $(document).on('click', '[data-bulk-action]', function () {
78
+ var actionIndex = $(this).data('bulk-action');
79
+ var ids = $('.item-check:checked').map(function () { return this.value; }).get();
80
+ if (!ids.length) return;
81
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
82
+ var $form = $('<form method="POST">').attr('action', PREFIX + '/' + SLUG + '/bulk-action');
83
+ $form.append('<input name="_csrf" value="' + csrf + '">');
84
+ $form.append('<input type="hidden" name="actionIndex" value="' + actionIndex + '">');
85
+ $.each(ids, function (_, id) {
86
+ $form.append('<input type="hidden" name="ids[]" value="' + id + '">');
87
+ });
88
+ $form.appendTo('body').submit();
89
+ });
90
+
91
+ // ── Export menu ───────────────────────────────────────────────────────────
92
+ var $exportBtn = $('#export-menu-btn');
93
+ var $exportPanel = $('#export-menu-panel');
94
+ if ($exportBtn.length && $exportPanel.length) {
95
+ var exportDd = UI.Dropdown.create({
96
+ anchor: $exportBtn[0],
97
+ content: $exportPanel[0],
98
+ placement: 'bottom-end',
99
+ offset: 4,
100
+ });
101
+ $exportBtn.on('click', function () { exportDd.toggle(); });
102
+ }
103
+
104
+ // ── Per-row action menus ──────────────────────────────────────────────────
105
+ $('.ui-menu').each(function () {
106
+ var $menu = $(this);
107
+ var $btn = $menu.find('.ui-menu-trigger');
108
+ var $panel = $menu.find('.ui-menu-panel');
109
+ if (!$btn.length || !$panel.length) return;
110
+
111
+ var dd = UI.Dropdown.create({
112
+ anchor: $btn[0],
113
+ content: $panel[0],
114
+ placement: 'bottom-end',
115
+ offset: 4,
116
+ });
117
+
118
+ $btn.on('click', function (e) {
119
+ e.stopPropagation();
120
+ dd.toggle();
121
+ });
122
+
123
+ $panel.find('[data-confirm-delete]').on('click', function () {
124
+ dd.close();
125
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
126
+ });
127
+
128
+ $panel.find('[data-row-action]').on('click', function () {
129
+ dd.close();
130
+ var url = $(this).data('row-action');
131
+ var label = $(this).data('row-action-label');
132
+ UI.Confirm.show({
133
+ title: label,
134
+ message: 'Run <strong>' + label + '</strong> on this record?',
135
+ confirm: label,
136
+ }).then(function (ok) {
137
+ if (!ok) return;
138
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
139
+ var $form = $('<form method="POST">').attr('action', url);
140
+ $form.append('<input name="_csrf" value="' + csrf + '">');
141
+ $form.appendTo('body').submit();
142
+ });
143
+ });
144
+
145
+ // ── Right-click context menu ──────────────────────────────────────────
146
+ (function ($menuPanel) {
147
+ var $row = $menuPanel.closest('tr');
148
+
149
+ $row.on('contextmenu', function (e) {
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+
153
+ $('.context-menu-portal').remove();
154
+
155
+ var $p = $('<div class="ui-menu-panel context-menu-portal"></div>');
156
+ $p.html($menuPanel.html());
157
+ $p.css({
158
+ position: 'fixed',
159
+ top: Math.min(e.clientY, window.innerHeight - 240) + 'px',
160
+ left: Math.min(e.clientX, window.innerWidth - 190) + 'px',
161
+ zIndex: 9999,
162
+ });
163
+ $('body').append($p);
164
+
165
+ $p.find('[data-confirm-delete]').on('click', function () {
166
+ $p.remove();
167
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
168
+ });
169
+
170
+ $p.find('[data-row-action]').on('click', function () {
171
+ $p.remove();
172
+ var _url = $(this).data('row-action');
173
+ var _label = $(this).data('row-action-label');
174
+ UI.Confirm.show({
175
+ title: _label,
176
+ message: 'Run <strong>' + _label + '</strong> on this record?',
177
+ confirm: _label,
178
+ }).then(function (ok) {
179
+ if (!ok) return;
180
+ var csrf = $('meta[name="csrf-token"]').attr('content') || '';
181
+ var $form = $('<form method="POST">').attr('action', _url);
182
+ $form.append('<input name="_csrf" value="' + csrf + '">');
183
+ $form.appendTo('body').submit();
184
+ });
185
+ });
186
+
187
+ $p.find('a.ui-menu-item').on('click', function () { $p.remove(); });
188
+
189
+ setTimeout(function () {
190
+ $(document).one('click.ctxmenu', function () { $p.remove(); });
191
+ }, 0);
192
+ $(document).one('keydown.ctxmenu', function (ev) {
193
+ if (ev.key === 'Escape') { $p.remove(); }
194
+ });
195
+ });
196
+ }($panel));
197
+ });
198
+
199
+ });
200
+
201
+ }(jQuery));
@@ -97,6 +97,13 @@
97
97
  overflow-y: auto;
98
98
  overflow-x: hidden;
99
99
  }
100
+ .nav-section-container{
101
+ display: flex;
102
+ flex-direction: column;
103
+ overflow-y: auto;
104
+ overflow-x: hidden;
105
+ flex-grow: 1;
106
+ }
100
107
 
101
108
  .sidebar-brand {
102
109
  padding: 18px 16px 16px;