@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.
- package/.env.example +20 -0
- package/README.md +174 -4
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -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 +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +16 -9
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pieces shared by every data table: sortable headers, the pager, and the
|
|
3
|
+
* empty and loading states.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { clampText } from './clamp.js';
|
|
7
|
+
import { el, replaceChildren } from './dom.js';
|
|
8
|
+
import { formatDate, formatNumber, formatTime } from './format.js';
|
|
9
|
+
import { t } from './i18n.js';
|
|
10
|
+
import { SORT_ORDER } from './constants.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A header cell. Sortable headers are buttons, so they are reachable and
|
|
14
|
+
* operable by keyboard, and expose the sort through aria-sort.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ label: string, sortKey?: string, sort?: string, order?: string,
|
|
17
|
+
* onSort?: (key: string) => void, className?: string }} options
|
|
18
|
+
*/
|
|
19
|
+
export function headerCell({ label, sortKey, sort, order, onSort, className = '' }) {
|
|
20
|
+
if (!sortKey) return el('th', { className, text: label, attrs: { scope: 'col' } });
|
|
21
|
+
|
|
22
|
+
const active = sort === sortKey;
|
|
23
|
+
const ariaSort = active ? (order === SORT_ORDER.ASC ? 'ascending' : 'descending') : 'none';
|
|
24
|
+
const indicator = active ? (order === SORT_ORDER.ASC ? '▲' : '▼') : '↕';
|
|
25
|
+
|
|
26
|
+
return el('th', { className, attrs: { scope: 'col', 'aria-sort': ariaSort } }, [
|
|
27
|
+
el('button', {
|
|
28
|
+
className: `sort-btn${active ? ' active' : ''}`,
|
|
29
|
+
attrs: { type: 'button' },
|
|
30
|
+
dataset: { sortKey },
|
|
31
|
+
on: { click: () => onSort(sortKey) },
|
|
32
|
+
}, [
|
|
33
|
+
el('span', { text: label }),
|
|
34
|
+
el('span', { className: 'sort-indicator', text: indicator, attrs: { 'aria-hidden': 'true' } }),
|
|
35
|
+
]),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A timestamp cell: the date above the time of day, like every other two-line
|
|
41
|
+
* cell, so it never needs the width of both on one line.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|Date} value
|
|
44
|
+
* @param {{ seconds?: boolean }} [options]
|
|
45
|
+
*/
|
|
46
|
+
export function timeCell(value, { seconds = false } = {}) {
|
|
47
|
+
return el('td', { className: 'col-time' }, [
|
|
48
|
+
clampText(formatDate(value), { className: 'cell-primary' }),
|
|
49
|
+
clampText(formatTime(value, { seconds }), { className: 'cell-secondary' }),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A full-width row for empty or loading states. */
|
|
54
|
+
export function messageRow(columns, text, className = 'table-message') {
|
|
55
|
+
return el('tr', {}, [el('td', { className, text, attrs: { colspan: String(columns) } })]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Previous / next pagination with a live summary.
|
|
60
|
+
* @param {(page: number) => void} onChange
|
|
61
|
+
*/
|
|
62
|
+
export function createPager(onChange) {
|
|
63
|
+
let page = 1;
|
|
64
|
+
let pages = 1;
|
|
65
|
+
|
|
66
|
+
const summary = el('span', { className: 'pager-summary', attrs: { 'aria-live': 'polite' } });
|
|
67
|
+
const prev = el('button', {
|
|
68
|
+
className: 'btn btn-secondary btn-small',
|
|
69
|
+
text: t('pager.previous'),
|
|
70
|
+
attrs: { type: 'button' },
|
|
71
|
+
on: { click: () => page > 1 && onChange(page - 1) },
|
|
72
|
+
});
|
|
73
|
+
const next = el('button', {
|
|
74
|
+
className: 'btn btn-secondary btn-small',
|
|
75
|
+
text: t('pager.next'),
|
|
76
|
+
attrs: { type: 'button' },
|
|
77
|
+
on: { click: () => page < pages && onChange(page + 1) },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const element = el('div', { className: 'pager' }, [prev, summary, next]);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
element,
|
|
84
|
+
/** @param {number} currentPage @param {number} pageSize @param {number} total */
|
|
85
|
+
update(currentPage, pageSize, total) {
|
|
86
|
+
page = currentPage;
|
|
87
|
+
pages = Math.max(1, Math.ceil(total / pageSize));
|
|
88
|
+
prev.disabled = page <= 1;
|
|
89
|
+
next.disabled = page >= pages;
|
|
90
|
+
replaceChildren(summary, [t('pager.summary', {
|
|
91
|
+
page: formatNumber(page),
|
|
92
|
+
pages: formatNumber(pages),
|
|
93
|
+
total: formatNumber(total),
|
|
94
|
+
count: total,
|
|
95
|
+
})]);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A segmented control: one pressed button among several (a single choice). */
|
|
101
|
+
export function createSegmented({ label, options, value, onChange }) {
|
|
102
|
+
let current = value;
|
|
103
|
+
const buttons = new Map();
|
|
104
|
+
const group = el('div', { className: 'segmented', attrs: { role: 'group', 'aria-label': label } });
|
|
105
|
+
|
|
106
|
+
const render = () => {
|
|
107
|
+
for (const [key, button] of buttons) {
|
|
108
|
+
const active = key === current;
|
|
109
|
+
button.classList.toggle('active', active);
|
|
110
|
+
button.setAttribute('aria-pressed', String(active));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (const option of options) {
|
|
115
|
+
const button = el('button', {
|
|
116
|
+
className: 'segmented-btn',
|
|
117
|
+
text: option.label,
|
|
118
|
+
attrs: { type: 'button' },
|
|
119
|
+
on: {
|
|
120
|
+
click: () => {
|
|
121
|
+
if (current === option.value) return;
|
|
122
|
+
current = option.value;
|
|
123
|
+
render();
|
|
124
|
+
onChange(current);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
buttons.set(option.value, button);
|
|
129
|
+
group.append(button);
|
|
130
|
+
}
|
|
131
|
+
render();
|
|
132
|
+
|
|
133
|
+
return { element: group, getValue: () => current };
|
|
134
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light / dark / system theme (FRONTEND.md §6).
|
|
3
|
+
*
|
|
4
|
+
* All colours are tokens on :root; the theme is a class on <html>, so
|
|
5
|
+
* switching it re-themes everything without per-component branching. The
|
|
6
|
+
* choice is a per-viewer convenience kept in localStorage, which may be
|
|
7
|
+
* unavailable (private mode, blocked storage); the UI works without it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { STORAGE_KEY, THEME } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const CLASS = {
|
|
13
|
+
[THEME.LIGHT]: 'theme-light',
|
|
14
|
+
[THEME.DARK]: 'theme-dark',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
18
|
+
let current = THEME.AUTO;
|
|
19
|
+
const buttons = new Map();
|
|
20
|
+
|
|
21
|
+
function readStored() {
|
|
22
|
+
try {
|
|
23
|
+
const value = localStorage.getItem(STORAGE_KEY.THEME);
|
|
24
|
+
return Object.values(THEME).includes(value) ? value : THEME.AUTO;
|
|
25
|
+
} catch {
|
|
26
|
+
return THEME.AUTO;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function store(value) {
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(STORAGE_KEY.THEME, value);
|
|
33
|
+
} catch {
|
|
34
|
+
// Storage blocked: the theme still applies for this page view.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolved() {
|
|
39
|
+
if (current !== THEME.AUTO) return current;
|
|
40
|
+
return darkQuery.matches ? THEME.DARK : THEME.LIGHT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function apply() {
|
|
44
|
+
const root = document.documentElement;
|
|
45
|
+
root.classList.remove(...Object.values(CLASS));
|
|
46
|
+
root.classList.add(CLASS[resolved()]);
|
|
47
|
+
for (const [value, button] of buttons) {
|
|
48
|
+
const active = value === current;
|
|
49
|
+
button.classList.toggle('active', active);
|
|
50
|
+
button.setAttribute('aria-pressed', String(active));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setTheme(value) {
|
|
55
|
+
current = Object.values(THEME).includes(value) ? value : THEME.AUTO;
|
|
56
|
+
store(current);
|
|
57
|
+
apply();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wire the switcher buttons and apply the stored theme.
|
|
62
|
+
* @param {Record<string, HTMLButtonElement>} themeButtons keyed by THEME value
|
|
63
|
+
*/
|
|
64
|
+
export function initTheme(themeButtons) {
|
|
65
|
+
for (const [value, button] of Object.entries(themeButtons)) {
|
|
66
|
+
buttons.set(value, button);
|
|
67
|
+
button.addEventListener('click', () => setTheme(value));
|
|
68
|
+
}
|
|
69
|
+
current = readStored();
|
|
70
|
+
darkQuery.addEventListener('change', apply);
|
|
71
|
+
apply();
|
|
72
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-blocking notices (FRONTEND.md §2): the same shape as the modal, one
|
|
3
|
+
* helper and one root. Errors interrupt (role=alert); everything else is
|
|
4
|
+
* polite (role=status).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { byId, el } from './dom.js';
|
|
8
|
+
import { TOAST_DURATION_MS } from './constants.js';
|
|
9
|
+
import { t } from './i18n.js';
|
|
10
|
+
|
|
11
|
+
export const TOAST_TYPE = Object.freeze({
|
|
12
|
+
SUCCESS: 'success',
|
|
13
|
+
ERROR: 'error',
|
|
14
|
+
INFO: 'info',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} message
|
|
19
|
+
* @param {string} [type]
|
|
20
|
+
*/
|
|
21
|
+
export function showToast(message, type = TOAST_TYPE.INFO) {
|
|
22
|
+
const toast = el('div', {
|
|
23
|
+
className: `toast toast-${type}`,
|
|
24
|
+
attrs: { role: type === TOAST_TYPE.ERROR ? 'alert' : 'status' },
|
|
25
|
+
}, [
|
|
26
|
+
el('span', { className: 'toast-prompt', text: '❯', attrs: { 'aria-hidden': 'true' } }),
|
|
27
|
+
el('span', { className: 'toast-message', text: message }),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const dismiss = () => {
|
|
31
|
+
toast.classList.add('toast-leaving');
|
|
32
|
+
toast.addEventListener('animationend', () => toast.remove(), { once: true });
|
|
33
|
+
// Reduced-motion users get no animation, so no animationend either.
|
|
34
|
+
setTimeout(() => toast.remove(), TOAST_DURATION_MS);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
toast.append(el('button', {
|
|
38
|
+
className: 'icon-btn toast-close',
|
|
39
|
+
text: '×',
|
|
40
|
+
attrs: { type: 'button', 'aria-label': t('common.dismiss') },
|
|
41
|
+
on: { click: dismiss },
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
byId('toast-root').append(toast);
|
|
45
|
+
setTimeout(dismiss, TOAST_DURATION_MS);
|
|
46
|
+
return toast;
|
|
47
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialogs for one or many views: details, edit, and note.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { el, uniqueId } from './dom.js';
|
|
6
|
+
import { openModal, VARIANT } from './modal.js';
|
|
7
|
+
import { createListbox } from './listbox.js';
|
|
8
|
+
import { formatDateTime, orNone } from './format.js';
|
|
9
|
+
import { t, tOr } from './i18n.js';
|
|
10
|
+
import {
|
|
11
|
+
DEVICE_SIZE_FIELD,
|
|
12
|
+
EVENT_DATA_FIELD,
|
|
13
|
+
EVENT_DATA_ROWS,
|
|
14
|
+
JSON_INDENT,
|
|
15
|
+
NOTE_ROWS,
|
|
16
|
+
TEXT_FIELDS,
|
|
17
|
+
VIEW_DETAIL_FIELDS,
|
|
18
|
+
} from './constants.js';
|
|
19
|
+
|
|
20
|
+
const DATE_FIELDS = new Set(['timestamp', 'adminModifiedAt', 'deletedAt']);
|
|
21
|
+
|
|
22
|
+
/** Render one field of a view for reading. */
|
|
23
|
+
function displayValue(field, value) {
|
|
24
|
+
if (DATE_FIELDS.has(field)) return formatDateTime(value);
|
|
25
|
+
if (field === 'isUnique') return value ? t('common.yes') : t('common.no');
|
|
26
|
+
if (field === 'sourceType' && value) return tOr(`sources.${value}`, value);
|
|
27
|
+
if (field === EVENT_DATA_FIELD) return value === null || value === undefined ? t('common.none') : JSON.stringify(value, null, JSON_INDENT);
|
|
28
|
+
return orNone(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read-only view of every field. */
|
|
32
|
+
export function openDetails(view) {
|
|
33
|
+
const list = el('dl', { className: 'detail-list' });
|
|
34
|
+
for (const field of VIEW_DETAIL_FIELDS) {
|
|
35
|
+
const value = displayValue(field, view[field]);
|
|
36
|
+
const valueNode = field === EVENT_DATA_FIELD
|
|
37
|
+
? el('pre', { className: 'detail-json', text: value })
|
|
38
|
+
: el('span', { className: 'detail-value', text: value });
|
|
39
|
+
list.append(el('dt', { text: t(`fields.${field}`) }), el('dd', {}, [valueNode]));
|
|
40
|
+
}
|
|
41
|
+
openModal({
|
|
42
|
+
title: t('details.title'),
|
|
43
|
+
body: list,
|
|
44
|
+
actions: [{ label: t('common.close'), variant: VARIANT.SECONDARY }],
|
|
45
|
+
wide: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Edit content fields of one or many views.
|
|
51
|
+
*
|
|
52
|
+
* With one view the fields start from its values and a field is included as
|
|
53
|
+
* soon as it is changed. With several, fields start empty and the admin ticks
|
|
54
|
+
* the ones to overwrite, so a batch never touches a field by accident.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ views: object[], meta: object, onSubmit: (changes: object) => Promise<boolean> }} options
|
|
57
|
+
*/
|
|
58
|
+
export function openEditor({ views, meta, onSubmit }) {
|
|
59
|
+
const single = views.length === 1 ? views[0] : null;
|
|
60
|
+
const rows = [];
|
|
61
|
+
const controls = new Map();
|
|
62
|
+
|
|
63
|
+
for (const field of meta.editableFields) {
|
|
64
|
+
const inputId = uniqueId(`edit-${field}`);
|
|
65
|
+
const include = el('input', {
|
|
66
|
+
className: 'field-include',
|
|
67
|
+
attrs: { type: 'checkbox', 'aria-label': t('edit.include', { field: t(`fields.${field}`) }) },
|
|
68
|
+
});
|
|
69
|
+
const markIncluded = () => { include.checked = true; };
|
|
70
|
+
|
|
71
|
+
let control;
|
|
72
|
+
let read;
|
|
73
|
+
if (field === DEVICE_SIZE_FIELD) {
|
|
74
|
+
const listbox = createListbox({
|
|
75
|
+
label: t(`fields.${field}`),
|
|
76
|
+
options: meta.deviceSizes.map((size) => ({ value: size, label: tOr(`deviceSizes.${size}`, size) })),
|
|
77
|
+
value: single?.deviceSize ?? meta.deviceSizes[0],
|
|
78
|
+
onChange: markIncluded,
|
|
79
|
+
});
|
|
80
|
+
control = listbox.element;
|
|
81
|
+
read = () => ({ ok: true, value: listbox.getValue() });
|
|
82
|
+
} else if (field === EVENT_DATA_FIELD) {
|
|
83
|
+
const initial = single?.eventData === null || single?.eventData === undefined
|
|
84
|
+
? '' : JSON.stringify(single.eventData, null, JSON_INDENT);
|
|
85
|
+
const textarea = el('textarea', {
|
|
86
|
+
className: 'input mono',
|
|
87
|
+
text: initial,
|
|
88
|
+
attrs: { id: inputId, rows: EVENT_DATA_ROWS, spellcheck: 'false' },
|
|
89
|
+
on: { input: markIncluded },
|
|
90
|
+
});
|
|
91
|
+
control = textarea;
|
|
92
|
+
read = () => {
|
|
93
|
+
const text = textarea.value.trim();
|
|
94
|
+
if (text === '') return { ok: true, value: null };
|
|
95
|
+
try {
|
|
96
|
+
return { ok: true, value: JSON.parse(text) };
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, error: t('edit.invalidJson') };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
} else if (TEXT_FIELDS.includes(field)) {
|
|
102
|
+
const input = el('input', {
|
|
103
|
+
className: 'input',
|
|
104
|
+
attrs: {
|
|
105
|
+
id: inputId,
|
|
106
|
+
type: 'text',
|
|
107
|
+
maxlength: meta.maxLength[field],
|
|
108
|
+
value: single ? (single[field] ?? '') : '',
|
|
109
|
+
spellcheck: 'false',
|
|
110
|
+
},
|
|
111
|
+
on: { input: markIncluded },
|
|
112
|
+
});
|
|
113
|
+
control = input;
|
|
114
|
+
read = () => ({ ok: true, value: input.value === '' ? null : input.value });
|
|
115
|
+
} else {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
120
|
+
error.hidden = true;
|
|
121
|
+
controls.set(field, { include, read, error });
|
|
122
|
+
rows.push(el('div', { className: 'edit-row' }, [
|
|
123
|
+
include,
|
|
124
|
+
el('label', { className: 'edit-label', text: t(`fields.${field}`), attrs: { for: inputId } }),
|
|
125
|
+
el('div', { className: 'edit-control' }, [control, error]),
|
|
126
|
+
]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const formError = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
130
|
+
formError.hidden = true;
|
|
131
|
+
|
|
132
|
+
const body = [
|
|
133
|
+
el('p', { className: 'modal-message', text: single ? t('edit.introSingle') : t('edit.introBatch', { count: views.length }) }),
|
|
134
|
+
el('p', { className: 'notice', text: t('edit.lockedNotice') }),
|
|
135
|
+
el('div', { className: 'edit-grid' }, rows),
|
|
136
|
+
formError,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
openModal({
|
|
140
|
+
title: single ? t('edit.titleSingle') : t('edit.titleBatch', { count: views.length }),
|
|
141
|
+
body,
|
|
142
|
+
wide: true,
|
|
143
|
+
actions: [
|
|
144
|
+
{ label: t('common.cancel'), variant: VARIANT.SECONDARY },
|
|
145
|
+
{
|
|
146
|
+
label: t('edit.save'),
|
|
147
|
+
variant: VARIANT.PRIMARY,
|
|
148
|
+
onClick: async () => {
|
|
149
|
+
const changes = {};
|
|
150
|
+
let valid = true;
|
|
151
|
+
for (const [field, { include, read, error }] of controls) {
|
|
152
|
+
error.hidden = true;
|
|
153
|
+
if (!include.checked) continue;
|
|
154
|
+
const result = read();
|
|
155
|
+
if (!result.ok) {
|
|
156
|
+
error.textContent = result.error;
|
|
157
|
+
error.hidden = false;
|
|
158
|
+
valid = false;
|
|
159
|
+
} else {
|
|
160
|
+
changes[field] = result.value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
formError.hidden = true;
|
|
164
|
+
if (!valid) return false;
|
|
165
|
+
if (Object.keys(changes).length === 0) {
|
|
166
|
+
formError.textContent = t('edit.nothingSelected');
|
|
167
|
+
formError.hidden = false;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return onSubmit(changes);
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Set or clear the note on one or many views.
|
|
179
|
+
* @param {{ views: object[], meta: object, onSubmit: (note: string|null) => Promise<boolean> }} options
|
|
180
|
+
*/
|
|
181
|
+
export function openNoteEditor({ views, meta, onSubmit }) {
|
|
182
|
+
const single = views.length === 1 ? views[0] : null;
|
|
183
|
+
const inputId = uniqueId('note');
|
|
184
|
+
const textarea = el('textarea', {
|
|
185
|
+
className: 'input',
|
|
186
|
+
text: single?.note ?? '',
|
|
187
|
+
attrs: { id: inputId, rows: NOTE_ROWS, maxlength: meta.maxLength.note },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
openModal({
|
|
191
|
+
title: single ? t('note.titleSingle') : t('note.titleBatch', { count: views.length }),
|
|
192
|
+
body: [
|
|
193
|
+
el('p', { className: 'modal-message', text: t('note.intro') }),
|
|
194
|
+
el('label', { className: 'sr-only', text: t('fields.note'), attrs: { for: inputId } }),
|
|
195
|
+
textarea,
|
|
196
|
+
],
|
|
197
|
+
initialFocus: textarea,
|
|
198
|
+
actions: [
|
|
199
|
+
{ label: t('common.cancel'), variant: VARIANT.SECONDARY },
|
|
200
|
+
{ label: t('note.clear'), variant: VARIANT.SECONDARY, onClick: () => onSubmit(null) },
|
|
201
|
+
{
|
|
202
|
+
label: t('note.save'),
|
|
203
|
+
variant: VARIANT.PRIMARY,
|
|
204
|
+
onClick: () => onSubmit(textarea.value.trim() === '' ? null : textarea.value),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
}
|