webspresso 0.0.49 → 0.0.50

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
@@ -530,6 +530,100 @@ Template helpers from analytics plugin:
530
530
 
531
531
  Individual helpers: `gtag()`, `gtm()`, `gtmNoscript()`, `yandexMetrika()`, `bingUET()`, `facebookPixel()`, `allAnalytics()`
532
532
 
533
+ **Admin Panel Plugin:**
534
+ - Modular admin panel with SPA (Mithril.js)
535
+ - Model CRUD UI (auto-generated from ORM)
536
+ - Extensible via custom pages, menu items, API routes, and dashboard widgets
537
+ - Other plugins (e.g. site-analytics) can register their own admin pages
538
+
539
+ ```javascript
540
+ const { adminPanelPlugin } = require('webspresso/plugins');
541
+
542
+ const { app } = createApp({
543
+ pagesDir: './pages',
544
+ plugins: [
545
+ adminPanelPlugin({
546
+ db,
547
+ path: '/_admin', // Admin URL (default: /_admin)
548
+ auth: authManager, // Optional: for user management
549
+ userManagement: { enabled: true, model: 'User' },
550
+ })
551
+ ]
552
+ });
553
+ ```
554
+
555
+ Options:
556
+ - `db` (required) - Database instance
557
+ - `path` - Admin panel path (default: `/_admin`)
558
+ - `auth` - Auth manager for user management
559
+ - `userManagement` - User management config (`enabled`, `model`, `fields`)
560
+ - `configure` - Callback `(registry) => void` for manual setup
561
+
562
+ **Custom Admin Pages (registerModule):**
563
+
564
+ Plugins can add custom admin pages using `registerModule` in `onRoutesReady`:
565
+
566
+ ```javascript
567
+ // In your plugin's onRoutesReady(ctx)
568
+ const adminApi = ctx.usePlugin('admin-panel');
569
+ if (adminApi) {
570
+ adminApi.registerModule({
571
+ id: 'my-module',
572
+
573
+ pages: [{
574
+ id: 'reports',
575
+ title: 'Reports',
576
+ path: '/reports',
577
+ icon: 'chart',
578
+ description: 'View reports',
579
+ component: `window.__customPages["reports"] = { view: () => m("div", "My Report") };`, // Mithril.js
580
+ }],
581
+
582
+ menu: [{ id: 'reports', label: 'Reports', path: '/reports', icon: 'chart', order: 5 }],
583
+
584
+ api: {
585
+ prefix: '/reports',
586
+ routes: [
587
+ { method: 'get', path: '/summary', handler: getSummaryHandler, auth: true },
588
+ ],
589
+ },
590
+
591
+ widgets: [{
592
+ id: 'reports-widget',
593
+ title: 'Quick Stats',
594
+ dataLoader: async () => ({ count: 42 }),
595
+ }],
596
+
597
+ menuGroups: [{ id: 'analytics', label: 'Analytics', order: 2 }],
598
+ });
599
+ }
600
+ ```
601
+
602
+ **registerModule config:**
603
+ | Field | Description |
604
+ |-------|-------------|
605
+ | `id` | Unique module identifier (required) |
606
+ | `pages` | Custom admin pages (each: `id`, `title`, `path`, `icon`, `description`, optional `component`) |
607
+ | `menu` | Sidebar menu items (`id`, `label`, `path`, `icon`, `order`) |
608
+ | `menuGroups` | Collapsible menu groups (`id`, `label`, `order`) |
609
+ | `api` | API routes (`prefix`, `routes`: `method`, `path`, `handler`, `auth`) |
610
+ | `widgets` | Dashboard widgets (`id`, `title`, `dataLoader`) |
611
+
612
+ For pages with `component`: provide Mithril.js code that assigns to `window.__customPages[pageId]`. Without `component`, the page shows a static placeholder.
613
+
614
+ **Manual registry API** (alternative to registerModule):
615
+
616
+ ```javascript
617
+ adminPanelPlugin({
618
+ db,
619
+ configure(registry) {
620
+ registry.registerPage('custom', { title: 'Custom', path: '/custom', icon: 'tool' });
621
+ registry.registerClientComponent('custom', 'window.__customPages["custom"] = { view: () => m("p","Hi") };');
622
+ registry.registerMenuItem({ id: 'custom', label: 'Custom', path: '/custom', icon: 'tool' });
623
+ },
624
+ })
625
+ ```
626
+
533
627
  **Site Analytics Plugin:**
