@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,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A data table whose columns the admin chooses, orders, and sizes.
|
|
3
|
+
*
|
|
4
|
+
* The admin picks which columns matter, and in what order. The table then
|
|
5
|
+
* shows as many of them as fit its width, in that order, and keeps the rest
|
|
6
|
+
* of each row one tap away, under it. A wider window shows more columns, a
|
|
7
|
+
* narrow one fewer, a phone shows the first few as a card, and nothing ever
|
|
8
|
+
* scrolls sideways (FRONTEND.md §12: reflow, never a horizontal scrollbar).
|
|
9
|
+
*
|
|
10
|
+
* Column edges drag to resize, by mouse, touch, or keyboard. Choices are
|
|
11
|
+
* remembered per table in this browser.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el, replaceChildren, uniqueId } from './dom.js';
|
|
15
|
+
import { icon } from './icons.js';
|
|
16
|
+
import { t } from './i18n.js';
|
|
17
|
+
import { openModal, VARIANT } from './modal.js';
|
|
18
|
+
import { KEY, SORT_ORDER, STORAGE_KEY, TABLE } from './constants.js';
|
|
19
|
+
|
|
20
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a table remembers: the columns in the admin's order, which of them are
|
|
24
|
+
* hidden, and any widths set by hand.
|
|
25
|
+
* @param {string} storageKey
|
|
26
|
+
* @param {Array<{ id: string, hidden?: boolean }>} columns
|
|
27
|
+
*/
|
|
28
|
+
function loadPreferences(storageKey, columns) {
|
|
29
|
+
const defaults = () => ({
|
|
30
|
+
order: columns.map((column) => column.id),
|
|
31
|
+
hidden: new Set(columns.filter((column) => column.hidden).map((column) => column.id)),
|
|
32
|
+
widths: {},
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY.TABLE_PREFIX + storageKey));
|
|
36
|
+
if (!stored || !Array.isArray(stored.order)) return { ...defaults(), defaults };
|
|
37
|
+
const known = new Set(columns.map((column) => column.id));
|
|
38
|
+
const order = stored.order.filter((id) => known.has(id));
|
|
39
|
+
const hidden = new Set((stored.hidden || []).filter((id) => known.has(id)));
|
|
40
|
+
// A column added since the choice was saved joins where it sits by
|
|
41
|
+
// default, shown or hidden as it is by default.
|
|
42
|
+
columns.forEach((column, index) => {
|
|
43
|
+
if (order.includes(column.id)) return;
|
|
44
|
+
order.splice(Math.min(index, order.length), 0, column.id);
|
|
45
|
+
if (column.hidden) hidden.add(column.id);
|
|
46
|
+
});
|
|
47
|
+
const widths = {};
|
|
48
|
+
for (const [id, width] of Object.entries(stored.widths || {})) {
|
|
49
|
+
if (known.has(id) && Number.isFinite(width)) widths[id] = width;
|
|
50
|
+
}
|
|
51
|
+
return { order, hidden, widths, defaults };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...defaults(), defaults };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function savePreferences(storageKey, { order, hidden, widths }) {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(STORAGE_KEY.TABLE_PREFIX + storageKey, JSON.stringify({ order, hidden: [...hidden], widths }));
|
|
60
|
+
} catch {
|
|
61
|
+
// Storage blocked: the choice lasts until the page is reloaded.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{
|
|
67
|
+
* storageKey: string,
|
|
68
|
+
* columns: Array<{ id: string, label: string, width: number, minWidth?: number, grow?: boolean, hidden?: boolean,
|
|
69
|
+
* sortKey?: string, available?: () => boolean, cell: (row: object) => Array<Node|string|null> }>,
|
|
70
|
+
* rowKey: (row: object) => string,
|
|
71
|
+
* rowAttrs?: (row: object) => { className?: string, dataset?: Record<string, string> },
|
|
72
|
+
* select?: { header: () => Node, cell: (row: object) => Node },
|
|
73
|
+
* actions?: { label: string, width: number, cell: (row: object) => Node },
|
|
74
|
+
* sort?: () => { key: string, order: string }, onSort?: (key: string) => void,
|
|
75
|
+
* empty: () => string, className?: string, id?: string,
|
|
76
|
+
* }} options
|
|
77
|
+
* `columns` are in their default order; `hidden` ones start unchosen; the
|
|
78
|
+
* first shown column with `grow` takes the spare width.
|
|
79
|
+
*/
|
|
80
|
+
export function createDataTable({
|
|
81
|
+
storageKey, columns, rowKey, rowAttrs = () => ({}), select, actions, sort, onSort, empty, className = '', id,
|
|
82
|
+
}) {
|
|
83
|
+
const byId = new Map(columns.map((column) => [column.id, column]));
|
|
84
|
+
const prefs = loadPreferences(storageKey, columns);
|
|
85
|
+
const state = { rows: [], expanded: new Set(), signature: '', layout: null, focusHandle: null };
|
|
86
|
+
let afterRender = () => {};
|
|
87
|
+
|
|
88
|
+
const thead = el('thead');
|
|
89
|
+
const tbody = el('tbody');
|
|
90
|
+
const table = el('table', { className: `data-table ${className}`.trim(), attrs: { id: id ?? null } }, [thead, tbody]);
|
|
91
|
+
const wrap = el('div', { className: 'table-wrap' }, [table]);
|
|
92
|
+
|
|
93
|
+
const minWidthOf = (column) => column.minWidth ?? TABLE.MIN_WIDTH;
|
|
94
|
+
const widthOf = (column) => clamp(prefs.widths[column.id] ?? column.width, minWidthOf(column), TABLE.MAX_WIDTH);
|
|
95
|
+
const chosenColumns = () => prefs.order
|
|
96
|
+
.filter((columnId) => !prefs.hidden.has(columnId))
|
|
97
|
+
.map((columnId) => byId.get(columnId))
|
|
98
|
+
.filter((column) => column && (column.available ? column.available() : true));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which chosen columns fit, in order; the rest go under each row. Below
|
|
102
|
+
* the card width, the first few are a card and the rest go under it.
|
|
103
|
+
*/
|
|
104
|
+
function computeLayout() {
|
|
105
|
+
const available = wrap.clientWidth;
|
|
106
|
+
if (!available) return null;
|
|
107
|
+
const chosen = chosenColumns();
|
|
108
|
+
if (available < TABLE.CARDS_BELOW) {
|
|
109
|
+
return { cards: true, shown: chosen.slice(0, TABLE.CARD_FIELDS), more: chosen.slice(TABLE.CARD_FIELDS), fill: null };
|
|
110
|
+
}
|
|
111
|
+
const fixed = (select ? TABLE.SELECT_WIDTH : 0) + (actions ? actions.width : 0);
|
|
112
|
+
const fit = (reserved) => {
|
|
113
|
+
let used = fixed + reserved;
|
|
114
|
+
const shown = [];
|
|
115
|
+
for (const column of chosen) {
|
|
116
|
+
const width = widthOf(column);
|
|
117
|
+
// The first column always shows; after it, strictly in order.
|
|
118
|
+
if (shown.length && used + width > available) break;
|
|
119
|
+
shown.push(column);
|
|
120
|
+
used += width;
|
|
121
|
+
}
|
|
122
|
+
return shown;
|
|
123
|
+
};
|
|
124
|
+
let shown = fit(0);
|
|
125
|
+
if (shown.length < chosen.length) shown = fit(TABLE.MORE_WIDTH);
|
|
126
|
+
return { cards: false, shown, more: chosen.slice(shown.length), fill: shown.find((column) => column.grow) ?? shown.at(-1) ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Head ---------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function headerCell(column, layout) {
|
|
132
|
+
const current = sort ? sort() : null;
|
|
133
|
+
const sortable = Boolean(column.sortKey && onSort);
|
|
134
|
+
const active = sortable && current?.key === column.sortKey;
|
|
135
|
+
const ascending = current?.order === SORT_ORDER.ASC;
|
|
136
|
+
const label = sortable
|
|
137
|
+
? el('button', {
|
|
138
|
+
className: `sort-btn${active ? ' active' : ''}`,
|
|
139
|
+
attrs: { type: 'button' },
|
|
140
|
+
dataset: { sortKey: column.sortKey },
|
|
141
|
+
on: { click: () => onSort(column.sortKey) },
|
|
142
|
+
}, [el('span', { text: column.label }), icon(active ? (ascending ? 'arrowUp' : 'arrowDown') : 'sort', { className: 'sort-indicator' })])
|
|
143
|
+
: el('span', { className: 'th-label', text: column.label });
|
|
144
|
+
|
|
145
|
+
const th = el('th', {
|
|
146
|
+
className: `col-${column.id}`,
|
|
147
|
+
attrs: { scope: 'col', 'aria-sort': sortable ? (active ? (ascending ? 'ascending' : 'descending') : 'none') : null },
|
|
148
|
+
dataset: { column: column.id },
|
|
149
|
+
}, [label, layout.cards ? null : resizeHandle(column)]);
|
|
150
|
+
if (!layout.cards && column !== layout.fill) th.style.width = `${widthOf(column)}px`;
|
|
151
|
+
return th;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The draggable right edge of a header: pointer, touch, or arrow keys. */
|
|
155
|
+
function resizeHandle(column) {
|
|
156
|
+
const handle = el('span', {
|
|
157
|
+
className: 'resize-handle',
|
|
158
|
+
attrs: {
|
|
159
|
+
role: 'separator',
|
|
160
|
+
tabindex: 0,
|
|
161
|
+
'aria-orientation': 'vertical',
|
|
162
|
+
'aria-label': t('table.resize', { column: column.label }),
|
|
163
|
+
'aria-valuemin': minWidthOf(column),
|
|
164
|
+
'aria-valuemax': TABLE.MAX_WIDTH,
|
|
165
|
+
'aria-valuenow': Math.round(widthOf(column)),
|
|
166
|
+
title: t('table.resizeHint'),
|
|
167
|
+
},
|
|
168
|
+
dataset: { resize: column.id },
|
|
169
|
+
});
|
|
170
|
+
const setWidth = (width) => {
|
|
171
|
+
prefs.widths[column.id] = clamp(Math.round(width), minWidthOf(column), TABLE.MAX_WIDTH);
|
|
172
|
+
savePreferences(storageKey, prefs);
|
|
173
|
+
// A rebuild replaces this handle; a keyboard user keeps their place.
|
|
174
|
+
state.focusHandle = document.activeElement === handle ? column.id : null;
|
|
175
|
+
refresh();
|
|
176
|
+
};
|
|
177
|
+
const reset = () => {
|
|
178
|
+
delete prefs.widths[column.id];
|
|
179
|
+
savePreferences(storageKey, prefs);
|
|
180
|
+
state.focusHandle = column.id;
|
|
181
|
+
refresh();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
handle.addEventListener('pointerdown', (event) => {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
// Start from the width on screen: the column taking the spare room
|
|
187
|
+
// is wider than the width it asks for.
|
|
188
|
+
const startWidth = handle.parentElement.getBoundingClientRect().width;
|
|
189
|
+
const startX = event.clientX;
|
|
190
|
+
document.body.classList.add('resizing-column');
|
|
191
|
+
const onMove = (move) => setWidth(startWidth + (move.clientX - startX));
|
|
192
|
+
const onUp = () => {
|
|
193
|
+
document.body.classList.remove('resizing-column');
|
|
194
|
+
window.removeEventListener('pointermove', onMove);
|
|
195
|
+
window.removeEventListener('pointerup', onUp);
|
|
196
|
+
window.removeEventListener('pointercancel', onUp);
|
|
197
|
+
};
|
|
198
|
+
window.addEventListener('pointermove', onMove);
|
|
199
|
+
window.addEventListener('pointerup', onUp);
|
|
200
|
+
window.addEventListener('pointercancel', onUp);
|
|
201
|
+
});
|
|
202
|
+
handle.addEventListener('dblclick', reset);
|
|
203
|
+
handle.addEventListener('keydown', (event) => {
|
|
204
|
+
const step = event.shiftKey ? TABLE.RESIZE_STEP_LARGE : TABLE.RESIZE_STEP;
|
|
205
|
+
const current = handle.parentElement.getBoundingClientRect().width;
|
|
206
|
+
if (event.key === KEY.ARROW_RIGHT) setWidth(current + step);
|
|
207
|
+
else if (event.key === KEY.ARROW_LEFT) setWidth(current - step);
|
|
208
|
+
else if (event.key === KEY.HOME) setWidth(minWidthOf(column));
|
|
209
|
+
else if (event.key === KEY.ENTER) reset();
|
|
210
|
+
else return;
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
});
|
|
213
|
+
return handle;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderHead(layout) {
|
|
217
|
+
const cells = [];
|
|
218
|
+
if (select) {
|
|
219
|
+
const th = el('th', { className: 'col-select', attrs: { scope: 'col' } }, [select.header()]);
|
|
220
|
+
th.style.width = `${TABLE.SELECT_WIDTH}px`;
|
|
221
|
+
cells.push(th);
|
|
222
|
+
}
|
|
223
|
+
if (layout.more.length && !layout.cards) {
|
|
224
|
+
const all = state.rows.length > 0 && state.rows.every((row) => state.expanded.has(rowKey(row)));
|
|
225
|
+
const names = layout.more.map((column) => column.label).join(', ');
|
|
226
|
+
const th = el('th', { className: 'col-more', attrs: { scope: 'col' } }, [el('button', {
|
|
227
|
+
className: 'icon-btn more-toggle',
|
|
228
|
+
attrs: {
|
|
229
|
+
type: 'button',
|
|
230
|
+
'aria-expanded': String(all),
|
|
231
|
+
'aria-label': t(all ? 'table.hideMoreAll' : 'table.showMoreAll', { columns: names }),
|
|
232
|
+
title: t(all ? 'table.hideMoreAll' : 'table.showMoreAll', { columns: names }),
|
|
233
|
+
},
|
|
234
|
+
on: { click: () => {
|
|
235
|
+
if (all) state.expanded.clear();
|
|
236
|
+
else for (const row of state.rows) state.expanded.add(rowKey(row));
|
|
237
|
+
render();
|
|
238
|
+
thead.querySelector('.more-toggle')?.focus();
|
|
239
|
+
} },
|
|
240
|
+
}, [icon(all ? 'chevronDown' : 'chevronRight')])]);
|
|
241
|
+
th.style.width = `${TABLE.MORE_WIDTH}px`;
|
|
242
|
+
cells.push(th);
|
|
243
|
+
}
|
|
244
|
+
cells.push(...layout.shown.map((column) => headerCell(column, layout)));
|
|
245
|
+
if (actions) {
|
|
246
|
+
const th = el('th', { className: 'col-actions', text: actions.label, attrs: { scope: 'col' } });
|
|
247
|
+
th.style.width = `${actions.width}px`;
|
|
248
|
+
cells.push(th);
|
|
249
|
+
}
|
|
250
|
+
replaceChildren(thead, [el('tr', {}, cells)]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Rows ---------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function moreButton(row, layout, key) {
|
|
256
|
+
const open = state.expanded.has(key);
|
|
257
|
+
const labelKey = open ? 'table.hideMore' : 'table.showMore';
|
|
258
|
+
return el('button', {
|
|
259
|
+
className: layout.cards ? 'btn btn-ghost btn-small more-toggle' : 'icon-btn more-toggle',
|
|
260
|
+
attrs: {
|
|
261
|
+
type: 'button',
|
|
262
|
+
'aria-expanded': String(open),
|
|
263
|
+
'aria-label': t(labelKey, { count: layout.more.length }),
|
|
264
|
+
title: layout.cards ? null : t(labelKey, { count: layout.more.length }),
|
|
265
|
+
},
|
|
266
|
+
on: { click: () => {
|
|
267
|
+
if (open) state.expanded.delete(key);
|
|
268
|
+
else state.expanded.add(key);
|
|
269
|
+
render();
|
|
270
|
+
[...tbody.querySelectorAll('tr[data-row-key]')].find((tr) => tr.dataset.rowKey === key)
|
|
271
|
+
?.querySelector('.more-toggle')?.focus();
|
|
272
|
+
} },
|
|
273
|
+
}, [icon(open ? 'chevronDown' : 'chevronRight'), layout.cards ? el('span', { text: t(labelKey, { count: layout.more.length }) }) : null]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderRow(row, layout, columnCount) {
|
|
277
|
+
const key = rowKey(row);
|
|
278
|
+
const { className: rowClass = '', dataset = {} } = rowAttrs(row);
|
|
279
|
+
const cells = [];
|
|
280
|
+
if (select) cells.push(el('td', { className: 'col-select' }, [select.cell(row)]));
|
|
281
|
+
const more = layout.more.length ? el('td', { className: 'col-more' }, [moreButton(row, layout, key)]) : null;
|
|
282
|
+
if (more && !layout.cards) cells.push(more);
|
|
283
|
+
for (const column of layout.shown) {
|
|
284
|
+
cells.push(el('td', { className: `col-${column.id}`, dataset: { label: column.label } }, column.cell(row)));
|
|
285
|
+
}
|
|
286
|
+
if (more && layout.cards) cells.push(more);
|
|
287
|
+
if (actions) cells.push(el('td', { className: 'col-actions' }, [actions.cell(row)]));
|
|
288
|
+
|
|
289
|
+
const rows = [el('tr', { className: rowClass, dataset: { ...dataset, rowKey: key } }, cells)];
|
|
290
|
+
if (layout.more.length && state.expanded.has(key)) {
|
|
291
|
+
// What did not fit, in the admin's order, under the row it belongs to.
|
|
292
|
+
rows.push(el('tr', { className: `row-more ${rowClass}`.trim() }, [el('td', { attrs: { colspan: String(columnCount) } }, [
|
|
293
|
+
el('dl', { className: 'more-list' }, layout.more.flatMap((column) => [
|
|
294
|
+
el('dt', { text: column.label }),
|
|
295
|
+
el('dd', { className: `col-${column.id}` }, column.cell(row)),
|
|
296
|
+
])),
|
|
297
|
+
])]));
|
|
298
|
+
}
|
|
299
|
+
return rows;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function render() {
|
|
303
|
+
const layout = computeLayout();
|
|
304
|
+
if (!layout) return;
|
|
305
|
+
state.layout = layout;
|
|
306
|
+
state.signature = signatureOf(layout);
|
|
307
|
+
table.classList.toggle('cards', layout.cards);
|
|
308
|
+
table.classList.toggle('has-more', layout.more.length > 0);
|
|
309
|
+
renderHead(layout);
|
|
310
|
+
const columnCount = layout.shown.length + (select ? 1 : 0) + (actions ? 1 : 0) + (layout.more.length ? 1 : 0);
|
|
311
|
+
if (state.rows.length === 0) {
|
|
312
|
+
replaceChildren(tbody, [el('tr', {}, [el('td', { className: 'table-message', text: empty(), attrs: { colspan: String(columnCount) } })])]);
|
|
313
|
+
} else {
|
|
314
|
+
replaceChildren(tbody, state.rows.flatMap((row) => renderRow(row, layout, columnCount)));
|
|
315
|
+
}
|
|
316
|
+
if (state.focusHandle) {
|
|
317
|
+
thead.querySelector(`[data-resize="${state.focusHandle}"]`)?.focus({ preventScroll: true });
|
|
318
|
+
state.focusHandle = null;
|
|
319
|
+
}
|
|
320
|
+
afterRender();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const signatureOf = (layout) => [
|
|
324
|
+
layout.cards ? 'cards' : 'table',
|
|
325
|
+
layout.shown.map((column) => column.id).join(','),
|
|
326
|
+
layout.more.map((column) => column.id).join(','),
|
|
327
|
+
layout.fill?.id ?? '',
|
|
328
|
+
].join('|');
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Re-fit after the width, or a column's width, changed. Rebuilds only when
|
|
332
|
+
* what is shown changes; otherwise it just sets widths, so dragging an
|
|
333
|
+
* edge stays smooth.
|
|
334
|
+
*/
|
|
335
|
+
function refresh() {
|
|
336
|
+
const layout = computeLayout();
|
|
337
|
+
if (!layout) return;
|
|
338
|
+
if (signatureOf(layout) !== state.signature) {
|
|
339
|
+
render();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
state.layout = layout;
|
|
343
|
+
for (const column of layout.shown) {
|
|
344
|
+
const th = thead.querySelector(`th[data-column="${column.id}"]`);
|
|
345
|
+
if (!th) continue;
|
|
346
|
+
th.style.width = layout.cards || column === layout.fill ? '' : `${widthOf(column)}px`;
|
|
347
|
+
th.querySelector('.resize-handle')?.setAttribute('aria-valuenow', String(Math.round(widthOf(column))));
|
|
348
|
+
}
|
|
349
|
+
if (state.focusHandle) {
|
|
350
|
+
thead.querySelector(`[data-resize="${state.focusHandle}"]`)?.focus({ preventScroll: true });
|
|
351
|
+
state.focusHandle = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let frame = null;
|
|
356
|
+
new ResizeObserver(() => {
|
|
357
|
+
if (frame) return;
|
|
358
|
+
frame = window.requestAnimationFrame(() => {
|
|
359
|
+
frame = null;
|
|
360
|
+
refresh();
|
|
361
|
+
});
|
|
362
|
+
}).observe(wrap);
|
|
363
|
+
|
|
364
|
+
// ---- Choosing columns ---------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function openChooser() {
|
|
367
|
+
let draft = { order: [...prefs.order], hidden: new Set(prefs.hidden), widths: { ...prefs.widths } };
|
|
368
|
+
const list = el('ol', { className: 'column-chooser' });
|
|
369
|
+
const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
370
|
+
error.hidden = true;
|
|
371
|
+
|
|
372
|
+
function renderList(focus) {
|
|
373
|
+
replaceChildren(list, draft.order.map((columnId, index) => {
|
|
374
|
+
const column = byId.get(columnId);
|
|
375
|
+
const checkbox = el('input', {
|
|
376
|
+
attrs: { type: 'checkbox' },
|
|
377
|
+
on: { change: (event) => {
|
|
378
|
+
if (event.currentTarget.checked) draft.hidden.delete(columnId);
|
|
379
|
+
else draft.hidden.add(columnId);
|
|
380
|
+
error.hidden = true;
|
|
381
|
+
} },
|
|
382
|
+
});
|
|
383
|
+
checkbox.checked = !draft.hidden.has(columnId);
|
|
384
|
+
const move = (delta, labelKey, iconName) => el('button', {
|
|
385
|
+
className: 'icon-btn',
|
|
386
|
+
attrs: {
|
|
387
|
+
type: 'button',
|
|
388
|
+
'aria-label': t(labelKey, { column: column.label }),
|
|
389
|
+
title: t(labelKey, { column: column.label }),
|
|
390
|
+
disabled: index + delta < 0 || index + delta >= draft.order.length,
|
|
391
|
+
},
|
|
392
|
+
dataset: { move: `${columnId}:${delta}` },
|
|
393
|
+
on: { click: () => {
|
|
394
|
+
const order = [...draft.order];
|
|
395
|
+
[order[index], order[index + delta]] = [order[index + delta], order[index]];
|
|
396
|
+
draft.order = order;
|
|
397
|
+
renderList(`${columnId}:${delta}`);
|
|
398
|
+
} },
|
|
399
|
+
}, [icon(iconName)]);
|
|
400
|
+
const unavailable = column.available && !column.available();
|
|
401
|
+
return el('li', { className: 'column-choice', dataset: { column: columnId } }, [
|
|
402
|
+
el('label', { className: 'column-choice-label' }, [
|
|
403
|
+
checkbox,
|
|
404
|
+
el('span', { text: column.label }),
|
|
405
|
+
unavailable && column.unavailableNote ? el('span', { className: 'column-choice-note', text: column.unavailableNote }) : null,
|
|
406
|
+
]),
|
|
407
|
+
move(-1, 'table.moveUp', 'arrowUp'),
|
|
408
|
+
move(1, 'table.moveDown', 'arrowDown'),
|
|
409
|
+
]);
|
|
410
|
+
}));
|
|
411
|
+
if (focus) {
|
|
412
|
+
// Keep the focus on the button just used, or its twin at an end of the list.
|
|
413
|
+
const [columnId, delta] = focus.split(':');
|
|
414
|
+
const same = list.querySelector(`[data-move="${columnId}:${delta}"]`);
|
|
415
|
+
(same && !same.disabled ? same : list.querySelector(`[data-move="${columnId}:${-Number(delta)}"]`))?.focus();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
renderList();
|
|
419
|
+
|
|
420
|
+
openModal({
|
|
421
|
+
title: t('table.columnsTitle'),
|
|
422
|
+
body: [el('p', { className: 'modal-message', text: t('table.columnsIntro') }), list, error],
|
|
423
|
+
actions: [
|
|
424
|
+
{ label: t('table.columnsReset'), onClick: () => {
|
|
425
|
+
const fresh = prefs.defaults();
|
|
426
|
+
draft = { order: fresh.order, hidden: fresh.hidden, widths: {} };
|
|
427
|
+
error.hidden = true;
|
|
428
|
+
renderList();
|
|
429
|
+
return false;
|
|
430
|
+
} },
|
|
431
|
+
{ label: t('common.cancel') },
|
|
432
|
+
{ label: t('table.columnsApply'), variant: VARIANT.PRIMARY, onClick: () => {
|
|
433
|
+
if (draft.order.every((columnId) => draft.hidden.has(columnId))) {
|
|
434
|
+
error.textContent = t('table.columnsNone');
|
|
435
|
+
error.hidden = false;
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
prefs.order = draft.order;
|
|
439
|
+
prefs.hidden = draft.hidden;
|
|
440
|
+
prefs.widths = draft.widths;
|
|
441
|
+
savePreferences(storageKey, prefs);
|
|
442
|
+
render();
|
|
443
|
+
return true;
|
|
444
|
+
} },
|
|
445
|
+
],
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const chooserId = uniqueId('columns-button');
|
|
450
|
+
const chooserButton = el('button', {
|
|
451
|
+
className: 'btn btn-secondary btn-small columns-button',
|
|
452
|
+
attrs: { type: 'button', id: chooserId, 'aria-haspopup': 'dialog' },
|
|
453
|
+
on: { click: openChooser },
|
|
454
|
+
}, [icon('columns'), el('span', { text: t('table.columns') })]);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
/** The bordered box holding the table. */
|
|
458
|
+
element: wrap,
|
|
459
|
+
table,
|
|
460
|
+
/** The "Columns" button, for the toolbar's view controls. */
|
|
461
|
+
chooserButton,
|
|
462
|
+
/** Show these rows. Rows that are gone lose their expanded state. */
|
|
463
|
+
setRows(rows) {
|
|
464
|
+
state.rows = rows;
|
|
465
|
+
const keys = new Set(rows.map(rowKey));
|
|
466
|
+
for (const key of state.expanded) if (!keys.has(key)) state.expanded.delete(key);
|
|
467
|
+
render();
|
|
468
|
+
},
|
|
469
|
+
/** Redraw as is: after a sort change, or when a column's availability changed. */
|
|
470
|
+
render,
|
|
471
|
+
/** Run after every redraw, for state the caller keeps on the rows (a selection, say). */
|
|
472
|
+
onRender(callback) {
|
|
473
|
+
afterRender = callback;
|
|
474
|
+
},
|
|
475
|
+
/** The sort header button for a key, to return focus to it after sorting. */
|
|
476
|
+
sortButton: (key) => thead.querySelector(`[data-sort-key="${key}"]`),
|
|
477
|
+
};
|
|
478
|
+
}
|
package/admin/js/dom.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM construction helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every value that came from the database is untrusted: page titles, paths,
|
|
5
|
+
* referrers, and event data are sent by anonymous visitors. This module is the
|
|
6
|
+
* only way the UI builds elements, and it only ever sets text through
|
|
7
|
+
* `textContent` and attributes through `setAttribute`. There is no path here,
|
|
8
|
+
* or anywhere in the UI, that parses a string as HTML; ESLint forbids
|
|
9
|
+
* innerHTML and its relatives under admin/.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create an element.
|
|
14
|
+
* @param {string} tag
|
|
15
|
+
* @param {{ className?: string, text?: string, attrs?: Record<string, string|number|boolean|null|undefined>,
|
|
16
|
+
* on?: Record<string, EventListener>, dataset?: Record<string, string> }} [options]
|
|
17
|
+
* @param {Array<Node|string|null|undefined|false>} [children]
|
|
18
|
+
* @returns {HTMLElement}
|
|
19
|
+
*/
|
|
20
|
+
export function el(tag, { className, text, attrs = {}, on = {}, dataset = {} } = {}, children = []) {
|
|
21
|
+
const node = document.createElement(tag);
|
|
22
|
+
if (className) node.className = className;
|
|
23
|
+
if (text !== undefined && text !== null) node.textContent = String(text);
|
|
24
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
25
|
+
if (value === false || value === null || value === undefined) continue;
|
|
26
|
+
node.setAttribute(name, value === true ? '' : String(value));
|
|
27
|
+
}
|
|
28
|
+
for (const [name, value] of Object.entries(dataset)) node.dataset[name] = value;
|
|
29
|
+
for (const [event, handler] of Object.entries(on)) node.addEventListener(event, handler);
|
|
30
|
+
append(node, children);
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Append children, turning strings into text nodes and skipping blanks. */
|
|
35
|
+
export function append(parent, children) {
|
|
36
|
+
for (const child of children) {
|
|
37
|
+
if (child === null || child === undefined || child === false) continue;
|
|
38
|
+
parent.append(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
39
|
+
}
|
|
40
|
+
return parent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remove every child. */
|
|
44
|
+
export function clear(node) {
|
|
45
|
+
while (node.firstChild) node.firstChild.remove();
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Replace a node's children in one step. */
|
|
50
|
+
export function replaceChildren(node, children) {
|
|
51
|
+
clear(node);
|
|
52
|
+
return append(node, children);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @returns {HTMLElement} */
|
|
56
|
+
export function byId(id) {
|
|
57
|
+
return document.getElementById(id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every focusable element inside `root`, in tab order. */
|
|
61
|
+
export function focusableWithin(root) {
|
|
62
|
+
const selector = [
|
|
63
|
+
'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'textarea:not([disabled])',
|
|
64
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
65
|
+
].join(',');
|
|
66
|
+
return [...root.querySelectorAll(selector)].filter((node) => !node.hidden && node.offsetParent !== null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Debounce a function by `ms`. */
|
|
70
|
+
export function debounce(fn, ms) {
|
|
71
|
+
let timer = null;
|
|
72
|
+
return (...args) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let idCounter = 0;
|
|
79
|
+
/** A document-unique id for ARIA relationships. */
|
|
80
|
+
export function uniqueId(prefix) {
|
|
81
|
+
idCounter += 1;
|
|
82
|
+
return `${prefix}-${idCounter}`;
|
|
83
|
+
}
|