@harshankur/viewcounter 3.0.1 → 3.2.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.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -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
|
package/admin/index.html
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
<span class="version-badge" id="app-version" hidden></span>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="topbar-actions">
|
|
41
|
+
<a class="header-link" id="website-link" href="https://viewcounter.harshankur.com" target="_blank"
|
|
42
|
+
rel="noopener noreferrer" data-icon="globe" data-i18n-attr="title:nav.websiteTitle"><span
|
|
43
|
+
data-i18n="nav.website"></span></a>
|
|
44
|
+
<div class="theme-switcher" role="group" data-i18n-attr="aria-label:theme.label">
|
|
45
|
+
<button class="theme-btn" id="theme-light" type="button" aria-pressed="false" data-icon="sun"
|
|
46
|
+
data-i18n-attr="aria-label:theme.light;title:theme.light"></button>
|
|
47
|
+
<button class="theme-btn" id="theme-dark" type="button" aria-pressed="false" data-icon="moon"
|
|
48
|
+
data-i18n-attr="aria-label:theme.dark;title:theme.dark"></button>
|
|
49
|
+
<button class="theme-btn" id="theme-auto" type="button" aria-pressed="false" data-icon="auto"
|
|
50
|
+
data-i18n-attr="aria-label:theme.auto;title:theme.auto"></button>
|
|
51
|
+
</div>
|
|
52
|
+
<button class="btn btn-secondary btn-small" id="logout-btn" type="button" hidden
|
|
53
|
+
data-icon="signOut"><span data-i18n="nav.logout"></span></button>
|
|
54
|
+
</div>
|
|
55
|
+
</nav>
|
|
56
|
+
|
|
57
|
+
<main id="main">
|
|
58
|
+
<section id="login-screen" class="login" hidden>
|
|
59
|
+
<div class="term-window login-window">
|
|
60
|
+
<div class="term-titlebar">
|
|
61
|
+
<div class="term-dots" aria-hidden="true">
|
|
62
|
+
<span class="term-dot red"></span>
|
|
63
|
+
<span class="term-dot amber"></span>
|
|
64
|
+
<span class="term-dot green"></span>
|
|
65
|
+
</div>
|
|
66
|
+
<span class="term-title" data-i18n="login.windowTitle"></span>
|
|
67
|
+
</div>
|
|
68
|
+
<form id="login-form" class="term-body login-form" novalidate>
|
|
69
|
+
<h1 class="login-heading">
|
|
70
|
+
<span class="prompt-char" aria-hidden="true">❯</span>
|
|
71
|
+
<span data-i18n="login.heading"></span>
|
|
72
|
+
</h1>
|
|
73
|
+
<p class="login-intro" data-i18n="login.intro"></p>
|
|
74
|
+
<label class="field-label" for="login-password" data-i18n="login.password"></label>
|
|
75
|
+
<input class="input" id="login-password" name="password" type="password"
|
|
76
|
+
autocomplete="current-password" required>
|
|
77
|
+
<p id="login-error" class="field-error" role="alert" hidden></p>
|
|
78
|
+
<button class="btn btn-primary" id="login-submit" type="submit" data-i18n="login.submit"></button>
|
|
79
|
+
</form>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<section id="app-screen" class="app-screen" hidden>
|
|
84
|
+
<div id="tabs" class="tabs" role="tablist" data-i18n-attr="aria-label:tabs.label"></div>
|
|
85
|
+
<div id="panels"></div>
|
|
86
|
+
</section>
|
|
87
|
+
</main>
|
|
88
|
+
|
|
89
|
+
<footer class="app-footer" id="app-footer" hidden></footer>
|
|
90
|
+
|
|
91
|
+
<div id="modal-root" hidden></div>
|
|
92
|
+
<div id="toast-root" class="toast-root"></div>
|
|
93
|
+
</body>
|
|
94
|
+
|
|
95
|
+
</html>
|
package/admin/js/api.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
const HTTP_FORBIDDEN = 403;
|
|
15
|
+
/** These establish a session; failing them is an answer, not something to recover from. */
|
|
16
|
+
const SESSION_PATHS = new Set(['session', 'login']);
|
|
17
|
+
|
|
18
|
+
let csrfToken = null;
|
|
19
|
+
const unauthenticatedListeners = new Set();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How a failed request recovers instead of failing, set by main.js:
|
|
23
|
+
* reauthenticate() a sign-in dialog over the page; true once signed in again
|
|
24
|
+
* confirmPassword() the password for an action that cannot be undone; true once confirmed
|
|
25
|
+
*/
|
|
26
|
+
const recovery = { reauthenticate: null, confirmPassword: null };
|
|
27
|
+
/** Several requests can find the session gone at once; they share one dialog. */
|
|
28
|
+
let pendingSignIn = null;
|
|
29
|
+
|
|
30
|
+
export function setRecovery(handlers) {
|
|
31
|
+
Object.assign(recovery, handlers);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function signInAgain() {
|
|
35
|
+
pendingSignIn ??= recovery.reauthenticate().finally(() => { pendingSignIn = null; });
|
|
36
|
+
return pendingSignIn;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ApiError {
|
|
40
|
+
/** @param {number} status @param {string} code @param {object} [body] */
|
|
41
|
+
constructor(status, code, body = {}) {
|
|
42
|
+
this.status = status;
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.body = body;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Called whenever the server says the session is gone. */
|
|
49
|
+
export function onUnauthenticated(listener) {
|
|
50
|
+
unauthenticatedListeners.add(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setCsrfToken(token) {
|
|
54
|
+
csrfToken = token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} method
|
|
59
|
+
* @param {string} path relative to the API base, no leading slash
|
|
60
|
+
* @param {{ body?: object, query?: Record<string, string|number|undefined> }} [options]
|
|
61
|
+
*/
|
|
62
|
+
export async function request(method, path, { body, query } = {}, { retried = false } = {}) {
|
|
63
|
+
const params = new URLSearchParams();
|
|
64
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
65
|
+
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
|
66
|
+
}
|
|
67
|
+
const url = `${API_BASE}/${path}${params.size ? `?${params}` : ''}`;
|
|
68
|
+
|
|
69
|
+
const headers = { Accept: 'application/json' };
|
|
70
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
71
|
+
if (!SAFE_METHODS.has(method) && csrfToken) headers[CSRF_HEADER] = csrfToken;
|
|
72
|
+
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(url, {
|
|
76
|
+
method,
|
|
77
|
+
headers,
|
|
78
|
+
credentials: 'same-origin',
|
|
79
|
+
// The API never redirects. A gateway in front of it (Cloudflare
|
|
80
|
+
// Access, say) does, to its own sign-in page, when its session ends.
|
|
81
|
+
redirect: 'manual',
|
|
82
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
throw new ApiError(0, ERROR_CODE.NETWORK);
|
|
86
|
+
}
|
|
87
|
+
if (response.type === 'opaqueredirect') throw new ApiError(0, ERROR_CODE.ACCESS_EXPIRED);
|
|
88
|
+
|
|
89
|
+
if (response.status === HTTP_NO_CONTENT) return null;
|
|
90
|
+
|
|
91
|
+
let payload = {};
|
|
92
|
+
try {
|
|
93
|
+
payload = await response.json();
|
|
94
|
+
} catch {
|
|
95
|
+
payload = {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const code = payload.code || ERROR_CODE.SERVER_ERROR;
|
|
100
|
+
const retry = () => request(method, path, { body, query }, { retried: true });
|
|
101
|
+
|
|
102
|
+
if (response.status === HTTP_UNAUTHORIZED && code === ERROR_CODE.UNAUTHENTICATED) {
|
|
103
|
+
// Sign in again over the page, then carry on with this request.
|
|
104
|
+
if (!retried && !SESSION_PATHS.has(path) && recovery.reauthenticate && await signInAgain()) return retry();
|
|
105
|
+
for (const listener of unauthenticatedListeners) listener();
|
|
106
|
+
}
|
|
107
|
+
if (response.status === HTTP_FORBIDDEN && code === ERROR_CODE.REAUTH_REQUIRED
|
|
108
|
+
&& !retried && recovery.confirmPassword && await recovery.confirmPassword()) {
|
|
109
|
+
return retry();
|
|
110
|
+
}
|
|
111
|
+
throw new ApiError(response.status, code, payload);
|
|
112
|
+
}
|
|
113
|
+
return payload;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A listing or analysis query for the wire: breakdown filters travel as JSON. */
|
|
117
|
+
function filterQuery({ where, ...query } = {}) {
|
|
118
|
+
return where && Object.keys(where).length ? { ...query, where: JSON.stringify(where) } : query;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const appPath = (appId, rest) => `apps/${encodeURIComponent(appId)}/${rest}`;
|
|
122
|
+
|
|
123
|
+
export const api = {
|
|
124
|
+
session: () => request('GET', 'session'),
|
|
125
|
+
login: (password) => request('POST', 'login', { body: { password } }),
|
|
126
|
+
reauth: (password) => request('POST', 'reauth', { body: { password } }),
|
|
127
|
+
logout: () => request('POST', 'logout'),
|
|
128
|
+
meta: () => request('GET', 'meta'),
|
|
129
|
+
apps: () => request('GET', 'apps'),
|
|
130
|
+
views: (appId, query) => request('GET', appPath(appId, 'views'), { query: filterQuery(query) }),
|
|
131
|
+
allViews: (query) => request('GET', 'views', { query: filterQuery(query) }),
|
|
132
|
+
analytics: (appId, query) => request('GET', appPath(appId, 'analytics'), { query: filterQuery(query) }),
|
|
133
|
+
analyticsAll: (query) => request('GET', 'analytics', { query: filterQuery(query) }),
|
|
134
|
+
eventTypes: (appId, query) => request('GET', appPath(appId, 'event-types'), { query }),
|
|
135
|
+
eventTypesAll: (query) => request('GET', 'event-types', { query }),
|
|
136
|
+
realtime: (appId) => request('GET', appPath(appId, 'realtime')),
|
|
137
|
+
realtimeAll: () => request('GET', 'realtime'),
|
|
138
|
+
edit: (appId, ids, changes) => request('PATCH', `apps/${encodeURIComponent(appId)}/views`, { body: { ids, changes } }),
|
|
139
|
+
note: (appId, ids, note) => request('PUT', `apps/${encodeURIComponent(appId)}/views/note`, { body: { ids, note } }),
|
|
140
|
+
remove: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/delete`, { body: { ids } }),
|
|
141
|
+
restore: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/restore`, { body: { ids } }),
|
|
142
|
+
purge: (appId, ids) => request('POST', `apps/${encodeURIComponent(appId)}/views/purge`, { body: { ids } }),
|
|
143
|
+
adminLog: (query) => request('GET', 'logs/admin', { query }),
|
|
144
|
+
trackingLog: (query) => request('GET', 'logs/tracking', { query }),
|
|
145
|
+
trackingSummary: (query) => request('GET', 'logs/tracking/summary', { query }),
|
|
146
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app tabs: one per app, led by one for every app together. Overview,
|
|
3
|
+
* Views, and Trash each show a strip, and all three follow the same choice:
|
|
4
|
+
* switching in one switches the others.
|
|
5
|
+
*
|
|
6
|
+
* ARIA tabs pattern with automatic activation: arrow keys, Home and End move
|
|
7
|
+
* and select.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { el, replaceChildren } from './dom.js';
|
|
11
|
+
import { formatNumber } from './format.js';
|
|
12
|
+
import { t } from './i18n.js';
|
|
13
|
+
import { ALL_APPS, KEY } from './constants.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every app, led by one entry for all of them together.
|
|
17
|
+
* @param {object[]} apps as GET api/apps lists them
|
|
18
|
+
*/
|
|
19
|
+
export function appEntries(apps) {
|
|
20
|
+
const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
appId: ALL_APPS,
|
|
24
|
+
label: t('appTabs.all'),
|
|
25
|
+
active: sum('active'),
|
|
26
|
+
deleted: sum('deleted'),
|
|
27
|
+
modified: sum('modified'),
|
|
28
|
+
available: true,
|
|
29
|
+
},
|
|
30
|
+
...apps.map((app) => ({ ...app, label: app.appId })),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ context: { apps: () => object[], appId: () => string, setAppId: (id: string) => void },
|
|
36
|
+
* controls: string, trash?: boolean }} options controls is the id of the region the tabs switch
|
|
37
|
+
* @returns {{ element: HTMLElement, render: () => void }}
|
|
38
|
+
*/
|
|
39
|
+
export function createAppTabs({ context, controls, trash = false }) {
|
|
40
|
+
const element = el('div', {
|
|
41
|
+
className: 'app-tabs',
|
|
42
|
+
attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
|
|
43
|
+
});
|
|
44
|
+
let focusOnRender = false;
|
|
45
|
+
|
|
46
|
+
function select(appId, { focus = false } = {}) {
|
|
47
|
+
focusOnRender = focus;
|
|
48
|
+
if (appId === context.appId()) {
|
|
49
|
+
render();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
context.setAppId(appId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function render() {
|
|
56
|
+
const current = context.appId();
|
|
57
|
+
replaceChildren(element, appEntries(context.apps()).map((app) => {
|
|
58
|
+
const selected = app.appId === current;
|
|
59
|
+
const count = trash ? app.deleted : app.active;
|
|
60
|
+
const countText = t(trash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
|
|
61
|
+
return el('button', {
|
|
62
|
+
className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
|
|
63
|
+
attrs: {
|
|
64
|
+
type: 'button',
|
|
65
|
+
role: 'tab',
|
|
66
|
+
'aria-selected': String(selected),
|
|
67
|
+
'aria-controls': controls,
|
|
68
|
+
'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
|
|
69
|
+
tabindex: selected ? '0' : '-1',
|
|
70
|
+
title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
|
|
71
|
+
},
|
|
72
|
+
dataset: { appId: app.appId },
|
|
73
|
+
on: { click: () => select(app.appId) },
|
|
74
|
+
}, [
|
|
75
|
+
el('span', { className: 'app-tab-name', text: app.label }),
|
|
76
|
+
el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
|
|
77
|
+
]);
|
|
78
|
+
}));
|
|
79
|
+
if (focusOnRender) {
|
|
80
|
+
element.querySelector('[aria-selected="true"]')?.focus();
|
|
81
|
+
focusOnRender = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
element.addEventListener('keydown', (event) => {
|
|
86
|
+
const ids = appEntries(context.apps()).map((app) => app.appId);
|
|
87
|
+
const index = Math.max(0, ids.indexOf(context.appId()));
|
|
88
|
+
const moves = {
|
|
89
|
+
[KEY.ARROW_RIGHT]: (index + 1) % ids.length,
|
|
90
|
+
[KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
|
|
91
|
+
[KEY.HOME]: 0,
|
|
92
|
+
[KEY.END]: ids.length - 1,
|
|
93
|
+
};
|
|
94
|
+
if (!(event.key in moves)) return;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
select(ids[moves[event.key]], { focus: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return { element, render };
|
|
100
|
+
}
|