534
628
  - Self-hosted page view analytics (no external services required)
535
629
  - Automatic page view tracking via Express middleware
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.49",
3
+ "version": "0.0.50",
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
  "bin": {
@@ -227,6 +227,7 @@ function createApiHandlers(options) {
227
227
  columns,
228
228
  relations: Object.keys(model.relations),
229
229
  queries: Object.keys(model.admin.queries || {}),
230
+ softDelete: !!model.scopes?.softDelete,
230
231
  });
231
232
  } catch (error) {
232
233
  res.status(500).json({ error: error.message });
@@ -250,9 +251,19 @@ function createApiHandlers(options) {
250
251
  const perPage = parseInt(req.query.perPage) || 15;
251
252
  const offset = (page - 1) * perPage;
252
253
 
253
- // Build query
254
+ // Build query (with soft delete scope if applicable)
254
255
  let query = repo.query();
255
256
  let countQuery = repo.query();
257
+ if (model.scopes?.softDelete) {
258
+ const trashed = req.query.trashed;
259
+ if (trashed === 'only') {
260
+ query = query.onlyTrashed();
261
+ countQuery = countQuery.onlyTrashed();
262
+ } else if (trashed === 'include') {
263
+ query = query.withTrashed();
264
+ countQuery = countQuery.withTrashed();
265
+ }
266
+ }
256
267
 
257
268
  // Parse filter parameters from query string
258
269
  // Express's qs library automatically parses filter[column][prop] into nested objects
@@ -492,7 +503,7 @@ function createApiHandlers(options) {
492
503
  }
493
504
 
494
505
  /**
495
- * Delete record
506
+ * Delete record (soft delete if model has softDelete scope)
496
507
  */
497
508
  async function deleteRecordHandler(req, res) {
498
509
  try {
@@ -516,6 +527,35 @@ function createApiHandlers(options) {
516
527
  }
517
528
  }
518
529
 
530
+ /**
531
+ * Restore soft-deleted record
532
+ */
533
+ async function restoreRecordHandler(req, res) {
534
+ try {
535
+ const { model: modelName, id } = req.params;
536
+ const model = getModelFromDb(modelName);
537
+
538
+ if (!model || !model.admin || model.admin.enabled !== true) {
539
+ return res.status(404).json({ error: 'Model not found or not enabled' });
540
+ }
541
+
542
+ if (!model.scopes?.softDelete) {
543
+ return res.status(400).json({ error: 'Model does not support soft delete' });
544
+ }
545
+
546
+ const repo = db.getRepository(model.name);
547
+ const record = await repo.restore(id);
548
+
549
+ if (!record) {
550
+ return res.status(404).json({ error: 'Record not found in trash' });
551
+ }
552
+
553
+ res.json({ success: true, data: record });
554
+ } catch (error) {
555
+ res.status(500).json({ error: error.message });
556
+ }
557
+ }
558
+
519
559
  /**
520
560
  * Get relation data
521
561
  */
@@ -615,6 +655,7 @@ function createApiHandlers(options) {
615
655
  createRecordHandler,
616
656
  updateRecordHandler,
617
657
  deleteRecordHandler,
658
+ restoreRecordHandler,
618
659
  relationHandler,
619
660
  queryHandler,
620
661
  resetHandler,
@@ -1590,6 +1590,9 @@ function loadRecords(modelName, page = 1, filters = null) {
1590
1590
  // Update URL with filters
1591
1591
  const queryParams = new URLSearchParams();
1592
1592
  queryParams.set('page', page);
1593
+ if (state.trashedView) {
1594
+ queryParams.set('trashed', 'only');
1595
+ }
1593
1596
  if (Object.keys(activeFilters).length > 0) {
1594
1597
  for (const [col, filter] of Object.entries(activeFilters)) {
1595
1598
  if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
@@ -1613,7 +1616,8 @@ function loadRecords(modelName, page = 1, filters = null) {
1613
1616
  const newUrl = window.location.pathname + (queryParams.toString() ? '?' + queryParams.toString() : '');
1614
1617
  window.history.replaceState({}, '', newUrl);
1615
1618
 
1616
- api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + filterQuery)
1619
+ const trashedParam = state.trashedView ? '&trashed=only' : '';
1620
+ api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
1617
1621
  .then(result => {
1618
1622
  state.records = result.data || [];
1619
1623
  state.pagination = {
@@ -1641,6 +1645,7 @@ function initializeModelView(modelName) {
1641
1645
  state.filterDrawerOpen = false;
1642
1646
  state.selectedRecords = new Set(); // Bulk selection
1643
1647
  state.selectAllMode = false; // Reset select all mode
1648
+ state.trashedView = false; // Soft delete: show trashed records
1644
1649
  state.bulkActionInProgress = false;
1645
1650
  state.bulkFields = []; // Reset bulk fields
1646
1651
  state.bulkFieldDropdownOpen = false;
@@ -1725,8 +1730,26 @@ const RecordList = {
1725
1730
  return m(Layout, { breadcrumbs }, [
1726
1731
  // Header
1727
1732
  m('.flex.items-center.justify-between.mb-4', [
1728
- m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
1729
- 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', {
1733
+ m('.flex.items-center.gap-3', [
1734
+ m('h2.text-2xl.font-bold', modelMeta?.label || modelName),
1735
+ modelMeta?.softDelete ? m('.flex.rounded-lg.border.border-gray-200.p-0.5', [
1736
+ m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
1737
+ class: !state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900',
1738
+ onclick: () => {
1739
+ state.trashedView = false;
1740
+ loadRecords(modelName, 1);
1741
+ },
1742
+ }, 'Active'),
1743
+ m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
1744
+ class: state.trashedView ? 'bg-indigo-600.text-white' : 'text-gray-600.hover:text-gray-900',
1745
+ onclick: () => {
1746
+ state.trashedView = true;
1747
+ loadRecords(modelName, 1);
1748
+ },
1749
+ }, 'Trash'),
1750
+ ]) : null,
1751
+ ]),
1752
+ !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', {
1730
1753
  onclick: () => {
1731
1754
  state.currentRecord = null;
1732
1755
  state.editing = true;
@@ -1737,7 +1760,7 @@ const RecordList = {
1737
1760
  m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4v16m8-8H4' }),
1738
1761
  ]),
1739
1762
  'New Record',
1740
- ]),
1763
+ ]) : null,
1741
1764
  ]),
1742
1765
 
1743
1766
  // Quick Filters Bar
@@ -1825,35 +1848,63 @@ const RecordList = {
1825
1848
  }, 'Select only this page (' + state.selectedRecords.size + ')'),
1826
1849
  ]),
1827
1850
  m('.flex.items-center.gap-2', [
1828
- m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
1829
- disabled: state.bulkActionInProgress,
1830
- onclick: async () => {
1831
- const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
1832
- if (!confirm('Are you sure you want to delete ' + count + ' records? This action cannot be undone.')) return;
1833
- state.bulkActionInProgress = true;
1834
- m.redraw();
1835
- try {
1836
- const payload = state.selectAllMode
1837
- ? { selectAll: true, filters: state.filters }
1838
- : { ids: Array.from(state.selectedRecords) };
1839
- await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
1840
- state.selectedRecords = new Set();
1841
- state.selectAllMode = false;
1842
- loadRecords(modelName, 1);
1843
- } catch (err) {
1844
- alert('Error: ' + err.message);
1845
- } finally {
1846
- state.bulkActionInProgress = false;
1847
- m.redraw();
1848
- }
1849
- },
1850
- }, [
1851
- m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1852
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
1853
- ),
1854
- 'Delete',
1855
- ]),
1856
- m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
1851
+ state.trashedView && modelMeta?.softDelete
1852
+ ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
1853
+ disabled: state.bulkActionInProgress,
1854
+ onclick: async () => {
1855
+ if (!confirm('Restore the selected records?')) return;
1856
+ state.bulkActionInProgress = true;
1857
+ m.redraw();
1858
+ try {
1859
+ const payload = state.selectAllMode
1860
+ ? { selectAll: true, filters: state.filters, trashed: true }
1861
+ : { ids: Array.from(state.selectedRecords), trashed: true };
1862
+ await api.post('/extensions/bulk-actions/bulk-restore/' + modelName, payload);
1863
+ state.selectedRecords = new Set();
1864
+ state.selectAllMode = false;
1865
+ loadRecords(modelName, 1);
1866
+ } catch (err) {
1867
+ alert('Error: ' + err.message);
1868
+ } finally {
1869
+ state.bulkActionInProgress = false;
1870
+ m.redraw();
1871
+ }
1872
+ },
1873
+ }, [
1874
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1875
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 13l4 4L19 7' })
1876
+ ),
1877
+ 'Restore',
1878
+ ])
1879
+ : m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
1880
+ disabled: state.bulkActionInProgress,
1881
+ onclick: async () => {
1882
+ const count = state.selectAllMode ? state.pagination.total : state.selectedRecords.size;
1883
+ if (!confirm('Are you sure you want to delete ' + count + ' records? This action cannot be undone.')) return;
1884
+ state.bulkActionInProgress = true;
1885
+ m.redraw();
1886
+ try {
1887
+ const payload = state.selectAllMode
1888
+ ? { selectAll: true, filters: state.filters }
1889
+ : { ids: Array.from(state.selectedRecords) };
1890
+ await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, payload);
1891
+ state.selectedRecords = new Set();
1892
+ state.selectAllMode = false;
1893
+ loadRecords(modelName, 1);
1894
+ } catch (err) {
1895
+ alert('Error: ' + err.message);
1896
+ } finally {
1897
+ state.bulkActionInProgress = false;
1898
+ m.redraw();
1899
+ }
1900
+ },
1901
+ }, [
1902
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1903
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
1904
+ ),
1905
+ 'Delete',
1906
+ ]),
1907
+ !state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
1857
1908
  disabled: state.bulkActionInProgress,
