@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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A password dialog over the current page, for the two times the admin UI
|
|
3
|
+
* needs the password without leaving what the admin was doing: signing in
|
|
4
|
+
* again after a session ends, and confirming an action that cannot be undone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ApiError } from './api.js';
|
|
8
|
+
import { el, uniqueId } from './dom.js';
|
|
9
|
+
import { t } from './i18n.js';
|
|
10
|
+
import { openModal, VARIANT } from './modal.js';
|
|
11
|
+
import { ERROR_CODE } from './constants.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ title: string, message: string, submitLabel: string,
|
|
15
|
+
* submit: (password: string) => Promise<void> }} options
|
|
16
|
+
* @returns {Promise<boolean>} whether `submit` succeeded; false if cancelled
|
|
17
|
+
*/
|
|
18
|
+
export function promptPassword({ title, message, submitLabel, submit }) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const inputId = uniqueId('password');
|
|
21
|
+
const input = el('input', {
|
|
22
|
+
className: 'input',
|
|
23
|
+
attrs: { id: inputId, type: 'password', autocomplete: 'current-password', required: 'true' },
|
|
24
|
+
});
|
|
25
|
+
const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
26
|
+
error.hidden = true;
|
|
27
|
+
let settled = false;
|
|
28
|
+
|
|
29
|
+
const attempt = async () => {
|
|
30
|
+
if (!input.value) {
|
|
31
|
+
input.focus();
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await submit(input.value);
|
|
36
|
+
settled = true;
|
|
37
|
+
resolve(true);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (failure) {
|
|
40
|
+
const code = failure instanceof ApiError ? failure.code : ERROR_CODE.SERVER_ERROR;
|
|
41
|
+
error.textContent = t(`errors.${code}`);
|
|
42
|
+
error.hidden = false;
|
|
43
|
+
input.select();
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const dialog = openModal({
|
|
49
|
+
title,
|
|
50
|
+
body: [
|
|
51
|
+
el('p', { text: message }),
|
|
52
|
+
el('label', { className: 'field-label', text: t('login.password'), attrs: { for: inputId } }),
|
|
53
|
+
input,
|
|
54
|
+
error,
|
|
55
|
+
],
|
|
56
|
+
actions: [
|
|
57
|
+
{ label: t('common.cancel'), variant: VARIANT.SECONDARY },
|
|
58
|
+
{ label: submitLabel, variant: VARIANT.PRIMARY, onClick: attempt },
|
|
59
|
+
],
|
|
60
|
+
onCancel: () => { if (!settled) resolve(false); },
|
|
61
|
+
initialFocus: input,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Enter submits, as on the sign-in screen.
|
|
65
|
+
input.addEventListener('keydown', (event) => {
|
|
66
|
+
if (event.key !== 'Enter') return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
dialog.querySelector('.modal-actions .btn-primary')?.click();
|
|
69
|
+
});
|
|
70
|
+
// A cancel button that closes the dialog is a cancel, too.
|
|
71
|
+
dialog.querySelector('.modal-actions .btn-secondary')?.addEventListener('click', () => {
|
|
72
|
+
if (!settled) resolve(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controls shared by the tables: the pager and the segmented control. The
|
|
3
|
+
* table itself is dataTable.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { el, replaceChildren } from './dom.js';
|
|
7
|
+
import { formatNumber } from './format.js';
|
|
8
|
+
import { t } from './i18n.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Previous / next pagination with a live summary.
|
|
12
|
+
* @param {(page: number) => void} onChange
|
|
13
|
+
*/
|
|
14
|
+
export function createPager(onChange) {
|
|
15
|
+
let page = 1;
|
|
16
|
+
let pages = 1;
|
|
17
|
+
|
|
18
|
+
const summary = el('span', { className: 'pager-summary', attrs: { 'aria-live': 'polite' } });
|
|
19
|
+
const prev = el('button', {
|
|
20
|
+
className: 'btn btn-secondary btn-small',
|
|
21
|
+
text: t('pager.previous'),
|
|
22
|
+
attrs: { type: 'button' },
|
|
23
|
+
on: { click: () => page > 1 && onChange(page - 1) },
|
|
24
|
+
});
|
|
25
|
+
const next = el('button', {
|
|
26
|
+
className: 'btn btn-secondary btn-small',
|
|
27
|
+
text: t('pager.next'),
|
|
28
|
+
attrs: { type: 'button' },
|
|
29
|
+
on: { click: () => page < pages && onChange(page + 1) },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const element = el('div', { className: 'pager' }, [prev, summary, next]);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
element,
|
|
36
|
+
/** @param {number} currentPage @param {number} pageSize @param {number} total */
|
|
37
|
+
update(currentPage, pageSize, total) {
|
|
38
|
+
page = currentPage;
|
|
39
|
+
pages = Math.max(1, Math.ceil(total / pageSize));
|
|
40
|
+
prev.disabled = page <= 1;
|
|
41
|
+
next.disabled = page >= pages;
|
|
42
|
+
replaceChildren(summary, [t('pager.summary', {
|
|
43
|
+
page: formatNumber(page),
|
|
44
|
+
pages: formatNumber(pages),
|
|
45
|
+
total: formatNumber(total),
|
|
46
|
+
count: total,
|
|
47
|
+
})]);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A segmented control: one pressed button among several (a single choice). */
|
|
53
|
+
export function createSegmented({ label, options, value, onChange }) {
|
|
54
|
+
let current = value;
|
|
55
|
+
const buttons = new Map();
|
|
56
|
+
const group = el('div', { className: 'segmented', attrs: { role: 'group', 'aria-label': label } });
|
|
57
|
+
|
|
58
|
+
const render = () => {
|
|
59
|
+
for (const [key, button] of buttons) {
|
|
60
|
+
const active = key === current;
|
|
61
|
+
button.classList.toggle('active', active);
|
|
62
|
+
button.setAttribute('aria-pressed', String(active));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (const option of options) {
|
|
67
|
+
const button = el('button', {
|
|
68
|
+
className: 'segmented-btn',
|
|
69
|
+
text: option.label,
|
|
70
|
+
attrs: { type: 'button' },
|
|
71
|
+
on: {
|
|
72
|
+
click: () => {
|
|
73
|
+
if (current === option.value) return;
|
|
74
|
+
current = option.value;
|
|
75
|
+
render();
|
|
76
|
+
onChange(current);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
buttons.set(option.value, button);
|
|
81
|
+
group.append(button);
|
|
82
|
+
}
|
|
83
|
+
render();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
element: group,
|
|
87
|
+
getValue: () => current,
|
|
88
|
+
/** Show a value chosen elsewhere, without reporting it as a change. */
|
|
89
|
+
setValue(value) {
|
|
90
|
+
current = value;
|
|
91
|
+
render();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -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,224 @@
|
|
|
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, formatDuration, orNone } from './format.js';
|
|
9
|
+
import { regionName } from './charts.js';
|
|
10
|
+
import { valueLabel } from './overview.js';
|
|
11
|
+
import { t, tOr } from './i18n.js';
|
|
12
|
+
import {
|
|
13
|
+
DEVICE_SIZE_FIELD,
|
|
14
|
+
EVENT_DATA_FIELD,
|
|
15
|
+
EVENT_DATA_ROWS,
|
|
16
|
+
JSON_INDENT,
|
|
17
|
+
NOTE_ROWS,
|
|
18
|
+
TEXT_FIELDS,
|
|
19
|
+
VIEW_DETAIL_GROUPS,
|
|
20
|
+
} from './constants.js';
|
|
21
|
+
|
|
22
|
+
const DATE_FIELDS = new Set(['timestamp', 'adminModifiedAt', 'deletedAt']);
|
|
23
|
+
|
|
24
|
+
/** Render one field of a view for reading. */
|
|
25
|
+
function displayValue(field, value) {
|
|
26
|
+
if (DATE_FIELDS.has(field)) return formatDateTime(value);
|
|
27
|
+
if (field === 'isUnique') return value ? t('common.yes') : t('common.no');
|
|
28
|
+
if (value === null || value === undefined || value === '') return t('common.none');
|
|
29
|
+
if (field === 'sourceType') return tOr(`sources.${value}`, value);
|
|
30
|
+
if (field === 'deviceSize') return tOr(`deviceSizes.${value}`, value);
|
|
31
|
+
if (field === 'deviceType') return valueLabel('deviceType', value);
|
|
32
|
+
if (field === 'language') return valueLabel('language', value);
|
|
33
|
+
if (field === 'eventType') return valueLabel('eventType', value);
|
|
34
|
+
if (field === 'country') return `${regionName(value)} (${value})`;
|
|
35
|
+
if (field === 'engagedMs') return formatDuration(value);
|
|
36
|
+
if (field === 'scrollDepth') return t('details.scrolled', { percent: value });
|
|
37
|
+
if (field === EVENT_DATA_FIELD) return JSON.stringify(value, null, JSON_INDENT);
|
|
38
|
+
return orNone(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read-only view of every field, grouped: the view, its page, source, visitor, engagement, and admin changes. */
|
|
42
|
+
export function openDetails(view) {
|
|
43
|
+
const body = el('div', { className: 'detail-groups' }, VIEW_DETAIL_GROUPS.map((group) => {
|
|
44
|
+
const list = el('dl', { className: 'detail-list' });
|
|
45
|
+
for (const field of group.fields) {
|
|
46
|
+
const value = displayValue(field, view[field]);
|
|
47
|
+
const valueNode = field === EVENT_DATA_FIELD && view[field] !== null && view[field] !== undefined
|
|
48
|
+
? el('pre', { className: 'detail-json', text: value })
|
|
49
|
+
: el('span', { className: 'detail-value', text: value });
|
|
50
|
+
list.append(el('dt', { text: t(`fields.${field}`) }), el('dd', {}, [valueNode]));
|
|
51
|
+
}
|
|
52
|
+
return el('section', { className: 'detail-group' }, [
|
|
53
|
+
el('h3', { className: 'detail-group-title', text: t(`details.groups.${group.key}`) }),
|
|
54
|
+
list,
|
|
55
|
+
]);
|
|
56
|
+
}));
|
|
57
|
+
openModal({
|
|
58
|
+
title: t('details.title'),
|
|
59
|
+
body,
|
|
60
|
+
actions: [{ label: t('common.close'), variant: VARIANT.SECONDARY }],
|
|
61
|
+
wide: true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Edit content fields of one or many views.
|
|
67
|
+
*
|
|
68
|
+
* With one view the fields start from its values and a field is included as
|
|
69
|
+
* soon as it is changed. With several, fields start empty and the admin ticks
|
|
70
|
+
* the ones to overwrite, so a batch never touches a field by accident.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ views: object[], meta: object, onSubmit: (changes: object) => Promise<boolean> }} options
|
|
73
|
+
*/
|
|
74
|
+
export function openEditor({ views, meta, onSubmit }) {
|
|
75
|
+
const single = views.length === 1 ? views[0] : null;
|
|
76
|
+
const rows = [];
|
|
77
|
+
const controls = new Map();
|
|
78
|
+
|
|
79
|
+
for (const field of meta.editableFields) {
|
|
80
|
+
const inputId = uniqueId(`edit-${field}`);
|
|
81
|
+
const include = el('input', {
|
|
82
|
+
className: 'field-include',
|
|
83
|
+
attrs: { type: 'checkbox', 'aria-label': t('edit.include', { field: t(`fields.${field}`) }) },
|
|
84
|
+
});
|
|
85
|
+
const markIncluded = () => { include.checked = true; };
|
|
86
|
+
|
|
87
|
+
let control;
|
|
88
|
+
let read;
|
|
89
|
+
if (field === DEVICE_SIZE_FIELD) {
|
|
90
|
+
const listbox = createListbox({
|
|
91
|
+
label: t(`fields.${field}`),
|
|
92
|
+
options: meta.deviceSizes.map((size) => ({ value: size, label: tOr(`deviceSizes.${size}`, size) })),
|
|
93
|
+
value: single?.deviceSize ?? meta.deviceSizes[0],
|
|
94
|
+
onChange: markIncluded,
|
|
95
|
+
});
|
|
96
|
+
control = listbox.element;
|
|
97
|
+
read = () => ({ ok: true, value: listbox.getValue() });
|
|
98
|
+
} else if (field === EVENT_DATA_FIELD) {
|
|
99
|
+
const initial = single?.eventData === null || single?.eventData === undefined
|
|
100
|
+
? '' : JSON.stringify(single.eventData, null, JSON_INDENT);
|
|
101
|
+
const textarea = el('textarea', {
|
|
102
|
+
className: 'input mono',
|
|
103
|
+
text: initial,
|
|
104
|
+
attrs: { id: inputId, rows: EVENT_DATA_ROWS, spellcheck: 'false' },
|
|
105
|
+
on: { input: markIncluded },
|
|
106
|
+
});
|
|
107
|
+
control = textarea;
|
|
108
|
+
read = () => {
|
|
109
|
+
const text = textarea.value.trim();
|
|
110
|
+
if (text === '') return { ok: true, value: null };
|
|
111
|
+
try {
|
|
112
|
+
return { ok: true, value: JSON.parse(text) };
|
|
113
|
+
} catch {
|
|
114
|
+
return { ok: false, error: t('edit.invalidJson') };
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
} else if (TEXT_FIELDS.includes(field)) {
|
|
118
|
+
const input = el('input', {
|
|
119
|
+
className: 'input',
|
|
120
|
+
attrs: {
|
|
121
|
+
id: inputId,
|
|
122
|
+
type: 'text',
|
|
123
|
+
maxlength: meta.maxLength[field],
|
|
124
|
+
value: single ? (single[field] ?? '') : '',
|
|
125
|
+
spellcheck: 'false',
|
|
126
|
+
},
|
|
127
|
+
on: { input: markIncluded },
|
|
128
|
+
});
|
|
129
|
+
control = input;
|
|
130
|
+
read = () => ({ ok: true, value: input.value === '' ? null : input.value });
|
|
131
|
+
} else {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
136
|
+
error.hidden = true;
|
|
137
|
+
controls.set(field, { include, read, error });
|
|
138
|
+
rows.push(el('div', { className: 'edit-row' }, [
|
|
139
|
+
include,
|
|
140
|
+
el('label', { className: 'edit-label', text: t(`fields.${field}`), attrs: { for: inputId } }),
|
|
141
|
+
el('div', { className: 'edit-control' }, [control, error]),
|
|
142
|
+
]));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const formError = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
146
|
+
formError.hidden = true;
|
|
147
|
+
|
|
148
|
+
const body = [
|
|
149
|
+
el('p', { className: 'modal-message', text: single ? t('edit.introSingle') : t('edit.introBatch', { count: views.length }) }),
|
|
150
|
+
el('p', { className: 'notice', text: t('edit.lockedNotice') }),
|
|
151
|
+
el('div', { className: 'edit-grid' }, rows),
|
|
152
|
+
formError,
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
openModal({
|
|
156
|
+
title: single ? t('edit.titleSingle') : t('edit.titleBatch', { count: views.length }),
|
|
157
|
+
body,
|
|
158
|
+
wide: true,
|
|
159
|
+
actions: [
|
|
160
|
+
{ label: t('common.cancel'), variant: VARIANT.SECONDARY },
|
|
161
|
+
{
|
|
162
|
+
label: t('edit.save'),
|
|
163
|
+
variant: VARIANT.PRIMARY,
|
|
164
|
+
onClick: async () => {
|
|
165
|
+
const changes = {};
|
|
166
|
+
let valid = true;
|
|
167
|
+
for (const [field, { include, read, error }] of controls) {
|
|
168
|
+
error.hidden = true;
|
|
169
|
+
if (!include.checked) continue;
|
|
170
|
+
const result = read();
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
error.textContent = result.error;
|
|
173
|
+
error.hidden = false;
|
|
174
|
+
valid = false;
|
|
175
|
+
} else {
|
|
176
|
+
changes[field] = result.value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
formError.hidden = true;
|
|
180
|
+
if (!valid) return false;
|
|
181
|
+
if (Object.keys(changes).length === 0) {
|
|
182
|
+
formError.textContent = t('edit.nothingSelected');
|
|
183
|
+
formError.hidden = false;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return onSubmit(changes);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Set or clear the note on one or many views.
|
|
195
|
+
* @param {{ views: object[], meta: object, onSubmit: (note: string|null) => Promise<boolean> }} options
|
|
196
|
+
*/
|
|
197
|
+
export function openNoteEditor({ views, meta, onSubmit }) {
|
|
198
|
+
const single = views.length === 1 ? views[0] : null;
|
|
199
|
+
const inputId = uniqueId('note');
|
|
200
|
+
const textarea = el('textarea', {
|
|
201
|
+
className: 'input',
|
|
202
|
+
text: single?.note ?? '',
|
|
203
|
+
attrs: { id: inputId, rows: NOTE_ROWS, maxlength: meta.maxLength.note },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
openModal({
|
|
207
|
+
title: single ? t('note.titleSingle') : t('note.titleBatch', { count: views.length }),
|
|
208
|
+
body: [
|
|
209
|
+
el('p', { className: 'modal-message', text: t('note.intro') }),
|
|
210
|
+
el('label', { className: 'sr-only', text: t('fields.note'), attrs: { for: inputId } }),
|
|
211
|
+
textarea,
|
|
212
|
+
],
|
|
213
|
+
initialFocus: textarea,
|
|
214
|
+
actions: [
|
|
215
|
+
{ label: t('common.cancel'), variant: VARIANT.SECONDARY },
|
|
216
|
+
{ label: t('note.clear'), variant: VARIANT.SECONDARY, onClick: () => onSubmit(null) },
|
|
217
|
+
{
|
|
218
|
+
label: t('note.save'),
|
|
219
|
+
variant: VARIANT.PRIMARY,
|
|
220
|
+
onClick: () => onSubmit(textarea.value.trim() === '' ? null : textarea.value),
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
}
|