@harshankur/viewcounter 3.0.1 → 3.1.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 (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
Binary file
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ViewCounter">
2
+ <title>ViewCounter</title>
3
+ <rect width="64" height="64" rx="14" fill="#0a0f0c"/>
4
+ <rect x="0.75" y="0.75" width="62.5" height="62.5" rx="13.25" fill="none" stroke="#3ddc84" stroke-opacity="0.35" stroke-width="1.5"/>
5
+ <g transform="translate(2.92 13.29) scale(1.17)">
6
+ <polyline points="3,6 15,16 3,26" fill="none" stroke="#3ddc84" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
7
+ <path d="M24.55 7.94L27.94 7.94L27.96 7.98L30.18 18.13L30.69 20.74L30.73 20.72L31.22 17.8L33.31 7.98L33.33 7.94L36.63 7.94L32.69 23.98L28.53 23.98ZM43.51 7.72L44.59 7.74L45.8 7.96L46.42 8.18L46.95 8.45L47.56 8.86L48.2 9.48L48.68 10.18L49.06 11.11L49.23 12.21L49.23 12.54L45.96 12.54L45.93 12.12L45.85 11.75L45.54 11.2L45.25 10.93L44.9 10.73L44.15 10.56L43.43 10.58L42.85 10.73L42.37 11.04L42.15 11.28L41.95 11.64L41.8 12.3L41.8 19.62L41.97 20.33L42.28 20.79L42.63 21.07L43.27 21.32L44.15 21.36L44.57 21.29L44.9 21.18L45.27 20.96L45.65 20.57L45.89 20.02L45.96 19.38L49.23 19.38L49.15 20.41L48.84 21.4L48.4 22.17L47.72 22.92L47.06 23.41L46.13 23.85L45.23 24.09L44.26 24.2L42.99 24.15L41.97 23.96L40.94 23.54L40.17 23.03L39.64 22.53L39.36 22.17L38.89 21.36L38.63 20.52L38.52 19.71L38.52 12.19L38.61 11.48L38.87 10.6L39.18 10.01L39.58 9.46L40.17 8.89L40.74 8.49L41.4 8.16L41.97 7.96L42.66 7.81Z" fill="#3ddc84"/>
8
+ </g>
9
+ </svg>
Binary file
Binary file
@@ -0,0 +1,89 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="theme-dark">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <meta name="referrer" content="no-referrer">
9
+ <title>ViewCounter</title>
10
+ <link rel="icon" href="favicon.ico" sizes="any">
11
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
12
+ <link rel="apple-touch-icon" href="apple-touch-icon.png">
13
+ <link rel="manifest" href="site.webmanifest">
14
+ <link rel="stylesheet" href="css/admin.css">
15
+ <script type="module" src="js/main.js"></script>
16
+ </head>
17
+
18
+ <body class="booting">
19
+ <div class="bg-grid" aria-hidden="true"></div>
20
+ <div class="bg-glow" aria-hidden="true"></div>
21
+ <div class="crt-overlay" aria-hidden="true"></div>
22
+
23
+ <a class="skip-link" href="#main" data-i18n="nav.skip"></a>
24
+
25
+ <nav class="topbar">
26
+ <div class="logo">
27
+ <svg class="logo-mark" viewBox="0 0 96 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
28
+ <polyline points="3,6 15,16 3,26" fill="none" stroke="currentColor" stroke-width="5"
29
+ stroke-linecap="round" stroke-linejoin="round" />
30
+ <path
31
+ d="M24.55 7.94L27.94 7.94L27.96 7.98L30.18 18.13L30.69 20.74L30.73 20.72L31.22 17.8L33.31 7.98L33.33 7.94L36.63 7.94L32.69 23.98L28.53 23.98ZM43.51 7.72L44.59 7.74L45.8 7.96L46.42 8.18L46.95 8.45L47.56 8.86L48.2 9.48L48.68 10.18L49.06 11.11L49.23 12.21L49.23 12.54L45.96 12.54L45.93 12.12L45.85 11.75L45.54 11.2L45.25 10.93L44.9 10.73L44.15 10.56L43.43 10.58L42.85 10.73L42.37 11.04L42.15 11.28L41.95 11.64L41.8 12.3L41.8 19.62L41.97 20.33L42.28 20.79L42.63 21.07L43.27 21.32L44.15 21.36L44.57 21.29L44.9 21.18L45.27 20.96L45.65 20.57L45.89 20.02L45.96 19.38L49.23 19.38L49.15 20.41L48.84 21.4L48.4 22.17L47.72 22.92L47.06 23.41L46.13 23.85L45.23 24.09L44.26 24.2L42.99 24.15L41.97 23.96L40.94 23.54L40.17 23.03L39.64 22.53L39.36 22.17L38.89 21.36L38.63 20.52L38.52 19.71L38.52 12.19L38.61 11.48L38.87 10.6L39.18 10.01L39.58 9.46L40.17 8.89L40.74 8.49L41.4 8.16L41.97 7.96L42.66 7.81Z"
32
+ fill="currentColor" />
33
+ <rect class="logo-cursor" x="70" y="7" width="10" height="19" fill="currentColor" />
34
+ </svg>
35
+ <span class="logo-text" data-i18n="app.name"></span>
36
+ <span class="logo-badge" data-i18n="app.badge"></span>
37
+ </div>
38
+
39
+ <div class="topbar-actions">
40
+ <div class="theme-switcher" role="group" data-i18n-attr="aria-label:theme.label">
41
+ <button class="theme-btn" id="theme-light" type="button" aria-pressed="false"
42
+ data-i18n-attr="aria-label:theme.light;title:theme.light">☀️</button>
43
+ <button class="theme-btn" id="theme-dark" type="button" aria-pressed="false"
44
+ data-i18n-attr="aria-label:theme.dark;title:theme.dark">🌙</button>
45
+ <button class="theme-btn" id="theme-auto" type="button" aria-pressed="false"
46
+ data-i18n-attr="aria-label:theme.auto;title:theme.auto">🌓</button>
47
+ </div>
48
+ <button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
49
+ data-i18n="nav.logout"></button>
50
+ </div>
51
+ </nav>
52
+
53
+ <main id="main">
54
+ <section id="login-screen" class="login" hidden>
55
+ <div class="term-window login-window">
56
+ <div class="term-titlebar">
57
+ <div class="term-dots" aria-hidden="true">
58
+ <span class="term-dot red"></span>
59
+ <span class="term-dot amber"></span>
60
+ <span class="term-dot green"></span>
61
+ </div>
62
+ <span class="term-title" data-i18n="login.windowTitle"></span>
63
+ </div>
64
+ <form id="login-form" class="term-body login-form" novalidate>
65
+ <h1 class="login-heading">
66
+ <span class="prompt-char" aria-hidden="true">❯</span>
67
+ <span data-i18n="login.heading"></span>
68
+ </h1>
69
+ <p class="login-intro" data-i18n="login.intro"></p>
70
+ <label class="field-label" for="login-password" data-i18n="login.password"></label>
71
+ <input class="input" id="login-password" name="password" type="password"
72
+ autocomplete="current-password" required>
73
+ <p id="login-error" class="field-error" role="alert" hidden></p>
74
+ <button class="btn btn-primary" id="login-submit" type="submit" data-i18n="login.submit"></button>
75
+ </form>
76
+ </div>
77
+ </section>
78
+
79
+ <section id="app-screen" class="app-screen" hidden>
80
+ <div id="tabs" class="tabs" role="tablist" data-i18n-attr="aria-label:tabs.label"></div>
81
+ <div id="panels"></div>
82
+ </section>
83
+ </main>
84
+
85
+ <div id="modal-root" hidden></div>
86
+ <div id="toast-root" class="toast-root"></div>
87
+ </body>
88
+
89
+ </html>
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Client for the admin API.
3
+ *
4
+ * Holds the session's CSRF token and sends it on every state-changing request.
5
+ * Failures become an ApiError carrying the server's stable `code`, which the
6
+ * UI translates; no server-side message text is ever shown.
7
+ */
8
+
9
+ import { API_BASE, CSRF_HEADER, ERROR_CODE } from './constants.js';
10
+
11
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
12
+ const HTTP_NO_CONTENT = 204;
13
+ const HTTP_UNAUTHORIZED = 401;
14
+
15
+ let csrfToken = null;
16
+ const unauthenticatedListeners = new Set();
17
+
18
+ export class ApiError {
19
+ /** @param {number} status @param {string} code @param {object} [body] */
20
+ constructor(status, code, body = {}) {
21
+ this.status = status;
22
+ this.code = code;
23
+ this.body = body;
24
+ }
25
+ }
26
+
27
+ /** Called whenever the server says the session is gone. */
28
+ export function onUnauthenticated(listener) {
29
+ unauthenticatedListeners.add(listener);
30
+ }
31
+
32
+ export function setCsrfToken(token) {
33
+ csrfToken = token;
34
+ }
35
+
36
+ /**
37
+ * @param {string} method
38
+ * @param {string} path relative to the API base, no leading slash
39
+ * @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
40
+ */
41
+ export async function request(method, path, { body, query } = {}) {
42
+ const params = new URLSearchParams();
43
+ for (const [key, value] of Object.entries(query || {})) {
44
+ if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
45
+ }
46
+ const url = `${API_BASE}/${path}${params.size ? `?${params}` : ''}`;
47
+
48
+ const headers = { Accept: 'application/json' };
49
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
50
+ if (!SAFE_METHODS.has(method) && csrfToken) headers[CSRF_HEADER] = csrfToken;
51
+
52
+ let response;
53
+ try {
54
+ response = await fetch(url, {
55
+ method,
56
+ headers,
57
+ credentials: 'same-origin',
58
+ body: body === undefined ? undefined : JSON.stringify(body),
59
+ });
60
+ } catch {
61
+ throw new ApiError(0, ERROR_CODE.NETWORK);
62
+ }
63
+
64
+ if (response.status === HTTP_NO_CONTENT) return null;
65
+
66
+ let payload = {};
67
+ try {
68
+ payload = await response.json();
69
+ } catch {
70
+ payload = {};
71
+ }
72
+
73
+ if (!response.ok) {
74
+ const code = payload.code || ERROR_CODE.SERVER_ERROR;
75
+ if (response.status === HTTP_UNAUTHORIZED && code === ERROR_CODE.UNAUTHENTICATED) {
76
+ for (const listener of unauthenticatedListeners) listener();
77
+ }
78
+ throw new ApiError(response.status, code, payload);
79
+ }
80
+ return payload;
81
+ }
82
+
83
+ export const api = {
84
+ session: () => request('GET', 'session'),
85
+ login: (password) => request('POST', 'login', { body: { password } }),
86
+ logout: () => request('POST', 'logout'),
87
+ meta: () => request('GET', 'meta'),
88
+ apps: () => request('GET', 'apps'),
89
+ views: (appId, query) => request('GET', `apps/${encodeURIComponent(appId)}/views`, { query }),
90
+ allViews: (query) => request('GET', 'views', { query }),
91
+ analytics: (appId, query) => request('GET', `apps/${encodeURIComponent(appId)}/analytics`, { query }),
92
+ analyticsAll: (query) => request('GET', 'analytics', { query }),
93
+ edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
94
+ note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
95
+ remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
96
+ restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
97
+ purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
98
+ adminLog: (query) => request('GET', 'logs/admin', { query }),
99
+ viewLog: (query) => request('GET', 'logs/views', { query }),
100
+ };