webspresso 0.0.73 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +4 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. package/templates/skills/webspresso-usage/SKILL.md +29 -275
@@ -0,0 +1,554 @@
1
+ // Filter Operator Labels
2
+ /** Null / presence checks — available for filterable scalar columns where applicable */
3
+ const FILTER_OPS_NULL_CHECK = [
4
+ { value: 'is_null', label: 'Is null' },
5
+ { value: 'is_not_null', label: 'Is not null' },
6
+ ];
7
+
8
+ const FILTER_OPERATORS = {
9
+ string: [
10
+ { value: 'contains', label: 'Contains' },
11
+ { value: 'equals', label: 'Equals' },
12
+ { value: 'starts_with', label: 'Starts with' },
13
+ { value: 'ends_with', label: 'Ends with' },
14
+ ...FILTER_OPS_NULL_CHECK,
15
+ ],
16
+ number: [
17
+ { value: 'eq', label: 'Equals' },
18
+ { value: 'gt', label: 'Greater than' },
19
+ { value: 'gte', label: 'Greater or equal' },
20
+ { value: 'lt', label: 'Less than' },
21
+ { value: 'lte', label: 'Less or equal' },
22
+ { value: 'between', label: 'Between' },
23
+ ...FILTER_OPS_NULL_CHECK,
24
+ ],
25
+ date: [
26
+ { value: 'eq', label: 'Equals' },
27
+ { value: 'gt', label: 'After' },
28
+ { value: 'gte', label: 'On or after' },
29
+ { value: 'lt', label: 'Before' },
30
+ { value: 'lte', label: 'On or before' },
31
+ { value: 'between', label: 'Between' },
32
+ ...FILTER_OPS_NULL_CHECK,
33
+ ],
34
+ };
35
+
36
+ function filterOpIsNullCheck(op) {
37
+ return op === 'is_null' || op === 'is_not_null';
38
+ }
39
+
40
+ function getOperatorLabel(op, colType) {
41
+ if (filterOpIsNullCheck(op)) {
42
+ return op === 'is_null' ? 'Is null' : 'Is not null';
43
+ }
44
+ const ops =
45
+ colType === 'date' || colType === 'datetime' || colType === 'timestamp'
46
+ ? FILTER_OPERATORS.date
47
+ : colType === 'integer' || colType === 'bigint' || colType === 'float' || colType === 'decimal'
48
+ ? FILTER_OPERATORS.number
49
+ : FILTER_OPERATORS.string;
50
+
51
+ const found = ops.find(o => o.value === op);
52
+ return found ? found.label : op;
53
+ }
54
+
55
+ // Filter Badge Component
56
+ const FilterBadge = {
57
+ view: (vnode) => {
58
+ const { colName, filter, colMeta, onRemove } = vnode.attrs;
59
+ const label = colMeta?.ui?.label || formatColumnLabel(colName);
60
+ const opLabel = getOperatorLabel(filter.op, colMeta?.type);
61
+
62
+ let displayValue = '';
63
+ if (filterOpIsNullCheck(filter.op)) {
64
+ displayValue = '';
65
+ } else if (filter.op === 'between') {
66
+ displayValue = (filter.from || '?') + ' - ' + (filter.to || '?');
67
+ } else if (filter.op === 'in' && Array.isArray(filter.value)) {
68
+ displayValue = filter.value.join(', ');
69
+ } else {
70
+ displayValue = String(filter.value || '');
71
+ }
72
+
73
+ const showQuotedValue = displayValue.length > 0;
74
+
75
+ if (showQuotedValue && displayValue.length > 20) {
76
+ displayValue = displayValue.substring(0, 20) + '...';
77
+ }
78
+
79
+ return m('span.inline-flex.items-center.gap-1.px-2.5.py-1.rounded-full.text-xs.font-medium.bg-indigo-50.text-indigo-700.border.border-indigo-200', [
80
+ m('span.font-semibold', label),
81
+ m('span.text-indigo-400', opLabel.toLowerCase()),
82
+ showQuotedValue ? m('span', '"' + displayValue + '"') : null,
83
+ m('button.ml-1.text-indigo-400.hover:text-indigo-600.focus:outline-none', {
84
+ onclick: (e) => {
85
+ e.stopPropagation();
86
+ onRemove(colName);
87
+ },
88
+ type: 'button',
89
+ }, [
90
+ m('svg.w-3.5.h-3.5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [
91
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M6 18L18 6M6 6l12 12' }),
92
+ ]),
93
+ ]),
94
+ ]);
95
+ },
96
+ };
97
+
98
+ // Active Filters Bar
99
+ const ActiveFiltersBar = {
100
+ view: (vnode) => {
101
+ const { filters, modelMeta, onRemove, onClearAll } = vnode.attrs;
102
+ if (!filters || Object.keys(filters).length === 0) return null;
103
+
104
+ const filterEntries = Object.entries(filters).filter(([_, f]) =>
105
+ f &&
106
+ (
107
+ filterOpIsNullCheck(f.op) ||
108
+ f.value !== '' ||
109
+ (Array.isArray(f.value) && f.value.length > 0) ||
110
+ f.from ||
111
+ f.to
112
+ ),
113
+ );
114
+
115
+ if (filterEntries.length === 0) return null;
116
+
117
+ return m('.flex.items-center.gap-2.py-2.flex-wrap', [
118
+ m('span.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wide', 'Active filters:'),
119
+ ...filterEntries.map(([colName, filter]) => {
120
+ const col = modelMeta?.columns?.find(c => c.name === colName);
121
+ return m(FilterBadge, { colName, filter, colMeta: col, onRemove });
122
+ }),
123
+ filterEntries.length > 1 ? m('button.text-xs.text-gray-500 dark:text-slate-400.hover:text-gray-700 dark:hover:text-slate-200 dark:hover:text-slate-200.underline.ml-2', {
124
+ onclick: onClearAll,
125
+ type: 'button',
126
+ }, 'Clear all') : null,
127
+ ]);
128
+ },
129
+ };
130
+
131
+ // Quick Filter Input
132
+ const QuickFilterInput = {
133
+ view: (vnode) => {
134
+ const { placeholder, value, onChange, onClear } = vnode.attrs;
135
+
136
+ return m('.relative.flex-1.max-w-xs', [
137
+ m('div.absolute.inset-y-0.left-0.pl-3.flex.items-center.pointer-events-none', [
138
+ m('svg.h-4.w-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
139
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }),
140
+ ]),
141
+ ]),
142
+ m('input.block.w-full.pl-9.pr-8.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.placeholder-gray-400 dark:placeholder-slate-500.focus:outline-none.focus:ring-2.focus:ring-indigo-500.focus:border-transparent', {
143
+ type: 'text',
144
+ placeholder: placeholder || 'Quick search...',
145
+ value: value || '',
146
+ oninput: (e) => onChange(e.target.value),
147
+ }),
148
+ value ? m('button.absolute.inset-y-0.right-0.pr-3.flex.items-center.text-gray-400 dark:text-slate-500.hover:text-gray-600 dark:hover:text-slate-300 dark:hover:text-slate-300', {
149
+ onclick: onClear,
150
+ type: 'button',
151
+ }, [
152
+ m('svg.h-4.w-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
153
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' }),
154
+ ]),
155
+ ]) : null,
156
+ ]);
157
+ },
158
+ };
159
+
160
+ // Quick Filters Bar
161
+ const QuickFiltersBar = {
162
+ view: (vnode) => {
163
+ const { modelMeta, filters, onFilterChange, onOpenDrawer, activeFilterCount } = vnode.attrs;
164
+
165
+ const searchableColumns = (modelMeta?.columns || []).filter(col =>
166
+ (col.type === 'string' || col.type === 'text') &&
167
+ !col.primary &&
168
+ ['name', 'title', 'email', 'slug', 'username'].some(n => col.name.includes(n))
169
+ );
170
+
171
+ const enumColumns = (modelMeta?.columns || []).filter(col =>
172
+ col.type === 'enum' && col.enumValues && col.enumValues.length <= 6
173
+ );
174
+
175
+ const quickSearchCol = searchableColumns[0];
176
+ const quickSearchFilter = quickSearchCol ? filters[quickSearchCol.name] : null;
177
+
178
+ return m('.bg-white dark:bg-slate-800.border.border-gray-200 dark:border-slate-600.rounded-lg.p-3.mb-4.shadow-sm', [
179
+ m('.flex.items-center.gap-3.flex-wrap', [
180
+ quickSearchCol ? m(QuickFilterInput, {
181
+ placeholder: 'Search by ' + (quickSearchCol.ui?.label || formatColumnLabel(quickSearchCol.name)).toLowerCase() + '...',
182
+ value: quickSearchFilter?.value || '',
183
+ onChange: (value) => {
184
+ if (value) {
185
+ onFilterChange(quickSearchCol.name, { op: 'contains', value });
186
+ } else {
187
+ onFilterChange(quickSearchCol.name, null);
188
+ }
189
+ },
190
+ onClear: () => onFilterChange(quickSearchCol.name, null),
191
+ }) : null,
192
+
193
+ ...enumColumns.slice(0, 2).map(col => {
194
+ const currentFilter = filters[col.name];
195
+ const currentValue = currentFilter?.value;
196
+ const label = col.ui?.label || formatColumnLabel(col.name);
197
+
198
+ return m('.flex.items-center.gap-1', [
199
+ m('span.text-xs.font-medium.text-gray-500', label + ':'),
200
+ m('.flex.gap-1', [
201
+ m('button.px-2.py-1.text-xs.rounded-md.transition-colors', {
202
+ class: !currentValue
203
+ ? 'bg-gray-200 text-gray-800'
204
+ : 'bg-gray-100 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-600 dark:hover:bg-slate-600',
205
+ onclick: () => onFilterChange(col.name, null),
206
+ }, 'All'),
207
+ ...col.enumValues.map(val =>
208
+ m('button.px-2.py-1.text-xs.rounded-md.transition-colors', {
209
+ class: currentValue === val
210
+ ? 'bg-indigo-600 text-white'
211
+ : 'bg-gray-100 text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-600 dark:hover:bg-slate-600',
212
+ onclick: () => onFilterChange(col.name, { op: 'equals', value: val }),
213
+ }, val)
214
+ ),
215
+ ]),
216
+ ]);
217
+ }),
218
+
219
+ m('.flex-1'),
220
+
221
+ m('button.inline-flex.items-center.gap-2.px-3.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800.border.border-gray-300 dark:border-slate-600.rounded-lg.hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
222
+ onclick: onOpenDrawer,
223
+ type: 'button',
224
+ }, [
225
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
226
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z' }),
227
+ ]),
228
+ 'All Filters',
229
+ activeFilterCount > 0 ? m('span.inline-flex.items-center.justify-center.w-5.h-5.text-xs.font-bold.text-white.bg-indigo-600.rounded-full', activeFilterCount) : null,
230
+ ]),
231
+ ]),
232
+ ]);
233
+ },
234
+ };
235
+
236
+ // Filter Field Component
237
+ const FilterField = {
238
+ view: (vnode) => {
239
+ const { col, filter, onChange } = vnode.attrs;
240
+ const currentFilter = filter || {};
241
+ const label = col.ui?.label || formatColumnLabel(col.name);
242
+
243
+ if (col.type === 'boolean') {
244
+ const mode =
245
+ filterOpIsNullCheck(currentFilter.op)
246
+ ? currentFilter.op
247
+ : (currentFilter.value || '')
248
+ ? currentFilter.value
249
+ : '';
250
+
251
+ const rows = [
252
+ ['true', 'Yes'],
253
+ ['false', 'No'],
254
+ ['is_null', 'Is null'],
255
+ ['is_not_null', 'Is not null'],
256
+ ['', 'Any'],
257
+ ];
258
+
259
+ return m('.space-y-2', [
260
+ m('label.block.text-sm.font-medium.text-gray-700', label),
261
+ m('.flex.flex-col.gap-2', [
262
+ ...rows.map(([val, lab]) =>
263
+ m('label.inline-flex.items-center.cursor-pointer', [
264
+ m('input.w-4.h-4.text-indigo-600.border-gray-300.dark:border-slate-600.focus:ring-indigo-500', {
265
+ type: 'radio',
266
+ name: 'filter_bool_' + col.name,
267
+ checked: mode === val,
268
+ onchange: () => {
269
+ if (val === '') onChange(null);
270
+ else if (val === 'is_null') onChange({ op: 'is_null' });
271
+ else if (val === 'is_not_null') onChange({ op: 'is_not_null' });
272
+ else onChange({ value: val });
273
+ },
274
+ }),
275
+ m('span.ml-2.text-sm.text-gray-600', lab),
276
+ ]),
277
+ ),
278
+ ]),
279
+ ]);
280
+ }
281
+
282
+ if (col.type === 'enum' && col.enumValues) {
283
+ const enumModeSelect = FILTER_OPS_NULL_CHECK;
284
+ const isNullEnum = filterOpIsNullCheck(currentFilter.op);
285
+ const selectedValues =
286
+ currentFilter.op === 'in' && Array.isArray(currentFilter.value)
287
+ ? currentFilter.value
288
+ : currentFilter.value
289
+ ? [currentFilter.value]
290
+ : [];
291
+
292
+ return m('.space-y-2', [
293
+ m('label.block.text-sm.font-medium.text-gray-700', label),
294
+ m('select.w-full.px-3.py-2.text-sm.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500.mb-2', {
295
+ value: isNullEnum ? currentFilter.op : 'match',
296
+ onchange: (e) => {
297
+ const v = e.target.value;
298
+ if (v === 'match') {
299
+ onChange(selectedValues.length > 0 ? { op: 'in', value: selectedValues } : null);
300
+ } else if (filterOpIsNullCheck(v)) {
301
+ onChange({ op: v });
302
+ }
303
+ },
304
+ }, [
305
+ m('option', { value: 'match' }, 'Match values…'),
306
+ ...enumModeSelect.map(o => m('option', { value: o.value }, o.label)),
307
+ ]),
308
+ !isNullEnum
309
+ ? m('.flex.flex-wrap.gap-2', [
310
+ ...col.enumValues.map(val => {
311
+ const isSelected = selectedValues.includes(val);
312
+ return m('button.px-3.py-1.5.text-sm.rounded-md.border.transition-colors', {
313
+ type: 'button',
314
+ class: isSelected
315
+ ? 'bg-indigo-100.border-indigo-300.text-indigo-700'
316
+ : 'bg-white.border-gray-300.dark:border-slate-600.text-gray-700.dark:text-slate-300.hover:bg-gray-50.dark:hover:bg-slate-800/50.dark:hover:bg-slate-800/50',
317
+ onclick: () => {
318
+ const newSelected = isSelected
319
+ ? selectedValues.filter(v => v !== val)
320
+ : [...selectedValues, val];
321
+ onChange(newSelected.length > 0 ? { op: 'in', value: newSelected } : null);
322
+ },
323
+ }, val);
324
+ }),
325
+ ])
326
+ : null,
327
+ ]);
328
+ }
329
+
330
+ if (col.type === 'date' || col.type === 'datetime' || col.type === 'timestamp') {
331
+ const inputType = col.type === 'date' ? 'date' : 'datetime-local';
332
+ const ops = FILTER_OPERATORS.date;
333
+ const activeOp = filterOpIsNullCheck(currentFilter.op)
334
+ ? currentFilter.op
335
+ : (currentFilter.op || 'eq');
336
+
337
+ return m('.space-y-2', [
338
+ m('label.block.text-sm.font-medium.text-gray-700', label),
339
+ m('.flex.items-start.gap-2.flex-wrap', [
340
+ m('select.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
341
+ value: activeOp,
342
+ onchange: (e) => {
343
+ const nextOp = e.target.value;
344
+ if (filterOpIsNullCheck(nextOp)) {
345
+ onChange({ op: nextOp });
346
+ return;
347
+ }
348
+ if (nextOp === 'between') {
349
+ onChange({ op: nextOp, from: currentFilter.from || '', to: currentFilter.to || '' });
350
+ } else {
351
+ onChange({ op: nextOp, value: currentFilter.value || '' });
352
+ }
353
+ },
354
+ }, ops.map(o => m('option', { value: o.value }, o.label))),
355
+ !filterOpIsNullCheck(activeOp) && activeOp === 'between'
356
+ ? [
357
+ m('input.flex-1.min-w-32.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
358
+ type: inputType,
359
+ value: currentFilter.from || '',
360
+ oninput: (e) => onChange({ op: 'between', from: e.target.value, to: currentFilter.to || '' }),
361
+ }),
362
+ m('span.text-gray-400 dark:text-slate-500.self-center', 'to'),
363
+ m('input.flex-1.min-w-32.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
364
+ type: inputType,
365
+ value: currentFilter.to || '',
366
+ oninput: (e) => onChange({ op: 'between', from: currentFilter.from || '', to: e.target.value }),
367
+ }),
368
+ ]
369
+ : !filterOpIsNullCheck(activeOp)
370
+ ? m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
371
+ type: inputType,
372
+ value: currentFilter.value || '',
373
+ oninput: (e) => onChange({ op: activeOp, value: e.target.value }),
374
+ })
375
+ : null,
376
+ ]),
377
+ ]);
378
+ }
379
+
380
+ if (col.type === 'integer' || col.type === 'bigint' || col.type === 'float' || col.type === 'decimal') {
381
+ const ops = FILTER_OPERATORS.number;
382
+ const activeOp = filterOpIsNullCheck(currentFilter.op)
383
+ ? currentFilter.op
384
+ : (currentFilter.op || 'eq');
385
+
386
+ return m('.space-y-2', [
387
+ m('label.block.text-sm.font-medium.text-gray-700', label),
388
+ m('.flex.items-start.gap-2', [
389
+ m('select.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.bg-white dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
390
+ value: activeOp,
391
+ onchange: (e) => {
392
+ const nextOp = e.target.value;
393
+ if (filterOpIsNullCheck(nextOp)) {
394
+ onChange({ op: nextOp });
395
+ return;
396
+ }
397
+ if (nextOp === 'between') {
398
+ onChange({ op: nextOp, from: currentFilter.from || '', to: currentFilter.to || '' });
399
+ } else {
400
+ onChange({ op: nextOp, value: currentFilter.value || '' });
401
+ }
402
+ },
403
+ }, ops.map(o => m('option', { value: o.value }, o.label))),
404
+ !filterOpIsNullCheck(activeOp) && activeOp === 'between'
405
+ ? [
406
+ m('input.w-24.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
407
+ type: 'number',
408
+ value: currentFilter.from || '',
409
+ placeholder: 'Min',
410
+ oninput: (e) => onChange({ op: 'between', from: e.target.value, to: currentFilter.to || '' }),
411
+ }),
412
+ m('span.text-gray-400 dark:text-slate-500.self-center', 'to'),
413
+ m('input.w-24.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
414
+ type: 'number',
415
+ value: currentFilter.to || '',
416
+ placeholder: 'Max',
417
+ oninput: (e) => onChange({ op: 'between', from: currentFilter.from || '', to: e.target.value }),
418
+ }),
419
+ ]
420
+ : !filterOpIsNullCheck(activeOp)
421
+ ? m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300 dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
422
+ type: 'number',
423
+ value: currentFilter.value || '',
424
+ placeholder: 'Enter value',
425
+ oninput: (e) => onChange({ op: activeOp, value: e.target.value }),
426
+ })
427
+ : null,
428
+ ]),
429
+ ]);
430
+ }
431
+
432
+ // String/Text field (default)
433
+ const ops = FILTER_OPERATORS.string;
434
+ const activeOpStr = filterOpIsNullCheck(currentFilter.op)
435
+ ? currentFilter.op
436
+ : (currentFilter.op || 'contains');
437
+
438
+ return m('.space-y-2', [
439
+ m('label.block.text-sm.font-medium.text-gray-700', label),
440
+ m('.flex.items-center.gap-2', [
441
+ m('select.px-3.py-2.text-sm.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-800.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
442
+ value: activeOpStr,
443
+ onchange: (e) => {
444
+ const nextOp = e.target.value;
445
+ if (filterOpIsNullCheck(nextOp)) {
446
+ onChange({ op: nextOp });
447
+ return;
448
+ }
449
+ onChange({ op: nextOp, value: currentFilter.value || '' });
450
+ },
451
+ }, ops.map(o => m('option', { value: o.value }, o.label))),
452
+ !filterOpIsNullCheck(activeOpStr)
453
+ ? m('input.flex-1.px-3.py-2.text-sm.border.border-gray-300.dark:border-slate-600.rounded-lg.focus:outline-none.focus:ring-2.focus:ring-indigo-500', {
454
+ type: 'text',
455
+ value: currentFilter.value || '',
456
+ placeholder: 'Enter search term',
457
+ oninput: (e) => onChange({ op: activeOpStr, value: e.target.value }),
458
+ })
459
+ : null,
460
+ ]),
461
+ ]);
462
+ },
463
+ };
464
+
465
+ // Filter Drawer Component
466
+ const FilterDrawer = {
467
+ view: (vnode) => {
468
+ const { isOpen, modelMeta, filters, onFilterChange, onApply, onClear, onClose } = vnode.attrs;
469
+
470
+ if (!isOpen) return null;
471
+
472
+ const filterableColumns = (modelMeta?.columns || []).filter(col => {
473
+ if (col.primary || col.autoIncrement) return false;
474
+ if (col.auto === 'create' || col.auto === 'update') return false;
475
+ if (col.type === 'json') return false;
476
+ if (col.type === 'file') return false;
477
+ return true;
478
+ });
479
+
480
+ const textColumns = filterableColumns.filter(c => c.type === 'string' || c.type === 'text');
481
+ const numericColumns = filterableColumns.filter(c => ['integer', 'bigint', 'float', 'decimal'].includes(c.type));
482
+ const dateColumns = filterableColumns.filter(c => ['date', 'datetime', 'timestamp'].includes(c.type));
483
+ const boolEnumColumns = filterableColumns.filter(c => c.type === 'boolean' || c.type === 'enum');
484
+
485
+ const renderGroup = (title, columns) => {
486
+ if (columns.length === 0) return null;
487
+ return m('.mb-6', [
488
+ m('h4.text-xs.font-semibold.text-gray-400 dark:text-slate-500.uppercase.tracking-wider.mb-3', title),
489
+ m('.space-y-4', columns.map(col =>
490
+ m(FilterField, {
491
+ col,
492
+ filter: filters[col.name],
493
+ onChange: (filter) => onFilterChange(col.name, filter),
494
+ })
495
+ )),
496
+ ]);
497
+ };
498
+
499
+ return [
500
+ m('.fixed.inset-0.bg-black.bg-opacity-25.z-40.transition-opacity', { onclick: onClose }),
501
+ m('.fixed.inset-y-0.right-0.w-full.max-w-md.bg-white dark:bg-slate-800.shadow-xl.z-50.flex.flex-col', {
502
+ style: 'animation: filterDrawerSlideIn 0.2s ease-out;',
503
+ }, [
504
+ m('.flex.items-center.justify-between.px-6.py-4.border-b.border-gray-200', [
505
+ m('div', [
506
+ m('h3.text-lg.font-semibold.text-gray-900', 'Advanced Filters'),
507
+ m('p.text-sm.text-gray-500', 'Filter records by multiple criteria'),
508
+ ]),
509
+ m('button.p-2.text-gray-400 dark:text-slate-500.hover:text-gray-600 dark:hover:text-slate-300 dark:hover:text-slate-300.rounded-lg.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
510
+ onclick: onClose,
511
+ type: 'button',
512
+ }, [
513
+ m('svg.w-5.h-5', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' }, [
514
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' }),
515
+ ]),
516
+ ]),
517
+ ]),
518
+ m('.flex-1.overflow-y-auto.px-6.py-4', [
519
+ renderGroup('Text Fields', textColumns),
520
+ renderGroup('Options', boolEnumColumns),
521
+ renderGroup('Numbers', numericColumns),
522
+ renderGroup('Dates', dateColumns),
523
+ ]),
524
+ m('.flex.items-center.justify-between.gap-3.px-6.py-4.border-t.border-gray-200 dark:border-slate-600.bg-gray-50', [
525
+ m('button.px-4.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.hover:text-gray-900 dark:hover:text-slate-100 dark:hover:text-slate-100', {
526
+ onclick: onClear,
527
+ type: 'button',
528
+ }, 'Clear all'),
529
+ m('.flex.gap-3', [
530
+ m('button.px-4.py-2.text-sm.font-medium.text-gray-700 dark:text-slate-300.bg-white dark:bg-slate-800.border.border-gray-300 dark:border-slate-600.rounded-lg.hover:bg-gray-50 dark:hover:bg-slate-800/50 dark:hover:bg-slate-800/50', {
531
+ onclick: onClose,
532
+ type: 'button',
533
+ }, 'Cancel'),
534
+ m('button.px-4.py-2.text-sm.font-medium.text-white.bg-indigo-600.rounded-lg.hover:bg-indigo-700', {
535
+ onclick: () => {
536
+ onApply();
537
+ onClose();
538
+ },
539
+ type: 'button',
540
+ }, 'Apply Filters'),
541
+ ]),
542
+ ]),
543
+ ]),
544
+ ];
545
+ },
546
+ };
547
+
548
+ // Add drawer animation styles
549
+ if (typeof document !== 'undefined' && !document.getElementById('filter-drawer-styles')) {
550
+ const style = document.createElement('style');
551
+ style.id = 'filter-drawer-styles';
552
+ style.textContent = '@keyframes filterDrawerSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }';
553
+ document.head.appendChild(style);
554
+ }
@@ -0,0 +1,70 @@
1
+ // Pagination Component
2
+ const Pagination = {
3
+ view: (vnode) => {
4
+ const { page, totalPages, total, perPage, onPageChange } = vnode.attrs;
5
+ if (totalPages <= 1) return null;
6
+
7
+ const pages = [];
8
+ const maxVisible = 5;
9
+ let start = Math.max(1, page - Math.floor(maxVisible / 2));
10
+ let end = Math.min(totalPages, start + maxVisible - 1);
11
+ if (end - start < maxVisible - 1) {
12
+ start = Math.max(1, end - maxVisible + 1);
13
+ }
14
+
15
+ for (let i = start; i <= end; i++) {
16
+ pages.push(i);
17
+ }
18
+
19
+ return m('.flex.items-center.justify-between.px-4.py-3.bg-white dark:bg-slate-800.border-t', [
20
+ m('.text-sm.text-gray-700', [
21
+ 'Showing ',
22
+ m('span.font-medium', ((page - 1) * perPage) + 1),
23
+ ' to ',
24
+ m('span.font-medium', Math.min(page * perPage, total)),
25
+ ' of ',
26
+ m('span.font-medium', total),
27
+ ' results',
28
+ ]),
29
+ m('nav.flex.items-center.space-x-1', [
30
+ // Previous button
31
+ m('button.px-3.py-1.rounded.border.text-sm', {
32
+ disabled: page <= 1,
33
+ class: page <= 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
34
+ onclick: () => page > 1 && onPageChange(page - 1),
35
+ }, '← Prev'),
36
+
37
+ // Page numbers
38
+ start > 1 ? [
39
+ m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
40
+ onclick: () => onPageChange(1),
41
+ }, '1'),
42
+ start > 2 ? m('span.px-2.text-gray-400', '...') : null,
43
+ ] : null,
44
+
45
+ ...pages.map(p =>
46
+ m('button.px-3.py-1.rounded.text-sm', {
47
+ class: p === page
48
+ ? 'bg-blue-600 text-white'
49
+ : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
50
+ onclick: () => onPageChange(p),
51
+ }, p)
52
+ ),
53
+
54
+ end < totalPages ? [
55
+ end < totalPages - 1 ? m('span.px-2.text-gray-400', '...') : null,
56
+ m('button.px-3.py-1.rounded.text-sm.text-gray-700 dark:text-slate-300.hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700', {
57
+ onclick: () => onPageChange(totalPages),
58
+ }, totalPages),
59
+ ] : null,
60
+
61
+ // Next button
62
+ m('button.px-3.py-1.rounded.border.text-sm', {
63
+ disabled: page >= totalPages,
64
+ class: page >= totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100 dark:hover:bg-slate-700 dark:hover:bg-slate-700',
65
+ onclick: () => page < totalPages && onPageChange(page + 1),
66
+ }, 'Next →'),
67
+ ]),
68
+ ]);
69
+ },
70
+ };