Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 065b557860 | |||
| a8dc89e23d | |||
| ead5df1574 | |||
| 3ced069134 |
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.39.0
|
||||
appVersion: "v0.39.0"
|
||||
version: 0.41.0
|
||||
appVersion: "v0.41.0"
|
||||
|
||||
+118
-35
@@ -10,11 +10,11 @@
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eff1f4;
|
||||
--surface-hover: #f7f8fa;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cfd3da;
|
||||
--border: #d9dde4;
|
||||
--border-strong: #c5cad3;
|
||||
--text: #16181d;
|
||||
--muted: #5b626e;
|
||||
--faint: #8a909b;
|
||||
--faint: #676d79;
|
||||
|
||||
--accent: #2f5bd3;
|
||||
--accent-text: #ffffff;
|
||||
@@ -72,17 +72,21 @@
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||
a declaration list between a media query and an attribute selector. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a20;
|
||||
--surface-2: #1f232b;
|
||||
--surface-hover: #1c2027;
|
||||
--border: #2a2f38;
|
||||
--border-strong: #394050;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #737a87;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
@@ -108,6 +112,42 @@
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
--accent-soft: #1d2640;
|
||||
|
||||
--crit: #ff6b61;
|
||||
--crit-soft: #3a1c1b;
|
||||
--warn: #f0b140;
|
||||
--warn-soft: #362a14;
|
||||
--info: #74a3ff;
|
||||
--info-soft: #1a2640;
|
||||
--ok: #4cc488;
|
||||
--ok-soft: #15301f;
|
||||
--snooze: #a89bff;
|
||||
--snooze-soft: #262245;
|
||||
|
||||
--teal: #4fc2d4;
|
||||
--teal-soft: #0f2e33;
|
||||
--pink: #f07fb8;
|
||||
--pink-soft: #3a1c2d;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||
.open-pill.all-clear::before { content: none; }
|
||||
.open-pill .icon { width: 14px; height: 14px; }
|
||||
|
||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||
left sidebar from 900px, where the desktop block below redeclares display
|
||||
@@ -290,13 +334,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
|
||||
.nav-team-selector,
|
||||
.team-selector-mobile {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
background: var(--surface); color: var(--text);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
padding: 4px 12px; max-width: 100%;
|
||||
}
|
||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||
|
||||
@@ -340,7 +384,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--surface); color: var(--muted);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.theme-picker { padding: 0 0 8px; }
|
||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||
box stays flush with its real right edge regardless of scroll position,
|
||||
@@ -650,7 +695,6 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
|
||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
||||
.avatar {
|
||||
flex: none; display: grid; place-items: center;
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
@@ -658,8 +702,6 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||
}
|
||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.now-name { font-size: 20px; font-weight: 750; }
|
||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||
is easier to spot there than this plain accent-coloured text. */
|
||||
@@ -673,25 +715,49 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
caught by the unrelated .label > span styling meant for label chips. */
|
||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||
.days { list-style: none; margin: 0 auto; padding: 0; }
|
||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
||||
.day + .day { border-top: 1px solid var(--border); }
|
||||
.day-name { font-weight: 650; }
|
||||
.day-date { color: var(--faint); font-size: 13px; }
|
||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
||||
/* A run of several days held by the same person (or left empty), replacing
|
||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
||||
.day.range { grid-template-columns: 1fr auto; }
|
||||
.day-range { font-weight: 650; }
|
||||
.day.today { background: var(--accent-soft); }
|
||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
||||
.day.past { opacity: 0.6; }
|
||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
||||
read fine layered (today's own row is almost always one of yours too). */
|
||||
.day.mine { background: var(--accent-soft); }
|
||||
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||
.oncall-grid { display: grid; gap: 0; }
|
||||
.oncall-main, .oncall-side { min-width: 0; }
|
||||
.hero-list { display: grid; gap: 12px; }
|
||||
|
||||
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||
|
||||
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||
crowding the date between them. */
|
||||
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||
.strip-day {
|
||||
position: relative; min-width: 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||
}
|
||||
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||
.strip-date { font-size: 15px; font-weight: 650; }
|
||||
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||
.strip-day.past { opacity: 0.6; }
|
||||
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||
reads as a marker and not as a pressed button. The accent stays for things
|
||||
you can act on. */
|
||||
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||
.strip-day.today::before {
|
||||
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||
border-radius: 0 0 3px 3px; background: var(--text);
|
||||
}
|
||||
.strip-day.today .strip-name { color: var(--text); }
|
||||
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||
@@ -845,6 +911,11 @@ kbd {
|
||||
.status-table th, .status-table td { white-space: nowrap; }
|
||||
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||
.status-table .source-row td { border-bottom-style: dashed; }
|
||||
.status-table tr.clickable { cursor: pointer; }
|
||||
.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); }
|
||||
.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; }
|
||||
.switch-facts dt { opacity: .7; }
|
||||
.switch-facts dd { margin: 0; }
|
||||
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
|
||||
@@ -1033,6 +1104,11 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.subnav::-webkit-scrollbar { display: none; }
|
||||
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||
the desktop width fits every entry, so it is phone-only. */
|
||||
@media (max-width: 899px) {
|
||||
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||
}
|
||||
.subnav-link {
|
||||
flex: none;
|
||||
padding: 8px 12px; margin-bottom: -1px;
|
||||
@@ -1092,3 +1168,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
@media (min-width: 900px) {
|
||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||
@media (min-width: 900px) {
|
||||
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -28,6 +28,9 @@ function render() {
|
||||
|
||||
...passwordSection(user, hasPassword),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||
themePicker(),
|
||||
|
||||
h('div', { class: 'only-desktop' },
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||
h('div', { class: 'card' }, shortcuts())),
|
||||
@@ -39,6 +42,23 @@ function render() {
|
||||
);
|
||||
}
|
||||
|
||||
// System / Light / Dark. Per browser, not per account: it lives in
|
||||
// localStorage (see js/theme.js), the same place the selected team does.
|
||||
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||
|
||||
function themePicker() {
|
||||
const theme = window.terdutTheme;
|
||||
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||
class: 'chip', type: 'button', role: 'radio', text,
|
||||
onclick: () => { theme.set(value); sync(); },
|
||||
}));
|
||||
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||
sync();
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||
}
|
||||
|
||||
// Where this user's pages go. The onboarding checklist's first step sends
|
||||
// people here for it, and until now there was nothing here to send them to:
|
||||
// the topic could only be set with curl or by an administrator.
|
||||
|
||||
@@ -102,13 +102,18 @@ function section() {
|
||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
|
||||
@@ -150,6 +150,8 @@ export const deleteIntegration = (id, integrationID) =>
|
||||
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
||||
export const createDeadmanSwitch = (id, body) =>
|
||||
call('POST', `/teams/${id}/deadman/switches`, { body });
|
||||
export const updateDeadmanSwitch = (id, switchID, body) =>
|
||||
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
|
||||
export const deleteDeadmanSwitch = (id, switchID) =>
|
||||
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
||||
|
||||
|
||||
@@ -202,7 +202,8 @@ function updateBadges() {
|
||||
|
||||
const pill = $('open-pill');
|
||||
pill.hidden = false;
|
||||
pill.textContent = open ? `${open} open` : 'All clear';
|
||||
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||
pill.classList.toggle('all-clear', open === 0);
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||
// Read-only for now; the TUI edits the schedule.
|
||||
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||
// button here.
|
||||
//
|
||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||
// team has nothing to choose between. "On call now" is the exception and shows
|
||||
@@ -59,11 +60,17 @@ function render() {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
const team = currentTeam();
|
||||
clear(view(),
|
||||
// The top bar carries the title on a phone; the desktop has none.
|
||||
h('div', { class: 'only-desktop oncall-title' },
|
||||
h('h1', { text: 'On-call' }),
|
||||
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
nowCard(),
|
||||
weekCard(),
|
||||
myShifts(),
|
||||
h('div', { class: 'oncall-grid' },
|
||||
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||
h('div', { class: 'oncall-side' }, myShifts()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,75 +85,73 @@ function nowCard() {
|
||||
const entries = data.now || [];
|
||||
const showTeam = entries.length > 1;
|
||||
if (entries.length === 0) {
|
||||
return h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar none', text: '–' }),
|
||||
h('div', {},
|
||||
h('div', { class: 'now-label', text: 'On call now' }),
|
||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
||||
),
|
||||
);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||
}
|
||||
return h('div', {}, ...entries.map((n) =>
|
||||
h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
||||
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||
const until = shiftEnd(n);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', {
|
||||
class: 'now-label',
|
||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
||||
}),
|
||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
||||
),
|
||||
)));
|
||||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||
until && h('div', { class: 'hero-until', text: until }))));
|
||||
}));
|
||||
}
|
||||
|
||||
// Groups the week's 7 days into runs held by the same person (or the same
|
||||
// empty slot) — the week's own version of the consecutive-day grouping
|
||||
// myShifts does for a single person's own dates, below. Seven identical rows
|
||||
// for one person all week collapses to the one bar this way.
|
||||
function weekRuns(byDate) {
|
||||
const runs = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = isoDate(addDays(weekStart, i));
|
||||
const e = byDate.get(date) || null;
|
||||
const uid = e ? e.user_id : null;
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.uid === uid) last.to = date;
|
||||
else runs.push({ uid, entry: e, from: date, to: date });
|
||||
}
|
||||
return runs;
|
||||
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||
// end is read off the rota (the run of consecutive days from today held by the
|
||||
// same person), and that is only loaded for one team. The same midnight
|
||||
// boundary the "Current shift" row below uses.
|
||||
function shiftEnd(n) {
|
||||
const team = currentTeam();
|
||||
if (!team || n.team_id !== team.id) return null;
|
||||
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||
let day = new Date();
|
||||
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||
// Still holding the last day loaded: the shift may run on past it.
|
||||
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||
const end = addDays(parse(isoDate(day)), 1);
|
||||
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||
}
|
||||
|
||||
// A stable colour per person from the six-colour rcN palette. The week page
|
||||
// has no member list to take an index from (team.js does), so the id decides.
|
||||
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||
|
||||
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||
// A hand-over in the middle of the week is visible without reading anything.
|
||||
function weekCard() {
|
||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||
const today = isoDate(new Date());
|
||||
const mine = myID();
|
||||
const days = weekRuns(byDate).map((r) => {
|
||||
const single = r.from === r.to;
|
||||
const cls = [
|
||||
'day',
|
||||
single ? '' : 'range',
|
||||
r.from <= today && today <= r.to ? 'today' : '',
|
||||
r.to < today ? 'past' : '',
|
||||
r.uid === mine ? 'mine' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const label = single
|
||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
||||
: [h('span', {
|
||||
class: 'day-range',
|
||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
||||
})];
|
||||
return h('li', { class: cls },
|
||||
...label,
|
||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
||||
);
|
||||
});
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
const date = isoDate(d);
|
||||
const e = byDate.get(date) || null;
|
||||
if (e) seen.set(e.user_id, e.username);
|
||||
cells.push(h('li', {
|
||||
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||
'aria-current': date === today ? 'date' : null,
|
||||
},
|
||||
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||
}
|
||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
return h('div', { class: 'card week-card' },
|
||||
h('div', { class: 'week-head' },
|
||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
icon('chevronLeft')),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost week-label',
|
||||
@@ -155,12 +160,13 @@ function weekCard() {
|
||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')),
|
||||
),
|
||||
),
|
||||
h('ul', { class: 'card days' }, days),
|
||||
];
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')))),
|
||||
h('ul', { class: 'strip' }, cells),
|
||||
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||
[...seen].map(([id, name]) => h('span', {},
|
||||
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||
id === mine ? `${name} (you)` : name))));
|
||||
}
|
||||
|
||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||
|
||||
+166
-76
@@ -168,13 +168,18 @@ function section() {
|
||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
}
|
||||
|
||||
// Names which team's settings the six sections below belong to. It used to be
|
||||
@@ -735,8 +740,7 @@ function minutesInput(seconds, onChange) {
|
||||
// --- integrations ----------------------------------------------------------
|
||||
|
||||
function integrationsCard() {
|
||||
const rows = (data.integrations || []).map((i) =>
|
||||
h('tr', {},
|
||||
const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
|
||||
h('td', {}, sourceBadge(i.status)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: i.name }),
|
||||
@@ -748,23 +752,7 @@ function integrationsCard() {
|
||||
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
||||
text: String(i.alerts_24h ?? 0) }),
|
||||
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Revoke ${i.name}?`,
|
||||
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteIntegration(teamID, i.id));
|
||||
},
|
||||
}))),
|
||||
));
|
||||
), () => openSourceDetail(i)));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
@@ -780,9 +768,9 @@ function integrationsCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Source' }),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
|
||||
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
|
||||
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))),
|
||||
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||||
);
|
||||
@@ -844,6 +832,34 @@ function openNameSheet({ title, submit, value, run }) {
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
|
||||
}
|
||||
|
||||
function openSourceDetail(i) {
|
||||
openDetailSheet(i.name, [
|
||||
sheetFact('Status', sourceBadge(i.status)),
|
||||
sheetFact('Kind', i.kind),
|
||||
sheetFact('Last webhook', timeCell(i.last_used_at)),
|
||||
sheetFact('Last alert', timeCell(i.last_alert_at)),
|
||||
sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)),
|
||||
sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Rename',
|
||||
onclick: () => { closeSheet(); openRenameSource(i); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Revoke',
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Revoke ${i.name}?`,
|
||||
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteIntegration(teamID, i.id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function openNewSource() {
|
||||
openNameSheet({
|
||||
title: 'New source', submit: 'Add source', value: '',
|
||||
@@ -899,6 +915,34 @@ const triggeredCell = (iso, incidentID) => {
|
||||
: h('span', { title: when(iso), text: ago(iso) });
|
||||
};
|
||||
|
||||
// A list row that opens its details: the list is for finding the thing, the
|
||||
// sheet it opens is where the buttons are. Links inside the row keep working.
|
||||
function clickableRow(tr, open) {
|
||||
tr.classList.add('clickable');
|
||||
tr.tabIndex = 0;
|
||||
tr.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('a')) open();
|
||||
});
|
||||
tr.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && e.target === tr) open();
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
// Label/value rows for a details sheet, and the sheet itself: facts above, then
|
||||
// Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out.
|
||||
const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)];
|
||||
|
||||
function openDetailSheet(title, facts, ...actions) {
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: title }),
|
||||
h('dl', { class: 'switch-facts' }, facts),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }),
|
||||
...actions),
|
||||
]);
|
||||
}
|
||||
|
||||
function switchRows(sw) {
|
||||
const main = h('tr', {},
|
||||
h('td', {}, switchBadge(sw.status)),
|
||||
@@ -908,19 +952,8 @@ function switchRows(sw) {
|
||||
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
|
||||
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||||
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
|
||||
h('td', {}, isOwner() && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${sw.name}?`,
|
||||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||||
},
|
||||
})),
|
||||
);
|
||||
clickableRow(main, () => openSwitchDetail(sw));
|
||||
|
||||
// One heartbeat is the switch's own times; several are worth telling apart,
|
||||
// since a live cluster must not hide a dead one.
|
||||
@@ -935,7 +968,7 @@ function switchRows(sw) {
|
||||
&& h('code', { class: 'small', text: src.fingerprint })),
|
||||
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
|
||||
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
|
||||
h('td'), h('td')))
|
||||
h('td')))
|
||||
: [];
|
||||
return [main, ...sources];
|
||||
}
|
||||
@@ -947,7 +980,7 @@ function deadmanCard() {
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Dead man’s switches' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
|
||||
class: 'btn', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||||
@@ -956,9 +989,9 @@ function deadmanCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
|
||||
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
|
||||
h('th', { text: 'Silent after' }), h('th'))),
|
||||
h('th', { text: 'Silent after' }))),
|
||||
h('tbody', {}, switches.flatMap(switchRows))))
|
||||
: h('p', { class: 'muted', text: 'Nothing watched.' }),
|
||||
);
|
||||
@@ -966,16 +999,22 @@ function deadmanCard() {
|
||||
|
||||
// The form lives in the sheet, not on the page: most visits are to look at the
|
||||
// list, and a form that is always open is the page this replaced.
|
||||
function openNewSwitch() {
|
||||
function openSwitchForm(existing) {
|
||||
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
|
||||
const matcher = h('input', {
|
||||
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
||||
});
|
||||
const timeout = h('input', {
|
||||
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
|
||||
type: 'number', min: '1', step: 'any', value: '15', class: 'setting-value', required: true,
|
||||
});
|
||||
const severity = h('select', {},
|
||||
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
|
||||
if (existing) {
|
||||
name.value = existing.name;
|
||||
matcher.value = existing.matcher;
|
||||
timeout.value = String(existing.timeout_seconds / 60);
|
||||
severity.value = existing.severity;
|
||||
}
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
@@ -990,17 +1029,20 @@ function openNewSwitch() {
|
||||
problem,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: existing ? 'Save' : 'Add switch' })));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.createDeadmanSwitch(teamID, {
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
matcher: matcher.value.trim(),
|
||||
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
||||
severity: severity.value,
|
||||
});
|
||||
};
|
||||
await (existing
|
||||
? api.updateDeadmanSwitch(teamID, existing.id, body)
|
||||
: api.createDeadmanSwitch(teamID, body));
|
||||
} catch (err) {
|
||||
problem.textContent = err.message;
|
||||
problem.hidden = false;
|
||||
@@ -1010,7 +1052,47 @@ function openNewSwitch() {
|
||||
refresh();
|
||||
});
|
||||
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]);
|
||||
}
|
||||
|
||||
// What the list row has no room for, and where Edit and Delete live.
|
||||
function openSwitchDetail(sw) {
|
||||
const sources = sw.sources.length > 1
|
||||
? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' },
|
||||
switchBadge(src.status),
|
||||
...Object.entries(src.labels || {})
|
||||
.filter(([k]) => k !== 'alertname')
|
||||
.map(([k, v]) => labelChip(k, v)),
|
||||
h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))]
|
||||
: [];
|
||||
|
||||
openDetailSheet(sw.name, [
|
||||
sheetFact('Status', switchBadge(sw.status)),
|
||||
sheetFact('Matcher', h('code', { text: sw.matcher })),
|
||||
sheetFact('Silent after', duration(sw.timeout_seconds * 1000)),
|
||||
sheetFact('Severity', sw.severity),
|
||||
sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)),
|
||||
sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||||
sources,
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit',
|
||||
onclick: () => { closeSheet(); openSwitchForm(sw); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Delete',
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Remove ${sw.name}?`,
|
||||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
@@ -1114,9 +1196,7 @@ function membersCard() {
|
||||
const members = data.members || [];
|
||||
const owners = members.filter((m) => m.role === 'owner').length;
|
||||
|
||||
const rows = members.map((m) => {
|
||||
const lastOwner = m.role === 'owner' && owners === 1;
|
||||
return h('tr', {},
|
||||
const rows = members.map((m) => clickableRow(h('tr', {},
|
||||
h('td', {}, memberBadge(m)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: m.username }),
|
||||
@@ -1127,32 +1207,7 @@ function membersCard() {
|
||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||
h('td', { class: 'muted small' },
|
||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Edit',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => openEditMember(m),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner || m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED
|
||||
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
text: 'They lose access to this team. Rota days already assigned to them are not '
|
||||
+ 'changed, so reassign those from the Rota tab.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.removeTeamMember(teamID, m.user_id));
|
||||
},
|
||||
}))),
|
||||
);
|
||||
});
|
||||
), () => openMemberDetail(m, owners)));
|
||||
|
||||
return [oidcGroupsCard(), h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
@@ -1167,14 +1222,49 @@ function membersCard() {
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
|
||||
h('th'))),
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
||||
)];
|
||||
}
|
||||
|
||||
function openMemberDetail(m, owners) {
|
||||
const lastOwner = m.role === 'owner' && owners === 1;
|
||||
openDetailSheet(m.username, [
|
||||
sheetFact('Status', memberBadge(m)),
|
||||
sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())),
|
||||
sheetFact('Rota', shiftCell(m)),
|
||||
sheetFact('Last active', timeCell(m.last_active_at)),
|
||||
sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })),
|
||||
],
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => { closeSheet(); openEditMember(m); },
|
||||
}),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner || m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED
|
||||
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
closeSheet();
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
text: 'They lose access to this team. Rota days already assigned to them are not '
|
||||
+ 'changed, so reassign those from the Rota tab.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.removeTeamMember(teamID, m.user_id));
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// One sheet for both jobs a member's row has: who, and as what. Adding is
|
||||
// choosing a person and a role; editing is the same with the person fixed. The
|
||||
// API is one call either way — POST upserts the role.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Theme override: System (no attribute, the OS decides), Light or Dark.
|
||||
//
|
||||
// A classic script loaded from <head>, not a module, so the saved choice is on
|
||||
// <html> before the first paint and a Dark user on a light OS never sees a
|
||||
// flash. The CSP allows no inline script, hence a file of its own. Account
|
||||
// (account.js) reads and writes the choice through window.terdutTheme.
|
||||
(function () {
|
||||
var KEY = 'terdut.theme';
|
||||
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
|
||||
|
||||
function get() {
|
||||
try {
|
||||
var v = localStorage.getItem(KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'system';
|
||||
} catch (e) {
|
||||
return 'system'; // storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// The browser chrome colour follows the choice too; the two <meta>s are
|
||||
// media-keyed to the OS, so a forced theme sets both to the same colour.
|
||||
function apply(theme) {
|
||||
var root = document.documentElement;
|
||||
if (theme === 'system') root.removeAttribute('data-theme');
|
||||
else root.setAttribute('data-theme', theme);
|
||||
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
for (var i = 0; i < metas.length; i++) {
|
||||
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
|
||||
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
|
||||
}
|
||||
}
|
||||
|
||||
function set(theme) {
|
||||
try {
|
||||
if (theme === 'system') localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, theme);
|
||||
} catch (e) {
|
||||
/* storage unavailable: applies for this page view only */
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
window.terdutTheme = { get: get, set: set };
|
||||
apply(get());
|
||||
})();
|
||||
Reference in New Issue
Block a user