webspresso 0.0.73 → 0.0.74

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.
@@ -6,6 +6,18 @@
6
6
 
7
7
  const { hash, verify } = require('../../../core/auth/hash');
8
8
 
9
+ /**
10
+ * DBs like SQLite store booleans as 0/1; `repo.count({ active: true })` can return 0 while rows are "active".
11
+ * Postgres/MySQL use real booleans — `true` is correct.
12
+ */
13
+ function truthyBooleanForDb(db) {
14
+ try {
15
+ const client = db?.knex?.client?.config?.client;
16
+ if (client === 'sqlite3' || client === 'better-sqlite3') return 1;
17
+ } catch (_) {}
18
+ return true;
19
+ }
20
+
9
21
  /**
10
22
  * Register user management in admin panel
11
23
  * @param {Object} options - Options
@@ -49,11 +61,15 @@ function registerUserManagement(options) {
49
61
  order: 100,
50
62
  });
51
63
 
64
+ // Sidebar targets ORM CRUD routes directly (avoids Mithril onmatch redirect races on /users/new).
65
+ const userModelListPath = '/models/' + encodeURIComponent(modelName);
66
+ const userModelNewPath = userModelListPath + '/new';
67
+
52
68
  // Register menu items
53
69
  registry.registerMenuItem({
54
70
  id: 'user-list',
55
71
  label: 'All Users',
56
- path: '/users',
72
+ path: userModelListPath,
57
73
  icon: 'users',
58
74
  group: 'users',
59
75
  order: 1,
@@ -62,7 +78,7 @@ function registerUserManagement(options) {
62
78
  registry.registerMenuItem({
63
79
  id: 'user-create',
64
80
  label: 'Add User',
65
- path: '/users/new',
81
+ path: userModelNewPath,
66
82
  icon: 'user-plus',
67
83
  group: 'users',
68
84
  order: 2,
@@ -87,21 +103,26 @@ function registerUserManagement(options) {
87
103
  dataLoader: async ({ db }) => {
88
104
  try {
89
105
  const repo = db.getRepository(modelName);
106
+ const model = repo.model;
90
107
  const total = await repo.count();
91
- const active = await repo.count({ [fieldMap.active]: true });
108
+ const activeEq = truthyBooleanForDb(db);
109
+ const active = await repo.query().where(fieldMap.active, activeEq).count();
92
110
  const admins = await repo.count({ [fieldMap.role]: 'admin' });
93
-
94
- // Recent users (last 7 days)
95
- const weekAgo = new Date();
96
- weekAgo.setDate(weekAgo.getDate() - 7);
97
- const recentUsers = await repo.query()
98
- .where(fieldMap.createdAt, '>=', weekAgo.toISOString())
99
- .count();
111
+
112
+ let recentUsers = 0;
113
+ const createdCol = fieldMap.createdAt;
114
+ if (model.columns && model.columns.has(createdCol)) {
115
+ const weekAgo = new Date();
116
+ weekAgo.setDate(weekAgo.getDate() - 7);
117
+ recentUsers = await repo.query()
118
+ .where(createdCol, '>=', weekAgo.toISOString())
119
+ .count();
120
+ }
100
121
 
101
122
  return {
102
123
  total,
103
124
  active,
104
- inactive: total - active,
125
+ inactive: Math.max(0, total - active),
105
126
  admins,
106
127
  recentUsers,
107
128
  };
@@ -30,6 +30,9 @@ async function buildXlsxBuffer(model, records) {
30
30
  const rowValues = columns.map((c) => {
31
31
  let v = rec[c];
32
32
  if (v === null || v === undefined) return '';
33
+ if (typeof v === 'bigint') return v.toString();
34
+ if (v instanceof Date) return v.toISOString();
35
+ if (Buffer.isBuffer(v)) return v.toString('base64');
33
36
  if (typeof v === 'object') v = JSON.stringify(v);
34
37
  if (typeof v === 'string' && /^[=+\-@]/.test(v)) return ` ${v}`;
35
38
  return v;
@@ -24,6 +24,10 @@ async function resolveExportRecords(db, modelName, req) {
24
24
  }
25
25
  }
26
26
 
27
+ const trashedOnly =
28
+ body.trashed === 'only' ||
29
+ req.query.trashed === 'only';
30
+
27
31
  let idList = null;
28
32
  if (req.body?.ids && Array.isArray(req.body.ids)) {
29
33
  idList = req.body.ids;
@@ -45,14 +49,26 @@ async function resolveExportRecords(db, modelName, req) {
45
49
  const repo = db.getRepository(model.name);
46
50
  let records;
47
51
 
52
+ const filterOpts =
53
+ trashedOnly && model.scopes?.softDelete ? { onlyTrashed: true } : {};
54
+
48
55
  if (selectAll) {
49
- const query = buildFilteredQuery(repo, filters);
56
+ const query = buildFilteredQuery(repo, filters, filterOpts);
50
57
  records = await query.list();
51
58
  } else if (idList && idList.length > 0) {
52
- records = [];
53
- for (const id of idList) {
54
- const record = await repo.findById(id);
55
- if (record) records.push(record);
59
+ if (trashedOnly && model.scopes?.softDelete) {
60
+ const pk = model.primaryKey || 'id';
61
+ records = await repo
62
+ .query()
63
+ .onlyTrashed()
64
+ .whereIn(pk, idList)
65
+ .list();
66
+ } else {
67
+ records = [];
68
+ for (const id of idList) {
69
+ const record = await repo.findById(id);
70
+ if (record) records.push(record);
71
+ }
56
72
  }
57
73
  } else {
58
74
  records = await repo.findAll();
@@ -67,13 +67,13 @@ function StatCard() {
67
67
  return {
68
68
  view: function(vnode) {
69
69
  var a = vnode.attrs;
70
- return m('div.bg-white.rounded-lg.shadow.p-5.flex.items-center.gap-4', [
70
+ return m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.p-5.flex.items-center.gap-4.border.border-transparent.dark:border-slate-700/80', [
71
71
  m('div.w-12.h-12.rounded-xl.flex.items-center.justify-center.text-xl', {
72
- class: a.bgClass || 'bg-blue-100',
72
+ class: a.bgClass || 'bg-blue-100 dark:bg-blue-900/50',
73
73
  }, a.icon),
74
74
  m('div', [
75
- m('p.text-2xl.font-bold.text-gray-900', formatNumber(a.value || 0)),
76
- m('p.text-xs.text-gray-500.uppercase.tracking-wide', a.label),
75
+ m('p.text-2xl.font-bold.text-gray-900.dark:text-slate-100', formatNumber(a.value || 0)),
76
+ m('p.text-xs.text-gray-500.dark:text-slate-400.uppercase.tracking-wide', a.label),
77
77
  ]),
78
78
  ]);
79
79
  }
@@ -86,6 +86,10 @@ function ViewsChart() {
86
86
  oncreate: function(vnode) {
87
87
  var ctx = vnode.dom.querySelector('canvas').getContext('2d');
88
88
  var data = vnode.attrs.data || [];
89
+ var isDark = typeof document !== 'undefined' && document.documentElement && document.documentElement.classList.contains('dark');
90
+ var tickColor = isDark ? '#94a3b8' : '#64748b';
91
+ var gridColor = isDark ? 'rgba(148,163,184,0.2)' : 'rgba(0,0,0,0.06)';
92
+ var legendColor = isDark ? '#e2e8f0' : '#334155';
89
93
  chartInstance = new Chart(ctx, {
90
94
  type: 'line',
91
95
  data: {
@@ -95,7 +99,7 @@ function ViewsChart() {
95
99
  label: 'Views',
96
100
  data: data.map(function(d) { return d.views; }),
97
101
  borderColor: '#3B82F6',
98
- backgroundColor: 'rgba(59,130,246,0.08)',
102
+ backgroundColor: isDark ? 'rgba(59,130,246,0.2)' : 'rgba(59,130,246,0.08)',
99
103
  fill: true,
100
104
  tension: 0.3,
101
105
  pointRadius: data.length > 60 ? 0 : 2,
@@ -104,7 +108,7 @@ function ViewsChart() {
104
108
  label: 'Visitors',
105
109
  data: data.map(function(d) { return d.visitors; }),
106
110
  borderColor: '#10B981',
107
- backgroundColor: 'rgba(16,185,129,0.08)',
111
+ backgroundColor: isDark ? 'rgba(16,185,129,0.2)' : 'rgba(16,185,129,0.08)',
108
112
  fill: true,
109
113
  tension: 0.3,
110
114
  pointRadius: data.length > 60 ? 0 : 2,
@@ -113,7 +117,7 @@ function ViewsChart() {
113
117
  label: 'Sessions',
114
118
  data: data.map(function(d) { return d.sessions; }),
115
119
  borderColor: '#F59E0B',
116
- backgroundColor: 'rgba(245,158,11,0.05)',
120
+ backgroundColor: isDark ? 'rgba(245,158,11,0.1)' : 'rgba(245,158,11,0.05)',
117
121
  fill: false,
118
122
  tension: 0.3,
119
123
  borderDash: [4, 4],
@@ -128,13 +132,19 @@ function ViewsChart() {
128
132
  plugins: {
129
133
  legend: {
130
134
  position: 'top',
131
- labels: { usePointStyle: true, boxWidth: 6, padding: 16 },
135
+ labels: {
136
+ usePointStyle: true,
137
+ boxWidth: 6,
138
+ padding: 16,
139
+ color: legendColor,
140
+ },
132
141
  },
133
142
  },
134
143
  scales: {
135
144
  x: {
136
145
  grid: { display: false },
137
146
  ticks: {
147
+ color: tickColor,
138
148
  maxTicksLimit: 12,
139
149
  callback: function(val, i) {
140
150
  var label = this.getLabelForValue(val);
@@ -144,8 +154,8 @@ function ViewsChart() {
144
154
  },
145
155
  y: {
146
156
  beginAtZero: true,
147
- grid: { color: 'rgba(0,0,0,0.04)' },
148
- ticks: { precision: 0 },
157
+ grid: { color: gridColor },
158
+ ticks: { color: tickColor, precision: 0 },
149
159
  },
150
160
  },
151
161
  },
@@ -168,7 +178,7 @@ function HBar() {
168
178
  view: function(vnode) {
169
179
  var pct = vnode.attrs.pct || 0;
170
180
  var color = vnode.attrs.color || 'bg-blue-500';
171
- return m('div.flex-1.h-2.bg-gray-100.rounded-full.overflow-hidden', [
181
+ return m('div.flex-1.h-2.bg-gray-100.dark:bg-slate-600.rounded-full.overflow-hidden', [
172
182
  m('div.h-full.rounded-full.transition-all', {
173
183
  class: color,
174
184
  style: 'width:' + Math.min(pct, 100) + '%',
@@ -273,11 +283,11 @@ var AnalyticsPage = {
273
283
  // Header row
274
284
  m('div.flex.items-center.justify-between.mb-6', [
275
285
  m('div', [
276
- m('h1.text-2xl.font-bold.text-gray-900.flex.items-center.gap-2', [
286
+ m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100.flex.items-center.gap-2', [
277
287
  m(Icon, { name: 'chart', class: 'w-6 h-6' }),
278
288
  'Analytics',
279
289
  ]),
280
- m('p.text-gray-500.text-sm.mt-1', 'Page view statistics and visitor analytics'),
290
+ m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Page view statistics and visitor analytics'),
281
291
  ]),
282
292
  m('div.flex.items-center.gap-2.flex-wrap.justify-end', [
283
293
  typeof RefreshIconButton !== 'undefined'
@@ -288,11 +298,11 @@ var AnalyticsPage = {
288
298
  })
289
299
  : null,
290
300
  // Day filter
291
- m('div.flex.gap-1.bg-gray-100.rounded-lg.p-1', [7, 30, 90].map(function(d) {
301
+ m('div.flex.gap-1.bg-gray-100.dark:bg-slate-800.rounded-lg.p-1', [7, 30, 90].map(function(d) {
292
302
  return m('button.px-3.py-1.5.text-sm.font-medium.rounded-md.transition-colors', {
293
303
  class: s.days === d
294
- ? 'bg-white text-gray-900 shadow-sm'
295
- : 'text-gray-500 hover:text-gray-700',
304
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-slate-700 dark:text-slate-100'
305
+ : 'text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200',
296
306
  onclick: function() { self.setDays(vnode, d); },
297
307
  }, 'Last ' + d + ' days');
298
308
  })),
@@ -304,46 +314,46 @@ var AnalyticsPage = {
304
314
  : [
305
315
  // Stat cards
306
316
  m('div.grid.grid-cols-2.sm:grid-cols-3.lg:grid-cols-3.xl:grid-cols-6.gap-4.mb-6', [
307
- m(StatCard, { icon: '👁', label: 'Views', value: s.stats?.views, bgClass: 'bg-blue-100' }),
308
- m(StatCard, { icon: '👤', label: 'Visitors', value: s.stats?.visitors, bgClass: 'bg-green-100' }),
309
- m(StatCard, { icon: '📄', label: 'Unique Pages', value: s.stats?.uniquePages, bgClass: 'bg-yellow-100' }),
310
- m(StatCard, { icon: '🔗', label: 'Sessions', value: s.stats?.sessions, bgClass: 'bg-purple-100' }),
311
- m(StatCard, { icon: '🏠', label: 'Direct traffic', value: s.stats?.directViews, bgClass: 'bg-slate-100' }),
312
- m(StatCard, { icon: '🌐', label: 'With referrer', value: s.stats?.referredViews, bgClass: 'bg-cyan-100' }),
317
+ m(StatCard, { icon: '👁', label: 'Views', value: s.stats?.views, bgClass: 'bg-blue-100 dark:bg-blue-900/50' }),
318
+ m(StatCard, { icon: '👤', label: 'Visitors', value: s.stats?.visitors, bgClass: 'bg-green-100 dark:bg-green-900/40' }),
319
+ m(StatCard, { icon: '📄', label: 'Unique Pages', value: s.stats?.uniquePages, bgClass: 'bg-yellow-100 dark:bg-yellow-900/35' }),
320
+ m(StatCard, { icon: '🔗', label: 'Sessions', value: s.stats?.sessions, bgClass: 'bg-purple-100 dark:bg-purple-900/40' }),
321
+ m(StatCard, { icon: '🏠', label: 'Direct traffic', value: s.stats?.directViews, bgClass: 'bg-slate-100 dark:bg-slate-700' }),
322
+ m(StatCard, { icon: '🌐', label: 'With referrer', value: s.stats?.referredViews, bgClass: 'bg-cyan-100 dark:bg-cyan-900/40' }),
313
323
  ]),
314
324
 
315
325
  // Chart + Bot Activity row
316
326
  m('div.grid.grid-cols-1.lg:grid-cols-3.gap-4.mb-6', [
317
327
  // Views over time chart
318
- m('div.lg:col-span-2.bg-white.rounded-lg.shadow', [
319
- m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
320
- m('h3.text-sm.font-semibold.text-gray-900.flex.items-center.gap-2', [
321
- m(Icon, { name: 'chart', class: 'w-4 h-4 text-gray-400' }),
328
+ m('div.lg:col-span-2.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80', [
329
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.flex.items-center.justify-between', [
330
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100.flex.items-center.gap-2', [
331
+ m(Icon, { name: 'chart', class: 'w-4 h-4 text-gray-400 dark:text-slate-500' }),
322
332
  'Views Over Time',
323
333
  ]),
324
334
  ]),
325
335
  m('div.p-5', [
326
336
  s.chartLoaded && s.viewsOverTime.length > 0
327
337
  ? m(ViewsChart, { key: 'chart-' + s.days + '-' + (s.chartDataVersion || 0), data: s.viewsOverTime })
328
- : m('div.flex.justify-center.py-16.text-gray-400.text-sm', 'Loading chart...'),
338
+ : m('div.flex.justify-center.py-16.text-gray-400.dark:text-slate-500.text-sm', 'Loading chart...'),
329
339
  ]),
330
340
  ]),
331
341
 
332
342
  // Bot Activity
333
- m('div.bg-white.rounded-lg.shadow', [
334
- m('div.px-5.py-4.border-b.border-gray-100', [
335
- m('h3.text-sm.font-semibold.text-gray-900', 'Bot Activity'),
343
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80', [
344
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700', [
345
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100', 'Bot Activity'),
336
346
  ]),
337
347
  m('div.p-4', [
338
348
  s.botActivity.length === 0
339
- ? m('p.text-gray-400.text-sm.text-center.py-4', 'No bot activity')
349
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-4', 'No bot activity')
340
350
  : (function() {
341
351
  var maxReqs = Math.max.apply(null, s.botActivity.map(function(b) { return b.requests; }));
342
352
  return m('div.space-y-2.5', s.botActivity.slice(0, 12).map(function(bot) {
343
353
  return m('div.flex.items-center.gap-3', [
344
- m('span.text-xs.font-medium.text-gray-700.w-24.truncate', { title: bot.name }, bot.name),
354
+ m('span.text-xs.font-medium.text-gray-700.dark:text-slate-300.w-24.truncate', { title: bot.name }, bot.name),
345
355
  m(HBar, { pct: (bot.requests / maxReqs) * 100, color: 'bg-indigo-500' }),
346
- m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(bot.requests)),
356
+ m('span.text-xs.text-gray-500.dark:text-slate-400.w-12.text-right.tabular-nums', formatNumber(bot.requests)),
347
357
  ]);
348
358
  }));
349
359
  })(),
@@ -354,28 +364,28 @@ var AnalyticsPage = {
354
364
  // Top Pages + Recent Activity row
355
365
  m('div.grid.grid-cols-1.lg:grid-cols-2.gap-4.mb-6', [
356
366
  // Top Pages
357
- m('div.bg-white.rounded-lg.shadow.flex.flex-col', { style: 'max-height:480px' }, [
358
- m('div.px-5.py-4.border-b.border-gray-100.shrink-0', [
359
- m('h3.text-sm.font-semibold.text-gray-900', 'Top Pages'),
367
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80.flex.flex-col', { style: 'max-height:480px' }, [
368
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.shrink-0', [
369
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100', 'Top Pages'),
360
370
  ]),
361
- m('div.divide-y.divide-gray-50.overflow-y-auto.flex-1', [
371
+ m('div.divide-y.divide-gray-50.dark:divide-slate-700/80.overflow-y-auto.flex-1', [
362
372
  s.topPages.length === 0
363
- ? m('p.text-gray-400.text-sm.text-center.py-6', 'No page views yet')
373
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-6', 'No page views yet')
364
374
  : (function() {
365
375
  var maxViews = s.topPages[0]?.views || 1;
366
376
  return s.topPages.slice(0, 15).map(function(page, i) {
367
- return m('div.flex.items-center.gap-3.px-5.py-2.5.hover:bg-gray-50', [
368
- m('span.text-xs.text-gray-400.w-5.text-right', i + 1),
377
+ return m('div.flex.items-center.gap-3.px-5.py-2.5.hover:bg-gray-50.dark:hover:bg-slate-700/50', [
378
+ m('span.text-xs.text-gray-400.dark:text-slate-500.w-5.text-right', i + 1),
369
379
  m('div.flex-1.min-w-0', [
370
- m('p.text-sm.text-gray-800.truncate', { title: page.path }, page.path),
380
+ m('p.text-sm.text-gray-800.dark:text-slate-200.truncate', { title: page.path }, page.path),
371
381
  m('div.mt-1', m(HBar, {
372
382
  pct: (page.views / maxViews) * 100,
373
383
  color: 'bg-blue-400',
374
384
  })),
375
385
  ]),
376
386
  m('div.text-right.shrink-0', [
377
- m('span.text-sm.font-semibold.text-gray-900', formatNumber(page.views)),
378
- m('span.text-xs.text-gray-400.ml-1', 'views'),
387
+ m('span.text-sm.font-semibold.text-gray-900.dark:text-slate-100', formatNumber(page.views)),
388
+ m('span.text-xs.text-gray-400.dark:text-slate-500.ml-1', 'views'),
379
389
  ]),
380
390
  ]);
381
391
  });
@@ -384,24 +394,24 @@ var AnalyticsPage = {
384
394
  ]),
385
395
 
386
396
  // Recent Activity
387
- m('div.bg-white.rounded-lg.shadow.flex.flex-col', { style: 'max-height:480px' }, [
388
- m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between.shrink-0', [
389
- m('h3.text-sm.font-semibold.text-gray-900', 'Recent Activity'),
390
- m('span.text-xs.text-gray-400', 'Live'),
397
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80.flex.flex-col', { style: 'max-height:480px' }, [
398
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.flex.items-center.justify-between.shrink-0', [
399
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100', 'Recent Activity'),
400
+ m('span.text-xs.text-gray-400.dark:text-slate-500', 'Live'),
391
401
  ]),
392
- m('div.divide-y.divide-gray-50.overflow-y-auto.flex-1', [
402
+ m('div.divide-y.divide-gray-50.dark:divide-slate-700/80.overflow-y-auto.flex-1', [
393
403
  s.recent.length === 0
394
- ? m('p.text-gray-400.text-sm.text-center.py-6', 'No recent activity')
404
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-6', 'No recent activity')
395
405
  : s.recent.slice(0, 10).map(function(item) {
396
- return m('div.flex.items-center.gap-3.px-5.py-2.5.hover:bg-gray-50', [
397
- m('div.w-7.h-7.rounded-full.bg-blue-50.flex.items-center.justify-center.shrink-0', [
406
+ return m('div.flex.items-center.gap-3.px-5.py-2.5.hover:bg-gray-50.dark:hover:bg-slate-700/50', [
407
+ m('div.w-7.h-7.rounded-full.bg-blue-50.dark:bg-slate-700.flex.items-center.justify-center.shrink-0', [
398
408
  m('span.text-xs', item.country ? (COUNTRY_FLAGS[item.country] || '🌐') : '🌐'),
399
409
  ]),
400
410
  m('div.flex-1.min-w-0', [
401
- m('p.text-sm.text-gray-800.truncate', { title: item.path }, item.path),
402
- m('p.text-xs.text-gray-400', relativeTime(item.created_at)),
411
+ m('p.text-sm.text-gray-800.dark:text-slate-200.truncate', { title: item.path }, item.path),
412
+ m('p.text-xs.text-gray-400.dark:text-slate-500', relativeTime(item.created_at)),
403
413
  ]),
404
- item.referrer && m('span.text-xs.text-gray-400.truncate.max-w-[120px]', {
414
+ item.referrer && m('span.text-xs.text-gray-400.dark:text-slate-500.truncate.max-w-[120px]', {
405
415
  title: item.referrer,
406
416
  }, (function() {
407
417
  try { return new URL(item.referrer).hostname; } catch(e) { return ''; }
@@ -413,51 +423,51 @@ var AnalyticsPage = {
413
423
  ]),
414
424
 
415
425
  // Client Errors
416
- m('div.bg-white.rounded-lg.shadow.mb-6', [
417
- m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
418
- m('h3.text-sm.font-semibold.text-gray-900.flex.items-center.gap-2', [
426
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80.mb-6', [
427
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.flex.items-center.justify-between', [
428
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100.flex.items-center.gap-2', [
419
429
  m('span', '⚠️'),
420
430
  'Client Errors',
421
431
  ]),
422
- m('span.text-xs.text-gray-400', 'Last ' + s.days + ' days'),
432
+ m('span.text-xs.text-gray-400.dark:text-slate-500', 'Last ' + s.days + ' days'),
423
433
  ]),
424
434
  m('div.p-4.max-h-64.overflow-y-auto', [
425
435
  !s.clientErrors || s.clientErrors.length === 0
426
- ? m('p.text-gray-400.text-sm.text-center.py-6', 'No client errors')
436
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-6', 'No client errors')
427
437
  : s.clientErrors.slice(0, 15).map(function(err) {
428
- return m('div.border-b.border-gray-50.pb-3.mb-3.last:border-0.last:mb-0.last:pb-0', [
438
+ return m('div.border-b.border-gray-50.dark:border-slate-700/60.pb-3.mb-3.last:border-0.last:mb-0.last:pb-0', [
429
439
  m('div.flex.items-start.gap-2', [
430
- m('span.text-xs.px-1.5.py-0.5.rounded.bg-red-100.text-red-700', err.error_type || 'error'),
431
- m('span.text-xs.text-gray-500', relativeTime(err.created_at)),
440
+ m('span.text-xs.px-1.5.py-0.5.rounded.bg-red-100.text-red-700.dark:bg-red-950/60.dark:text-red-300', err.error_type || 'error'),
441
+ m('span.text-xs.text-gray-500.dark:text-slate-500', relativeTime(err.created_at)),
432
442
  ]),
433
- m('p.text-sm.text-gray-800.font-mono.break-all.mt-1', {
443
+ m('p.text-sm.text-gray-800.dark:text-slate-200.font-mono.break-all.mt-1', {
434
444
  title: err.stack || err.message,
435
445
  }, (err.message || '').slice(0, 120) + (err.message && err.message.length > 120 ? '…' : '')),
436
- err.path && m('p.text-xs.text-gray-500.mt-0.5', err.path),
446
+ err.path && m('p.text-xs.text-gray-500.dark:text-slate-500.mt-0.5', err.path),
437
447
  ]);
438
448
  }),
439
449
  ]),
440
450
  ]),
441
451
 
442
452
  // Referrer sources (hostname-level)
443
- m('div.bg-white.rounded-lg.shadow.mb-6', [
444
- m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
445
- m('h3.text-sm.font-semibold.text-gray-900', 'Referrer sources'),
446
- m('span.text-xs.text-gray-400', 'By hostname · last ' + s.days + ' days'),
453
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80.mb-6', [
454
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.flex.items-center.justify-between', [
455
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100', 'Referrer sources'),
456
+ m('span.text-xs.text-gray-400.dark:text-slate-500', 'By hostname · last ' + s.days + ' days'),
447
457
  ]),
448
458
  m('div.p-4', [
449
459
  !s.referrerSources || s.referrerSources.length === 0
450
- ? m('p.text-gray-400.text-sm.text-center.py-4', 'No external referrer data (direct visits or missing Referer header)')
460
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-4', 'No external referrer data (direct visits or missing Referer header)')
451
461
  : (function() {
452
462
  var maxV = s.referrerSources[0]?.views || 1;
453
463
  return m('div.grid.grid-cols-1.sm:grid-cols-2.gap-x-8.gap-y-2', s.referrerSources.map(function(r) {
454
464
  return m('div.flex.items-center.gap-3.py-1', [
455
- m('span.text-sm.text-gray-700.flex-1.min-w-0.truncate', { title: r.source }, r.source),
465
+ m('span.text-sm.text-gray-700.dark:text-slate-300.flex-1.min-w-0.truncate', { title: r.source }, r.source),
456
466
  m('div.w-32.shrink-0', m(HBar, {
457
467
  pct: (r.views / maxV) * 100,
458
468
  color: 'bg-sky-500',
459
469
  })),
460
- m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(r.views)),
470
+ m('span.text-xs.text-gray-500.dark:text-slate-400.w-12.text-right.tabular-nums', formatNumber(r.views)),
461
471
  ]);
462
472
  }));
463
473
  })(),
@@ -465,25 +475,25 @@ var AnalyticsPage = {
465
475
  ]),
466
476
 
467
477
  // Country Stats
468
- m('div.bg-white.rounded-lg.shadow.mb-6', [
469
- m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
470
- m('h3.text-sm.font-semibold.text-gray-900', 'Country Stats'),
471
- m('span.text-xs.text-gray-400', 'Last ' + s.days + ' days'),
478
+ m('div.bg-white.dark:bg-slate-800/90.rounded-lg.shadow.dark:shadow-slate-900/50.border.border-transparent.dark:border-slate-700/80.mb-6', [
479
+ m('div.px-5.py-4.border-b.border-gray-100.dark:border-slate-700.flex.items-center.justify-between', [
480
+ m('h3.text-sm.font-semibold.text-gray-900.dark:text-slate-100', 'Country Stats'),
481
+ m('span.text-xs.text-gray-400.dark:text-slate-500', 'Last ' + s.days + ' days'),
472
482
  ]),
473
483
  m('div.p-4', [
474
484
  s.countries.length === 0
475
- ? m('p.text-gray-400.text-sm.text-center.py-4', 'No country data')
485
+ ? m('p.text-gray-400.dark:text-slate-500.text-sm.text-center.py-4', 'No country data')
476
486
  : (function() {
477
487
  var maxViews = s.countries[0]?.views || 1;
478
488
  return m('div.grid.grid-cols-1.sm:grid-cols-2.gap-x-8.gap-y-2', s.countries.map(function(c) {
479
489
  return m('div.flex.items-center.gap-3.py-1', [
480
490
  m('span.text-base.w-6.text-center', COUNTRY_FLAGS[c.country] || '🏳️'),
481
- m('span.text-sm.text-gray-700.w-6.font-medium', c.country),
491
+ m('span.text-sm.text-gray-700.dark:text-slate-300.w-6.font-medium', c.country),
482
492
  m('div.flex-1', m(HBar, {
483
493
  pct: (c.views / maxViews) * 100,
484
494
  color: 'bg-emerald-500',
485
495
  })),
486
- m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(c.views)),
496
+ m('span.text-xs.text-gray-500.dark:text-slate-400.w-12.text-right.tabular-nums', formatNumber(c.views)),
487
497
  ]);
488
498
  }));
489
499
  })(),
@@ -140,6 +140,69 @@ function extractMethodFromFilename(filename) {
140
140
  return result;
141
141
  }
142
142
 
143
+ /**
144
+ * Whether `load()` return values for `stylesheets` and `scripts` are promoted to
145
+ * `pageHead` in Nunjucks (see `createApp({ pageAssets })`).
146
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}|null|undefined} raw
147
+ * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean }}
148
+ */
149
+ function resolvePageAssets(raw) {
150
+ if (raw === true) {
151
+ return { enabled: true, stylesheets: true, scripts: true };
152
+ }
153
+ if (raw == null || raw === false) {
154
+ return { enabled: false, stylesheets: false, scripts: false };
155
+ }
156
+ if (typeof raw === 'object') {
157
+ const on = raw.enabled !== false;
158
+ if (!on) {
159
+ return { enabled: false, stylesheets: false, scripts: false };
160
+ }
161
+ return {
162
+ enabled: true,
163
+ stylesheets: raw.stylesheets !== false,
164
+ scripts: raw.scripts !== false,
165
+ };
166
+ }
167
+ return { enabled: false, stylesheets: false, scripts: false };
168
+ }
169
+
170
+ /**
171
+ * @param {unknown} v
172
+ * @returns {unknown[]}
173
+ */
174
+ function toList(v) {
175
+ if (v == null) return [];
176
+ return Array.isArray(v) ? v : [v];
177
+ }
178
+
179
+ /**
180
+ * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean }} cfg
181
+ * @param {Object} data
182
+ * @returns {{ data: Object, pageHead: { stylesheets: unknown[], scripts: unknown[] }|null, pageAssets: boolean }}
183
+ */
184
+ function applyPageAssetsToTemplateData(cfg, data) {
185
+ if (!cfg || !cfg.enabled) {
186
+ return { data, pageHead: null, pageAssets: false };
187
+ }
188
+ const out = { ...data };
189
+ let styles = [];
190
+ let scriptItems = [];
191
+ if (cfg.stylesheets && Object.prototype.hasOwnProperty.call(out, 'stylesheets')) {
192
+ styles = toList(out.stylesheets);
193
+ delete out.stylesheets;
194
+ }
195
+ if (cfg.scripts && Object.prototype.hasOwnProperty.call(out, 'scripts')) {
196
+ scriptItems = toList(out.scripts);
197
+ delete out.scripts;
198
+ }
199
+ return {
200
+ data: out,
201
+ pageHead: { stylesheets: styles, scripts: scriptItems },
202
+ pageAssets: true,
203
+ };
204
+ }
205
+
143
206
  /**
144
207
  * Recursively scan a directory for files
145
208
  * @param {string} dir - Directory to scan
@@ -439,6 +502,7 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
439
502
  * @param {boolean} options.silent - Suppress console output
440
503
  * @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
441
504
  * @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
505
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}} [options.pageAssets] - If set, `load()` may return `stylesheets` / `scripts` promoted to `pageHead` in templates
442
506
  * @returns {Array} Route metadata for plugins
443
507
  */