1858
1909
  onclick: async () => {
1859
1910
  state.bulkActionInProgress = true;
@@ -1883,8 +1934,8 @@ const RecordList = {
1883
1934
  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' })
1884
1935
  ),
1885
1936
  'Export JSON',
1886
- ]),
1887
- m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
1937
+ ]) : null,
1938
+ !state.trashedView ? m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
1888
1939
  disabled: state.bulkActionInProgress,
1889
1940
  onclick: async () => {
1890
1941
  state.bulkActionInProgress = true;
@@ -1914,9 +1965,8 @@ const RecordList = {
1914
1965
  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' })
1915
1966
  ),
1916
1967
  'Export CSV',
1917
- ]),
1918
- // Bulk Field Update Dropdown
1919
- m(BulkFieldUpdateDropdown, {
1968
+ ]) : null,
1969
+ !state.trashedView ? m(BulkFieldUpdateDropdown, {
1920
1970
  modelName: modelName,
1921
1971
  selectedIds: state.selectAllMode ? null : Array.from(state.selectedRecords),
1922
1972
  selectAllMode: state.selectAllMode,
@@ -1926,7 +1976,7 @@ const RecordList = {
1926
1976
  state.selectAllMode = false;
1927
1977
  loadRecords(modelName, state.pagination.page);
1928
1978
  },
1929
- }),
1979
+ }) : null,
1930
1980
  m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
