dashboard: theme all remaining dialogs (replace native confirm/alert/prompt)
Follow-up to the themed-modal component: route every remaining native browser dialog through modal.js so nothing falls back to the OS chrome. - modal.js: add themedPrompt (input dialog) + themedToast (non-blocking transient notification, info/error/ok) alongside openDialog/themedConfirm. - Migrate call sites: bindAsyncForms confirm/prompt/alerts (common.js), the answer-validation alert (call.js), the M0V3 reparent confirm + action toasts (tabs.js), and the schedule form/cancel/fire confirms + validation and error alerts (schedules.js). - UX: blocking modal for confirms/prompts; non-blocking toast for transient errors + validation. Destructive confirms keep the danger styling. - CSS for the toast stack + prompt input. common.js <-> modal.js is a safe deferred import cycle (usage is call-time only); esbuild bundles it clean.
This commit is contained in:
parent
ff3317eb20
commit
7c2de690ec
6 changed files with 141 additions and 32 deletions
|
|
@ -15,6 +15,7 @@
|
|||
// registers once via `initCall`.
|
||||
|
||||
import { $, el, form, Panel, appendLinkified } from './common.js';
|
||||
import { themedToast } from './modal.js';
|
||||
import { fmtAgo } from './util.js';
|
||||
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
|
||||
|
||||
|
|
@ -575,7 +576,7 @@ function buildQuestionLi(q) {
|
|||
const existing = f.querySelector('input[name="answer"]');
|
||||
if (existing) existing.remove();
|
||||
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
|
||||
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||||
if (!merged) { ev.preventDefault(); themedToast('pick an option or type an answer', { type: 'error' }); }
|
||||
}, true);
|
||||
if (hasOptions) f.append(optionGroup);
|
||||
const buttons = el('div', { class: 'q-buttons' });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
// infrastructure for the side panel.
|
||||
|
||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
// Themed dialog/toast helpers (modal.js imports `el` back from here — a safe
|
||||
// deferred cycle: neither side uses the other at module-init time, only inside
|
||||
// runtime handlers).
|
||||
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
|
@ -58,9 +62,9 @@ export function bindAsyncForms(onSuccess) {
|
|||
const f = e.target;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||
e.preventDefault();
|
||||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||||
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
|
||||
if (f.dataset.prompt) {
|
||||
const ans = prompt(f.dataset.prompt, '');
|
||||
const ans = await themedPrompt({ message: f.dataset.prompt });
|
||||
if (ans === null) return; // operator hit Cancel
|
||||
// Drop into a hidden input named after `data-prompt-field` (or
|
||||
// 'note' by default) so the value rides along on the POST.
|
||||
|
|
@ -88,7 +92,7 @@ export function bindAsyncForms(onSuccess) {
|
|||
|| (resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||
return;
|
||||
}
|
||||
|
|
@ -101,7 +105,7 @@ export function bindAsyncForms(onSuccess) {
|
|||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('action failed: ' + err);
|
||||
themedToast('action failed: ' + err, { type: 'error' });
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -467,6 +467,52 @@ body.dashboard-shell {
|
|||
.tc-confirm { color: var(--green); }
|
||||
.tc-confirm.tc-danger { color: var(--red); }
|
||||
|
||||
/* themedPrompt input field */
|
||||
.tc-promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.tc-promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
||||
.tc-input {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--crust);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
.tc-input:focus { outline: 1px solid var(--green); }
|
||||
|
||||
/* themedToast — non-blocking transient notifications (errors/info/ok) */
|
||||
.tc-toasts {
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
max-width: min(28em, 92vw);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tc-toast {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-left-width: 3px;
|
||||
box-shadow: 0 4px 20px -6px var(--crust);
|
||||
padding: 0.6em 0.9em;
|
||||
font-size: 0.9em;
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.tc-toast-out { opacity: 0; transform: translateX(0.5em); }
|
||||
.tc-toast-error { border-left-color: var(--red); }
|
||||
.tc-toast-info { border-left-color: var(--purple-dim); }
|
||||
.tc-toast-ok { border-left-color: var(--green); }
|
||||
|
||||
.agent-status {
|
||||
font-size: 0.82em;
|
||||
color: var(--subtext0);
|
||||
|
|
|
|||
|
|
@ -123,3 +123,60 @@ export function themedConfirm(opts = {}) {
|
|||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel })
|
||||
// → Promise<string | null>. Themed replacement for window.prompt(): an input
|
||||
// dialog that resolves to the entered string on confirm, or null on cancel.
|
||||
export function themedPrompt(opts = {}) {
|
||||
const {
|
||||
title = '', message = '', label = '', placeholder = '', value = '',
|
||||
confirmLabel = 'ok', cancelLabel = 'cancel',
|
||||
} = opts;
|
||||
const input = el('input', { type: 'text', class: 'tc-input', placeholder });
|
||||
if (value) input.value = value;
|
||||
const content = el('div', { class: 'tc-promptfield' },
|
||||
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
|
||||
input);
|
||||
const result = openDialog({
|
||||
title,
|
||||
message,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
|
||||
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
|
||||
],
|
||||
}).then((v) => (v === '__ok__' ? input.value : null));
|
||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
||||
setTimeout(() => input.focus(), 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
// themedToast(message, { type, duration }) — non-blocking transient
|
||||
// notification; a lighter alternative to a modal for feedback that needs no
|
||||
// decision (errors, validation, status). Stacks in a fixed top-right
|
||||
// container, auto-dismisses after `duration` ms (errors linger longer), and
|
||||
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
|
||||
export function themedToast(message, opts = {}) {
|
||||
const { type = 'info', duration } = opts;
|
||||
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
||||
let container = document.getElementById('tc-toasts');
|
||||
if (!container) {
|
||||
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
|
||||
document.body.append(container);
|
||||
}
|
||||
const toast = el('div', {
|
||||
class: 'tc-toast tc-toast-' + type,
|
||||
role: type === 'error' ? 'alert' : 'status',
|
||||
}, message);
|
||||
let removed = false;
|
||||
const remove = () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
toast.classList.add('tc-toast-out');
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
};
|
||||
toast.addEventListener('click', remove);
|
||||
container.append(toast);
|
||||
if (ms > 0) setTimeout(remove, ms);
|
||||
return toast;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
// target-chip pickers. Render + format helpers come from util.js.
|
||||
|
||||
import { $, el } from './common.js';
|
||||
import { themedConfirm, themedToast } from './modal.js';
|
||||
import { paintAtomic, fmtAgo, fmtDuration } from './util.js';
|
||||
import { containersState } from './state.js';
|
||||
|
||||
|
|
@ -530,14 +531,14 @@ async function submitNewScheduleInline(tr, submitBtn) {
|
|||
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
|
||||
|
||||
if (!targets.length) {
|
||||
alert('schedule must have at least one target — tick at least one agent column');
|
||||
themedToast('schedule must have at least one target — tick at least one agent column', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!body) { alert('prompt body must be non-empty'); return; }
|
||||
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
|
||||
if (!body) { themedToast('prompt body must be non-empty', { type: 'error' }); return; }
|
||||
if (!firstFireStr) { themedToast('first fire timestamp is required', { type: 'error' }); return; }
|
||||
const firstFireDate = new Date(firstFireStr);
|
||||
if (Number.isNaN(firstFireDate.getTime())) {
|
||||
alert('first fire is not a valid datetime'); return;
|
||||
themedToast('first fire is not a valid datetime', { type: 'error' }); return;
|
||||
}
|
||||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||||
|
||||
|
|
@ -554,7 +555,7 @@ async function submitNewScheduleInline(tr, submitBtn) {
|
|||
};
|
||||
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
|
||||
if (Number.isNaN(intervalTotal)) {
|
||||
alert('interval fields must be non-negative integers (or blank for one-shot)');
|
||||
themedToast('interval fields must be non-negative integers (or blank for one-shot)', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||||
|
|
@ -574,14 +575,14 @@ async function submitNewScheduleInline(tr, submitBtn) {
|
|||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
// Reset carry so the next render shows an empty row.
|
||||
resetNewScheduleCarry();
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('schedule submit failed: ' + err);
|
||||
themedToast('schedule submit failed: ' + err, { type: 'error' });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalLabel;
|
||||
|
|
@ -899,11 +900,11 @@ async function submitEditSchedule(originalSchedule, form_) {
|
|||
const newDescription = String(fd.get('description') || '').trim();
|
||||
const newNextFireStr = String(fd.get('next_fire') || '');
|
||||
|
||||
if (!newBody) { alert('body must be non-empty'); return; }
|
||||
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
|
||||
if (!newBody) { themedToast('body must be non-empty', { type: 'error' }); return; }
|
||||
if (!newNextFireStr) { themedToast('next-fire timestamp is required', { type: 'error' }); return; }
|
||||
const newNextFireDate = new Date(newNextFireStr);
|
||||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||||
alert('next-fire is not a valid datetime'); return;
|
||||
themedToast('next-fire is not a valid datetime', { type: 'error' }); return;
|
||||
}
|
||||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||||
|
||||
|
|
@ -911,7 +912,7 @@ async function submitEditSchedule(originalSchedule, form_) {
|
|||
// `edit_interval_*` names in this form).
|
||||
const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_');
|
||||
if (Number.isNaN(intervalTotal)) {
|
||||
alert('interval fields must be non-negative integers');
|
||||
themedToast('interval fields must be non-negative integers', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||||
|
|
@ -929,7 +930,7 @@ async function submitEditSchedule(originalSchedule, form_) {
|
|||
.map((t) => t.target),
|
||||
);
|
||||
if (!newTargets.size) {
|
||||
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
|
||||
themedToast('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
|
||||
|
|
@ -971,14 +972,14 @@ async function submitEditSchedule(originalSchedule, form_) {
|
|||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
editingSchedules.delete(s.id);
|
||||
scheduleEditCarry.delete(s.id);
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('edit failed: ' + err);
|
||||
themedToast('edit failed: ' + err, { type: 'error' });
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
|
||||
}
|
||||
|
|
@ -992,7 +993,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
|
|||
: `fire schedule #${id} now to ${targetList}?\n\n`
|
||||
+ 'this is RECURRING — sends an extra pulse out-of-band. '
|
||||
+ 'the regular cadence keeps firing on schedule.';
|
||||
if (!confirm(prompt)) return;
|
||||
if (!(await themedConfirm({ message: prompt, danger: true }))) return;
|
||||
// Capture child nodes so we can restore on error, then replace
|
||||
// with DOM-built content (textContent + element children rather
|
||||
// than innerHTML — the format string only carries server-side
|
||||
|
|
@ -1017,7 +1018,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
|
|||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
restoreBtn();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1041,16 +1042,16 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
|
|||
// refresh wipes the row in place.
|
||||
setTimeout(refreshSchedules, 1500);
|
||||
} catch (err) {
|
||||
alert('fire-now failed: ' + err);
|
||||
themedToast('fire-now failed: ' + err, { type: 'error' });
|
||||
restoreBtn();
|
||||
}
|
||||
}
|
||||
async function cancelScheduleAll(id) {
|
||||
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
|
||||
if (!(await themedConfirm({ message: `cancel schedule #${id}? this stops all future fires for every target.`, danger: true }))) return;
|
||||
await postScheduleCancel(id, null);
|
||||
}
|
||||
async function cancelScheduleTargets(id, targets) {
|
||||
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
|
||||
if (!(await themedConfirm({ message: `cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`, danger: true }))) return;
|
||||
await postScheduleCancel(id, targets);
|
||||
}
|
||||
async function postScheduleCancel(id, targets) {
|
||||
|
|
@ -1063,12 +1064,12 @@ async function postScheduleCancel(id, targets) {
|
|||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('cancel failed: ' + err);
|
||||
themedToast('cancel failed: ' + err, { type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
makePathLink, appendText, appendLinkified,
|
||||
openStream, renderServerWarnings, bindAsyncForms,
|
||||
} from './common.js';
|
||||
import { themedConfirm } from './modal.js';
|
||||
import { themedConfirm, themedToast } from './modal.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import {
|
||||
containersState, syncContainersFromSnapshot,
|
||||
|
|
@ -295,10 +295,10 @@ window.marked = marked;
|
|||
|| (resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
alert('action failed: ' + err);
|
||||
themedToast('action failed: ' + err, { type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1056,7 +1056,7 @@ window.marked = marked;
|
|||
const promptMsg = names.length === 1
|
||||
? `move ${names[0]} → ${newParentLabel}?`
|
||||
: `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
|
||||
if (!confirm(promptMsg)) {
|
||||
if (!(await themedConfirm({ message: promptMsg, danger: true }))) {
|
||||
sel.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
|
@ -1105,7 +1105,7 @@ window.marked = marked;
|
|||
sel.disabled = false;
|
||||
sel.selectedIndex = 0;
|
||||
if (failures.length) {
|
||||
alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
|
||||
themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error' });
|
||||
}
|
||||
});
|
||||
wrap.append(sel);
|
||||
|
|
@ -1216,7 +1216,7 @@ window.marked = marked;
|
|||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
if (failures.length) {
|
||||
alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
|
||||
themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error' });
|
||||
}
|
||||
// Container-lifecycle events (ContainerStateChanged /
|
||||
// ContainerRemoved / RebuildQueueChanged) flow over the existing
|
||||
|
|
|
|||
Loading…
Reference in a new issue