webspresso 0.0.71 → 0.0.73
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 +40 -1
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +3 -1
- package/plugins/admin-panel/components.js +182 -17
- package/plugins/admin-panel/core/api-extensions.js +2 -0
- package/plugins/admin-panel/core/registry.js +2 -0
- package/plugins/admin-panel/modules/custom-pages.js +75 -14
- package/plugins/admin-panel/modules/dashboard.js +64 -13
- package/plugins/admin-panel/modules/menu.js +43 -0
- package/plugins/audit-log/middleware.js +3 -0
- package/plugins/data-exchange/export-xlsx.js +83 -0
- package/plugins/data-exchange/import.js +289 -0
- package/plugins/data-exchange/index.js +80 -0
- package/plugins/data-exchange/parse-table.js +64 -0
- package/plugins/data-exchange/record-selection.js +64 -0
- package/plugins/index.js +3 -0
- package/plugins/site-analytics/admin-component.js +32 -11
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
|
15
15
|
- **Lifecycle Hooks**: Global and route-level hooks for request processing
|
|
16
16
|
- **Template Helpers**: Laravel-inspired helper functions available in templates
|
|
17
17
|
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
18
|
-
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models, optional admin UI for ORM query cache metrics and purge
|
|
18
|
+
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models, optional admin UI for ORM query cache metrics and purge, optional **admin-only spreadsheet exchange** (Excel export, CSV/XLSX import via `dataExchangePlugin`)
|
|
19
19
|
- **Session authentication** (optional): `createAuth` / `quickAuth` in **`webspresso/core/auth`** — pass the manager to **`createApp({ auth })`** for `express-session`, `req.user` / `req.auth`, remember-me tokens, and policy-style authorization. Full walkthrough: **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
20
20
|
- **Optional client runtime** (Alpine.js + [swup](https://swup.js.org/)): **`createApp({ clientRuntime: { alpine, swup } })`** serves scripts under **`/__webspresso/client-runtime/`** and exposes **`clientRuntime`** in Nunjucks; layouts can include **`views/partials/webspresso-client-runtime.njk`**. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`**. Demo: **`examples/alpine-swup-demo/`**. Details: **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
|
|
21
21
|
- **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
|
|
@@ -833,6 +833,45 @@ adminPanelPlugin({
|
|
|
833
833
|
})
|
|
834
834
|
```
|
|
835
835
|
|
|
836
|
+
### Data exchange plugin (`dataExchangePlugin`)
|
|
837
|
+
|
|
838
|
+
- **Admin session only** — same `requireAuth` / `req.session.adminUser` as the admin panel; paths live under your admin prefix (default `/_admin`).
|
|
839
|
+
- **Excel export** (`.xlsx`) for models with `admin.enabled`; record selection matches the built-in JSON/CSV export (`ids`, `selectAll`, `filters` via query or POST body).
|
|
840
|
+
- **Import** — `multipart/form-data` field `file` (`.csv` or `.xlsx`); query/body `mode=insert|upsert`, `upsertKey` (default primary key). Rows are validated through the ORM (`repository.create` / `update`). Hidden columns are excluded; caps: `maxRows`, `maxFileBytes`.
|
|
841
|
+
- **UI** — when the plugin is loaded, the admin model list adds **Export Excel**, **Import**, and bulk **Export Excel** (alongside the existing JSON/CSV export actions). Registers bulk action `export-xlsx` for download URLs.
|
|
842
|
+
- **Dependencies** — implemented with `exceljs` and `csv-parse` (declared on the `webspresso` package).
|
|
843
|
+
|
|
844
|
+
Register **after** `adminPanelPlugin` so session middleware and the admin registry exist; use the **same** `db` and `adminPath`:
|
|
845
|
+
|
|
846
|
+
```javascript
|
|
847
|
+
const { adminPanelPlugin, dataExchangePlugin } = require('webspresso/plugins');
|
|
848
|
+
|
|
849
|
+
const { app } = createApp({
|
|
850
|
+
pagesDir: './pages',
|
|
851
|
+
db,
|
|
852
|
+
plugins: [
|
|
853
|
+
adminPanelPlugin({ db, path: '/_admin' }),
|
|
854
|
+
dataExchangePlugin({
|
|
855
|
+
db,
|
|
856
|
+
adminPath: '/_admin',
|
|
857
|
+
maxRows: 10_000,
|
|
858
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
859
|
+
}),
|
|
860
|
+
],
|
|
861
|
+
});
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
Options:
|
|
865
|
+
- `db` — database instance (defaults to `ctx.db` in `onRoutesReady` if omitted but `createApp({ db })` was set)
|
|
866
|
+
- `adminPath` — must match the admin panel path (default `/_admin`)
|
|
867
|
+
- `enabled` — set `false` to skip registering routes (default `true`)
|
|
868
|
+
- `maxRows` — export and import row limit (default `10000`)
|
|
869
|
+
- `maxFileBytes` — multipart upload limit (default 10 MiB)
|
|
870
|
+
|
|
871
|
+
API (all require admin cookie):
|
|
872
|
+
- `GET|POST ${adminPath}/api/data-exchange/export/:model` — spreadsheet download
|
|
873
|
+
- `POST ${adminPath}/api/data-exchange/import/:model` — import summary `{ success, created, updated, failed, errors: [{ row, message }] }`
|
|
874
|
+
|
|
836
875
|
**Site Analytics Plugin:**
|
|
837
876
|
- Self-hosted page view analytics (no external services required)
|
|
838
877
|
- Automatic page view tracking via Express middleware
|
package/index.d.ts
CHANGED
|
@@ -516,6 +516,8 @@ export function schemaExplorerPlugin(options?: Record<string, unknown>): Webspre
|
|
|
516
516
|
|
|
517
517
|
export function adminPanelPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
518
518
|
|
|
519
|
+
export function dataExchangePlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
520
|
+
|
|
519
521
|
export function siteAnalyticsPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
520
522
|
|
|
521
523
|
export function auditLogPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
package/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const {
|
|
|
52
52
|
ormCacheAdminPlugin,
|
|
53
53
|
uploadPlugin,
|
|
54
54
|
createLocalFileProvider,
|
|
55
|
+
dataExchangePlugin,
|
|
55
56
|
} = require('./plugins');
|
|
56
57
|
|
|
57
58
|
module.exports = {
|
|
@@ -109,4 +110,5 @@ module.exports = {
|
|
|
109
110
|
ormCacheAdminPlugin,
|
|
110
111
|
uploadPlugin,
|
|
111
112
|
createLocalFileProvider,
|
|
113
|
+
dataExchangePlugin,
|
|
112
114
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.73",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
"commander": "^11.1.0",
|
|
57
57
|
"connect-timeout": "^1.9.1",
|
|
58
58
|
"cookie-parser": "^1.4.7",
|
|
59
|
+
"csv-parse": "^5.6.0",
|
|
59
60
|
"dayjs": "^1.11.19",
|
|
61
|
+
"exceljs": "^4.4.0",
|
|
60
62
|
"express": "^4.18.2",
|
|
61
63
|
"express-session": "^1.18.0",
|
|
62
64
|
"helmet": "^7.2.0",
|
|
@@ -1407,10 +1407,11 @@ const SetupForm = {
|
|
|
1407
1407
|
|
|
1408
1408
|
// Model List Component
|
|
1409
1409
|
const ModelList = {
|
|
1410
|
-
|
|
1410
|
+
_load() {
|
|
1411
1411
|
state.loading = true;
|
|
1412
1412
|
state.error = null;
|
|
1413
|
-
|
|
1413
|
+
m.redraw();
|
|
1414
|
+
return api.get('/models')
|
|
1414
1415
|
.then(result => {
|
|
1415
1416
|
state.models = result.models || [];
|
|
1416
1417
|
})
|
|
@@ -1422,8 +1423,31 @@ const ModelList = {
|
|
|
1422
1423
|
m.redraw();
|
|
1423
1424
|
});
|
|
1424
1425
|
},
|
|
1425
|
-
|
|
1426
|
-
|
|
1426
|
+
oninit(vnode) {
|
|
1427
|
+
vnode.state._stopPoll = null;
|
|
1428
|
+
vnode.state.refreshing = false;
|
|
1429
|
+
ModelList._load();
|
|
1430
|
+
vnode.state._stopPoll = runAdminAutoRefresh(() => ModelList._load());
|
|
1431
|
+
},
|
|
1432
|
+
onremove(vnode) {
|
|
1433
|
+
if (vnode.state._stopPoll) vnode.state._stopPoll();
|
|
1434
|
+
},
|
|
1435
|
+
view: (vnode) => m(Layout, [
|
|
1436
|
+
m('.flex.items-center.justify-between.mb-6', [
|
|
1437
|
+
m('h2.text-2xl.font-bold', 'Models'),
|
|
1438
|
+
m(RefreshIconButton, {
|
|
1439
|
+
title: 'Reload models',
|
|
1440
|
+
spinning: vnode.state.refreshing || state.loading,
|
|
1441
|
+
onclick: () => {
|
|
1442
|
+
vnode.state.refreshing = true;
|
|
1443
|
+
m.redraw();
|
|
1444
|
+
ModelList._load().finally(() => {
|
|
1445
|
+
vnode.state.refreshing = false;
|
|
1446
|
+
m.redraw();
|
|
1447
|
+
});
|
|
1448
|
+
},
|
|
1449
|
+
}),
|
|
1450
|
+
]),
|
|
1427
1451
|
state.error ? m('.bg-red-100.border.border-red-400.text-red-700.px-4.py-3.rounded.mb-4', state.error) : null,
|
|
1428
1452
|
state.loading
|
|
1429
1453
|
? m('p.text-gray-600', 'Loading models...')
|
|
@@ -1807,7 +1831,7 @@ function loadRecords(modelName, page = 1, filters = null) {
|
|
|
1807
1831
|
window.history.replaceState({}, '', newUrl);
|
|
1808
1832
|
|
|
1809
1833
|
const trashedParam = state.trashedView ? '&trashed=only' : '';
|
|
1810
|
-
api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
|
|
1834
|
+
return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
|
|
1811
1835
|
.then(result => {
|
|
1812
1836
|
state.records = result.data || [];
|
|
1813
1837
|
state.pagination = {
|
|
@@ -1866,9 +1890,19 @@ function initializeModelView(modelName) {
|
|
|
1866
1890
|
|
|
1867
1891
|
// Record List Component - displays records with dynamic columns
|
|
1868
1892
|
const RecordList = {
|
|
1869
|
-
oninit
|
|
1893
|
+
oninit(vnode) {
|
|
1894
|
+
vnode.state._stopPoll = null;
|
|
1895
|
+
vnode.state.manualRefresh = false;
|
|
1870
1896
|
const modelName = m.route.param('model');
|
|
1871
1897
|
initializeModelView(modelName);
|
|
1898
|
+
vnode.state._stopPoll = runAdminAutoRefresh(() => {
|
|
1899
|
+
const mName = m.route.param('model');
|
|
1900
|
+
if (state._currentModelName !== mName || !state.currentModelMeta) return;
|
|
1901
|
+
loadRecords(mName, state.pagination.page, state.filters);
|
|
1902
|
+
});
|
|
1903
|
+
},
|
|
1904
|
+
onremove(vnode) {
|
|
1905
|
+
if (vnode.state._stopPoll) vnode.state._stopPoll();
|
|
1872
1906
|
},
|
|
1873
1907
|
onbeforeupdate: () => {
|
|
1874
1908
|
// Check if model changed (navigation between different models)
|
|
@@ -1878,7 +1912,7 @@ const RecordList = {
|
|
|
1878
1912
|
}
|
|
1879
1913
|
return true;
|
|
1880
1914
|
},
|
|
1881
|
-
view: () => {
|
|
1915
|
+
view: (vnode) => {
|
|
1882
1916
|
const modelName = m.route.param('model');
|
|
1883
1917
|
const modelMeta = state.currentModelMeta;
|
|
1884
1918
|
const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
|
|
@@ -1922,6 +1956,18 @@ const RecordList = {
|
|
|
1922
1956
|
m('.flex.items-center.justify-between.mb-4', [
|
|
1923
1957
|
m('.flex.items-center.gap-3', [
|
|
1924
1958
|
m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
|
|
1959
|
+
m(RefreshIconButton, {
|
|
1960
|
+
title: 'Reload records',
|
|
1961
|
+
spinning: vnode.state.manualRefresh || state.loading,
|
|
1962
|
+
onclick: () => {
|
|
1963
|
+
vnode.state.manualRefresh = true;
|
|
1964
|
+
m.redraw();
|
|
1965
|
+
loadRecords(modelName, state.pagination.page, state.filters).finally(() => {
|
|
1966
|
+
vnode.state.manualRefresh = false;
|
|
1967
|
+
m.redraw();
|
|
1968
|
+
});
|
|
1969
|
+
},
|
|
1970
|
+
}),
|
|
1925
1971
|
modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
|
|
1926
1972
|
m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
|
|
1927
1973
|
class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100',
|
|
@@ -1939,17 +1985,96 @@ const RecordList = {
|
|
|
1939
1985
|
}, 'Trash'),
|
|
1940
1986
|
]) : null,
|
|
1941
1987
|
]),
|
|
1942
|
-
!state.trashedView ? m('
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1988
|
+
!state.trashedView ? m('.flex.items-center.gap-2', [
|
|
1989
|
+
m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-indigo-700.bg-white.dark:bg-slate-800.border.border-indigo-200.rounded-lg.hover:bg-indigo-50.transition-colors', {
|
|
1990
|
+
onclick: async () => {
|
|
1991
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
1992
|
+
try {
|
|
1993
|
+
const payload = { selectAll: true, filters: state.filters };
|
|
1994
|
+
const res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
|
|
1995
|
+
method: 'POST',
|
|
1996
|
+
credentials: 'include',
|
|
1997
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1998
|
+
body: JSON.stringify(payload),
|
|
1999
|
+
});
|
|
2000
|
+
if (!res.ok) {
|
|
2001
|
+
const err = await res.json().catch(function () { return {}; });
|
|
2002
|
+
throw new Error(err.error || 'Export failed');
|
|
2003
|
+
}
|
|
2004
|
+
const blob = await res.blob();
|
|
2005
|
+
const url = URL.createObjectURL(blob);
|
|
2006
|
+
const a = document.createElement('a');
|
|
2007
|
+
a.href = url;
|
|
2008
|
+
a.download = modelName + '-export.xlsx';
|
|
2009
|
+
a.click();
|
|
2010
|
+
URL.revokeObjectURL(url);
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
alert('Error: ' + err.message);
|
|
2013
|
+
}
|
|
2014
|
+
},
|
|
2015
|
+
}, [
|
|
2016
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
2017
|
+
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' })
|
|
2018
|
+
),
|
|
2019
|
+
'Export Excel',
|
|
2020
|
+
]),
|
|
2021
|
+
m('input[type=file]', {
|
|
2022
|
+
id: 'data-exchange-import-' + modelName,
|
|
2023
|
+
style: 'display:none',
|
|
2024
|
+
accept: '.csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv',
|
|
2025
|
+
onchange: async (e) => {
|
|
2026
|
+
const file = e.target.files && e.target.files[0];
|
|
2027
|
+
e.target.value = '';
|
|
2028
|
+
if (!file) return;
|
|
2029
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
2030
|
+
const mode = window.confirm('OK = upsert by id, Cancel = insert only') ? 'upsert' : 'insert';
|
|
2031
|
+
const upsertKey = 'id';
|
|
2032
|
+
const fd = new FormData();
|
|
2033
|
+
fd.append('file', file);
|
|
2034
|
+
try {
|
|
2035
|
+
const res = await fetch(
|
|
2036
|
+
adminPath + '/api/data-exchange/import/' + modelName +
|
|
2037
|
+
'?mode=' + encodeURIComponent(mode) + '&upsertKey=' + encodeURIComponent(upsertKey),
|
|
2038
|
+
{ method: 'POST', body: fd, credentials: 'include' }
|
|
2039
|
+
);
|
|
2040
|
+
const body = await res.json().catch(function () { return ({}); });
|
|
2041
|
+
if (!res.ok) {
|
|
2042
|
+
throw new Error(body.error || 'Import failed');
|
|
2043
|
+
}
|
|
2044
|
+
var msg = 'Import finished: created ' + body.created + ', updated ' + (body.updated || 0) + ', failed ' + (body.failed || 0);
|
|
2045
|
+
if (body.errors && body.errors.length) {
|
|
2046
|
+
msg += 'First errors: ' + body.errors.slice(0, 3).map(function (x) { return 'row ' + x.row + ': ' + x.message; }).join('; ');
|
|
2047
|
+
}
|
|
2048
|
+
alert(msg);
|
|
2049
|
+
loadRecords(modelName, state.pagination.page, state.filters);
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
alert('Error: ' + err.message);
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
}),
|
|
2055
|
+
m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-indigo-700.bg-white.dark:bg-slate-800.border.border-indigo-200.rounded-lg.hover:bg-indigo-50.transition-colors', {
|
|
2056
|
+
onclick: function () {
|
|
2057
|
+
var el = document.getElementById('data-exchange-import-' + modelName);
|
|
2058
|
+
if (el) el.click();
|
|
2059
|
+
},
|
|
2060
|
+
}, [
|
|
2061
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
2062
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M7 10l5 5m0 0l5-5m-5 5V4' })
|
|
2063
|
+
),
|
|
2064
|
+
'Import',
|
|
2065
|
+
]),
|
|
2066
|
+
m('button.inline-flex.items-center.gap-2.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
|
|
2067
|
+
onclick: () => {
|
|
2068
|
+
state.currentRecord = null;
|
|
2069
|
+
state.editing = true;
|
|
2070
|
+
m.route.set('/models/' + modelName + '/new');
|
|
2071
|
+
},
|
|
2072
|
+
}, [
|
|
2073
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
|
|
2074
|
+
m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4v16m8-8H4' }),
|
|
2075
|
+
]),
|
|
2076
|
+
'New Record',
|
|
1951
2077
|
]),
|
|
1952
|
-
'New Record',
|
|
1953
2078
|
]) : null,
|
|
1954
2079
|
]),
|
|
1955
2080
|
|
|
@@ -2156,6 +2281,46 @@ const RecordList = {
|
|
|
2156
2281
|
),
|
|
2157
2282
|
'Export CSV',
|
|
2158
2283
|
]) : null,
|
|
2284
|
+
!state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-violet-600.bg-white dark:bg-slate-800.border.border-violet-200.rounded.hover:bg-violet-50.transition-colors', {
|
|
2285
|
+
disabled: state.bulkActionInProgress,
|
|
2286
|
+
onclick: async () => {
|
|
2287
|
+
state.bulkActionInProgress = true;
|
|
2288
|
+
m.redraw();
|
|
2289
|
+
try {
|
|
2290
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
2291
|
+
const payload = state.selectAllMode
|
|
2292
|
+
? { selectAll: true, filters: state.filters }
|
|
2293
|
+
: { ids: Array.from(state.selectedRecords) };
|
|
2294
|
+
const res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
|
|
2295
|
+
method: 'POST',
|
|
2296
|
+
credentials: 'include',
|
|
2297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2298
|
+
body: JSON.stringify(payload),
|
|
2299
|
+
});
|
|
2300
|
+
if (!res.ok) {
|
|
2301
|
+
const err = await res.json().catch(function () { return {}; });
|
|
2302
|
+
throw new Error(err.error || 'Export failed');
|
|
2303
|
+
}
|
|
2304
|
+
const blob = await res.blob();
|
|
2305
|
+
const url = URL.createObjectURL(blob);
|
|
2306
|
+
const a = document.createElement('a');
|
|
2307
|
+
a.href = url;
|
|
2308
|
+
a.download = modelName + '-export.xlsx';
|
|
2309
|
+
a.click();
|
|
2310
|
+
URL.revokeObjectURL(url);
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
alert('Error: ' + err.message);
|
|
2313
|
+
} finally {
|
|
2314
|
+
state.bulkActionInProgress = false;
|
|
2315
|
+
m.redraw();
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
}, [
|
|
2319
|
+
m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
|
|
2320
|
+
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' })
|
|
2321
|
+
),
|
|
2322
|
+
'Export Excel',
|
|
2323
|
+
]) : null,
|
|
2159
2324
|
!state.trashedView ? m(BulkFieldUpdateDropdown, {
|
|
2160
2325
|
modelName: modelName,
|
|
2161
2326
|
selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
|
|
@@ -153,23 +153,38 @@ function createCustomPage(pageConfig) {
|
|
|
153
153
|
vnode.state.data = null;
|
|
154
154
|
vnode.state.loading = true;
|
|
155
155
|
vnode.state.error = null;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
vnode.state.refreshing = false;
|
|
157
|
+
vnode.state._stopPoll = null;
|
|
158
|
+
|
|
159
|
+
vnode.state.load = () => {
|
|
160
|
+
if (!pageConfig.dataLoader) {
|
|
161
|
+
vnode.state.loading = false;
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
vnode.state.loading = true;
|
|
165
|
+
vnode.state.error = null;
|
|
166
|
+
m.redraw();
|
|
167
|
+
return api.get('/extensions/pages/' + pageConfig.id + '/data')
|
|
160
168
|
.then(result => {
|
|
161
169
|
vnode.state.data = result.data;
|
|
162
|
-
vnode.state.loading = false;
|
|
163
|
-
m.redraw();
|
|
164
170
|
})
|
|
165
171
|
.catch(err => {
|
|
166
172
|
vnode.state.error = err.message;
|
|
173
|
+
})
|
|
174
|
+
.finally(() => {
|
|
167
175
|
vnode.state.loading = false;
|
|
168
176
|
m.redraw();
|
|
169
177
|
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
vnode.state.load();
|
|
181
|
+
vnode.state._stopPoll = pageConfig.dataLoader
|
|
182
|
+
? runAdminAutoRefresh(() => vnode.state.load())
|
|
183
|
+
: null;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
onremove(vnode) {
|
|
187
|
+
if (vnode.state._stopPoll) vnode.state._stopPoll();
|
|
173
188
|
},
|
|
174
189
|
|
|
175
190
|
view(vnode) {
|
|
@@ -180,9 +195,25 @@ function createCustomPage(pageConfig) {
|
|
|
180
195
|
{ label: pageConfig.title, href: pageConfig.path },
|
|
181
196
|
]}),
|
|
182
197
|
|
|
183
|
-
m('div.mb-6', [
|
|
184
|
-
m('
|
|
185
|
-
|
|
198
|
+
m('div.mb-6.flex.items-start.justify-between.gap-4', [
|
|
199
|
+
m('div', [
|
|
200
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', pageConfig.title),
|
|
201
|
+
pageConfig.description && m('p.text-gray-500.dark:text-slate-400.mt-1', pageConfig.description),
|
|
202
|
+
]),
|
|
203
|
+
pageConfig.dataLoader
|
|
204
|
+
? m(RefreshIconButton, {
|
|
205
|
+
title: 'Reload page data',
|
|
206
|
+
spinning: vnode.state.refreshing || loading,
|
|
207
|
+
onclick: () => {
|
|
208
|
+
vnode.state.refreshing = true;
|
|
209
|
+
m.redraw();
|
|
210
|
+
vnode.state.load().finally(() => {
|
|
211
|
+
vnode.state.refreshing = false;
|
|
212
|
+
m.redraw();
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
: null,
|
|
186
217
|
]),
|
|
187
218
|
|
|
188
219
|
loading
|
|
@@ -208,6 +239,25 @@ const SettingsPage = {
|
|
|
208
239
|
vnode.state.loading = true;
|
|
209
240
|
vnode.state.saving = false;
|
|
210
241
|
vnode.state.formData = {};
|
|
242
|
+
vnode.state.reloadBusy = false;
|
|
243
|
+
|
|
244
|
+
vnode.state.reloadFromServer = () => {
|
|
245
|
+
vnode.state.reloadBusy = true;
|
|
246
|
+
vnode.state.error = null;
|
|
247
|
+
m.redraw();
|
|
248
|
+
return api.get('/extensions/config')
|
|
249
|
+
.then(result => {
|
|
250
|
+
vnode.state.config = result;
|
|
251
|
+
vnode.state.formData = { ...result.settings };
|
|
252
|
+
})
|
|
253
|
+
.catch(err => {
|
|
254
|
+
vnode.state.error = err.message;
|
|
255
|
+
})
|
|
256
|
+
.finally(() => {
|
|
257
|
+
vnode.state.reloadBusy = false;
|
|
258
|
+
m.redraw();
|
|
259
|
+
});
|
|
260
|
+
};
|
|
211
261
|
|
|
212
262
|
api.get('/extensions/config')
|
|
213
263
|
.then(result => {
|
|
@@ -233,8 +283,19 @@ const SettingsPage = {
|
|
|
233
283
|
return m(Layout, [
|
|
234
284
|
m(Breadcrumb, { items: [{ label: 'Settings', href: '/settings' }] }),
|
|
235
285
|
|
|
236
|
-
m('div.mb-6', [
|
|
237
|
-
m('
|
|
286
|
+
m('div.mb-6.flex.items-start.justify-between.gap-4', [
|
|
287
|
+
m('div', [
|
|
288
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'Admin Settings'),
|
|
289
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Reload discards unsaved changes'),
|
|
290
|
+
]),
|
|
291
|
+
m(RefreshIconButton, {
|
|
292
|
+
title: 'Reload settings from server',
|
|
293
|
+
disabled: saving,
|
|
294
|
+
spinning: vnode.state.reloadBusy,
|
|
295
|
+
onclick: () => {
|
|
296
|
+
vnode.state.reloadFromServer();
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
238
299
|
]),
|
|
239
300
|
|
|
240
301
|
error && m('div.bg-red-50.border.border-red-200.rounded.p-4.text-red-700.mb-4', error),
|
|
@@ -310,22 +310,37 @@ const Widget = {
|
|
|
310
310
|
vnode.state.data = null;
|
|
311
311
|
vnode.state.loading = true;
|
|
312
312
|
vnode.state.error = null;
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
vnode.state.refreshing = false;
|
|
314
|
+
vnode.state._stopPoll = null;
|
|
315
|
+
|
|
315
316
|
const { widget } = vnode.attrs;
|
|
316
|
-
|
|
317
|
-
|
|
317
|
+
vnode.state.load = () => {
|
|
318
|
+
if (!widget.id) {
|
|
319
|
+
vnode.state.loading = false;
|
|
320
|
+
return Promise.resolve();
|
|
321
|
+
}
|
|
322
|
+
vnode.state.loading = true;
|
|
323
|
+
vnode.state.error = null;
|
|
324
|
+
m.redraw();
|
|
325
|
+
return api.get('/extensions/widgets/' + widget.id + '/data')
|
|
318
326
|
.then(result => {
|
|
319
327
|
vnode.state.data = result.data;
|
|
320
|
-
vnode.state.loading = false;
|
|
321
|
-
m.redraw();
|
|
322
328
|
})
|
|
323
329
|
.catch(err => {
|
|
324
330
|
vnode.state.error = err.message;
|
|
331
|
+
})
|
|
332
|
+
.finally(() => {
|
|
325
333
|
vnode.state.loading = false;
|
|
326
334
|
m.redraw();
|
|
327
335
|
});
|
|
328
|
-
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
vnode.state.load();
|
|
339
|
+
vnode.state._stopPoll = runAdminAutoRefresh(() => vnode.state.load());
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
onremove(vnode) {
|
|
343
|
+
if (vnode.state._stopPoll) vnode.state._stopPoll();
|
|
329
344
|
},
|
|
330
345
|
|
|
331
346
|
view(vnode) {
|
|
@@ -343,8 +358,22 @@ const Widget = {
|
|
|
343
358
|
return m('div.bg-white dark:bg-slate-800.rounded-lg.shadow', {
|
|
344
359
|
class: sizeClasses[widget.size] || sizeClasses.md,
|
|
345
360
|
}, [
|
|
346
|
-
m('div.px-4.py-3.border-b.border-gray-200', [
|
|
347
|
-
m('
|
|
361
|
+
m('div.px-4.py-3.border-b.border-gray-200.dark:border-slate-700', [
|
|
362
|
+
m('div.flex.items-center.justify-between.gap-2', [
|
|
363
|
+
m('h3.text-sm.font-medium.text-gray-900.dark:text-slate-100', widget.title),
|
|
364
|
+
m(RefreshIconButton, {
|
|
365
|
+
title: 'Refresh',
|
|
366
|
+
spinning: vnode.state.refreshing || loading,
|
|
367
|
+
onclick: () => {
|
|
368
|
+
vnode.state.refreshing = true;
|
|
369
|
+
m.redraw();
|
|
370
|
+
vnode.state.load().finally(() => {
|
|
371
|
+
vnode.state.refreshing = false;
|
|
372
|
+
m.redraw();
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
]),
|
|
348
377
|
]),
|
|
349
378
|
m('div.p-4', [
|
|
350
379
|
loading
|
|
@@ -362,6 +391,8 @@ const Dashboard = {
|
|
|
362
391
|
oninit(vnode) {
|
|
363
392
|
vnode.state.config = null;
|
|
364
393
|
vnode.state.loading = true;
|
|
394
|
+
vnode.state.refreshKey = 0;
|
|
395
|
+
vnode.state.dashRefreshing = false;
|
|
365
396
|
|
|
366
397
|
// Load admin config
|
|
367
398
|
api.get('/extensions/config')
|
|
@@ -389,14 +420,34 @@ const Dashboard = {
|
|
|
389
420
|
const widgets = config?.widgets || [];
|
|
390
421
|
|
|
391
422
|
return m(Layout, [
|
|
392
|
-
m('div.mb-6', [
|
|
393
|
-
m('
|
|
394
|
-
|
|
423
|
+
m('div.mb-6.flex.items-start.justify-between.gap-4', [
|
|
424
|
+
m('div', [
|
|
425
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', config?.settings?.title || 'Dashboard'),
|
|
426
|
+
m('p.text-gray-500.dark:text-slate-400.mt-1', 'Welcome back, ' + (state.user?.name || 'Admin')),
|
|
427
|
+
]),
|
|
428
|
+
m(RefreshIconButton, {
|
|
429
|
+
title: 'Refresh all widgets',
|
|
430
|
+
spinning: vnode.state.dashRefreshing,
|
|
431
|
+
onclick: () => {
|
|
432
|
+
vnode.state.dashRefreshing = true;
|
|
433
|
+
m.redraw();
|
|
434
|
+
api.get('/extensions/config')
|
|
435
|
+
.then(config => {
|
|
436
|
+
vnode.state.config = config;
|
|
437
|
+
vnode.state.refreshKey = (vnode.state.refreshKey || 0) + 1;
|
|
438
|
+
})
|
|
439
|
+
.catch(err => console.error('Failed to refresh dashboard:', err))
|
|
440
|
+
.finally(() => {
|
|
441
|
+
vnode.state.dashRefreshing = false;
|
|
442
|
+
m.redraw();
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
395
446
|
]),
|
|
396
447
|
|
|
397
448
|
widgets.length > 0
|
|
398
449
|
? m('div.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-4.gap-4',
|
|
399
|
-
widgets.map(widget => m(Widget, { key: widget.id, widget }))
|
|
450
|
+
widgets.map(widget => m(Widget, { key: widget.id + '-' + vnode.state.refreshKey, widget }))
|
|
400
451
|
)
|
|
401
452
|
: m('div.text-center.py-12.text-gray-500', [
|
|
402
453
|
m('p', 'No dashboard widgets configured'),
|