@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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -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 +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Admin UI entry point: session check, login, sections, and wiring.
3
+ */
4
+
5
+ import { api, ApiError, onUnauthenticated, setCsrfToken, setRecovery } from './api.js';
6
+ import { promptPassword } from './passwordPrompt.js';
7
+ import { byId, el, replaceChildren } from './dom.js';
8
+ import { applyTranslations, loadLocale, t } from './i18n.js';
9
+ import { icon } from './icons.js';
10
+ import { createAdminLogPanel, createTrackingLogPanel } from './logs.js';
11
+ import { closeModal } from './modal.js';
12
+ import { createOverviewPanel } from './overview.js';
13
+ import { initTheme } from './theme.js';
14
+ import { showToast, TOAST_TYPE } from './toast.js';
15
+ import { createViewsPanel, PANEL_MODE } from './views.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';
19
+
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];
30
+
31
+ const shell = {
32
+ meta: null,
33
+ apps: [],
34
+ appId: null,
35
+ panels: new Map(),
36
+ activeTab: null,
37
+ tabButtons: new Map(),
38
+ };
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
+
45
+ /** Translate any failure into a message and show it. */
46
+ function reportError(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);
57
+ }
58
+
59
+ function readLastApp() {
60
+ try {
61
+ return localStorage.getItem(STORAGE_KEY.LAST_APP);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function rememberApp(appId) {
68
+ try {
69
+ localStorage.setItem(STORAGE_KEY.LAST_APP, appId);
70
+ } catch {
71
+ // Storage blocked: the choice simply is not remembered.
72
+ }
73
+ }
74
+
75
+ // ---- Login ------------------------------------------------------------------
76
+
77
+ function showLogin(messageKey) {
78
+ closeModal();
79
+ shell.panels.get(shell.activeTab)?.hide?.();
80
+ byId('app-screen').hidden = true;
81
+ byId('logout-btn').hidden = true;
82
+ byId('app-footer').hidden = true;
83
+ byId('login-screen').hidden = false;
84
+ const error = byId('login-error');
85
+ error.hidden = !messageKey;
86
+ error.textContent = messageKey ? t(messageKey) : '';
87
+ const input = byId('login-password');
88
+ input.value = '';
89
+ input.focus();
90
+ }
91
+
92
+ function errorKey(error) {
93
+ return `errors.${error instanceof ApiError ? error.code : ERROR_CODE.UNEXPECTED}`;
94
+ }
95
+
96
+ function wireLogin() {
97
+ const form = byId('login-form');
98
+ const submit = byId('login-submit');
99
+ form.addEventListener('submit', async (event) => {
100
+ event.preventDefault();
101
+ const password = byId('login-password').value;
102
+ if (!password) return;
103
+ submit.disabled = true;
104
+ try {
105
+ const session = await api.login(password);
106
+ setCsrfToken(session.csrfToken);
107
+ await enterApp();
108
+ } catch (error) {
109
+ if (!(error instanceof ApiError)) reportToBrowser(error);
110
+ showLogin(errorKey(error));
111
+ } finally {
112
+ submit.disabled = false;
113
+ }
114
+ });
115
+ }
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
+
205
+ // ---- App shell --------------------------------------------------------------
206
+
207
+ async function refreshApps() {
208
+ const { apps } = await api.apps();
209
+ shell.apps = apps;
210
+ for (const tab of APP_TABS) shell.panels.get(tab)?.syncApps();
211
+ }
212
+
213
+ function setAppId(appId) {
214
+ shell.appId = appId;
215
+ rememberApp(appId);
216
+ for (const tab of APP_TABS) {
217
+ const panel = shell.panels.get(tab);
218
+ panel.syncApps();
219
+ panel.appChanged();
220
+ }
221
+ }
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
+
229
+ function selectTab(tab, { focus = false } = {}) {
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;
233
+ for (const [key, button] of shell.tabButtons) {
234
+ const active = key === next;
235
+ button.setAttribute('aria-selected', String(active));
236
+ button.tabIndex = active ? 0 : -1;
237
+ button.classList.toggle('active', active);
238
+ if (active && focus) button.focus();
239
+ }
240
+ for (const [key, panel] of shell.panels) panel.element.hidden = key !== next;
241
+ shell.panels.get(next).show();
242
+
243
+ if (window.location.hash !== `#${TAB_HASH[next]}`) history.replaceState(null, '', `#${TAB_HASH[next]}`);
244
+ }
245
+
246
+ function buildTabs() {
247
+ const tablist = byId('tabs');
248
+ const buttons = TAB_ORDER.map((tab) => {
249
+ const button = el('button', {
250
+ className: 'tab',
251
+ attrs: { type: 'button', role: 'tab', id: `tab-${tab}`, 'aria-controls': `panel-${tab}` },
252
+ on: { click: () => selectTab(tab) },
253
+ }, [icon(TAB_ICON[tab]), el('span', { text: t(`tabs.${tab}`) })]);
254
+ shell.tabButtons.set(tab, button);
255
+ return button;
256
+ });
257
+ replaceChildren(tablist, buttons);
258
+ }
259
+
260
+ /** Arrow keys, Home, and End move between sections (ARIA tabs pattern). Wired once. */
261
+ function wireTabKeys() {
262
+ byId('tabs').addEventListener('keydown', (event) => {
263
+ const index = TAB_ORDER.indexOf(shell.activeTab);
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 });
273
+ });
274
+ }
275
+
276
+ function buildPanels() {
277
+ const context = {
278
+ apps: () => shell.apps,
279
+ appId: () => shell.appId,
280
+ setAppId,
281
+ refreshApps,
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
+ },
294
+ };
295
+ const appIds = () => shell.apps.map((app) => app.appId);
296
+
297
+ shell.panels.set(TAB.OVERVIEW, createOverviewPanel({ meta: shell.meta, context }));
298
+ shell.panels.set(TAB.VIEWS, createViewsPanel({ mode: PANEL_MODE.VIEWS, meta: shell.meta, context }));
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
+ }));
311
+ shell.panels.set(TAB.ADMIN_LOG, createAdminLogPanel({ meta: shell.meta, appIds, reportError }));
312
+
313
+ const container = byId('panels');
314
+ replaceChildren(container, TAB_ORDER.map((tab) => {
315
+ const panel = shell.panels.get(tab);
316
+ panel.element.id = `panel-${tab}`;
317
+ panel.element.setAttribute('role', 'tabpanel');
318
+ panel.element.setAttribute('aria-labelledby', `tab-${tab}`);
319
+ panel.element.hidden = true;
320
+ return panel.element;
321
+ }));
322
+ }
323
+
324
+ async function enterApp() {
325
+ shell.meta = await api.meta();
326
+ const { apps } = await api.apps();
327
+ shell.apps = apps;
328
+ // Every app together by default; a remembered app wins while it still exists.
329
+ const remembered = readLastApp();
330
+ shell.appId = apps.some((app) => app.appId === remembered) ? remembered : ALL_APPS;
331
+
332
+ shell.activeTab = null;
333
+ shell.panels.clear();
334
+ shell.tabButtons.clear();
335
+ buildTabs();
336
+ buildPanels();
337
+ for (const tab of APP_TABS) shell.panels.get(tab).syncApps();
338
+ renderChrome();
339
+
340
+ byId('login-screen').hidden = true;
341
+ byId('app-screen').hidden = false;
342
+ byId('logout-btn').hidden = false;
343
+
344
+ selectTab(tabFromHash(window.location.hash));
345
+ }
346
+
347
+ async function logout() {
348
+ try {
349
+ await api.logout();
350
+ } catch (error) {
351
+ reportError(error);
352
+ }
353
+ setCsrfToken(null);
354
+ showLogin('login.signedOut');
355
+ }
356
+
357
+ // ---- Boot -------------------------------------------------------------------
358
+
359
+ async function boot() {
360
+ await loadLocale();
361
+ applyTranslations();
362
+ document.title = t('app.documentTitle');
363
+
364
+ for (const node of document.querySelectorAll('[data-icon]')) node.prepend(icon(node.dataset.icon));
365
+ initTheme({
366
+ [THEME.LIGHT]: byId('theme-light'),
367
+ [THEME.DARK]: byId('theme-dark'),
368
+ [THEME.AUTO]: byId('theme-auto'),
369
+ });
370
+ wireLogin();
371
+ wireTabKeys();
372
+ byId('logout-btn').addEventListener('click', logout);
373
+ setRecovery({ reauthenticate, confirmPassword });
374
+ keepSessionAlive();
375
+ onUnauthenticated(() => {
376
+ setCsrfToken(null);
377
+ showLogin('errors.UNAUTHENTICATED');
378
+ });
379
+ window.addEventListener('hashchange', () => {
380
+ if (!byId('app-screen').hidden) selectTab(tabFromHash(window.location.hash));
381
+ });
382
+
383
+ try {
384
+ const session = await api.session();
385
+ if (session.authenticated) {
386
+ setCsrfToken(session.csrfToken);
387
+ await enterApp();
388
+ } else {
389
+ showLogin();
390
+ }
391
+ } catch (error) {
392
+ if (!(error instanceof ApiError)) reportToBrowser(error);
393
+ showLogin(errorKey(error));
394
+ } finally {
395
+ document.body.classList.remove('booting');
396
+ }
397
+ }
398
+
399
+ boot();
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The one blocking dialog (FRONTEND.md §1.1 and §2).
3
+ *
4
+ * Replaces alert/confirm/prompt everywhere. Rendered once into #modal-root.
5
+ * By construction, not by each call site remembering:
6
+ * - a backdrop blocks the page and focus is trapped inside the dialog,
7
+ * - Escape and a backdrop click run the SAME cancel handler, which is always
8
+ * the safest option, never the destructive one,
9
+ * - focus returns to whatever opened the dialog.
10
+ */
11
+
12
+ import { byId, clear, el, focusableWithin, uniqueId } from './dom.js';
13
+ import { KEY } from './constants.js';
14
+ import { t } from './i18n.js';
15
+
16
+ /** Visual weight of a dialog button. */
17
+ export const VARIANT = Object.freeze({
18
+ PRIMARY: 'primary',
19
+ SECONDARY: 'secondary',
20
+ DANGER: 'danger',
21
+ });
22
+
23
+ let active = null;
24
+
25
+ function root() {
26
+ return byId('modal-root');
27
+ }
28
+
29
+ function trapFocus(event, dialog) {
30
+ if (event.key !== KEY.TAB) return;
31
+ const focusable = focusableWithin(dialog);
32
+ if (focusable.length === 0) {
33
+ event.preventDefault();
34
+ return;
35
+ }
36
+ const first = focusable[0];
37
+ const last = focusable[focusable.length - 1];
38
+ if (event.shiftKey && document.activeElement === first) {
39
+ event.preventDefault();
40
+ last.focus();
41
+ } else if (!event.shiftKey && document.activeElement === last) {
42
+ event.preventDefault();
43
+ first.focus();
44
+ }
45
+ }
46
+
47
+ /** Close the open dialog without running any handler. */
48
+ export function closeModal() {
49
+ if (!active) return;
50
+ const { opener, onKeydown } = active;
51
+ document.removeEventListener('keydown', onKeydown, true);
52
+ clear(root());
53
+ root().hidden = true;
54
+ document.body.classList.remove('modal-open');
55
+ active = null;
56
+ if (opener && typeof opener.focus === 'function' && document.contains(opener)) opener.focus();
57
+ }
58
+
59
+ /**
60
+ * Open a dialog.
61
+ *
62
+ * @param {{ title: string, body: Node|Node[], actions: Array<{ label: string, variant?: string,
63
+ * onClick?: () => (boolean|void|Promise<boolean|void>) }>, onCancel?: () => void,
64
+ * role?: 'dialog'|'alertdialog', initialFocus?: HTMLElement, wide?: boolean }} options
65
+ * An action's onClick may return false to keep the dialog open (for example
66
+ * when a form does not validate). The first action with variant SECONDARY
67
+ * labelled as cancel is not special: cancel is `onCancel`, always.
68
+ */
69
+ export function openModal({ title, body, actions, onCancel = () => {}, role = 'dialog', initialFocus, wide = false }) {
70
+ closeModal();
71
+
72
+ const titleId = uniqueId('modal-title');
73
+ const cancel = () => {
74
+ closeModal();
75
+ onCancel();
76
+ };
77
+
78
+ const buttons = actions.map((action) => el('button', {
79
+ className: `btn btn-${action.variant || VARIANT.SECONDARY}`,
80
+ text: action.label,
81
+ attrs: { type: 'button' },
82
+ on: {
83
+ click: async (event) => {
84
+ const button = event.currentTarget;
85
+ button.disabled = true;
86
+ try {
87
+ const keepOpen = action.onClick ? (await action.onClick()) === false : false;
88
+ if (!keepOpen) closeModal();
89
+ } finally {
90
+ button.disabled = false;
91
+ }
92
+ },
93
+ },
94
+ }));
95
+
96
+ const dialog = el('div', {
97
+ className: `modal term-window${wide ? ' modal-wide' : ''}`,
98
+ attrs: { role, 'aria-modal': 'true', 'aria-labelledby': titleId },
99
+ }, [
100
+ el('div', { className: 'term-titlebar' }, [
101
+ el('div', { className: 'term-dots', attrs: { 'aria-hidden': 'true' } }, [
102
+ el('span', { className: 'term-dot red' }),
103
+ el('span', { className: 'term-dot amber' }),
104
+ el('span', { className: 'term-dot green' }),
105
+ ]),
106
+ el('h2', { className: 'modal-title', text: title, attrs: { id: titleId } }),
107
+ el('button', {
108
+ className: 'icon-btn modal-close',
109
+ text: '×',
110
+ attrs: { type: 'button', 'aria-label': t('common.close') },
111
+ on: { click: cancel },
112
+ }),
113
+ ]),
114
+ el('div', { className: 'modal-body' }, Array.isArray(body) ? body : [body]),
115
+ el('div', { className: 'modal-actions' }, buttons),
116
+ ]);
117
+
118
+ const backdrop = el('div', { className: 'modal-backdrop', on: { mousedown: (event) => {
119
+ if (event.target === event.currentTarget) cancel();
120
+ } } }, [dialog]);
121
+
122
+ const onKeydown = (event) => {
123
+ if (event.key === KEY.ESCAPE) {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ cancel();
127
+ return;
128
+ }
129
+ trapFocus(event, dialog);
130
+ };
131
+
132
+ active = { opener: document.activeElement, onKeydown };
133
+ document.addEventListener('keydown', onKeydown, true);
134
+ root().append(backdrop);
135
+ root().hidden = false;
136
+ document.body.classList.add('modal-open');
137
+
138
+ const target = initialFocus || focusableWithin(dialog.querySelector('.modal-body'))[0] || buttons[buttons.length - 1];
139
+ target?.focus();
140
+ return dialog;
141
+ }
142
+
143
+ /**
144
+ * A yes/no confirmation. Resolves true only when the confirm button is used;
145
+ * Escape, the backdrop, the close button, and Cancel all resolve false.
146
+ *
147
+ * @param {{ title: string, message: string, confirmLabel: string, danger?: boolean,
148
+ * detail?: Node }} options
149
+ * @returns {Promise<boolean>}
150
+ */
151
+ export function confirmModal({ title, message, confirmLabel, danger = false, detail }) {
152
+ return new Promise((resolve) => {
153
+ let settled = false;
154
+ const settle = (value) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ resolve(value);
158
+ };
159
+ const cancelButtonFirst = { label: t('common.cancel'), variant: VARIANT.SECONDARY, onClick: () => settle(false) };
160
+ openModal({
161
+ title,
162
+ role: 'alertdialog',
163
+ body: [el('p', { className: 'modal-message', text: message }), detail].filter(Boolean),
164
+ actions: [
165
+ cancelButtonFirst,
166
+ { label: confirmLabel, variant: danger ? VARIANT.DANGER : VARIANT.PRIMARY, onClick: () => settle(true) },
167
+ ],
168
+ onCancel: () => settle(false),
169
+ });
170
+ });
171
+ }