3cdd5aee1f
The Team tab was five cards stacked on one page: the rota, the escalation ladder, the alert sources, the dead man's switches and the membership.07914d5split the Admin tab for three reasons, and all three were sharper here. There was no way to link somebody to the escalation ladder, which is the thing a team owner most often has to be talked through. There was no way to the switches but scrolling past a month of rota -- and the rota became a month grid in v0.18.0, which made the page taller rather than shorter. And the poll loop refetched six endpoints every tick however little of the page you were looking at. Each is now a route: /team/rota, /team/members, /team/escalation, /team/sources, /team/deadman, reached from the same strip of links the Admin tab uses, with /team an overview. A page fetches only what it shows, so the switches are one GET and the sources are one, where every tick used to be six. Three of the five fetch the member list besides their own endpoint, and for the same reason each time: a rota entry, a ladder target and a role are all a person, and the page has to be able to name them. The overview is the one that fetches everything, because saying how much of each there is means asking each of them -- who is on call today, how many members and owners, how many ladder levels and whether a fallback follows them, how many keys and how many never used, how many switches. That is what it is for; a strip that already links to the five does not need a second menu that repeats it. team.js owns the table of its six routes, as admin.js owns its four, and app.js parses against both rather than keeping a third list to drift from them. The table carries a title beside the label where the strip's word is too thin to name a page on its own: "Sources" is a fine tab and a poor browser tab, so that page titles as Alert sources and the switches keep their apostrophe in the top bar. menuItem left admin.js for ui.js as menuCard, since both tabs now open on one, and its CSS went from .admin-menu* to .overview-*. That is the rename .user-link -> .row-link was in v0.18.0, for the same reason: the class was named after the first page that used it rather than after what it is. The read-only notice a member sees is now on the overview only. It explains why the controls further down are missing, and a page that is nothing but the rota grid has no controls to explain. The team picker sits above the strip, because it changes the subject of all five, and it drops the ladder draft when it moves -- an unsaved edit belongs to the team it was started in. No server change. Extensionless paths already fall back to index.html, so /team/rota survives a reload the way /admin/users/{id} does, and no endpoint, payload or permission moved. Nobody has looked at this in a browser, the caveat07914d5anda6fa673carried. What is checked is the wiring, and rather more of it than last time: every sub-page was rendered against a stub fetch and a pocket DOM, each with exactly one aria-current and fetching only the endpoints named above; and app.js itself was booted the same way and walked through all seventeen URLs the app has, which resolve to one section each with the right title -- the six new ones, the four Admin ones, both subject pages, and /incidents/42 and /nonsense still falling to the queue. Whether six entries scroll cleanly at phone width is not checked. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
186 lines
5.9 KiB
JavaScript
186 lines
5.9 KiB
JavaScript
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||
|
||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||
export function h(tag, attrs = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k === 'text') el.textContent = v;
|
||
else if (k === 'dataset') Object.assign(el.dataset, v);
|
||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
||
else if (k in el && typeof v !== 'string') el[k] = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
function append(el, children) {
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
}
|
||
|
||
export function clear(el, ...children) {
|
||
el.replaceChildren();
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||
const ICONS = {
|
||
back: ['M15 18l-6-6 6-6'],
|
||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||
chevronRight: ['M9 6l6 6-6 6'],
|
||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
||
};
|
||
|
||
const SVG = 'http://www.w3.org/2000/svg';
|
||
export function icon(name, cls = 'icon') {
|
||
const svg = document.createElementNS(SVG, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('class', cls);
|
||
for (const d of ICONS[name] || []) {
|
||
let node;
|
||
if (d.startsWith('circle:')) {
|
||
const [cx, cy, r] = d.slice(7).split(',');
|
||
node = document.createElementNS(SVG, 'circle');
|
||
node.setAttribute('cx', cx);
|
||
node.setAttribute('cy', cy);
|
||
node.setAttribute('r', r);
|
||
} else {
|
||
node = document.createElementNS(SVG, 'path');
|
||
node.setAttribute('d', d);
|
||
}
|
||
svg.append(node);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
// ---------- sheet ----------
|
||
|
||
const sheet = () => document.getElementById('sheet');
|
||
let sheetResolve = null;
|
||
|
||
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
|
||
// and resolves with whatever closeSheet is given, or null when dismissed.
|
||
export function openSheet(build) {
|
||
const dlg = sheet();
|
||
if (dlg.open) closeSheet(null);
|
||
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
|
||
append(inner, [build()]);
|
||
clear(dlg, inner);
|
||
dlg.showModal();
|
||
const first = dlg.querySelector('[autofocus]');
|
||
if (first) first.focus();
|
||
return new Promise((resolve) => {
|
||
sheetResolve = resolve;
|
||
});
|
||
}
|
||
|
||
export function closeSheet(value = null) {
|
||
const dlg = sheet();
|
||
const resolve = sheetResolve;
|
||
sheetResolve = null;
|
||
if (dlg.open) dlg.close();
|
||
if (resolve) resolve(value);
|
||
}
|
||
|
||
export function sheetIsOpen() {
|
||
return sheet().open;
|
||
}
|
||
|
||
export function initSheet() {
|
||
const dlg = sheet();
|
||
// A tap on the backdrop lands on the dialog element itself.
|
||
dlg.addEventListener('click', (e) => {
|
||
if (e.target === dlg) closeSheet(null);
|
||
});
|
||
dlg.addEventListener('cancel', (e) => {
|
||
e.preventDefault();
|
||
closeSheet(null);
|
||
});
|
||
}
|
||
|
||
// confirm asks a yes/no question in the sheet.
|
||
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
|
||
return openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: title }),
|
||
text && h('p', { class: 'sheet-text', text }),
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
|
||
h('button', {
|
||
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
|
||
type: 'button',
|
||
autofocus: true,
|
||
onclick: () => closeSheet(true),
|
||
text: confirmLabel,
|
||
}),
|
||
),
|
||
]).then((v) => v === true);
|
||
}
|
||
|
||
// ---------- toast ----------
|
||
|
||
let toastTimer = 0;
|
||
export function toast(message, kind = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = message;
|
||
el.className = `toast ${kind}`;
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, kind === 'error' ? 5000 : 2500);
|
||
}
|
||
|
||
// ---------- misc ----------
|
||
|
||
export function badge(text, cls = '') {
|
||
return h('span', { class: `badge ${cls}`, text });
|
||
}
|
||
|
||
export function labelChip(k, v) {
|
||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||
}
|
||
|
||
// One entry in a section's overview: a card that is a link, carrying the count
|
||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||
export function menuCard(href, label, count, note) {
|
||
return h('a', { class: 'card overview-item', href },
|
||
h('div', { class: 'overview-head' },
|
||
h('strong', { text: label }),
|
||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||
h('p', { class: 'muted small', text: note }),
|
||
);
|
||
}
|
||
|
||
export function emptyState(title, text, iconName) {
|
||
return h('div', { class: 'empty' },
|
||
iconName && icon(iconName),
|
||
h('strong', { text: title }),
|
||
text && h('span', { text }),
|
||
);
|
||
}
|
||
|
||
export function spinner() {
|
||
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
|
||
}
|