webspresso 0.0.70 → 0.0.72

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 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.70",
3
+ "version": "0.0.72",
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
- oninit: () => {
1410
+ _load() {
1411
1411
  state.loading = true;
1412
1412
  state.error = null;
1413
- api.get('/models')
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
- view: () => m(Layout, [
1426
- m('h2.text-2xl.font-bold.mb-6', 'Models'),
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...')
@@ -1484,14 +1508,15 @@ function formatCellValue(value, col) {
1484
1508
  }
1485
1509
  return String(value);
1486
1510
 
1487
- case 'text':
1511
+ case 'text': {
1488
1512
  const textStr = String(value);
1489
1513
  return textStr.length > 50 ? textStr.substring(0, 50) + '...' : textStr;
1514
+ }
1490
1515
 
1491
1516
  case 'file': {
1492
1517
  const s = String(value);
1493
1518
  const short = s.length > 72 ? s.substring(0, 72) + '…' : s;
1494
- if (/^https?:\/\//.test(s) || s.startsWith('/')) {
1519
+ if (/^https?:\\/\\//.test(s) || s.startsWith('/')) {
1495
1520
  return m('a.text-indigo-600.dark:text-indigo-400.hover:underline.break-all', {
1496
1521
  href: s,
1497
1522
  target: '_blank',
@@ -1501,9 +1526,10 @@ function formatCellValue(value, col) {
1501
1526
  return short || m('span.text-gray-400', '—');
1502
1527
  }
1503
1528
 
1504
- default:
1529
+ default: {
1505
1530
  const str = String(value);
1506
1531
  return str.length > 100 ? str.substring(0, 100) + '...' : str;
1532
+ }
1507
1533
  }
1508
1534
  }
1509
1535
 
@@ -1805,7 +1831,7 @@ function loadRecords(modelName, page = 1, filters = null) {
1805
1831
  window.history.replaceState({}, '', newUrl);
1806
1832
 
1807
1833
  const trashedParam = state.trashedView ? '&trashed=only' : '';
1808
- api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
1834
+ return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
1809
1835
  .then(result => {
1810
1836
  state.records = result.data || [];
1811
1837
  state.pagination = {
@@ -1864,9 +1890,19 @@ function initializeModelView(modelName) {
1864
1890
 
1865
1891
  // Record List Component - displays records with dynamic columns
1866
1892
  const RecordList = {
1867
- oninit: () => {
1893
+ oninit(vnode) {
1894
+ vnode.state._stopPoll = null;
1895
+ vnode.state.manualRefresh = false;
1868
1896
  const modelName = m.route.param('model');
1869
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();
1870
1906
  },
1871
1907
  onbeforeupdate: () => {
1872
1908
  // Check if model changed (navigation between different models)
@@ -1876,7 +1912,7 @@ const RecordList = {
1876
1912
  }
1877
1913
  return true;
1878
1914
  },
1879
- view: () => {
1915
+ view: (vnode) => {
1880
1916
  const modelName = m.route.param('model');
1881
1917
  const modelMeta = state.currentModelMeta;
1882
1918
  const displayColumns = modelMeta ? getDisplayColumns(modelMeta.columns) : [];
@@ -1920,6 +1956,18 @@ const RecordList = {
1920
1956
  m('.flex.items-center.justify-between.mb-4', [
1921
1957
  m('.flex.items-center.gap-3', [
1922
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
+ }),
1923
1971
  modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200 dark:border-slate-600.p-0.5', [
1924
1972
  m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
1925
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',
@@ -1937,17 +1985,96 @@ const RecordList = {
1937
1985
  }, 'Trash'),
1938
1986
  ]) : null,
1939
1987
  ]),
1940
- !state.trashedView ? 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', {
1941
- onclick: () => {
1942
- state.currentRecord = null;
1943
- state.editing = true;
1944
- m.route.set('/models/' + modelName + '/new');
1945
- }
1946
- }, [
1947
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
1948
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4v16m8-8H4' }),
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 += '\nFirst 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',
1949
2077
  ]),
1950
- 'New Record',
1951
2078
  ]) : null,
1952
2079
  ]),
1953
2080
 
@@ -2154,6 +2281,46 @@ const RecordList = {
2154
2281
  ),
2155
2282
  'Export CSV',
2156
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,
2157
2324
  !state.trashedView ? m(BulkFieldUpdateDropdown, {
2158
2325
  modelName: modelName,
2159
2326
  selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
@@ -658,4 +658,6 @@ function createExtensionApiHandlers(options) {
658
658
 
659
659
  module.exports = {
660
660
  createExtensionApiHandlers,
661
+ buildFilteredQuery,
662
+ getAllMatchingIds,
661
663
  };
@@ -68,6 +68,8 @@ class AdminRegistry {
68
68
  dateFormat: 'YYYY-MM-DD',
69
69
  timeFormat: 'HH:mm',
70
70
  uploadUrl: null,
71
+ /** Client: auto-refresh data on list/dashboard pages (ms). 0 = off. Min 10s when > 0. */
72
+ autoRefreshMs: 60000,
71
73
  };
72
74
 
73
75
  // User management config
@@ -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
- // Load page data
158
- if (pageConfig.dataLoader) {
159
- api.get('/extensions/pages/' + pageConfig.id + '/data')
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
- } else {
171
- vnode.state.loading = false;
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('h1.text-2xl.font-bold.text-gray-900', pageConfig.title),
185
- pageConfig.description && m('p.text-gray-500 dark:text-slate-400.mt-1', pageConfig.description),
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('h1.text-2xl.font-bold.text-gray-900', 'Admin Settings'),
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
- // Load widget data
313
+ vnode.state.refreshing = false;
314
+ vnode.state._stopPoll = null;
315
+
315
316
  const { widget } = vnode.attrs;
316
- if (widget.id) {
317
- api.get('/extensions/widgets/' + widget.id + '/data')
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('h3.text-sm.font-medium.text-gray-900', widget.title),
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('h1.text-2xl.font-bold.text-gray-900', config?.settings?.title || 'Dashboard'),
394
- m('p.text-gray-500 dark:text-slate-400.mt-1', 'Welcome back, ' + (state.user?.name || 'Admin')),
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'),