domma-cms 0.14.10 → 0.16.0

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 (37) hide show
  1. package/admin/js/lib/effect-defs.js +1 -0
  2. package/admin/js/lib/effects-builder.js +3 -0
  3. package/admin/js/lib/markdown-toolbar.js +17 -46
  4. package/admin/js/templates/effects.html +83 -0
  5. package/admin/js/views/page-editor.js +12 -12
  6. package/package.json +2 -2
  7. package/plugins/analytics/admin/templates/analytics.html +52 -1
  8. package/plugins/analytics/admin/views/analytics.js +157 -32
  9. package/plugins/analytics/config.js +10 -2
  10. package/plugins/analytics/plugin.js +214 -25
  11. package/plugins/analytics/plugin.json +9 -5
  12. package/plugins/analytics/public/inject-body.html +25 -7
  13. package/plugins/blog/admin/templates/blog.html +25 -2
  14. package/plugins/blog/admin/views/blog.js +72 -56
  15. package/plugins/blog/admin/views/post-editor.js +98 -79
  16. package/plugins/blog/plugin.js +133 -0
  17. package/plugins/blog/plugin.json +3 -3
  18. package/plugins/blog/templates/post.html +2 -1
  19. package/plugins/invoice/admin/templates/editor.html +129 -0
  20. package/plugins/invoice/admin/templates/index.html +43 -0
  21. package/plugins/invoice/admin/templates/issuers.html +5 -0
  22. package/plugins/invoice/admin/templates/receivers.html +5 -0
  23. package/plugins/invoice/admin/views/editor.js +267 -0
  24. package/plugins/invoice/admin/views/index.js +155 -0
  25. package/plugins/invoice/admin/views/issuers.js +23 -0
  26. package/plugins/invoice/admin/views/party-view.js +148 -0
  27. package/plugins/invoice/admin/views/receivers.js +22 -0
  28. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  29. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  30. package/plugins/invoice/collections/invoices/schema.json +27 -0
  31. package/plugins/invoice/config.js +16 -0
  32. package/plugins/invoice/plugin.js +283 -0
  33. package/plugins/invoice/plugin.json +85 -0
  34. package/plugins/invoice/templates/invoice-print.html +213 -0
  35. package/public/js/effects.js +1 -1
  36. package/server/services/markdown.js +114 -25
  37. package/server/services/renderer.js +9 -3
@@ -1,51 +1,176 @@
1
1
  /**
2
2
  * Analytics Plugin — Admin View
3
- * Shows a sortable table of page hit counts.
4
- * Loaded dynamically from /plugins/ static path.
3
+ * KPI cards, trend chart (Chart.js via CDN), filterable top-pages table, CSV export.
5
4
  */
