webspresso 0.0.89 → 0.0.91

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 (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/index.js CHANGED
@@ -10,9 +10,21 @@ const {
10
10
  getAppContext,
11
11
  getDb,
12
12
  hasDb,
13
+ getShutdownManager,
14
+ hasShutdownManager,
15
+ getServiceRegistry,
16
+ hasServiceRegistry,
13
17
  resetAppContext,
14
18
  setAppContext,
15
19
  } = require('./src/app-context');
20
+ const {
21
+ createServiceRegistry,
22
+ defineService,
23
+ ServiceRegistry,
24
+ } = require('./src/services');
25
+ const { ShutdownManager, NodeHttpAdapter } = require('./core/shutdown');
26
+ const compression = require('./core/compression');
27
+ const errors = require('./core/errors');
16
28
  const {
17
29
  mountPages,
18
30
  filePathToRoute,
@@ -64,6 +76,17 @@ const {
64
76
  rateLimitPlugin,
65
77
  contentPlugin,
66
78
  csrfPlugin,
79
+ corsPlugin,
80
+ emailPlugin,
81
+ basicAuthPlugin,
82
+ realtimePlugin,
83
+ realtime,
84
+ createRealtime,
85
+ websocket,
86
+ sse,
87
+ socketIo,
88
+ redis,
89
+ createRedisAdapter,
67
90
  } = require('./plugins');
68
91
 
69
92
  const content = require('./core/content');
@@ -78,8 +101,67 @@ module.exports = {
78
101
  getAppContext,
79
102
  getDb,
80
103
  hasDb,
104
+ getShutdownManager,
105
+ hasShutdownManager,
106
+ getServiceRegistry,
107
+ hasServiceRegistry,
81
108
  resetAppContext,
82
109
  setAppContext,
110
+
111
+ // Services Layer
112
+ createServiceRegistry,
113
+ defineService,
114
+ service: defineService,
115
+ ServiceRegistry,
116
+ memoize: require('./src/services').memoize,
117
+ parseTtlMs: require('./src/services').parseTtlMs,
118
+ stableCacheKey: require('./src/services').stableCacheKey,
119
+ sanitizeInput: require('./src/services').sanitizeInput,
120
+ maskSensitiveData: require('./src/services').maskSensitiveData,
121
+ discoverServices: require('./src/services').discoverServices,
122
+ filePathToServiceName: require('./src/services').filePathToServiceName,
123
+ toCamelCase: require('./src/services').toCamelCase,
124
+ createAuthServices: require('./src/services').createAuthServices,
125
+ createMailServices: require('./src/services').createMailServices,
126
+ createMediaServices: require('./src/services').createMediaServices,
127
+ createSystemServices: require('./src/services').createSystemServices,
128
+ createExchangeServices: require('./src/services').createExchangeServices,
129
+ validateServiceInput: require('./src/services').validateServiceInput,
130
+ executeService: require('./src/services').executeService,
131
+
132
+ // Shutdown & Lifecycle
133
+ ShutdownManager,
134
+ NodeHttpAdapter,
135
+
136
+ // HTTP Response Compression
137
+ compression,
138
+ createCompressionMiddleware: compression.createCompressionMiddleware,
139
+
140
+ // Exceptions & Error Handling
141
+ errors,
142
+ WebspressoError: errors.WebspressoError,
143
+ HttpError: errors.HttpError,
144
+ BadRequestError: errors.BadRequestError,
145
+ UnauthorizedError: errors.UnauthorizedError,
146
+ ForbiddenError: errors.ForbiddenError,
147
+ NotFoundError: errors.NotFoundError,
148
+ MethodNotAllowedError: errors.MethodNotAllowedError,
149
+ ConflictError: errors.ConflictError,
150
+ PayloadTooLargeError: errors.PayloadTooLargeError,
151
+ UnsupportedMediaTypeError: errors.UnsupportedMediaTypeError,
152
+ UnprocessableEntityError: errors.UnprocessableEntityError,
153
+ TooManyRequestsError: errors.TooManyRequestsError,
154
+ ValidationError: errors.ValidationError,
155
+ ConfigurationError: errors.ConfigurationError,
156
+ PluginError: errors.PluginError,
157
+ SecurityError: errors.SecurityError,
158
+ RequestError: errors.RequestError,
159
+ RequestAbortedError: errors.RequestAbortedError,
160
+ RouterError: errors.RouterError,
161
+ RouteNotFoundError: errors.RouteNotFoundError,
162
+ RouteGenerationError: errors.RouteGenerationError,
163
+ normalizeError: errors.normalizeError,
164
+ toErrorResponseObject: errors.toErrorResponseObject,
83
165
 
84
166
  // Router utilities (for advanced use)
85
167
  mountPages,
@@ -97,6 +179,11 @@ module.exports = {
97
179
  // Template helpers
98
180
  createHelpers,
99
181
  utils,
182
+
183
+ // SSR Streaming
184
+ ssr: require('./core/ssr'),
185
+ renderStream: require('./core/ssr').renderStream,
186
+ createHtmlStream: require('./core/ssr').createHtmlStream,
100
187
 
101
188
  // Asset management
102
189
  AssetManager,
@@ -135,7 +222,23 @@ module.exports = {
135
222
  rateLimitPlugin,
136
223
  contentPlugin,
137
224
  csrfPlugin,
225
+ corsPlugin,
226
+ emailPlugin,
227
+ basicAuthPlugin,
228
+ realtimePlugin,
229
+ realtime,
230
+ createRealtime,
231
+ websocket,
232
+ sse,
233
+ socketIo,
234
+ redis,
235
+ createRedisAdapter,
236
+ queuePlugin: require('./plugins/queue'),
237
+ queue: require('./core/queue'),
238
+ createQueueManager: require('./core/queue').createQueueManager,
239
+ QueueManager: require('./core/queue').QueueManager,
138
240
 
139
241
  // Schema-driven CMS core (framework-agnostic)
140
242
  content,
141
243
  };
244
+
package/package.json CHANGED
@@ -1,14 +1,34 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.89",
3
+ "version": "0.0.91",
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",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.js"
11
+ },
12
+ "./errors": {
13
+ "types": "./index.d.ts",
14
+ "default": "./core/errors/index.js"
15
+ },
16
+ "./plugins": "./plugins/index.js",
17
+ "./plugins/*": "./plugins/*",
18
+ "./realtime": "./core/realtime/index.js",
19
+ "./core/*": "./core/*",
20
+ "./src/*": "./src/*",
21
+ "./views/*": "./views/*",
22
+ "./templates/*": "./templates/*",
23
+ "./package.json": "./package.json"
24
+ },
7
25
  "bin": {
8
26
  "webspresso": "./bin/webspresso.js"
9
27
  },
10
28
  "scripts": {
11
29
  "test": "vitest run",
30
+ "test:security": "vitest run tests/security/",
31
+ "security": "npm run test:security && npm audit",
12
32
  "test:watch": "vitest",
13
33
  "test:coverage": "vitest run --coverage",
14
34
  "test:e2e": "playwright test",
@@ -62,7 +82,7 @@
62
82
  "csv-parse": "^5.6.0",
63
83
  "dayjs": "^1.11.19",
64
84
  "exceljs": "^4.4.0",
65
- "express": "^4.18.2",
85
+ "express": "^5.2.1",
66
86
  "express-session": "^1.18.0",
67
87
  "helmet": "^7.2.0",
68
88
  "inquirer": "^8.2.6",
@@ -384,6 +384,9 @@ function isColumnSortable(name, meta, model) {
384
384
 
385
385
  // Apply filters
386
386
  for (const [colName, filter] of Object.entries(filterParams)) {
387
+ if (!model.columns.has(colName) || !filter || typeof filter !== 'object') {
388
+ continue;
389
+ }
387
390
  const colMeta = model.columns.get(colName);
388
391
  const colType = colMeta?.type || 'string';
389
392
  const op = filter.op || (colType === 'boolean' ? 'eq' : 'contains');
@@ -568,6 +571,32 @@ function isColumnSortable(name, meta, model) {
568
571
  }
569
572
  }
570
573
 
574
+ /** Extract structured field errors from Zod, ORM or database validation exceptions */
575
+ function extractValidationErrors(error) {
576
+ if (!error) return null;
577
+ const fields = {};
578
+ if (error.errors && typeof error.errors === 'object') {
579
+ if (Array.isArray(error.errors)) {
580
+ for (const item of error.errors) {
581
+ if (item.path && item.message) {
582
+ const key = Array.isArray(item.path) ? item.path.join('.') : String(item.path);
583
+ fields[key] = item.message;
584
+ }
585
+ }
586
+ } else {
587
+ Object.assign(fields, error.errors);
588
+ }
589
+ } else if (error.issues && Array.isArray(error.issues)) {
590
+ for (const issue of error.issues) {
591
+ const key = issue.path && issue.path.length > 0 ? issue.path.join('.') : 'root';
592
+ fields[key] = issue.message;
593
+ }
594
+ } else if (error.fields && typeof error.fields === 'object') {
595
+ Object.assign(fields, error.fields);
596
+ }
597
+ return Object.keys(fields).length > 0 ? fields : null;
598
+ }
599
+
571
600
  /**
572
601
  * Create record
573
602
  */
@@ -588,7 +617,8 @@ function isColumnSortable(name, meta, model) {
588
617
  const value = req.body[colName];
589
618
  if (isRichTextEmpty(value)) {
590
619
  return res.status(400).json({
591
- error: `Field "${colName}" is required`
620
+ error: `Field "${colName}" is required`,
621
+ fields: { [colName]: `Field "${colName}" is required` },
592
622
  });
593
623
  }
594
624
  }
@@ -599,7 +629,8 @@ function isColumnSortable(name, meta, model) {
599
629
 
600
630
  res.status(201).json({ data: sanitizeForOutput(record, model) });
601
631
  } catch (error) {
602
- res.status(400).json({ error: error.message });
632
+ const fields = extractValidationErrors(error);
633
+ res.status(400).json({ error: error.message, ...(fields ? { fields } : {}) });
603
634
  }
604
635
  }
605
636
 
@@ -623,7 +654,8 @@ function isColumnSortable(name, meta, model) {
623
654
  const value = req.body[colName];
624
655
  if (isRichTextEmpty(value)) {
625
656
  return res.status(400).json({
626
- error: `Field "${colName}" is required`
657
+ error: `Field "${colName}" is required`,
658
+ fields: { [colName]: `Field "${colName}" is required` },
627
659
  });
628
660
  }
629
661
  }
@@ -638,7 +670,8 @@ function isColumnSortable(name, meta, model) {
638
670
 
639
671
  res.json({ data: sanitizeForOutput(record, model) });
640
672
  } catch (error) {
641
- res.status(400).json({ error: error.message });
673
+ const fields = extractValidationErrors(error);
674
+ res.status(400).json({ error: error.message, ...(fields ? { fields } : {}) });
642
675
  }
643
676
  }
644
677
 
@@ -171,6 +171,16 @@ var routes = {
171
171
  return ProfilePage;
172
172
  }
173
173
  },
174
+ '/admins': {
175
+ onmatch: async () => {
176
+ const isAuth = await checkAuth();
177
+ if (!isAuth) {
178
+ redirectToLogin();
179
+ return;
180
+ }
181
+ return AdminUsersPage;
182
+ }
183
+ },
174
184
  '/users/sessions': {
175
185
  onmatch: async () => {
176
186
  if (!(await guardUserManagementRoutes())) return;
@@ -237,36 +247,76 @@ function wrapCustomPageWithLayout(page, rawComp) {
237
247
 
238
248
  return {
239
249
  oninit: function(vnode) {
240
- vnode.state.instance = typeof rawComp === 'function' ? rawComp.call(this, vnode) : rawComp;
241
- if (vnode.state.instance && typeof vnode.state.instance.oninit === 'function') {
242
- return vnode.state.instance.oninit.call(this, vnode);
250
+ try {
251
+ vnode.state.instance = typeof rawComp === 'function' ? rawComp.call(this, vnode) : rawComp;
252
+ if (vnode.state.instance && typeof vnode.state.instance.oninit === 'function') {
253
+ return vnode.state.instance.oninit.call(this, vnode);
254
+ }
255
+ } catch (err) {
256
+ console.error('[Admin Panel] Error in oninit for custom page "' + page.id + '":', err);
257
+ vnode.state.renderError = err;
243
258
  }
244
259
  },
245
260
  oncreate: function(vnode) {
246
- const comp = vnode.state.instance || rawComp;
247
- if (comp && typeof comp.oncreate === 'function') return comp.oncreate.call(this, vnode);
261
+ try {
262
+ const comp = vnode.state.instance || rawComp;
263
+ if (comp && typeof comp.oncreate === 'function') return comp.oncreate.call(this, vnode);
264
+ } catch (err) {
265
+ console.error('[Admin Panel] Error in oncreate for custom page "' + page.id + '":', err);
266
+ }
248
267
  },
249
268
  onupdate: function(vnode) {
250
- const comp = vnode.state.instance || rawComp;
251
- if (comp && typeof comp.onupdate === 'function') return comp.onupdate.call(this, vnode);
269
+ try {
270
+ const comp = vnode.state.instance || rawComp;
271
+ if (comp && typeof comp.onupdate === 'function') return comp.onupdate.call(this, vnode);
272
+ } catch (err) {
273
+ console.error('[Admin Panel] Error in onupdate for custom page "' + page.id + '":', err);
274
+ }
252
275
  },
253
276
  onbeforeremove: function(vnode) {
254
- const comp = vnode.state.instance || rawComp;
255
- if (comp && typeof comp.onbeforeremove === 'function') return comp.onbeforeremove.call(this, vnode);
277
+ try {
278
+ const comp = vnode.state.instance || rawComp;
279
+ if (comp && typeof comp.onbeforeremove === 'function') return comp.onbeforeremove.call(this, vnode);
280
+ } catch (err) {
281
+ console.error('[Admin Panel] Error in onbeforeremove for custom page "' + page.id + '":', err);
282
+ }
256
283
  },
257
284
  onremove: function(vnode) {
258
- const comp = vnode.state.instance || rawComp;
259
- if (comp && typeof comp.onremove === 'function') return comp.onremove.call(this, vnode);
285
+ try {
286
+ const comp = vnode.state.instance || rawComp;
287
+ if (comp && typeof comp.onremove === 'function') return comp.onremove.call(this, vnode);
288
+ } catch (err) {
289
+ console.error('[Admin Panel] Error in onremove for custom page "' + page.id + '":', err);
290
+ }
260
291
  },
261
292
  view: function(vnode) {
293
+ if (vnode.state.renderError) {
294
+ var err = vnode.state.renderError;
295
+ var breadcrumbs = [{ label: page.title, href: page.path }];
296
+ return m(Layout, [
297
+ m(Breadcrumb, { items: breadcrumbs }),
298
+ m('.p-6.bg-red-50.border.border-red-200.rounded-xl', [
299
+ m('h3.text-lg.font-semibold.text-red-800', 'Sayfa Yüklenirken Hata Oluştu'),
300
+ m('p.text-sm.text-red-600.mt-1', err.message || String(err)),
301
+ ])
302
+ ]);
303
+ }
262
304
  const comp = vnode.state.instance || rawComp;
263
305
  var content;
264
- if (comp && typeof comp.view === 'function') {
265
- content = comp.view.call(this, vnode);
266
- } else if (typeof comp === 'function') {
267
- content = comp.call(this, vnode);
268
- } else {
269
- content = comp;
306
+ try {
307
+ if (comp && typeof comp.view === 'function') {
308
+ content = comp.view.call(this, vnode);
309
+ } else if (typeof comp === 'function') {
310
+ content = comp.call(this, vnode);
311
+ } else {
312
+ content = comp;
313
+ }
314
+ } catch (err) {
315
+ console.error('[Admin Panel] Error rendering custom page "' + page.id + '":', err);
316
+ content = m('.p-6.bg-red-50.border.border-red-200.rounded-xl', [
317
+ m('h3.text-lg.font-semibold.text-red-800', 'Sayfa Çizilirken Hata Oluştu'),
318
+ m('p.text-sm.text-red-600.mt-1', err.message || String(err)),
319
+ ]);
270
320
  }
271
321
  if (content && (content.tag === Layout || (content.tag && content.tag.name === 'Layout'))) {
272
322
  return content;
@@ -18,7 +18,10 @@ const api = {
18
18
  if (response.status === 401 && path.indexOf('/auth/') !== 0) {
19
19
  redirectToLogin();
20
20
  }
21
- throw new Error(error.error || 'Request failed');
21
+ const err = new Error(error.error || 'Request failed');
22
+ if (error.fields) err.fields = error.fields;
23
+ err.response = error;
24
+ throw err;
22
25
  }
23
26
 
24
27
  return response.json();
@@ -16,52 +16,59 @@ const Pagination = {
16
16
  pages.push(i);
17
17
  }
18
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', [
19
+ return m('.flex.flex-col.sm:flex-row.items-center.justify-between.gap-3.px-4.py-3.bg-white.dark:bg-slate-800.border-t.border-gray-200.dark:border-slate-700', [
20
+ m('.text-xs.sm:text-sm.text-gray-600.dark:text-slate-400.text-center.sm:text-left', [
21
21
  'Showing ',
22
- m('span.font-medium', ((page - 1) * perPage) + 1),
22
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', ((page - 1) * perPage) + 1),
23
23
  ' to ',
24
- m('span.font-medium', Math.min(page * perPage, total)),
24
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', Math.min(page * perPage, total)),
25
25
  ' of ',
26
- m('span.font-medium', total),
26
+ m('span.font-semibold.text-gray-900.dark:text-slate-200', total),
27
27
  ' results',
28
28
  ]),
29
- m('nav.flex.items-center.space-x-1', [
29
+ m('nav.flex.items-center.gap-1', [
30
30
  // Previous button
31
- m('button.px-3.py-1.rounded.border.text-sm', {
31
+ m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
32
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',
33
+ class: page <= 1
34
+ ? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
35
+ : 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
34
36
  onclick: () => page > 1 && onPageChange(page - 1),
35
37
  }, '← Prev'),
38
+
39
+ // Mobile page indicator
40
+ m('span.sm:hidden.px-2.text-xs.font-medium.text-gray-600.dark:text-slate-400', `${page} / ${totalPages}`),
36
41
 
37
- // Page numbers
42
+ // Page numbers (desktop only)
38
43
  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', {
44
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
40
45
  onclick: () => onPageChange(1),
41
46
  }, '1'),
42
- start > 2 ? m('span.px-2.text-gray-400', '...') : null,
47
+ start > 2 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
43
48
  ] : null,
44
49
 
45
50
  ...pages.map(p =>
46
- m('button.px-3.py-1.rounded.text-sm', {
51
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.transition-colors', {
47
52
  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',
53
+ ? 'bg-blue-600 dark:bg-blue-500 text-white shadow-sm'
54
+ : 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700',
50
55
  onclick: () => onPageChange(p),
51
56
  }, p)
52
57
  ),
53
58
 
54
59
  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', {
60
+ end < totalPages - 1 ? m('span.hidden.sm:inline-flex.px-1.text-gray-400.dark:text-slate-500', '...') : null,
61
+ m('button.hidden.sm:inline-flex.px-3.py-1.5.rounded-lg.text-xs.sm:text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.transition-colors', {
57
62
  onclick: () => onPageChange(totalPages),
58
63
  }, totalPages),
59
64
  ] : null,
60
65
 
61
66
  // Next button
62
- m('button.px-3.py-1.rounded.border.text-sm', {
67
+ m('button.px-3.py-1.5.rounded-lg.border.border-gray-200.dark:border-slate-600.text-xs.sm:text-sm.font-medium.transition-colors', {
63
68
  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',
69
+ class: page >= totalPages
70
+ ? 'text-gray-300 dark:text-slate-600 cursor-not-allowed bg-gray-50 dark:bg-slate-900/50'
71
+ : 'text-gray-700 dark:text-slate-200 hover:bg-gray-100 dark:hover:bg-slate-700 bg-white dark:bg-slate-800',
65
72
  onclick: () => page < totalPages && onPageChange(page + 1),
66
73
  }, 'Next →'),
67
74
  ]),