webspresso 0.0.73 → 0.0.75

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 (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +4 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. package/templates/skills/webspresso-usage/SKILL.md +29 -275
@@ -7,19 +7,8 @@
7
7
  const { getAllModels, getModel } = require('../../core/orm/model');
8
8
  const { sanitizeForOutput } = require('../../core/orm/utils');
9
9
  const { checkAdminExists, setupAdmin, login, logout, requireAuth } = require('./auth');
10
-
11
- /**
12
- * Check if rich-text content is empty
13
- * @param {string} value - Rich-text HTML value
14
- * @returns {boolean} True if empty
15
- */
16
- function isRichTextEmpty(value) {
17
- if (!value) return true;
18
- // Remove all HTML tags and check if only whitespace remains
19
- const stripped = value.replace(/<[^>]*>/g, '').trim();
20
- // Check for common empty Quill outputs
21
- return stripped === '' || value === '<p><br></p>' || value === '<p></p>';
22
- }
10
+ const { isRichTextEmpty } = require('./lib/is-rich-text-empty');
11
+ const { sanitizeRichHtml } = require('./lib/sanitize-rich-html');
23
12
 
24
13
  /**
25
14
  * Create API route handlers
@@ -29,13 +18,37 @@ function isRichTextEmpty(value) {
29
18
  * @param {Object} options.AdminUser - AdminUser model
30
19
  * @param {Function} options.hashPassword - Bcrypt hash function
31
20
  * @param {Function} options.comparePassword - Bcrypt compare function
21
+ * @param {boolean} [options.richTextSanitize=true] - Sanitize rich-text HTML before create/update
32
22
  * @returns {Object} Route handlers
33
23
  */
34
24
  function createApiHandlers(options) {
35
- const { path, db, AdminUser, hashPassword, comparePassword } = options;
25
+ const {
26
+ path,
27
+ db,
28
+ AdminUser,
29
+ hashPassword,
30
+ comparePassword,
31
+ richTextSanitize = true,
32
+ } = options;
36
33
  const adminPath = path || '/_admin';
37
34
  const apiPath = `${adminPath}/api`;
38
35
 
36
+ /** Strip XSS vectors from rich-text columns present on body; nullable columns normalize empty → null */
37
+ function mutateRichTextFieldsInBody(body, model) {
38
+ if (!richTextSanitize || !model.admin?.customFields) return;
39
+ for (const [colName, colMeta] of model.columns) {
40
+ if (model.admin.customFields[colName]?.type !== 'rich-text') continue;
41
+ if (!(colName in body)) continue;
42
+ const raw = body[colName];
43
+ if (raw === null || raw === undefined) continue;
44
+ if (typeof raw !== 'string') continue;
45
+ body[colName] = sanitizeRichHtml(raw);
46
+ if (colMeta.nullable && isRichTextEmpty(body[colName])) {
47
+ body[colName] = null;
48
+ }
49
+ }
50
+ }
51
+
39
52
  // Helper to get model from db instance or global registry
40
53
  function getModelFromDb(modelName) {
41
54
  if (db && typeof db.getModel === 'function') {
@@ -283,8 +296,19 @@ function createApiHandlers(options) {
283
296
  const from = filter.from;
284
297
  const to = filter.to;
285
298
 
299
+ if (op === 'is_null') {
300
+ query = query.whereNull(colName);
301
+ countQuery = countQuery.whereNull(colName);
302
+ continue;
303
+ }
304
+ if (op === 'is_not_null') {
305
+ query = query.whereNotNull(colName);
306
+ countQuery = countQuery.whereNotNull(colName);
307
+ continue;
308
+ }
309
+
286
310
  // Handle boolean values - convert string 'true'/'false' to actual boolean
287
- if (colType === 'boolean' && value !== undefined && value !== null) {
311
+ if (colType === 'boolean' && value !== undefined && value !== null && value !== '') {
288
312
  const boolValue = value === 'true' || value === true ? 1 : 0;
289
313
  query = query.where(colName, '=', boolValue);
290
314
  countQuery = countQuery.where(colName, '=', boolValue);
@@ -448,6 +472,8 @@ function createApiHandlers(options) {
448
472
  return res.status(404).json({ error: 'Model not found or not enabled' });
449
473
  }
450
474
 
475
+ mutateRichTextFieldsInBody(req.body, model);
476
+
451
477
  // Validate rich-text fields
452
478
  for (const [colName, colMeta] of model.columns) {
453
479
  if (model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
@@ -481,6 +507,8 @@ function createApiHandlers(options) {
481
507
  return res.status(404).json({ error: 'Model not found or not enabled' });
482
508
  }
483
509
 
510
+ mutateRichTextFieldsInBody(req.body, model);
511
+
484
512
  // Validate rich-text fields (only if field is being updated)
485
513
  for (const [colName, colMeta] of model.columns) {
486
514
  if (colName in req.body && model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
@@ -35,6 +35,87 @@ async function checkSetupNeeded() {
35
35
  }
36
36
  }
37
37
 
38
+ // Site user management: menu uses /models/{userModel}*; /users* kept as aliases (redirect) + /users/sessions
39
+ function getUserManagementModel() {
40
+ var um = window.__ADMIN_CONFIG__ && window.__ADMIN_CONFIG__.userManagement;
41
+ if (!um || !um.enabled) return null;
42
+ return um.model || 'User';
43
+ }
44
+
45
+ async function guardUserManagementRoutes() {
46
+ var isAuth = await checkAuth();
47
+ if (!isAuth) {
48
+ m.route.set('/login');
49
+ return false;
50
+ }
51
+ if (!getUserManagementModel()) {
52
+ m.route.set('/');
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ const UserSessionsPage = {
59
+ oninit: async function (vnode) {
60
+ vnode.state.loading = true;
61
+ vnode.state.rows = [];
62
+ vnode.state.message = null;
63
+ vnode.state.error = null;
64
+ try {
65
+ var res = await api.get('/users/sessions');
66
+ vnode.state.rows = res.data || [];
67
+ vnode.state.message = res.message || null;
68
+ } catch (e) {
69
+ vnode.state.error = e.message || String(e);
70
+ }
71
+ vnode.state.loading = false;
72
+ m.redraw();
73
+ },
74
+ view: function (vnode) {
75
+ var rows = vnode.state.rows || [];
76
+ var umModel = getUserManagementModel();
77
+ var usersCrudHref = umModel ? '/models/' + encodeURIComponent(umModel) : '/';
78
+ return m(Layout, { breadcrumbs: [{ label: 'Users', href: usersCrudHref }, { label: 'Active Sessions', href: '/users/sessions' }] }, [
79
+ m('h2.text-2xl.font-bold.mb-4.text-gray-900.dark:text-slate-100', 'Active Sessions'),
80
+ vnode.state.loading ? m('p.text-gray-600.dark:text-slate-400', 'Loading…') : null,
81
+ vnode.state.error ? m('p.text-red-600.dark:text-red-400.mb-2', vnode.state.error) : null,
82
+ vnode.state.message ? m('p.text-sm.text-gray-600.dark:text-slate-400.mb-2', vnode.state.message) : null,
83
+ !vnode.state.loading && rows.length === 0 && !vnode.state.error
84
+ ? m('p.text-gray-600.dark:text-slate-400', 'No remember-me sessions (or tracking not enabled).')
85
+ : m('.overflow-x-auto.rounded-lg.border.border-gray-200.dark:border-slate-700.bg-white.dark:bg-slate-800/50', [
86
+ m('table.min-w-full.text-sm.text-left', [
87
+ m('thead.bg-gray-50.dark:bg-slate-900', m('tr', [
88
+ m('th.px-3.py-2.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'User'),
89
+ m('th.px-3.py-2.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Token (prefix)'),
90
+ m('th.px-3.py-2.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Created'),
91
+ m('th.px-3.py-2', ''),
92
+ ])),
93
+ m('tbody.divide-y.divide-gray-100.dark:divide-slate-700', rows.map(function (r) {
94
+ var tok = (r.token || '').slice(0, 12) + '…';
95
+ return m('tr.bg-white.dark:bg-slate-800/30', [
96
+ m('td.px-3.py-2.text-gray-900.dark:text-slate-200', (r.user_email || r.user_id || '') + ''),
97
+ m('td.px-3.py-2.font-mono.text-xs.text-gray-800.dark:text-slate-300', tok),
98
+ m('td.px-3.py-2.text-gray-700.dark:text-slate-300', r.created_at ? new Date(r.created_at).toLocaleString() : ''),
99
+ m('td.px-3.py-2', m('button.text-red-600.dark:text-red-400.text-xs', {
100
+ onclick: async function () {
101
+ if (!confirm('Revoke this session?')) return;
102
+ try {
103
+ await api.delete('/users/sessions/' + encodeURIComponent(r.token));
104
+ vnode.state.rows = vnode.state.rows.filter(function (x) { return x.token !== r.token; });
105
+ m.redraw();
106
+ } catch (err) {
107
+ alert(err.message || err);
108
+ }
109
+ },
110
+ }, 'Revoke')),
111
+ ]);
112
+ })),
113
+ ]),
114
+ ]),
115
+ ]);
116
+ },
117
+ };
118
+
38
119
  // Build routes
39
120
  var routes = {
40
121
  '/': {
@@ -80,6 +161,34 @@ var routes = {
80
161
  return SettingsPage;
81
162
  }
82
163
  },
164
+ '/users/sessions': {
165
+ onmatch: async () => {
166
+ if (!(await guardUserManagementRoutes())) return;
167
+ return UserSessionsPage;
168
+ }
169
+ },
170
+ '/users/new': {
171
+ onmatch: async () => {
172
+ if (!(await guardUserManagementRoutes())) return;
173
+ var model = getUserManagementModel();
174
+ m.route.set('/models/' + encodeURIComponent(model) + '/new');
175
+ }
176
+ },
177
+ '/users/:id/edit': {
178
+ onmatch: async () => {
179
+ if (!(await guardUserManagementRoutes())) return;
180
+ var model = getUserManagementModel();
181
+ var id = m.route.param('id');
182
+ m.route.set('/models/' + encodeURIComponent(model) + '/edit/' + encodeURIComponent(id));
183
+ }
184
+ },
185
+ '/users': {
186
+ onmatch: async () => {
187
+ if (!(await guardUserManagementRoutes())) return;
188
+ var model = getUserManagementModel();
189
+ m.route.set('/models/' + encodeURIComponent(model));
190
+ }
191
+ },
83
192
  '/models/:model': {
84
193
  onmatch: async () => {
85
194
  const isAuth = await checkAuth();
@@ -0,0 +1,39 @@
1
+ # Admin panel client (Mithril SPA)
2
+
3
+ The embedded admin SPA is **browser-side JavaScript** assembled from plain snippet files (`parts/*.js`) and inlined in `generateAdminPanelHtml()` in [`../index.js`](../index.js). It is **not** compiled by default so the framework install stays simple.
4
+
5
+ ## Layout
6
+
7
+ | Path | Purpose |
8
+ |------|---------|
9
+ | [`manifest.parts.json`](./manifest.parts.json) | **Order matters**: concatenated top-to-bottom. |
10
+ | [`parts/*.js`](./parts/) | Raw script chunks (same content that used to live in a single `components.js` template). |
11
+ | [`load-parts.js`](./load-parts.js) | Node loader: reads manifest → single string consumed by [`../components.js`](../components.js). |
12
+ | [`../app.js`](../app.js) | Routes + bootstrap (still appended **after** the components blob in HTML). |
13
+
14
+ Naming roughly follows DOM flow: filters → pagination → fields → CRUD screens.
15
+
16
+ ## Editing workflow
17
+
18
+ 1. Change or add chunks under **`parts/`**.
19
+ 2. If you **add/remove/reorder** files, update **`manifest.parts.json`** accordingly.
20
+ 3. Sanity check:
21
+
22
+ ```bash
23
+ node plugins/admin-panel/client/verify-spa-parts.js
24
+ ```
25
+
26
+ Unit tests cover manifest vs disk in **`tests/unit/admin-panel/spa-parts.test.js`**.
27
+
28
+ ## Optional tooling (local only)
29
+
30
+ The Webspresso **core** package does **not** depend on Vite/esbuild/Rollup. If you want a bundler locally (analysis, splitting, compression experiments):
31
+
32
+ ```bash
33
+ cd plugins/admin-panel/client
34
+ npm install --no-save vite@5
35
+ ```
36
+
37
+ Use a small rollup/esbuild concat script targeting `manifest.parts.json`, **or** keep editing snippets by hand — the shipped plugin always uses **`load-parts.js`** unless you deliberately replace `components.js` to read a generated file.
38
+
39
+ `vite.config.example.mjs` (if present) is an illustrative stub; it is **not** run by CI or `webspresso` installs.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Concatenate browser SPA snippets for the admin panel (Mithril inline bundle).
3
+ * @module plugins/admin-panel/client/load-parts
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const MANIFEST_NAME = './manifest.parts.json';
12
+
13
+ /**
14
+ * Absolute path to this folder (client/).
15
+ */
16
+ function clientDir() {
17
+ return path.join(__dirname);
18
+ }
19
+
20
+ function partsDir() {
21
+ return path.join(__dirname, 'parts');
22
+ }
23
+
24
+ /**
25
+ * Inline admin SPA runs in the browser; CommonJS `module.exports` throws ReferenceError and breaks the whole bundle.
26
+ * @param {string} src
27
+ * @returns {string}
28
+ */
29
+ function stripCommonJsExportsForBrowser(src) {
30
+ return src
31
+ .split(/\r?\n/)
32
+ .filter((line) => !/^\s*module\.exports\s*=/.test(line))
33
+ .join('\n');
34
+ }
35
+
36
+ /**
37
+ * @returns {string[]} ordered part filenames from manifest.parts.json
38
+ */
39
+ function getManifestFilenames() {
40
+ const fp = path.join(clientDir(), 'manifest.parts.json');
41
+ /** @type {string[]} */
42
+ const list = JSON.parse(fs.readFileSync(fp, 'utf8'));
43
+ if (!Array.isArray(list) || list.length === 0) {
44
+ throw new Error('admin-panel client/manifest.parts.json must be a non-empty array');
45
+ }
46
+ return list;
47
+ }
48
+
49
+ /**
50
+ * Full SPA script body injected before app routes (no outer wrapper).
51
+ * @returns {string}
52
+ */
53
+ function buildComponentsBody() {
54
+ const sharedLib = stripCommonJsExportsForBrowser(
55
+ fs.readFileSync(path.join(__dirname, '..', 'lib', 'is-rich-text-empty.js'), 'utf8'),
56
+ );
57
+ const dir = partsDir();
58
+ const body = getManifestFilenames()
59
+ .map((filename) => {
60
+ const p = path.join(dir, filename);
61
+ if (!fs.existsSync(p)) {
62
+ throw new Error(`Missing admin SPA part: ${filename} (${p})`);
63
+ }
64
+ return fs.readFileSync(p, 'utf8');
65
+ })
66
+ .join('\n');
67
+ return `${sharedLib}\n${body}`;
68
+ }
69
+
70
+ module.exports = {
71
+ buildComponentsBody,
72
+ getManifestFilenames,
73
+ partsDir,
74
+ };
@@ -0,0 +1,12 @@
1
+ [
2
+ "01-state-api-breadcrumb.js",
3
+ "02-filter-components.js",
4
+ "03-pagination-intro.js",
5
+ "04-field-renderers.js",
6
+ "05-rich-text-file-helpers.js",
7
+ "06-login-setup-forms.js",
8
+ "07-model-list.js",
9
+ "08-record-list.js",
10
+ "09-record-form.js",
11
+ "10-export-registry.js"
12
+ ]
@@ -0,0 +1,150 @@
1
+
2
+ // API helper
3
+ const api = {
4
+ async request(path, options = {}) {
5
+ const adminPath = window.__ADMIN_PATH__ || '/_admin';
6
+ const url = adminPath + '/api' + path;
7
+ const response = await fetch(url, {
8
+ ...options,
9
+ headers: {
10
+ 'Content-Type': 'application/json',
11
+ ...options.headers,
12
+ },
13
+ credentials: 'include',
14
+ });
15
+
16
+ if (!response.ok) {
17
+ const error = await response.json().catch(() => ({ error: 'Request failed' }));
18
+ throw new Error(error.error || 'Request failed');
19
+ }
20
+
21
+ return response.json();
22
+ },
23
+
24
+ get(path) { return this.request(path, { method: 'GET' }); },
25
+ post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
26
+ put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
27
+ delete(path) { return this.request(path, { method: 'DELETE' }); },
28
+ };
29
+
30
+ /** POST /data-exchange/export/:model — validates JSON errors vs .xlsx blob */
31
+ async function downloadDataExchangeXlsx(modelName, payload) {
32
+ const adminPath = window.__ADMIN_PATH__ || '/_admin';
33
+ const res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
34
+ method: 'POST',
35
+ credentials: 'include',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(payload),
38
+ });
39
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
40
+ if (!res.ok || ct.indexOf('spreadsheet') === -1) {
41
+ var msg = 'Export failed';
42
+ try {
43
+ if (ct.indexOf('json') !== -1) {
44
+ var j = await res.json();
45
+ msg = j.error || msg;
46
+ } else {
47
+ var t = await res.text();
48
+ if (t) msg = t.slice(0, 300);
49
+ }
50
+ } catch (e) {}
51
+ throw new Error(msg);
52
+ }
53
+ const blob = await res.blob();
54
+ var url = URL.createObjectURL(blob);
55
+ var a = document.createElement('a');
56
+ a.href = url;
57
+ a.download = modelName + '-export.xlsx';
58
+ a.click();
59
+ URL.revokeObjectURL(url);
60
+ }
61
+
62
+ // Helper: Capitalize first letter of each word
63
+ function capitalizeWords(str) {
64
+ if (!str) return '';
65
+ return str.split(' ').map(function(word) {
66
+ return word.charAt(0).toUpperCase() + word.slice(1);
67
+ }).join(' ');
68
+ }
69
+
70
+ // Helper: Format column name to label
71
+ function formatColumnLabel(name) {
72
+ if (!name) return '';
73
+ return capitalizeWords(name.replace(/_/g, ' '));
74
+ }
75
+
76
+ // State
77
+ const state = {
78
+ user: null,
79
+ needsSetup: false,
80
+ loading: false,
81
+ error: null,
82
+ models: [],
83
+ currentModel: null,
84
+ currentModelMeta: null, // Full model metadata with columns
85
+ records: [],
86
+ pagination: {
87
+ page: 1,
88
+ perPage: 20,
89
+ total: 0,
90
+ totalPages: 0,
91
+ },
92
+ currentRecord: null,
93
+ formData: {}, // Form field values
94
+ editing: false,
95
+ filters: {}, // Active filters { column: { op, value, from, to } }
96
+ filterPanelOpen: false, // Filter panel visibility (deprecated)
97
+ filterDrawerOpen: false, // Filter drawer visibility
98
+ bulkFields: [], // Bulk-updatable fields (enum/boolean/date/datetime/timestamp)
99
+ bulkFieldDropdownOpen: false, // Bulk field dropdown visibility
100
+ selectedBulkField: null, // Currently selected bulk field for update
101
+ selectAllMode: false, // true = all records selected (not just current page)
102
+ };
103
+
104
+ // Breadcrumb Component
105
+ const Breadcrumb = {
106
+ view: (vnode) => {
107
+ const items = vnode.attrs.items || [];
108
+ if (items.length === 0) return null;
109
+
110
+ return m('nav.mb-4', { 'aria-label': 'Breadcrumb' }, [
111
+ m('ol.flex.items-center.space-x-2.text-sm', [
112
+ // Home link
113
+ m('li', [
114
+ m('a.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200', {
115
+ href: '/',
116
+ onclick: (e) => {
117
+ e.preventDefault();
118
+ m.route.set('/');
119
+ }
120
+ }, [
121
+ m('svg.w-4.h-4', { fill: 'currentColor', viewBox: '0 0 20 20' }, [
122
+ m('path', { d: 'M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z' }),
123
+ ]),
124
+ ]),
125
+ ]),
126
+ // Dynamic items
127
+ ...items.map((item, idx) => [
128
+ m('li.flex.items-center', [
129
+ m('svg.w-4.h-4.text-gray-400 dark:text-slate-500.mx-1', { fill: 'currentColor', viewBox: '0 0 20 20' }, [
130
+ m('path', { 'fill-rule': 'evenodd', d: 'M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z', 'clip-rule': 'evenodd' }),
131
+ ]),
132
+ idx === items.length - 1
133
+ ? m('span.text-gray-700 dark:text-slate-300.font-medium', item.label)
134
+ : m('a.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200', {
135
+ href: item.href,
136
+ onclick: (e) => {
137
+ e.preventDefault();
138
+ m.route.set(item.href);
139
+ }
140
+ }, item.label),
141
+ ]),
142
+ ]),
143
+ ]),
144
+ ]);
145
+ },
146
+ };
147
+
148
+ // ==========================================
149
+ // NEW FILTER COMPONENTS - Imported from filter-components.js
150
+ // ==========================================