@harshankur/viewcounter 3.1.0 → 3.3.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 (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
package/admin/js/main.js CHANGED
@@ -1,33 +1,59 @@
1
1
  /**
2
- * Admin UI entry point: session check, login, tabs, and wiring.
2
+ * Admin UI entry point: session check, login, sections, and wiring.
3
3
  */
4
4
 
5
- import { api, ApiError, onUnauthenticated, setCsrfToken } from './api.js';
5
+ import { api, ApiError, onUnauthenticated, setCsrfToken, setRecovery } from './api.js';
6
+ import { promptPassword } from './passwordPrompt.js';
6
7
  import { byId, el, replaceChildren } from './dom.js';
7
8
  import { applyTranslations, loadLocale, t } from './i18n.js';
8
- import { createAdminLogPanel, createViewLogPanel } from './logs.js';
9
+ import { icon } from './icons.js';
10
+ import { createAdminLogPanel, createTrackingLogPanel } from './logs.js';
9
11
  import { closeModal } from './modal.js';
12
+ import { createOverviewPanel } from './overview.js';
10
13
  import { initTheme } from './theme.js';
11
14
  import { showToast, TOAST_TYPE } from './toast.js';
12
15
  import { createViewsPanel, PANEL_MODE } from './views.js';
13
- import { ALL_APPS, ERROR_CODE, KEY, STORAGE_KEY, TAB, THEME } from './constants.js';
16
+ import {
17
+ ALL_APPS, APP_NAME, COPYRIGHT, ERROR_CODE, KEY, LEGACY_TAB_HASH, LINKS, SESSION_PING_INTERVAL_MS, STORAGE_KEY, TAB, TAB_HASH, THEME,
18
+ } from './constants.js';
14
19
 
15
- const TAB_ORDER = [TAB.VIEWS, TAB.TRASH, TAB.ADMIN_LOG, TAB.VIEW_LOG];
20
+ const TAB_ORDER = [TAB.OVERVIEW, TAB.VIEWS, TAB.TRASH, TAB.TRACKING_LOG, TAB.ADMIN_LOG];
21
+ const TAB_ICON = {
22
+ [TAB.OVERVIEW]: 'overview',
23
+ [TAB.VIEWS]: 'views',
24
+ [TAB.TRASH]: 'trash',
25
+ [TAB.TRACKING_LOG]: 'trackingLog',
26
+ [TAB.ADMIN_LOG]: 'adminLog',
27
+ };
28
+ /** Sections that show one app (or all), and follow the app chosen in any of them. */
29
+ const APP_TABS = [TAB.OVERVIEW, TAB.VIEWS, TAB.TRASH];
16
30
 
17
31
  const shell = {
18
32
  meta: null,
19
33
  apps: [],
20
34
  appId: null,
21
35
  panels: new Map(),
22
- activeTab: TAB.VIEWS,
36
+ activeTab: null,
23
37
  tabButtons: new Map(),
24
38
  };
25
39
 
40
+ /** Hand an exception to the browser's own error reporting (the console, and error listeners). */
41
+ function reportToBrowser(error) {
42
+ window.reportError?.(error);
43
+ }
44
+
26
45
  /** Translate any failure into a message and show it. */
27
46
  function reportError(error) {
28
- const code = error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR;
29
- if (code === ERROR_CODE.UNAUTHENTICATED) return;
30
- showToast(t(`errors.${code}`), TOAST_TYPE.ERROR);
47
+ if (!(error instanceof ApiError)) {
48
+ // A bug in this page, not an answer from the server: say so, and
49
+ // report the exception to the browser as if uncaught, where a
50
+ // developer finds it with its stack.
51
+ reportToBrowser(error);
52
+ showToast(t(`errors.${ERROR_CODE.UNEXPECTED}`), TOAST_TYPE.ERROR);
53
+ return;
54
+ }
55
+ if (error.code === ERROR_CODE.UNAUTHENTICATED) return;
56
+ showToast(t(`errors.${error.code}`), TOAST_TYPE.ERROR);
31
57
  }
32
58
 
33
59
  function readLastApp() {
@@ -50,8 +76,10 @@ function rememberApp(appId) {
50
76
 
51
77
  function showLogin(messageKey) {
52
78
  closeModal();
79
+ shell.panels.get(shell.activeTab)?.hide?.();
53
80
  byId('app-screen').hidden = true;
54
81
  byId('logout-btn').hidden = true;
82
+ byId('app-footer').hidden = true;
55
83
  byId('login-screen').hidden = false;
56
84
  const error = byId('login-error');
57
85
  error.hidden = !messageKey;
@@ -61,6 +89,10 @@ function showLogin(messageKey) {
61
89
  input.focus();
62
90
  }
63
91
 
92
+ function errorKey(error) {
93
+ return `errors.${error instanceof ApiError ? error.code : ERROR_CODE.UNEXPECTED}`;
94
+ }
95
+
64
96
  function wireLogin() {
65
97
  const form = byId('login-form');
66
98
  const submit = byId('login-submit');
@@ -74,50 +106,141 @@ function wireLogin() {
74
106
  setCsrfToken(session.csrfToken);
75
107
  await enterApp();
76
108
  } catch (error) {
77
- const code = error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR;
78
- showLogin(`errors.${code}`);
109
+ if (!(error instanceof ApiError)) reportToBrowser(error);
110
+ showLogin(errorKey(error));
79
111
  } finally {
80
112
  submit.disabled = false;
81
113
  }
82
114
  });
83
115
  }
84
116
 
117
+ // ---- Staying signed in ------------------------------------------------------
118
+
119
+ /** Sign in again over whatever the admin was doing, then carry on. */
120
+ function reauthenticate() {
121
+ return promptPassword({
122
+ title: t('session.endedTitle'),
123
+ message: t('session.endedMessage'),
124
+ submitLabel: t('login.submit'),
125
+ submit: async (password) => {
126
+ const session = await api.login(password);
127
+ setCsrfToken(session.csrfToken);
128
+ },
129
+ });
130
+ }
131
+
132
+ /** The password again, before something that cannot be undone. */
133
+ function confirmPassword() {
134
+ return promptPassword({
135
+ title: t('session.confirmTitle'),
136
+ message: t('session.confirmMessage'),
137
+ submitLabel: t('session.confirmAction'),
138
+ submit: (password) => api.reauth(password),
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Reading counts as using the UI: clicks, keys, and scrolling extend the
144
+ * session even when they cause no request, at most once per interval.
145
+ */
146
+ function keepSessionAlive() {
147
+ let lastPing = Date.now();
148
+ const ping = () => {
149
+ if (document.visibilityState !== 'visible' || Date.now() - lastPing < SESSION_PING_INTERVAL_MS) return;
150
+ lastPing = Date.now();
151
+ api.session().catch(() => {});
152
+ };
153
+ for (const type of ['pointerdown', 'keydown', 'wheel', 'scroll', 'visibilitychange']) {
154
+ document.addEventListener(type, ping, { passive: true, capture: true });
155
+ }
156
+ }
157
+
158
+ // ---- Header and footer ------------------------------------------------------
159
+
160
+ /** An outbound link: new tab, no referrer, no opener. */
161
+ function outboundLink(href, label, iconName) {
162
+ return el('a', {
163
+ className: 'footer-link',
164
+ attrs: { href, target: '_blank', rel: 'noopener noreferrer' },
165
+ }, [iconName ? icon(iconName) : null, el('span', { text: label })]);
166
+ }
167
+
168
+ function renderChrome() {
169
+ const version = byId('app-version');
170
+ version.textContent = t('app.version', { version: shell.meta.version });
171
+ version.hidden = false;
172
+
173
+ const credits = (shell.meta.attributions ?? []).map((credit) => (credit.url
174
+ ? el('a', { text: credit.text, attrs: { href: credit.url, target: '_blank', rel: 'noopener noreferrer' } })
175
+ : el('span', { text: credit.text })));
176
+ const external = (text, href) => el('a', { text, attrs: { href, target: '_blank', rel: 'noopener noreferrer' } });
177
+ // "ViewCounter © 2026 Harsh Ankur", with the name a link: built around
178
+ // the name so a translation can put the parts in its own order.
179
+ const [beforeHolder, afterHolder] = t('footer.copyright', { product: APP_NAME, year: COPYRIGHT.YEAR, holder: '\u0000' })
180
+ .split('\u0000');
181
+ replaceChildren(byId('app-footer'), [
182
+ el('div', { className: 'footer-row' }, [
183
+ el('p', { className: 'footer-copyright' }, [
184
+ beforeHolder, external(COPYRIGHT.HOLDER, COPYRIGHT.HOLDER_URL), afterHolder ?? '',
185
+ ]),
186
+ el('nav', { className: 'footer-links', attrs: { 'aria-label': t('footer.links') } }, [
187
+ outboundLink(LINKS.WEBSITE, t('footer.website'), 'globe'),
188
+ outboundLink(LINKS.DOCS_ADMIN, t('footer.docs'), 'book'),
189
+ outboundLink(LINKS.CHANGELOG, t('footer.changelog'), 'clock'),
190
+ outboundLink(LINKS.SOURCE, t('footer.source'), 'code'),
191
+ outboundLink(LINKS.NPM, t('footer.npm'), 'package'),
192
+ ]),
193
+ ]),
194
+ el('div', { className: 'footer-row footer-legal' }, [
195
+ el('p', { className: 'footer-version', text: t('footer.product', { version: shell.meta.version }) }),
196
+ credits.length ? el('p', { className: 'footer-credits' }, [
197
+ el('span', { text: t('footer.credits') }),
198
+ ...credits.flatMap((credit, index) => [index ? ' · ' : ' ', credit]),
199
+ ]) : null,
200
+ ]),
201
+ ]);
202
+ byId('app-footer').hidden = false;
203
+ }
204
+
85
205
  // ---- App shell --------------------------------------------------------------
86
206
 
87
207
  async function refreshApps() {
88
208
  const { apps } = await api.apps();
89
209
  shell.apps = apps;
90
- for (const panel of shell.panels.values()) panel.syncApps?.();
210
+ for (const tab of APP_TABS) shell.panels.get(tab)?.syncApps();
91
211
  }
92
212
 
93
213
  function setAppId(appId) {
94
214
  shell.appId = appId;
95
215
  rememberApp(appId);
96
- for (const tab of [TAB.VIEWS, TAB.TRASH]) {
216
+ for (const tab of APP_TABS) {
97
217
  const panel = shell.panels.get(tab);
98
218
  panel.syncApps();
99
- panel.reload();
219
+ panel.appChanged();
100
220
  }
101
221
  }
102
222
 
223
+ /** The section named by the address, including the names earlier versions used. */
224
+ function tabFromHash(hash) {
225
+ const name = hash.replace(/^#/, '');
226
+ return TAB_ORDER.find((tab) => TAB_HASH[tab] === name) ?? LEGACY_TAB_HASH[name] ?? TAB.OVERVIEW;
227
+ }
228
+
103
229
  function selectTab(tab, { focus = false } = {}) {
104
- shell.activeTab = TAB_ORDER.includes(tab) ? tab : TAB.VIEWS;
230
+ const next = TAB_ORDER.includes(tab) ? tab : TAB.OVERVIEW;
231
+ if (shell.activeTab && shell.activeTab !== next) shell.panels.get(shell.activeTab)?.hide();
232
+ shell.activeTab = next;
105
233
  for (const [key, button] of shell.tabButtons) {
106
- const active = key === shell.activeTab;
234
+ const active = key === next;
107
235
  button.setAttribute('aria-selected', String(active));
108
236
  button.tabIndex = active ? 0 : -1;
109
237
  button.classList.toggle('active', active);
110
238
  if (active && focus) button.focus();
111
239
  }
112
- for (const [key, panel] of shell.panels) panel.element.hidden = key !== shell.activeTab;
240
+ for (const [key, panel] of shell.panels) panel.element.hidden = key !== next;
241
+ shell.panels.get(next).show();
113
242
 
114
- const panel = shell.panels.get(shell.activeTab);
115
- if (shell.activeTab === TAB.ADMIN_LOG || shell.activeTab === TAB.VIEW_LOG) panel.load();
116
- else panel.refresh();
117
-
118
- if (window.location.hash !== `#${shell.activeTab}`) {
119
- history.replaceState(null, '', `#${shell.activeTab}`);
120
- }
243
+ if (window.location.hash !== `#${TAB_HASH[next]}`) history.replaceState(null, '', `#${TAB_HASH[next]}`);
121
244
  }
122
245
 
123
246
  function buildTabs() {
@@ -127,21 +250,27 @@ function buildTabs() {
127
250
  className: 'tab',
128
251
  attrs: { type: 'button', role: 'tab', id: `tab-${tab}`, 'aria-controls': `panel-${tab}` },
129
252
  on: { click: () => selectTab(tab) },
130
- }, [
131
- el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
132
- el('span', { text: t(`tabs.${tab}`) }),
133
- ]);
253
+ }, [icon(TAB_ICON[tab]), el('span', { text: t(`tabs.${tab}`) })]);
134
254
  shell.tabButtons.set(tab, button);
135
255
  return button;
136
256
  });
257
+ replaceChildren(tablist, buttons);
258
+ }
137
259
 
138
- // Arrow keys move between tabs (ARIA tabs pattern).
139
- tablist.addEventListener('keydown', (event) => {
260
+ /** Arrow keys, Home, and End move between sections (ARIA tabs pattern). Wired once. */
261
+ function wireTabKeys() {
262
+ byId('tabs').addEventListener('keydown', (event) => {
140
263
  const index = TAB_ORDER.indexOf(shell.activeTab);
141
- if (event.key === KEY.ARROW_RIGHT) selectTab(TAB_ORDER[(index + 1) % TAB_ORDER.length], { focus: true });
142
- if (event.key === KEY.ARROW_LEFT) selectTab(TAB_ORDER[(index - 1 + TAB_ORDER.length) % TAB_ORDER.length], { focus: true });
264
+ const moves = {
265
+ [KEY.ARROW_RIGHT]: (index + 1) % TAB_ORDER.length,
266
+ [KEY.ARROW_LEFT]: (index - 1 + TAB_ORDER.length) % TAB_ORDER.length,
267
+ [KEY.HOME]: 0,
268
+ [KEY.END]: TAB_ORDER.length - 1,
269
+ };
270
+ if (!(event.key in moves)) return;
271
+ event.preventDefault();
272
+ selectTab(TAB_ORDER[moves[event.key]], { focus: true });
143
273
  });
144
- replaceChildren(tablist, buttons);
145
274
  }
146
275
 
147
276
  function buildPanels() {
@@ -151,13 +280,35 @@ function buildPanels() {
151
280
  setAppId,
152
281
  refreshApps,
153
282
  reportError,
283
+ /** Views were edited, trashed, restored, or erased: every count is out of date. */
284
+ viewsChanged() {
285
+ for (const tab of APP_TABS) {
286
+ if (tab !== shell.activeTab) shell.panels.get(tab).invalidate();
287
+ }
288
+ },
289
+ /** Open the Views table on exactly the rows an Overview describes. */
290
+ openViews(filters) {
291
+ shell.panels.get(TAB.VIEWS).applyFilters(filters);
292
+ selectTab(TAB.VIEWS);
293
+ },
154
294
  };
155
295
  const appIds = () => shell.apps.map((app) => app.appId);
156
296
 
297
+ shell.panels.set(TAB.OVERVIEW, createOverviewPanel({ meta: shell.meta, context }));
157
298
  shell.panels.set(TAB.VIEWS, createViewsPanel({ mode: PANEL_MODE.VIEWS, meta: shell.meta, context }));
158
299
  shell.panels.set(TAB.TRASH, createViewsPanel({ mode: PANEL_MODE.TRASH, meta: shell.meta, context }));
300
+ shell.panels.set(TAB.TRACKING_LOG, createTrackingLogPanel({
301
+ meta: shell.meta,
302
+ appIds,
303
+ reportError,
304
+ // A logged view, found in the table whether or not it is in the trash.
305
+ openView: (appId, viewId) => {
306
+ if (shell.appId !== appId && shell.apps.some((app) => app.appId === appId)) setAppId(appId);
307
+ shell.panels.get(TAB.VIEWS).findView(viewId);
308
+ selectTab(TAB.VIEWS);
309
+ },
310
+ }));
159
311
  shell.panels.set(TAB.ADMIN_LOG, createAdminLogPanel({ meta: shell.meta, appIds, reportError }));
160
- shell.panels.set(TAB.VIEW_LOG, createViewLogPanel({ meta: shell.meta, appIds, reportError }));
161
312
 
162
313
  const container = byId('panels');
163
314
  replaceChildren(container, TAB_ORDER.map((tab) => {
@@ -165,6 +316,7 @@ function buildPanels() {
165
316
  panel.element.id = `panel-${tab}`;
166
317
  panel.element.setAttribute('role', 'tabpanel');
167
318
  panel.element.setAttribute('aria-labelledby', `tab-${tab}`);
319
+ panel.element.hidden = true;
168
320
  return panel.element;
169
321
  }));
170
322
  }
@@ -177,15 +329,19 @@ async function enterApp() {
177
329
  const remembered = readLastApp();
178
330
  shell.appId = apps.some((app) => app.appId === remembered) ? remembered : ALL_APPS;
179
331
 
332
+ shell.activeTab = null;
333
+ shell.panels.clear();
334
+ shell.tabButtons.clear();
180
335
  buildTabs();
181
336
  buildPanels();
182
- for (const tab of [TAB.VIEWS, TAB.TRASH]) shell.panels.get(tab).syncApps();
337
+ for (const tab of APP_TABS) shell.panels.get(tab).syncApps();
338
+ renderChrome();
183
339
 
184
340
  byId('login-screen').hidden = true;
185
341
  byId('app-screen').hidden = false;
186
342
  byId('logout-btn').hidden = false;
187
343
 
188
- selectTab(window.location.hash.slice(1));
344
+ selectTab(tabFromHash(window.location.hash));
189
345
  }
190
346
 
191
347
  async function logout() {
@@ -205,17 +361,24 @@ async function boot() {
205
361
  applyTranslations();
206
362
  document.title = t('app.documentTitle');
207
363
 
364
+ for (const node of document.querySelectorAll('[data-icon]')) node.prepend(icon(node.dataset.icon));
208
365
  initTheme({
209
366
  [THEME.LIGHT]: byId('theme-light'),
210
367
  [THEME.DARK]: byId('theme-dark'),
211
368
  [THEME.AUTO]: byId('theme-auto'),
212
369
  });
213
370
  wireLogin();
371
+ wireTabKeys();
214
372
  byId('logout-btn').addEventListener('click', logout);
373
+ setRecovery({ reauthenticate, confirmPassword });
374
+ keepSessionAlive();
215
375
  onUnauthenticated(() => {
216
376
  setCsrfToken(null);
217
377
  showLogin('errors.UNAUTHENTICATED');
218
378
  });
379
+ window.addEventListener('hashchange', () => {
380
+ if (!byId('app-screen').hidden) selectTab(tabFromHash(window.location.hash));
381
+ });
219
382
 
220
383
  try {
221
384
  const session = await api.session();
@@ -226,7 +389,8 @@ async function boot() {
226
389
  showLogin();
227
390
  }
228
391
  } catch (error) {
229
- showLogin(`errors.${error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR}`);
392
+ if (!(error instanceof ApiError)) reportToBrowser(error);
393
+ showLogin(errorKey(error));
230
394
  } finally {
231
395
  document.body.classList.remove('booting');
232
396
  }