webspresso 0.0.74 → 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.
- package/README.md +41 -3
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +3 -2
- package/plugins/admin-panel/modules/user-management.js +90 -20
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +191 -50
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +26 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -278
|
@@ -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 {
|
|
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) {
|
|
@@ -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
|
+
// ==========================================
|