444
508
  function mountPages(app, options) {
@@ -450,7 +514,9 @@ function mountPages(app, options) {
450
514
  silent = false,
451
515
  db = null,
452
516
  clientRuntime: clientRuntimeOpt = null,
517
+ pageAssets: pageAssetsOpt = null,
453
518
  } = options;
519
+ const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
454
520
  const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
455
521
  ? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
456
522
  : { alpine: false, swup: false };
@@ -686,9 +752,9 @@ function mountPages(app, options) {
686
752
  await executeHook(globalHooks, 'beforeRender', ctx);
687
753
  await executeHook(routeHooks, 'beforeRender', ctx);
688
754
 
689
- // Render the template
690
- const templatePath = route.file.split(path.sep).join('/');
691
- const html = nunjucks.render(templatePath, {
755
+ const pageAssetBundle = applyPageAssetsToTemplateData(pageAssetsResolved, ctx.data);
756
+ ctx.data = pageAssetBundle.data;
757
+ const renderContext = {
692
758
  ...ctx.data,
693
759
  meta: ctx.meta,
694
760
  locale: ctx.locale,
@@ -700,7 +766,15 @@ function mountPages(app, options) {
700
766
  query: req.query,
701
767
  params: req.params
702
768
  }
703
- });
769
+ };
770
+ if (pageAssetBundle.pageAssets) {
771
+ renderContext.pageAssets = true;
772
+ renderContext.pageHead = pageAssetBundle.pageHead;
773
+ }
774
+
775
+ // Render the template
776
+ const templatePath = route.file.split(path.sep).join('/');
777
+ const html = nunjucks.render(templatePath, renderContext);
704
778
 
705
779
  // Execute hooks: afterRender
706
780
  ctx.html = html;
@@ -769,5 +843,7 @@ module.exports = {
769
843
  resolveMiddlewares,
770
844
  routeRegistrationMeta,
771
845
  compareRouteRegistrationOrder,
846
+ resolvePageAssets,
847
+ applyPageAssetsToTemplateData,
772
848
  };
773
849
 
package/src/server.js CHANGED
@@ -261,6 +261,7 @@ function haltOnTimedout(req, res, next) {
261
261
  * @param {Object} options.auth - Authentication manager instance (from createAuth)
262
262
  * @param {Object} options.db - Database instance (exposed as ctx.db to plugins)
263
263
  * @param {Object} [options.clientRuntime] - Optional client assets: `{ alpine?: boolean|object, swup?: boolean|object }`. Overridable by env `WEBSPRESSO_ALPINE` / `WEBSPRESSO_SWUP` (=1 or true). Serves `/__webspresso/client-runtime/*` when either flag is on.
264
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}} [options.pageAssets] - If truthy, route `load()` return values for `stylesheets` and `scripts` are reserved: removed from the root template context and passed as `pageHead` to Nunjucks, with `pageAssets: true` (for layout to emit `<link>` / `<script>`). Default off.
264
265
  * @param {function(import('express').Express, Object): void} [options.setupRoutes] - Called after file routes and plugins, before 404 handler
265
266
  * @returns {Object} { app, nunjucksEnv, pluginManager, authMiddleware }
266
267
  */
@@ -448,6 +449,7 @@ function createApp(options = {}) {
448
449
  silent: isTest,
449
450
  db: options.db ?? null,
450
451
  clientRuntime,
452
+ pageAssets: options.pageAssets,
451
453
  });
452
454
 
453
455
  // Set route metadata in plugin manager