1931
1981
  onclick: () => {
1932
1982
  state.selectedRecords = new Set();
@@ -1938,14 +1988,14 @@ const RecordList = {
1938
1988
  }, 'Clear'),
1939
1989
  ]),
1940
1990
  ]) : null,
1941
- // Table container with sticky header and actions
1942
- m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
1991
+ // Table container with sticky header, fixed columns, and overflow scroll
1992
+ m('.overflow-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
1943
1993
  m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
1944
1994
  // Sticky header
1945
- m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 10;' }, [
1995
+ m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 20; background: #f9fafb;' }, [
1946
1996
  m('tr', [
1947
- // Checkbox column header
1948
- m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px;' }, [
1997
+ // Checkbox column header (sticky left, box-shadow on right)
1998
+ m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px; position: sticky; left: 0; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' }, [
1949
1999
  m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1950
2000
  checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
1951
2001
  indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
@@ -1959,15 +2009,16 @@ const RecordList = {
1959
2009
  },
1960
2010
  }),
1961
2011
  ]),
1962
- // Dynamic column headers
1963
- ...displayColumns.map(col =>
2012
+ // Dynamic column headers (first column sticky left with box-shadow)
2013
+ ...displayColumns.map((col, i) =>
1964
2014
  m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.border-b.border-gray-200',
2015
+ i === 0 ? { style: 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
1965
2016
  formatColumnLabel(col.name)
1966
2017
  )
1967
2018
  ),
1968
- // Sticky actions header
2019
+ // Sticky actions header (sticky right, box-shadow on left)
1969
2020
  m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500.uppercase.tracking-wider.bg-gray-50.border-b.border-gray-200', {
1970
- style: 'position: sticky; right: 0; min-width: 120px;',
2021
+ style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
1971
2022
  }, 'Actions'),
