@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.
Files changed (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -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 +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,685 @@
1
+ /**
2
+ * The views table, used twice: the Views tab (live rows, optionally with the
3
+ * trashed ones shown inline) and the Trash tab (trashed rows only).
4
+ *
5
+ * Selection is a set of view IDs that survives paging, so a batch can span
6
+ * pages, and is cleared whenever the query changes shape (app, filter,
7
+ * search) so an action can never reach rows the admin can no longer see.
8
+ */
9
+
10
+ import { api } from './api.js';
11
+ import { clampText } from './clamp.js';
12
+ import { el, replaceChildren, debounce, uniqueId } from './dom.js';
13
+ import { formatDateTime, formatNumber, orNone } from './format.js';
14
+ import { t, tOr } from './i18n.js';
15
+ import { createListbox } from './listbox.js';
16
+ import { confirmModal } from './modal.js';
17
+ import { createPager, createSegmented, headerCell, messageRow, timeCell } from './table.js';
18
+ import { showToast, TOAST_TYPE } from './toast.js';
19
+ import { openDetails, openEditor, openNoteEditor } from './viewDialogs.js';
20
+ import { createInsightsPanel } from './insights.js';
21
+ import {
22
+ ALL_APPS,
23
+ CLAMP_LINES,
24
+ KEY,
25
+ RANGE,
26
+ MODIFIED_FILTER,
27
+ SEARCH_DEBOUNCE_MS,
28
+ SORT_ORDER,
29
+ VIEW_STATUS,
30
+ } from './constants.js';
31
+
32
+ /** Which rows a panel shows. */
33
+ export const PANEL_MODE = Object.freeze({
34
+ VIEWS: 'views',
35
+ TRASH: 'trash',
36
+ });
37
+
38
+ /** Columns in display order; `sort` names the API sort key. */
39
+ const COLUMNS = [
40
+ { key: 'timestamp', sort: 'timestamp', className: 'col-time' },
41
+ { key: 'app', className: 'col-app' },
42
+ { key: 'page', sort: 'page', className: 'col-page' },
43
+ { key: 'source', sort: 'source', className: 'col-source' },
44
+ { key: 'device', sort: 'deviceSize', className: 'col-device' },
45
+ { key: 'country', sort: 'country', className: 'col-country' },
46
+ { key: 'client', sort: 'browser', className: 'col-client' },
47
+ { key: 'event', sort: 'eventType', className: 'col-event' },
48
+ { key: 'status', className: 'col-status' },
49
+ { key: 'actions', className: 'col-actions' },
50
+ ];
51
+ /** Plus the selection column. */
52
+ const COLUMN_COUNT = COLUMNS.length + 1;
53
+
54
+ /**
55
+ * @param {{ mode: string, meta: object, context: { apps: () => object[], appId: () => string,
56
+ * setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void } }} deps
57
+ */
58
+ export function createViewsPanel({ mode, meta, context }) {
59
+ const isTrash = mode === PANEL_MODE.TRASH;
60
+ const state = {
61
+ showDeleted: false,
62
+ modified: MODIFIED_FILTER.ANY,
63
+ range: RANGE.ALL,
64
+ eventType: '',
65
+ knownEventTypes: new Set(),
66
+ search: '',
67
+ sort: isTrash ? 'deletedAt' : 'timestamp',
68
+ order: SORT_ORDER.DESC,
69
+ page: 1,
70
+ pageSize: meta.pageSizeDefault,
71
+ total: 0,
72
+ rows: [],
73
+ selection: new Map(),
74
+ requestSeq: 0,
75
+ focusSort: null,
76
+ };
77
+
78
+ const status = () => {
79
+ if (isTrash) return VIEW_STATUS.DELETED;
80
+ return state.showDeleted ? VIEW_STATUS.ALL : VIEW_STATUS.ACTIVE;
81
+ };
82
+
83
+ /** The filters the table and the insights above it share. */
84
+ const filters = () => ({
85
+ status: status(),
86
+ modified: state.modified,
87
+ range: state.range,
88
+ eventType: state.eventType,
89
+ search: state.search,
90
+ });
91
+
92
+ // ---- Toolbar -----------------------------------------------------------
93
+
94
+ // One tab per app (each app is its own table). Shared by the Views and
95
+ // Trash panels: switching here switches both. ARIA tabs pattern with
96
+ // automatic activation: arrow keys, Home and End move and select.
97
+ const tableId = uniqueId('views-table');
98
+ const appTabs = el('div', {
99
+ className: 'app-tabs',
100
+ attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
101
+ });
102
+ let focusAppOnRender = false;
103
+
104
+ function selectApp(appId, { focus = false } = {}) {
105
+ focusAppOnRender = focus;
106
+ if (appId === context.appId()) {
107
+ renderAppTabs();
108
+ return;
109
+ }
110
+ context.setAppId(appId);
111
+ }
112
+
113
+ /** Every app's tab, led by one for all of them together. */
114
+ function tabEntries() {
115
+ const apps = context.apps();
116
+ const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
117
+ return [
118
+ {
119
+ appId: ALL_APPS,
120
+ label: t('appTabs.all'),
121
+ active: sum('active'),
122
+ deleted: sum('deleted'),
123
+ modified: sum('modified'),
124
+ available: true,
125
+ },
126
+ ...apps.map((app) => ({ ...app, label: app.appId })),
127
+ ];
128
+ }
129
+
130
+ function renderAppTabs() {
131
+ const current = context.appId();
132
+ replaceChildren(appTabs, tabEntries().map((app) => {
133
+ const selected = app.appId === current;
134
+ const count = isTrash ? app.deleted : app.active;
135
+ const countText = t(isTrash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
136
+ return el('button', {
137
+ className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
138
+ attrs: {
139
+ type: 'button',
140
+ role: 'tab',
141
+ 'aria-selected': String(selected),
142
+ 'aria-controls': tableId,
143
+ 'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
144
+ tabindex: selected ? '0' : '-1',
145
+ title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
146
+ },
147
+ dataset: { appId: app.appId },
148
+ on: { click: () => selectApp(app.appId) },
149
+ }, [
150
+ el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
151
+ el('span', { className: 'app-tab-name', text: app.label }),
152
+ el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
153
+ ]);
154
+ }));
155
+ if (focusAppOnRender) {
156
+ appTabs.querySelector('[aria-selected="true"]')?.focus();
157
+ focusAppOnRender = false;
158
+ }
159
+ }
160
+
161
+ appTabs.addEventListener('keydown', (event) => {
162
+ const ids = tabEntries().map((app) => app.appId);
163
+ if (ids.length === 0) return;
164
+ const index = Math.max(0, ids.indexOf(context.appId()));
165
+ const moves = {
166
+ [KEY.ARROW_RIGHT]: (index + 1) % ids.length,
167
+ [KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
168
+ [KEY.HOME]: 0,
169
+ [KEY.END]: ids.length - 1,
170
+ };
171
+ if (!(event.key in moves)) return;
172
+ event.preventDefault();
173
+ selectApp(ids[moves[event.key]], { focus: true });
174
+ });
175
+
176
+ const searchInput = el('input', {
177
+ className: 'input search-input',
178
+ attrs: {
179
+ type: 'search',
180
+ placeholder: t('toolbar.searchPlaceholder'),
181
+ 'aria-label': t('toolbar.search'),
182
+ maxlength: meta.searchMaxLength,
183
+ },
184
+ on: { input: debounce(() => {
185
+ state.search = searchInput.value.trim();
186
+ resetAndLoad();
187
+ }, SEARCH_DEBOUNCE_MS) },
188
+ });
189
+
190
+ const modifiedFilter = createSegmented({
191
+ label: t('toolbar.modifiedFilter'),
192
+ value: MODIFIED_FILTER.ANY,
193
+ options: Object.values(MODIFIED_FILTER).map((value) => ({ value, label: t(`modifiedFilter.${value}`) })),
194
+ onChange: (value) => {
195
+ state.modified = value;
196
+ resetAndLoad();
197
+ },
198
+ });
199
+
200
+ const showDeletedToggle = el('input', {
201
+ className: 'switch-input',
202
+ attrs: { type: 'checkbox', role: 'switch' },
203
+ on: { change: () => {
204
+ state.showDeleted = showDeletedToggle.checked;
205
+ resetAndLoad();
206
+ } },
207
+ });
208
+
209
+ const rangeFilter = createSegmented({
210
+ label: t('toolbar.range'),
211
+ value: RANGE.ALL,
212
+ options: meta.ranges.map((value) => ({ value, label: t(`ranges.${value}`) })),
213
+ onChange: (value) => {
214
+ state.range = value;
215
+ resetAndLoad();
216
+ },
217
+ });
218
+
219
+ const eventTypeOptions = () => [
220
+ { value: '', label: t('toolbar.allEventTypes') },
221
+ ...[...state.knownEventTypes].sort().map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
222
+ ];
223
+ const eventTypePicker = createListbox({
224
+ label: t('toolbar.eventType'),
225
+ options: eventTypeOptions(),
226
+ value: '',
227
+ className: 'event-type-picker',
228
+ onChange: (value) => {
229
+ state.eventType = value;
230
+ resetAndLoad();
231
+ },
232
+ });
233
+
234
+ const pageSizePicker = createListbox({
235
+ label: t('toolbar.pageSize'),
236
+ options: meta.pageSizes.map((size) => ({ value: String(size), label: t('toolbar.perPage', { count: size }) })),
237
+ value: String(meta.pageSizeDefault),
238
+ onChange: (value) => {
239
+ state.pageSize = Number(value);
240
+ state.page = 1;
241
+ load();
242
+ },
243
+ });
244
+
245
+ const toolbar = el('div', { className: 'toolbar' }, [
246
+ el('div', { className: 'toolbar-group' }, [
247
+ rangeFilter.element,
248
+ isTrash ? null : eventTypePicker.element,
249
+ searchInput,
250
+ ]),
251
+ el('div', { className: 'toolbar-group' }, [
252
+ modifiedFilter.element,
253
+ isTrash ? null : el('label', { className: 'switch' }, [
254
+ showDeletedToggle,
255
+ el('span', { className: 'switch-track', attrs: { 'aria-hidden': 'true' } }),
256
+ el('span', { className: 'switch-label', text: t('toolbar.showDeleted') }),
257
+ ]),
258
+ pageSizePicker.element,
259
+ ]),
260
+ ]);
261
+
262
+ const summary = el('p', { className: 'panel-summary', attrs: { 'aria-live': 'polite' } });
263
+
264
+ // ---- Batch bar ---------------------------------------------------------
265
+
266
+ const batchCount = el('span', { className: 'batch-count' });
267
+ const batchButton = (key, variant, handler) => el('button', {
268
+ className: `btn btn-${variant} btn-small`,
269
+ text: t(key),
270
+ attrs: { type: 'button' },
271
+ on: { click: handler },
272
+ });
273
+
274
+ const batchBar = el('div', { className: 'batch-bar', attrs: { role: 'region', 'aria-label': t('batch.region') } }, [
275
+ el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
276
+ batchCount,
277
+ el('div', { className: 'batch-actions' }, [
278
+ isTrash ? null : batchButton('batch.edit', 'secondary', () => editViews(selectedViews())),
279
+ batchButton('batch.note', 'secondary', () => noteViews(selectedViews())),
280
+ isTrash ? null : batchButton('batch.delete', 'danger', () => deleteViews(selectedViews())),
281
+ batchButton('batch.restore', 'secondary', () => restoreViews(selectedViews())),
282
+ isTrash ? batchButton('batch.purge', 'danger', () => purgeViews(selectedViews())) : null,
283
+ batchButton('batch.clear', 'ghost', () => {
284
+ state.selection.clear();
285
+ renderSelection();
286
+ }),
287
+ ]),
288
+ ]);
289
+ batchBar.hidden = true;
290
+
291
+ // ---- Table -------------------------------------------------------------
292
+
293
+ const selectAll = el('input', {
294
+ attrs: { type: 'checkbox', 'aria-label': t('table.selectPage') },
295
+ on: { change: () => togglePage(selectAll.checked) },
296
+ });
297
+ const thead = el('thead');
298
+ const tbody = el('tbody');
299
+ const table = el('table', { className: 'data-table views-table', attrs: { id: tableId } }, [thead, tbody]);
300
+ const pager = createPager((page) => {
301
+ state.page = page;
302
+ load();
303
+ });
304
+
305
+ const retention = isTrash ? el('p', { className: 'notice' }) : null;
306
+
307
+ // Insights live on the Views tab; the trash is for recovery, not analysis.
308
+ const insights = isTrash ? null : createInsightsPanel({
309
+ reportError: context.reportError,
310
+ onData: (data) => {
311
+ // The server lists every event type these apps hold, whatever the
312
+ // other filters say, so choosing one never hides the others. The
313
+ // current choice stays on offer even if its last view was edited.
314
+ const types = new Set(data.eventTypes);
315
+ if (state.eventType) types.add(state.eventType);
316
+ const known = state.knownEventTypes;
317
+ if (types.size === known.size && [...types].every((type) => known.has(type))) return;
318
+ state.knownEventTypes = types;
319
+ eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
320
+ },
321
+ });
322
+
323
+ const element = el('section', {
324
+ className: 'panel',
325
+ attrs: { 'aria-label': isTrash ? t('tabs.trash') : t('tabs.views') },
326
+ }, [
327
+ appTabs,
328
+ toolbar,
329
+ retention,
330
+ insights?.element,
331
+ summary,
332
+ batchBar,
333
+ el('div', { className: 'table-wrap' }, [table]),
334
+ pager.element,
335
+ ]);
336
+
337
+ // ---- Selection ---------------------------------------------------------
338
+
339
+ function selectedViews() {
340
+ return [...state.selection.values()];
341
+ }
342
+
343
+ function toggleRow(view, checked) {
344
+ if (checked && !state.selection.has(view.id) && state.selection.size >= meta.maxBatchIds) {
345
+ showToast(t('batch.limit', { count: meta.maxBatchIds }), TOAST_TYPE.ERROR);
346
+ renderSelection();
347
+ return;
348
+ }
349
+ if (checked) state.selection.set(view.id, view);
350
+ else state.selection.delete(view.id);
351
+ renderSelection();
352
+ }
353
+
354
+ function togglePage(checked) {
355
+ for (const view of state.rows) {
356
+ if (!checked) {
357
+ state.selection.delete(view.id);
358
+ } else if (!state.selection.has(view.id)) {
359
+ if (state.selection.size >= meta.maxBatchIds) {
360
+ showToast(t('batch.limit', { count: meta.maxBatchIds }), TOAST_TYPE.ERROR);
361
+ break;
362
+ }
363
+ state.selection.set(view.id, view);
364
+ }
365
+ }
366
+ renderSelection();
367
+ }
368
+
369
+ // ---- Rendering ---------------------------------------------------------
370
+
371
+ function onSort(key) {
372
+ state.focusSort = key;
373
+ if (state.sort === key) {
374
+ state.order = state.order === SORT_ORDER.ASC ? SORT_ORDER.DESC : SORT_ORDER.ASC;
375
+ } else {
376
+ state.sort = key;
377
+ state.order = SORT_ORDER.DESC;
378
+ }
379
+ state.page = 1;
380
+ load();
381
+ }
382
+
383
+ function renderHead() {
384
+ replaceChildren(thead, [el('tr', {}, [
385
+ el('th', { className: 'col-select', attrs: { scope: 'col' } }, [selectAll]),
386
+ ...COLUMNS.map((column) => headerCell({
387
+ label: t(`columns.${column.key}`),
388
+ sortKey: column.sort,
389
+ sort: state.sort,
390
+ order: state.order,
391
+ onSort,
392
+ className: column.className,
393
+ })),
394
+ ])]);
395
+ }
396
+
397
+ function badge(key, className, title) {
398
+ return el('span', { className: `badge ${className}`, text: t(key), attrs: title ? { title } : {} });
399
+ }
400
+
401
+ function actionButton(icon, labelKey, handler, variant = '') {
402
+ return el('button', {
403
+ className: `icon-btn row-action ${variant}`.trim(),
404
+ text: icon,
405
+ attrs: { type: 'button', 'aria-label': t(labelKey), title: t(labelKey) },
406
+ on: { click: handler },
407
+ });
408
+ }
409
+
410
+ function renderRow(view) {
411
+ const deleted = view.deletedAt !== null && view.deletedAt !== undefined;
412
+ const checkbox = el('input', {
413
+ attrs: {
414
+ type: 'checkbox',
415
+ 'aria-label': t('table.selectRow', { time: formatDateTime(view.timestamp) }),
416
+ },
417
+ on: { change: (event) => toggleRow(view, event.currentTarget.checked) },
418
+ });
419
+ checkbox.checked = state.selection.has(view.id);
420
+
421
+ const badges = [
422
+ deleted ? badge('status.deleted', 'badge-danger', formatDateTime(view.deletedAt)) : null,
423
+ view.adminModifiedAt ? badge('status.modified', 'badge-warning', formatDateTime(view.adminModifiedAt)) : null,
424
+ view.note ? badge('status.note', 'badge-info', view.note) : null,
425
+ view.isUnique ? null : badge('status.repeat', 'badge-muted'),
426
+ ];
427
+
428
+ const actions = [
429
+ actionButton('ⓘ', 'rowActions.details', () => openDetails(view)),
430
+ deleted ? null : actionButton('✎', 'rowActions.edit', () => editViews([view])),
431
+ actionButton('✐', 'rowActions.note', () => noteViews([view])),
432
+ deleted
433
+ ? actionButton('↺', 'rowActions.restore', () => restoreViews([view]))
434
+ : actionButton('🗑', 'rowActions.delete', () => deleteViews([view]), 'danger'),
435
+ deleted && isTrash ? actionButton('⨯', 'rowActions.purge', () => purgeViews([view]), 'danger') : null,
436
+ ];
437
+
438
+ const source = view.sourceType ? tOr(`sources.${view.sourceType}`, view.sourceType) : t('common.none');
439
+
440
+ return el('tr', { className: deleted ? 'row-deleted' : '', dataset: { viewId: view.id } }, [
441
+ el('td', { className: 'col-select' }, [checkbox]),
442
+ timeCell(view.timestamp),
443
+ el('td', { className: 'col-app' }, [clampText(view.appId, { className: 'mono' })]),
444
+ el('td', { className: 'col-page' }, [
445
+ clampText(orNone(view.pagePath), { className: 'cell-primary' }),
446
+ clampText(orNone(view.pageTitle), { lines: CLAMP_LINES.SINGLE, className: 'cell-secondary' }),
447
+ ]),
448
+ el('td', { className: 'col-source' }, [
449
+ clampText(source, { className: 'cell-primary' }),
450
+ clampText(orNone(view.referrerDomain), { className: 'cell-secondary' }),
451
+ ]),
452
+ el('td', { className: 'col-device' }, [
453
+ clampText(view.deviceSize ? tOr(`deviceSizes.${view.deviceSize}`, view.deviceSize) : t('common.none'), { className: 'cell-primary' }),
454
+ clampText(orNone(view.deviceType), { className: 'cell-secondary' }),
455
+ ]),
456
+ el('td', { className: 'col-country' }, [clampText(orNone(view.country))]),
457
+ el('td', { className: 'col-client' }, [
458
+ clampText(orNone(view.browser), { className: 'cell-primary' }),
459
+ clampText(orNone(view.os), { className: 'cell-secondary' }),
460
+ ]),
461
+ el('td', { className: 'col-event' }, [clampText(orNone(view.eventType))]),
462
+ el('td', { className: 'col-status' }, [el('div', { className: 'badges' }, badges)]),
463
+ el('td', { className: 'col-actions' }, [el('div', { className: 'row-actions' }, actions)]),
464
+ ]);
465
+ }
466
+
467
+ /**
468
+ * Reflect the selection without rebuilding any row, so the checkbox the
469
+ * admin just used keeps keyboard focus.
470
+ */
471
+ function renderSelection() {
472
+ const pageIds = state.rows.map((view) => view.id);
473
+ const selectedOnPage = pageIds.filter((id) => state.selection.has(id)).length;
474
+ selectAll.checked = pageIds.length > 0 && selectedOnPage === pageIds.length;
475
+ selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < pageIds.length;
476
+ selectAll.disabled = pageIds.length === 0;
477
+
478
+ for (const row of tbody.querySelectorAll('tr[data-view-id]')) {
479
+ const checkbox = row.querySelector('.col-select input');
480
+ if (checkbox) checkbox.checked = state.selection.has(row.dataset.viewId);
481
+ }
482
+
483
+ batchBar.hidden = state.selection.size === 0;
484
+ batchCount.textContent = t('batch.selected', { count: state.selection.size });
485
+ }
486
+
487
+ /** Rebuild the table from state. After a sort, focus returns to its header. */
488
+ function render() {
489
+ renderHead();
490
+ if (state.rows.length === 0) {
491
+ replaceChildren(tbody, [messageRow(COLUMN_COUNT, isTrash ? t('table.emptyTrash') : t('table.empty'))]);
492
+ } else {
493
+ replaceChildren(tbody, state.rows.map(renderRow));
494
+ }
495
+ renderSelection();
496
+ pager.update(state.page, state.pageSize, state.total);
497
+ if (state.focusSort) {
498
+ thead.querySelector(`[data-sort-key="${state.focusSort}"]`)?.focus();
499
+ state.focusSort = null;
500
+ }
501
+
502
+ const app = tabEntries().find((entry) => entry.appId === context.appId());
503
+ summary.textContent = app
504
+ ? t('views.summary', {
505
+ active: formatNumber(app.active),
506
+ modified: formatNumber(app.modified),
507
+ deleted: formatNumber(app.deleted),
508
+ count: app.active,
509
+ })
510
+ : '';
511
+ if (retention) {
512
+ retention.textContent = meta.trashRetentionDays > 0
513
+ ? t('trash.retention', { count: meta.trashRetentionDays })
514
+ : t('trash.retentionOff');
515
+ }
516
+ }
517
+
518
+ // ---- Loading -----------------------------------------------------------
519
+
520
+ async function load() {
521
+ const appId = context.appId();
522
+ if (!appId) return;
523
+ const seq = ++state.requestSeq;
524
+ table.setAttribute('aria-busy', 'true');
525
+ try {
526
+ const query = {
527
+ ...filters(),
528
+ sort: state.sort,
529
+ order: state.order,
530
+ page: state.page,
531
+ pageSize: state.pageSize,
532
+ };
533
+ const result = appId === ALL_APPS ? await api.allViews(query) : await api.views(appId, query);
534
+ // A slower, older response must never overwrite a newer one.
535
+ if (seq !== state.requestSeq) return;
536
+ state.rows = result.views;
537
+ state.total = result.total;
538
+ // Rows name their app only under All apps; one app's tab already does.
539
+ table.classList.toggle('has-app', appId === ALL_APPS);
540
+ if (state.rows.length === 0 && state.page > 1 && state.total > 0) {
541
+ state.page = Math.max(1, Math.ceil(state.total / state.pageSize));
542
+ await load();
543
+ return;
544
+ }
545
+ render();
546
+ } catch (error) {
547
+ if (seq === state.requestSeq) context.reportError(error);
548
+ } finally {
549
+ if (seq === state.requestSeq) table.removeAttribute('aria-busy');
550
+ }
551
+ }
552
+
553
+ /** Refresh the insights for the current filters; the table has its own paging. */
554
+ function loadInsights() {
555
+ if (insights && context.appId()) insights.load({ appId: context.appId(), ...filters() });
556
+ }
557
+
558
+ function resetAndLoad() {
559
+ state.page = 1;
560
+ state.selection.clear();
561
+ load();
562
+ loadInsights();
563
+ }
564
+
565
+ // ---- Operations --------------------------------------------------------
566
+
567
+ /**
568
+ * Run a mutation, report the outcome, and refresh. The server returns the
569
+ * IDs it actually changed, which may be fewer than requested (a row
570
+ * already restored, or already erased by someone else).
571
+ */
572
+ async function mutate(views, run, successKey) {
573
+ // Grouped by app, since each app is its own table: a selection made
574
+ // under "All apps" can span several.
575
+ const byApp = new Map();
576
+ for (const view of views) byApp.set(view.appId, [...(byApp.get(view.appId) || []), view.id]);
577
+ // Each app is its own request and its own transaction. One that fails
578
+ // must not hide what the others already did: those rows are changed
579
+ // for real, so they leave the selection and the table is refreshed.
580
+ const result = { attempted: 0, affected: 0, ids: [] };
581
+ let failure = null;
582
+ for (const [appId, ids] of byApp) {
583
+ try {
584
+ const part = await run(appId, ids);
585
+ result.attempted += ids.length;
586
+ result.affected += part.affected;
587
+ result.ids.push(...part.ids);
588
+ } catch (error) {
589
+ failure ??= error;
590
+ }
591
+ }
592
+ for (const id of result.ids) state.selection.delete(id);
593
+
594
+ if (result.attempted > 0) {
595
+ // Skipped means refused for a reason the server gave; rows whose
596
+ // request failed are covered by the error instead.
597
+ const skipped = result.attempted - result.affected;
598
+ showToast(
599
+ skipped > 0
600
+ ? t(`${successKey}Partial`, { count: result.affected, skipped })
601
+ : t(successKey, { count: result.affected }),
602
+ TOAST_TYPE.SUCCESS,
603
+ );
604
+ }
605
+ if (failure) context.reportError(failure);
606
+ if (result.affected > 0 || !failure) {
607
+ try {
608
+ await context.refreshApps();
609
+ await load();
610
+ loadInsights();
611
+ } catch (error) {
612
+ context.reportError(error);
613
+ }
614
+ }
615
+ return !failure;
616
+ }
617
+
618
+ function editViews(views) {
619
+ if (views.length === 0) return;
620
+ openEditor({
621
+ views,
622
+ meta,
623
+ onSubmit: (changes) => mutate(views, (appId, ids) => api.edit(appId, ids, changes), 'toasts.edited'),
624
+ });
625
+ }
626
+
627
+ function noteViews(views) {
628
+ if (views.length === 0) return;
629
+ openNoteEditor({
630
+ views,
631
+ meta,
632
+ onSubmit: (note) => mutate(
633
+ views,
634
+ (appId, ids) => api.note(appId, ids, note),
635
+ note === null ? 'toasts.noteCleared' : 'toasts.noteSaved',
636
+ ),
637
+ });
638
+ }
639
+
640
+ async function deleteViews(views) {
641
+ if (views.length === 0) return;
642
+ const confirmed = await confirmModal({
643
+ title: t('confirm.deleteTitle', { count: views.length }),
644
+ message: meta.trashRetentionDays > 0
645
+ ? t('confirm.deleteMessage', { count: views.length, days: meta.trashRetentionDays })
646
+ : t('confirm.deleteMessageNoRetention', { count: views.length }),
647
+ confirmLabel: t('confirm.deleteAction'),
648
+ danger: true,
649
+ });
650
+ if (confirmed) await mutate(views, api.remove, 'toasts.deleted');
651
+ }
652
+
653
+ async function restoreViews(views) {
654
+ if (views.length === 0) return;
655
+ await mutate(views, api.restore, 'toasts.restored');
656
+ }
657
+
658
+ async function purgeViews(views) {
659
+ if (views.length === 0) return;
660
+ const confirmed = await confirmModal({
661
+ title: t('confirm.purgeTitle', { count: views.length }),
662
+ message: t('confirm.purgeMessage', { count: views.length }),
663
+ confirmLabel: t('confirm.purgeAction'),
664
+ danger: true,
665
+ });
666
+ if (confirmed) await mutate(views, api.purge, 'toasts.purged');
667
+ }
668
+
669
+ render();
670
+
671
+ return {
672
+ element,
673
+ /** The app list or selected app changed. */
674
+ syncApps() {
675
+ renderAppTabs();
676
+ },
677
+ /** Start over for the current app. */
678
+ reload: resetAndLoad,
679
+ /** Refresh the current page and the insights, keeping the selection. */
680
+ refresh() {
681
+ load();
682
+ loadInsights();
683
+ },
684
+ };
685
+ }