|
@@ -280,5 +280,181 @@ function formatTimes(root) {
|
|
|
document.addEventListener('DOMContentLoaded', () => formatTimes(document));
|
|
document.addEventListener('DOMContentLoaded', () => formatTimes(document));
|
|
|
document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
|
|
document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
|
|
|
|
|
|
|
|
|
|
+// Sortable tables. Tables marked with `data-sortable-table="<id>"` get
|
|
|
|
|
+// click-to-sort headers. Sort state lives in sessionStorage under
|
|
|
|
|
+// `irdb-sort:<id>` and is wiped on the login page so logging out
|
|
|
|
|
+// forgets it. The comparator is selected by the th's `data-sort-type`
|
|
|
|
|
+// (`string` | `number` | `date`); cells may override the displayed
|
|
|
|
|
+// text via `data-sort-value`.
|
|
|
|
|
+const SORT_KEY_PREFIX = 'irdb-sort:';
|
|
|
|
|
+
|
|
|
|
|
+function readSortState(tableId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
|
|
|
|
|
+ if (!raw) return null;
|
|
|
|
|
+ const parsed = JSON.parse(raw);
|
|
|
|
|
+ if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
|
|
|
|
|
+ return parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) { /* ignore */ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function writeSortState(tableId, state) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (state) {
|
|
|
|
|
+ sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) { /* ignore */ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearAllSortState() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const keys = [];
|
|
|
|
|
+ for (let i = 0; i < sessionStorage.length; i++) {
|
|
|
|
|
+ const k = sessionStorage.key(i);
|
|
|
|
|
+ if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
|
|
|
|
|
+ }
|
|
|
|
|
+ keys.forEach((k) => sessionStorage.removeItem(k));
|
|
|
|
|
+ } catch (e) { /* ignore */ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cellSortValue(cell, type) {
|
|
|
|
|
+ if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
|
|
|
|
|
+ const explicit = cell.getAttribute('data-sort-value');
|
|
|
|
|
+ if (explicit !== null) return explicit;
|
|
|
|
|
+ // <time datetime="..."> is the canonical date carrier.
|
|
|
|
|
+ const timeEl = cell.querySelector('time[datetime]');
|
|
|
|
|
+ if (timeEl) return timeEl.getAttribute('datetime') || '';
|
|
|
|
|
+ return (cell.textContent || '').trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function compareValues(a, b, type) {
|
|
|
|
|
+ if (type === 'number') {
|
|
|
|
|
+ const na = parseFloat(a);
|
|
|
|
|
+ const nb = parseFloat(b);
|
|
|
|
|
+ const aNaN = Number.isNaN(na);
|
|
|
|
|
+ const bNaN = Number.isNaN(nb);
|
|
|
|
|
+ if (aNaN && bNaN) return 0;
|
|
|
|
|
+ if (aNaN) return 1; // empty/— goes last on asc
|
|
|
|
|
+ if (bNaN) return -1;
|
|
|
|
|
+ return na - nb;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type === 'date') {
|
|
|
|
|
+ const ta = a ? Date.parse(a) : NaN;
|
|
|
|
|
+ const tb = b ? Date.parse(b) : NaN;
|
|
|
|
|
+ const aNaN = Number.isNaN(ta);
|
|
|
|
|
+ const bNaN = Number.isNaN(tb);
|
|
|
|
|
+ if (aNaN && bNaN) return 0;
|
|
|
|
|
+ if (aNaN) return 1;
|
|
|
|
|
+ if (bNaN) return -1;
|
|
|
|
|
+ return ta - tb;
|
|
|
|
|
+ }
|
|
|
|
|
+ // string
|
|
|
|
|
+ return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function findSortableThs(table) {
|
|
|
|
|
+ // Only <th> in the *first* thead row (avoid scanning nested tables
|
|
|
|
|
+ // — the score-chart has its own SVG layout, not a sortable table).
|
|
|
|
|
+ const head = table.tHead;
|
|
|
|
|
+ if (!head || !head.rows.length) return [];
|
|
|
|
|
+ return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applySort(table, key, dir) {
|
|
|
|
|
+ const ths = findSortableThs(table);
|
|
|
|
|
+ const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
|
|
|
|
|
+ if (!th) return;
|
|
|
|
|
+ const colIndex = th.cellIndex;
|
|
|
|
|
+ const type = th.getAttribute('data-sort-type') || 'string';
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = table.tBodies[0];
|
|
|
|
|
+ if (!tbody) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Treat each contiguous run of rows with the same `data-sort-row-group`
|
|
|
|
|
+ // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
|
|
|
|
|
+ // expandable rows (e.g. the audit log payload toggle) stay glued to
|
|
|
|
|
+ // their parent. By default, every row sorts independently.
|
|
|
|
|
+ const allRows = Array.from(tbody.rows);
|
|
|
|
|
+ const groups = [];
|
|
|
|
|
+ let current = null;
|
|
|
|
|
+ for (const row of allRows) {
|
|
|
|
|
+ if (row.hasAttribute('data-sort-row-detail') && current) {
|
|
|
|
|
+ current.push(row);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ current = [row];
|
|
|
|
|
+ groups.push(current);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ groups.sort((ga, gb) => {
|
|
|
|
|
+ const va = cellSortValue(ga[0].cells[colIndex], type);
|
|
|
|
|
+ const vb = cellSortValue(gb[0].cells[colIndex], type);
|
|
|
|
|
+ const cmp = compareValues(va, vb, type);
|
|
|
|
|
+ return dir === 'desc' ? -cmp : cmp;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const frag = document.createDocumentFragment();
|
|
|
|
|
+ groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
|
|
|
|
|
+ tbody.appendChild(frag);
|
|
|
|
|
+
|
|
|
|
|
+ // Update indicators.
|
|
|
|
|
+ ths.forEach((t) => {
|
|
|
|
|
+ const ind = t.querySelector('.sort-indicator');
|
|
|
|
|
+ const isActive = t === th;
|
|
|
|
|
+ if (ind) {
|
|
|
|
|
+ ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
|
|
|
|
|
+ ind.classList.toggle('text-slate-300', !isActive);
|
|
|
|
|
+ ind.classList.toggle('dark:text-slate-600', !isActive);
|
|
|
|
|
+ ind.classList.toggle('text-indigo-600', isActive);
|
|
|
|
|
+ ind.classList.toggle('dark:text-indigo-400', isActive);
|
|
|
|
|
+ }
|
|
|
|
|
+ t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function initSortableTable(table) {
|
|
|
|
|
+ if (!table || table.dataset.sortableInit === '1') return;
|
|
|
|
|
+ table.dataset.sortableInit = '1';
|
|
|
|
|
+ const tableId = table.getAttribute('data-sortable-table');
|
|
|
|
|
+ if (!tableId) return;
|
|
|
|
|
+ const ths = findSortableThs(table);
|
|
|
|
|
+ if (ths.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ ths.forEach((th) => {
|
|
|
|
|
+ th.classList.add('cursor-pointer', 'select-none');
|
|
|
|
|
+ th.addEventListener('click', () => {
|
|
|
|
|
+ const key = th.getAttribute('data-sort-key');
|
|
|
|
|
+ const current = readSortState(tableId);
|
|
|
|
|
+ const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
|
|
|
|
|
+ writeSortState(tableId, { key, dir });
|
|
|
|
|
+ applySort(table, key, dir);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const initial = readSortState(tableId);
|
|
|
|
|
+ if (initial) {
|
|
|
|
|
+ applySort(table, initial.key, initial.dir);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function initSortableTables(root) {
|
|
|
|
|
+ const scope = root && root.querySelectorAll ? root : document;
|
|
|
|
|
+ scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
+ // The login page is the canonical "logged out" landing spot —
|
|
|
|
|
+ // wipe any sort state left over from the previous session.
|
|
|
|
|
+ if (window.location && window.location.pathname === '/login') {
|
|
|
|
|
+ clearAllSortState();
|
|
|
|
|
+ }
|
|
|
|
|
+ initSortableTables(document);
|
|
|
|
|
+});
|
|
|
|
|
+document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
|
|
|
|
|
+
|
|
|
window.Alpine = Alpine;
|
|
window.Alpine = Alpine;
|
|
|
Alpine.start();
|
|
Alpine.start();
|