1972
2023
  ]),
1973
2024
  ]),
@@ -1975,8 +2026,10 @@ const RecordList = {
1975
2026
  m('tr.hover:bg-gray-50.transition-colors', {
1976
2027
  class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50' : '',
1977
2028
  }, [
1978
- // Checkbox cell
1979
- m('td.px-4.py-3', [
2029
+ // Checkbox cell (sticky left, box-shadow on right)
2030
+ m('td.px-4.py-3.bg-white', {
2031
+ style: 'position: sticky; left: 0; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);',
2032
+ }, [
1980
2033
  m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1981
2034
  checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
1982
2035
  onchange: (e) => {
@@ -1990,35 +2043,49 @@ const RecordList = {
1990
2043
  },
1991
2044
  }),
1992
2045
  ]),
1993
- // Dynamic cell values
1994
- ...displayColumns.map(col =>
1995
- m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700',
2046
+ // Dynamic cell values (first column sticky left with box-shadow)
2047
+ ...displayColumns.map((col, i) =>
2048
+ m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700.bg-white',
2049
+ i === 0 ? { style: 'position: sticky; left: 40px; z-index: 5; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
1996
2050
  formatCellValue(record[col.name], col)
1997
2051
  )
1998
2052
  ),
1999
- // Sticky actions cell
2053
+ // Sticky actions cell (sticky right, box-shadow on left)
2000
2054
  m('td.px-4.py-3.text-sm.text-right.whitespace-nowrap.bg-white', {
2001
- style: 'position: sticky; right: 0; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.05);',
2055
+ style: 'position: sticky; right: 0; z-index: 5; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
2002
2056
  }, [
2003
- m('button.inline-flex.items-center.px-2.py-1.text-sm.text-indigo-600.hover:text-indigo-800.hover:bg-indigo-50.rounded.mr-1.transition-colors', {
2004
- onclick: () => {
2005
- state.currentRecord = record;
2006
- state.editing = true;
2007
- m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
2008
- }
2009
- }, 'Edit'),
2010
- m('button.inline-flex.items-center.px-2.py-1.text-sm.text-red-600.hover:text-red-800.hover:bg-red-50.rounded.transition-colors', {
2011
- onclick: async () => {
2012
- if (confirm('Are you sure you want to delete this record?')) {
2013
- try {
2014
- await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
2015
- loadRecords(modelName, state.pagination.page);
2016
- } catch (err) {
2017
- alert('Error: ' + err.message);
2018
- }
2019
- }
2020
- }
2021
- }, 'Delete'),
2057
+ state.trashedView && modelMeta?.softDelete
2058
+ ? m('button.inline-flex.items-center.px-2.py-1.text-sm.text-green-600.hover:text-green-800.hover:bg-green-50.rounded.transition-colors', {
2059
+ onclick: async () => {
2060
+ try {
2061
+ await api.post('/models/' + modelName + '/records/' + record[primaryKey] + '/restore');
2062
+ loadRecords(modelName, state.pagination.page);
2063
+ } catch (err) {
2064
+ alert('Error: ' + err.message);
2065
+ }
2066
+ },
2067
+ }, 'Restore')
2068
+ : [
2069
+ m('button.inline-flex.items-center.px-2.py-1.text-sm.text-indigo-600.hover:text-indigo-800.hover:bg-indigo-50.rounded.mr-1.transition-colors', {
2070
+ onclick: () => {
2071
+ state.currentRecord = record;
2072
+ state.editing = true;
2073
+ m.route.set('/models/' + modelName + '/edit/' + record[primaryKey]);
2074
+ },
2075
+ }, 'Edit'),
2076
+ m('button.inline-flex.items-center.px-2.py-1.text-sm.text-red-600.hover:text-red-800.hover:bg-red-50.rounded.transition-colors', {
2077
+ onclick: async () => {
2078
+ if (confirm('Are you sure you want to delete this record?')) {
2079
+ try {
2080
+ await api.delete('/models/' + modelName + '/records/' + record[primaryKey]);
2081
+ loadRecords(modelName, state.pagination.page);
2082
+ } catch (err) {
2083
+ alert('Error: ' + err.message);
2084
+ }
2085
+ }
2086
+ },
2087
+ }, 'Delete'),
2088
+ ],
2022
2089
  ]),
2023
2090
  ])
2024
2091
  )),
@@ -8,10 +8,15 @@
8
8
  * Build query with filters applied
9
9
  * @param {Object} repo - Repository instance
10
10
  * @param {Object} filters - Filter object from frontend
11
+ * @param {Object} [options] - Options
12
+ * @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
11
13
  * @returns {Object} Query builder with filters applied
12
14
  */
13
- function buildFilteredQuery(repo, filters) {
15
+ function buildFilteredQuery(repo, filters, options = {}) {
14
16
  let query = repo.query();
17
+ if (options.onlyTrashed) {
18
+ query = query.onlyTrashed();
19
+ }
15
20
 
16
21
  if (!filters || Object.keys(filters).length === 0) {
17
22
  return query;
@@ -20,7 +25,7 @@ function buildFilteredQuery(repo, filters) {
20
25
  for (const [column, filter] of Object.entries(filters)) {
21
26
  if (!filter || (filter.value === '' && !filter.from && !filter.to)) continue;
22
27
 
23
- const op = filter.op || 'contains';
28
+ const op = filter.op || filter.operator || 'contains';
24
29
 
25
30
  switch (op) {
26
31
  case 'contains':
@@ -67,13 +72,14 @@ function buildFilteredQuery(repo, filters) {
67
72
 
68
73
  /**
69
74
  * Get all record IDs matching filters (for selectAll mode)
70
- * @param {Object} repo - Repository instance
75
+ * @param {Object} repo - Repository instance
71
76
  * @param {Object} filters - Filter object
72
77
  * @param {string} primaryKey - Primary key column name
78
+ * @param {Object} [options] - Options (onlyTrashed for soft delete)
73
79
  * @returns {Promise<Array>} Array of IDs
74
80
  */
75
- async function getAllMatchingIds(repo, filters, primaryKey = 'id') {
76
- const query = buildFilteredQuery(repo, filters);
81
+ async function getAllMatchingIds(repo, filters, primaryKey = 'id', options = {}) {
82
+ const query = buildFilteredQuery(repo, filters, options);
77
83
  const records = await query.select(primaryKey).list();
78
84
  return records.map(r => r[primaryKey]);
79
85
  }
@@ -178,7 +184,7 @@ function createExtensionApiHandlers(options) {
178
184
  async function bulkActionHandler(req, res) {
179
185
  try {
180
186
  const { actionId, model: modelName } = req.params;
181
- const { ids, selectAll, filters } = req.body;
187
+ const { ids, selectAll, filters, trashed } = req.body;
182
188
 
183
189
  const action = registry.bulkActions.get(actionId);
184
190
 
@@ -194,17 +200,20 @@ function createExtensionApiHandlers(options) {
194
200
  }
195
201
  }
196
202
 
197
- // Get repository
203
+ const { getModel } = require('../../../core/orm/model');
204
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
205
+ const primaryKey = model?.primaryKey || 'id';
198
206
  const repo = db.getRepository(modelName);
199
-
207
+
208
+ // Options for getAllMatchingIds (e.g. onlyTrashed for bulk-restore)
209
+ const fetchOptions = (actionId === 'bulk-restore' && trashed && model?.scopes?.softDelete)
210
+ ? { onlyTrashed: true }
211
+ : {};
212
+
200
213
  // Determine IDs to process
201
214
  let recordIds;
202
215
  if (selectAll) {
203
- // Get all matching record IDs based on filters
204
- const { getModel } = require('../../../core/orm/model');
205
- const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
206
- const primaryKey = model?.primaryKey || 'id';
207
- recordIds = await getAllMatchingIds(repo, filters, primaryKey);
216
+ recordIds = await getAllMatchingIds(repo, filters, primaryKey, fetchOptions);
208
217
  } else {
209
218
  if (!ids || !Array.isArray(ids) || ids.length === 0) {
210
219
  return res.status(400).json({ error: 'No records selected' });
@@ -216,12 +225,15 @@ function createExtensionApiHandlers(options) {
216
225
  return res.status(400).json({ error: 'No records match the criteria' });
217
226
  }
218
227
 
219
- // Get records
220
- const records = [];
221
- for (const id of recordIds) {
222
- const record = await repo.findById(id);
223
- if (record) {
224
- records.push(record);
228
+ // Get records (for bulk-restore with trashed, findById won't find them - pass minimal { id })
229
+ let records;
230
+ if (actionId === 'bulk-restore' && trashed && model?.scopes?.softDelete) {
231
+ records = recordIds.map(id => ({ id }));
232
+ } else {
233
+ records = [];
234
+ for (const id of recordIds) {
235
+ const record = await repo.findById(id);
236
+ if (record) records.push(record);
225
237
  }
226
238
  }
227
239
 
@@ -488,12 +500,10 @@ function createExtensionApiHandlers(options) {
488
500
  try {
489
501
  const updates = req.body || {};
490
502
 
491
- // Merge with existing settings
503
+ // Merge with existing settings (configure expects flat key-value at top level)
492
504
  const currentSettings = registry.settings || {};
493
505
  const newSettings = { ...currentSettings, ...updates };
494
-
495
- // Update registry settings
496
- registry.configure({ settings: newSettings });
506
+ registry.settings = newSettings;
497
507
 
498
508
  res.json({ success: true, settings: registry.settings });
499
509
  } catch (error) {
@@ -511,8 +521,19 @@ function createExtensionApiHandlers(options) {
511
521
  // Support both path param and query param for model name
512
522
  const modelName = req.params.model || req.query.model;
513
523
  const format = req.query.format || 'json';
514
- const { selectAll, filters } = req.body || {};
515
-
524
+ // Support selectAll and filters from body (POST) or query (GET)
525
+ const body = req.body || {};
526
+ let selectAll = body.selectAll ?? req.query.selectAll;
527
+ let filters = body.filters ?? req.query.filters;
528
+ if (typeof selectAll === 'string') selectAll = selectAll === 'true';
529
+ if (typeof filters === 'string') {
530
+ try {
531
+ filters = JSON.parse(filters);
532
+ } catch {
533
+ filters = undefined;
534
+ }
535
+ }
536
+
516
537
  // Support IDs from query string (GET) or body (POST)
517
538
  let idList = null;
518
539
  if (req.body?.ids && Array.isArray(req.body.ids)) {
@@ -270,6 +270,7 @@ function adminPanelPlugin(options = {}) {
270
270
  ctx.addRoute('post', `${adminPath}/api/models/:model/records`, requireAuth, apiHandlers.createRecordHandler);
271
271
  ctx.addRoute('put', `${adminPath}/api/models/:model/records/:id`, requireAuth, apiHandlers.updateRecordHandler);
272
272
  ctx.addRoute('delete', `${adminPath}/api/models/:model/records/:id`, requireAuth, apiHandlers.deleteRecordHandler);
273
+ ctx.addRoute('post', `${adminPath}/api/models/:model/records/:id/restore`, requireAuth, apiHandlers.restoreRecordHandler);
273
274
 
274
275
  // Relation API routes (auth required)
275
276
  ctx.addRoute('get', `${adminPath}/api/models/:model/relations/:relation`, requireAuth, apiHandlers.relationHandler);
@@ -13,6 +13,34 @@
13
13
  function registerDefaultBulkActions(options) {
14
14
  const { registry, db } = options;
15
15
 
16
+ // Bulk restore (soft delete models only - use with trashed view)
17
+ registry.registerBulkAction('bulk-restore', {
18
+ label: 'Restore Selected',
19
+ icon: 'check',
20
+ color: 'green',
21
+ models: '*',
22
+ confirm: true,
23
+ confirmMessage: 'Restore the selected records?',
24
+ handler: async (records, modelName, { db }) => {
25
+ const { getModel } = require('../../../core/orm/model');
26
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
27
+ if (!model?.scopes?.softDelete) {
28
+ return { message: 'Model does not support restore', error: true };
29
+ }
30
+ const repo = db.getRepository(modelName);
31
+ let restored = 0;
32
+ for (const record of records) {
33
+ try {
34
+ const result = await repo.restore(record.id);
35
+ if (result) restored++;
36
+ } catch (e) {
37
+ console.error(`Failed to restore record ${record.id}:`, e.message);
38
+ }
39
+ }
40
+ return { message: `${restored} of ${records.length} records restored`, restored };
41
+ },
42
+ });
43
+
16
44
  // Bulk delete
17
45
  registry.registerBulkAction('bulk-delete', {
18
46
  label: 'Delete Selected',