6
- export const analyticsView = {
7
- templateUrl: '/plugins/example-analytics/admin/templates/analytics.html',
5
+ const CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
8
6
 
9
- async onMount($container) {
10
- await loadStats($container);
7
+ let chartInstance = null;
8
+ let currentRange = '7d';
9
+ let currentTop = [];
10
+ let filterTerm = '';
11
11
 
12
- $container.find('#reset-btn').on('click', async () => {
13
- const confirmed = await E.confirm('Reset all analytics data? This cannot be undone.');
14
- if (!confirmed) return;
15
- try {
16
- await fetch('/api/plugins/example-analytics/stats', {
17
- method: 'DELETE',
18
- headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
19
- });
20
- E.toast('Analytics reset.', { type: 'success' });
21
- await loadStats($container);
22
- } catch {
23
- E.toast('Reset failed.', { type: 'error' });
24
- }
25
- });
12
+ export const analyticsView = {
13
+ templateUrl: '/plugins/analytics/admin/templates/analytics.html',
26
14
 
15
+ async onMount($container) {
16
+ await ensureChartJs();
17
+ await refresh($container, currentRange);
18
+ wireRangeTabs($container);
19
+ wireExport($container);
20
+ wireReset($container);
21
+ wireFilter($container);
27
22
  Domma.icons.scan();
28
23
  }
29
24
  };
30
25
 
31
- async function loadStats($container) {
32
- let stats = [];
26
+ function authHeaders() {
27
+ return { 'Authorization': 'Bearer ' + (S.get('auth_token') || '') };
28
+ }
29
+
30
+ function ensureChartJs() {
31
+ if (window.Chart) return Promise.resolve();
32
+ return new Promise((resolve, reject) => {
33
+ const s = document.createElement('script');
34
+ s.src = CHART_CDN;
35
+ s.async = true;
36
+ s.onload = () => resolve();
37
+ s.onerror = () => reject(new Error('chart.js load failed'));
38
+ document.head.appendChild(s);
39
+ });
40
+ }
41
+
42
+ async function refresh($container, range) {
43
+ currentRange = range;
33
44
  try {
34
- const res = await fetch('/api/plugins/example-analytics/stats', {
35
- headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
36
- });
37
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
38
- stats = await res.json();
39
- } catch {
45
+ const [stats, series] = await Promise.all([
46
+ fetch('/api/plugins/analytics/stats?range=' + encodeURIComponent(range), { headers: authHeaders() })
47
+ .then((r) => r.json()),
48
+ fetch('/api/plugins/analytics/series?range=' + encodeURIComponent(range === 'all' ? '30d' : range), { headers: authHeaders() })
49
+ .then((r) => r.json())
50
+ ]);
51
+
52
+ $container.find('#kpi-range').text(stats.total.toLocaleString());
53
+ $container.find('#kpi-pages').text(stats.uniquePages.toLocaleString());
54
+ $container.find('#kpi-lifetime').text(stats.lifetimeTotal.toLocaleString());
55
+
56
+ currentTop = stats.top || [];
57
+ renderTable($container);
58
+ renderChart($container, series.series || []);
59
+ updateExportLink($container);
60
+ } catch (err) {
40
61
  E.toast('Could not load analytics data.', { type: 'error' });
41
62
  }
63
+ }
64
+
65
+ function renderTable($container) {
66
+ const filtered = filterTerm
67
+ ? currentTop.filter((row) => row.url.toLowerCase().includes(filterTerm))
68
+ : currentTop;
42
69
 
43
70
  T.create('#analytics-table', {
44
- data: stats,
71
+ data: filtered,
45
72
  columns: [
46
- {key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>`},
47
- {key: 'hits', title: 'Page views'}
73
+ { key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>` },
74
+ { key: 'hits', title: 'Page views' }
48
75
  ],
49
- emptyMessage: 'No page views recorded yet.'
76
+ emptyMessage: filterTerm ? 'No pages match that filter.' : 'No page views recorded yet.'
77
+ });
78
+ }
79
+
80
+ function renderChart($container, series) {
81
+ const canvas = $container.find('#trend-chart').get(0);
82
+ if (!canvas || !window.Chart) return;
83
+ if (chartInstance) {
84
+ chartInstance.destroy();
85
+ chartInstance = null;
86
+ }
87
+ chartInstance = new window.Chart(canvas, {
88
+ type: 'line',
89
+ data: {
90
+ labels: series.map((p) => p.date),
91
+ datasets: [{
92
+ label: 'Hits',
93
+ data: series.map((p) => p.hits),
94
+ borderColor: 'rgba(99, 102, 241, 1)',
95
+ backgroundColor: 'rgba(99, 102, 241, 0.15)',
96
+ fill: true,
97
+ tension: 0.3,
98
+ borderWidth: 2,
99
+ pointRadius: 2
100
+ }]
101
+ },
102
+ options: {
103
+ responsive: true,
104
+ maintainAspectRatio: false,
105
+ plugins: { legend: { display: false } },
106
+ scales: {
107
+ y: { beginAtZero: true, ticks: { precision: 0 } },
108
+ x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
109
+ }
110
+ }
111
+ });
112
+ }
113
+
114
+ function wireRangeTabs($container) {
115
+ $container.find('.range-btn').on('click', function () {
116
+ const btn = $(this);
117
+ $container.find('.range-btn').removeClass('active');
118
+ btn.addClass('active');
119
+ refresh($container, btn.attr('data-range'));
120
+ });
121
+ }
122
+
123
+ function wireExport($container) {
124
+ $container.find('#export-btn').on('click', async () => {
125
+ try {
126
+ const res = await fetch('/api/plugins/analytics/export.csv?range=' + encodeURIComponent(currentRange), {
127
+ headers: authHeaders()
128
+ });
129
+ if (!res.ok) throw new Error('export failed');
130
+ const blob = await res.blob();
131
+ const cd = res.headers.get('Content-Disposition') || '';
132
+ const match = cd.match(/filename="?([^"]+)"?/);
133
+ const filename = match ? match[1] : 'analytics.csv';
134
+ const url = URL.createObjectURL(blob);
135
+ const a = document.createElement('a');
136
+ a.href = url;
137
+ a.download = filename;
138
+ document.body.appendChild(a);
139
+ a.click();
140
+ a.remove();
141
+ URL.revokeObjectURL(url);
142
+ } catch {
143
+ E.toast('Export failed.', { type: 'error' });
144
+ }
145
+ });
146
+ }
147
+
148
+ function updateExportLink() {
149
+ // no-op; export button uses fetch+blob to preserve Bearer auth
150
+ }
151
+
152
+ function wireReset($container) {
153
+ $container.find('#reset-btn').on('click', async () => {
154
+ const confirmed = await E.confirm('Reset all analytics data? This cannot be undone.');
155
+ if (!confirmed) return;
156
+ try {
157
+ const res = await fetch('/api/plugins/analytics/stats', {
158
+ method: 'DELETE',
159
+ headers: authHeaders()
160
+ });
161
+ if (!res.ok) throw new Error('reset failed');
162
+ E.toast('Analytics reset.', { type: 'success' });
163
+ await refresh($container, currentRange);
164
+ } catch {
165
+ E.toast('Reset failed.', { type: 'error' });
166
+ }
167
+ });
168
+ }
169
+
170
+ function wireFilter($container) {
171
+ const input = $container.find('#filter-input');
172
+ input.on('input', () => {
173
+ filterTerm = (input.val() || '').toString().toLowerCase().trim();
174
+ renderTable($container);
50
175
  });
51
176
  }
@@ -1,6 +1,14 @@
1
1
  /**
2
- * Example Analytics Plugin — Default Configuration
2
+ * Analytics Plugin — Default Configuration
3
+ *
4
+ * excludeAdmin strip /admin pageviews before recording
5
+ * excludePaths array of path prefixes to ignore (e.g. ['/api', '/healthz'])
6
+ * honourDnt respect navigator.doNotTrack on the client
7
+ * sessionDedup only record one hit per URL per browser session
3
8
  */
4
9
  export default {
5
- excludeAdmin: true
10
+ excludeAdmin: true,
11
+ excludePaths: ['/api'],
12
+ honourDnt: true,
13
+ sessionDedup: true
6
14
  };
@@ -1,58 +1,247 @@
1
1
  /**
2
2
  * Analytics Plugin — Server
3
- * Tracks page hits in a JSON file alongside the plugin.
4
- * Endpoints:
5
- * POST /api/plugins/analytics/hit - public: record a hit { url }
6
- * GET /api/plugins/analytics/stats - admin: return all hit counts
7
- * DELETE /api/plugins/analytics/stats - admin: reset all stats
3
+ * Tracks page hits with per-day buckets so we can chart trends and
4
+ * filter by time range.
5
+ *
6
+ * Storage:
7
+ * lifetime.json { "/url": totalHits }
8
+ * daily.json { "YYYY-MM-DD": { "/url": hits } }
9
+ *
10
+ * Endpoints (mounted at /api/plugins/analytics):
11
+ * POST /hit public record a hit { url, dnt }
12
+ * GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
13
+ * GET /series admin per-day totals series (?days=N or ?from=&to=)
14
+ * GET /export.csv admin CSV export of top pages for the active range
15
+ * DELETE /stats admin reset all stats
8
16
  */
9
17
  import fs from 'fs/promises';
10
18
  import path from 'path';
11
19
  import {fileURLToPath} from 'url';
12
20
 
13
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- const STATS_FILE = path.join(__dirname, 'stats.json');
22
+ const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
23
+ const DAILY_FILE = path.join(__dirname, 'daily.json');
24
+ const LEGACY_FILE = path.join(__dirname, 'stats.json');
15
25
 
16
- async function readStats() {
26
+ async function readJson(file, fallback) {
17
27
  try {
18
- return JSON.parse(await fs.readFile(STATS_FILE, 'utf8'));
28
+ return JSON.parse(await fs.readFile(file, 'utf8'));
19
29
  } catch {
20
- return {};
30
+ return fallback;
21
31
  }
22
32
  }
23
33
 
24
- async function writeStats(stats) {
25
- await fs.writeFile(STATS_FILE, JSON.stringify(stats, null, 2) + '\n', 'utf8');
34
+ async function writeJson(file, data) {
35
+ const tmp = file + '.tmp';
36
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
37
+ await fs.rename(tmp, file);
38
+ }
39
+
40
+ /**
41
+ * One-shot migration from the legacy flat stats.json.
42
+ * Preserves existing totals as lifetime numbers; daily history starts empty.
43
+ */
44
+ async function migrateLegacy() {
45
+ try {
46
+ await fs.access(LIFETIME_FILE);
47
+ return; // already migrated
48
+ } catch {
49
+ // fall through
50
+ }
51
+ const legacy = await readJson(LEGACY_FILE, null);
52
+ if (legacy && typeof legacy === 'object') {
53
+ await writeJson(LIFETIME_FILE, legacy);
54
+ await writeJson(DAILY_FILE, {});
55
+ // keep stats.json on disk as a backup; the next reset will wipe it
56
+ } else {
57
+ await writeJson(LIFETIME_FILE, {});
58
+ await writeJson(DAILY_FILE, {});
59
+ }
60
+ }
61
+
62
+ function todayKey(date = new Date()) {
63
+ return date.toISOString().slice(0, 10);
64
+ }
65
+
66
+ function rangeToDates(range, from, to) {
67
+ const today = new Date();
68
+ today.setUTCHours(0, 0, 0, 0);
69
+ const end = to ? new Date(to + 'T00:00:00Z') : today;
70
+ let start;
71
+ switch (range) {
72
+ case 'today':
73
+ start = today;
74
+ break;
75
+ case '7d':
76
+ start = new Date(today);
77
+ start.setUTCDate(start.getUTCDate() - 6);
78
+ break;
79
+ case '30d':
80
+ start = new Date(today);
81
+ start.setUTCDate(start.getUTCDate() - 29);
82
+ break;
83
+ case '90d':
84
+ start = new Date(today);
85
+ start.setUTCDate(start.getUTCDate() - 89);
86
+ break;
87
+ case 'custom':
88
+ start = from ? new Date(from + 'T00:00:00Z') : today;
89
+ break;
90
+ case 'all':
91
+ default:
92
+ return null; // all-time → use lifetime aggregate
93
+ }
94
+ return { start, end };
95
+ }
96
+
97
+ function aggregateDaily(daily, start, end) {
98
+ const totals = {};
99
+ let total = 0;
100
+ const startMs = start.getTime();
101
+ const endMs = end.getTime();
102
+ for (const [day, urls] of Object.entries(daily)) {
103
+ const dayMs = new Date(day + 'T00:00:00Z').getTime();
104
+ if (dayMs < startMs || dayMs > endMs) continue;
105
+ for (const [url, hits] of Object.entries(urls)) {
106
+ totals[url] = (totals[url] || 0) + hits;
107
+ total += hits;
108
+ }
109
+ }
110
+ return { totals, total };
111
+ }
112
+
113
+ function buildSeries(daily, start, end) {
114
+ const series = [];
115
+ const cursor = new Date(start);
116
+ while (cursor.getTime() <= end.getTime()) {
117
+ const key = todayKey(cursor);
118
+ const day = daily[key] || {};
119
+ const hits = Object.values(day).reduce((acc, n) => acc + n, 0);
120
+ series.push({ date: key, hits });
121
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
122
+ }
123
+ return series;
124
+ }
125
+
126
+ function escapeCsvCell(value) {
127
+ const str = String(value ?? '');
128
+ if (/[",\n\r]/.test(str)) {
129
+ return '"' + str.replace(/"/g, '""') + '"';
130
+ }
131
+ return str;
26
132
  }
27
133
 
28
134
  export default async function analyticsPlugin(fastify, options) {
29
- const {authenticate, requireAdmin} = options.auth;
135
+ const { authenticate, requireAdmin } = options.auth;
136
+ const settings = options.settings || {};
137
+
138
+ await migrateLegacy();
30
139
 
31
- // Record a page hit — called by the client-side injection script (public)
32
140
  fastify.post('/hit', async (request, reply) => {
33
- const { url } = request.body || {};
141
+ const { url, dnt } = request.body || {};
34
142
  if (!url || typeof url !== 'string') {
35
143
  return reply.status(400).send({ error: 'url is required' });
36
144
  }
145
+ if (dnt === true || dnt === '1') {
146
+ return { ok: true, ignored: 'dnt' };
147
+ }
148
+
149
+ const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
150
+ if (settings.excludeAdmin && normalised.startsWith('/admin')) {
151
+ return { ok: true, ignored: 'admin' };
152
+ }
153
+ if (Array.isArray(settings.excludePaths)) {
154
+ for (const prefix of settings.excludePaths) {
155
+ if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
156
+ return { ok: true, ignored: 'excluded' };
157
+ }
158
+ }
159
+ }
37
160
 
38
- const normalised = url.split('?')[0].replace(/\/$/, '') || '/';
39
- const stats = await readStats();
40
- stats[normalised] = (stats[normalised] || 0) + 1;
41
- await writeStats(stats);
161
+ const today = todayKey();
162
+ const [lifetime, daily] = await Promise.all([
163
+ readJson(LIFETIME_FILE, {}),
164
+ readJson(DAILY_FILE, {})
165
+ ]);
166
+ lifetime[normalised] = (lifetime[normalised] || 0) + 1;
167
+ if (!daily[today]) daily[today] = {};
168
+ daily[today][normalised] = (daily[today][normalised] || 0) + 1;
169
+ await Promise.all([writeJson(LIFETIME_FILE, lifetime), writeJson(DAILY_FILE, daily)]);
42
170
  return { ok: true };
43
171
  });
44
172
 
45
- // Return all stats admin only
46
- fastify.get('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
47
- const stats = await readStats();
48
- return Object.entries(stats)
173
+ fastify.get('/stats', { preHandler: [authenticate, requireAdmin] }, async (request) => {
174
+ const { range = '30d', from, to } = request.query || {};
175
+ const dates = rangeToDates(range, from, to);
176
+ const [lifetime, daily] = await Promise.all([
177
+ readJson(LIFETIME_FILE, {}),
178
+ readJson(DAILY_FILE, {})
179
+ ]);
180
+
181
+ let totals;
182
+ let total;
183
+ if (!dates) {
184
+ totals = lifetime;
185
+ total = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
186
+ } else {
187
+ const agg = aggregateDaily(daily, dates.start, dates.end);
188
+ totals = agg.totals;
189
+ total = agg.total;
190
+ }
191
+
192
+ const top = Object.entries(totals)
193
+ .map(([url, hits]) => ({ url, hits }))
194
+ .sort((a, b) => b.hits - a.hits);
195
+
196
+ const lifetimeTotal = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
197
+ return {
198
+ range,
199
+ from: dates ? todayKey(dates.start) : null,
200
+ to: dates ? todayKey(dates.end) : null,
201
+ total,
202
+ lifetimeTotal,
203
+ uniquePages: top.length,
204
+ top
205
+ };
206
+ });
207
+
208
+ fastify.get('/series', { preHandler: [authenticate, requireAdmin] }, async (request) => {
209
+ const { range = '30d', from, to } = request.query || {};
210
+ const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
211
+ const daily = await readJson(DAILY_FILE, {});
212
+ return { series: buildSeries(daily, dates.start, dates.end) };
213
+ });
214
+
215
+ fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
216
+ const { range = '30d', from, to } = request.query || {};
217
+ const dates = rangeToDates(range, from, to);
218
+ const [lifetime, daily] = await Promise.all([
219
+ readJson(LIFETIME_FILE, {}),
220
+ readJson(DAILY_FILE, {})
221
+ ]);
222
+ const totals = dates ? aggregateDaily(daily, dates.start, dates.end).totals : lifetime;
223
+ const rows = Object.entries(totals)
49
224
  .map(([url, hits]) => ({ url, hits }))
50
225
  .sort((a, b) => b.hits - a.hits);
226
+
227
+ const lines = ['url,hits'];
228
+ for (const row of rows) {
229
+ lines.push(`${escapeCsvCell(row.url)},${row.hits}`);
230
+ }
231
+ const filename = `analytics-${range}-${new Date().toISOString().slice(0, 10)}.csv`;
232
+ reply
233
+ .header('Content-Type', 'text/csv; charset=utf-8')
234
+ .header('Content-Disposition', `attachment; filename="${filename}"`);
235
+ return lines.join('\n') + '\n';
51
236
  });
52
237
 
53
- // Reset stats admin only
54
- fastify.delete('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
55
- await writeStats({});
238
+ fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
239
+ await Promise.all([writeJson(LIFETIME_FILE, {}), writeJson(DAILY_FILE, {})]);
240
+ try {
241
+ await fs.unlink(LEGACY_FILE);
242
+ } catch {
243
+ // ignore
244
+ }
56
245
  return { ok: true };
57
246
  });
58
247
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "analytics",
3
3
  "displayName": "Analytics",
4
- "version": "1.0.0",
5
- "description": "Basic page view analytics. Tracks hits per page using a simple JSON store.",
4
+ "version": "2.0.0",
5
+ "description": "Page view analytics with daily trends, time-range filtering, top-pages table and CSV export. Honours Do-Not-Track and dedups per session.",
6
6
  "author": "Darryl Waterhouse",
7
- "date": "2026-03-01",
7
+ "date": "2026-05-04",
8
8
  "icon": "chart-bar",
9
9
  "admin": {
10
10
  "sidebar": [
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "views": {
27
27
  "plugin-analytics": {
28
- "entry": "analytics/admin/views/analytics.js?v=80339cdc",
28
+ "entry": "analytics/admin/views/analytics.js?v=2c26480d",
29
29
  "exportName": "analyticsView"
30
30
  }
31
31
  }
@@ -37,7 +37,11 @@
37
37
  "scaffold": {
38
38
  "reset": [
39
39
  {
40
- "path": "stats.json",
40
+ "path": "lifetime.json",
41
+ "content": "{}"
42
+ },
43
+ {
44
+ "path": "daily.json",
41
45
  "content": "{}"
42
46
  }
43
47
  ]
@@ -1,13 +1,31 @@
1
- <!-- analytics: page view tracker -->
1
+ <!-- analytics: page view tracker (DNT-aware, session-deduped) -->
2
2
  <script>
3
3
  (function () {
4
+ if (typeof fetch !== 'function') return;
5
+
6
+ var dnt = navigator.doNotTrack === '1'
7
+ || window.doNotTrack === '1'
8
+ || navigator.msDoNotTrack === '1';
9
+ if (dnt) return;
10
+
4
11
  var url = window.location.pathname;
5
- if (typeof fetch === 'function') {
6
- fetch('/api/plugins/analytics/hit', {
7
- method: 'POST',
8
- headers: { 'Content-Type': 'application/json' },
9
- body: JSON.stringify({ url: url })
10
- }).catch(function () { /* silent fail */ });
12
+
13
+ try {
14
+ var key = 'dm_analytics_seen';
15
+ var seen = sessionStorage.getItem(key);
16
+ var list = seen ? seen.split('|') : [];
17
+ if (list.indexOf(url) !== -1) return;
18
+ list.push(url);
19
+ sessionStorage.setItem(key, list.join('|'));
20
+ } catch (e) {
21
+ // sessionStorage unavailable (e.g. private mode) — record anyway
11
22
  }
23
+
24
+ fetch('/api/plugins/analytics/hit', {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ keepalive: true,
28
+ body: JSON.stringify({ url: url })
29
+ }).catch(function () { /* silent */ });
12
30
  })();
13
31
  </script>
@@ -1,10 +1,29 @@
1
1
  <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0">Blog Posts</h1>
2
+ <h1 class="h3 mb-0"><span data-icon="file-text"></span> Blog Posts</h1>
3
3
  <button id="btn-new-post" class="btn btn-primary">
4
4
  <span data-icon="plus"></span> New Post
5
5
  </button>
6
6
  </div>
7
7
 
8
+ <div class="grid grid-cols-4 gap-3 mb-4">
9
+ <div class="card"><div class="card-body">
10
+ <div class="text-muted small">Total</div>
11
+ <div class="h3 mb-0" id="kpi-total">—</div>
12
+ </div></div>
13
+ <div class="card"><div class="card-body">
14
+ <div class="text-muted small">Published</div>
15
+ <div class="h3 mb-0" id="kpi-published">—</div>
16
+ </div></div>
17
+ <div class="card"><div class="card-body">
18
+ <div class="text-muted small">Drafts</div>
19
+ <div class="h3 mb-0" id="kpi-drafts">—</div>
20
+ </div></div>
21
+ <div class="card"><div class="card-body">
22
+ <div class="text-muted small">Scheduled</div>
23
+ <div class="h3 mb-0" id="kpi-scheduled">—</div>
24
+ </div></div>
25
+ </div>
26
+
8
27
  <div class="card mb-3">
9
28
  <div class="card-body">
10
29
  <div class="d-flex gap-2 flex-wrap">
@@ -14,7 +33,11 @@
14
33
  <option value="scheduled">Scheduled</option>
15
34
  <option value="published">Published</option>
16
35
  </select>
17
- <input id="filter-search" type="text" class="form-control" style="width:200px;" placeholder="Search posts…">
36
+ <select id="filter-category" class="form-select" style="width:auto;">
37
+ <option value="">All categories</option>
38
+ </select>
39
+ <input id="filter-search" type="text" class="form-control" style="width:240px;" placeholder="Search title or excerpt…">
40
+ <button id="filter-reset" class="btn btn-ghost btn-sm">Reset</button>
18
41
  </div>
19
42
  </div>
20
43
  </div>