/** * Shared UI utility functions * Used by both app.js and templateDeploy.js to avoid code duplication * * Feedback policy: * - Bottom job drawer (#job-drawer) is the primary surface for actions. * - Top-center toasts are OFF by default (never double-notify with the tray). * - Same message within a short window is deduped across toast / tray / job. */ import notificationManager from './notifications.js'; /** @type {Map} */ const recentFeedback = new Map() const FEEDBACK_DEDUPE_MS = 10_000 /** * Normalize message for dedupe keys. * @param {string} type * @param {string} message */ function feedbackKey(type, message) { return `${type}|${String(message || '') .replace(/\s+/g, ' ') .trim() .slice(0, 160) .toLowerCase()}` } /** * Register that feedback was already shown (job tray, etc.) so showAlert skips it. * @param {string} type * @param {string} message */ export function markFeedbackShown(type, message) { const key = feedbackKey(type || 'info', message) recentFeedback.set(key, Date.now()) if (recentFeedback.size > 100) { const now = Date.now() for (const [k, t] of recentFeedback) { if (now - t > FEEDBACK_DEDUPE_MS * 2) recentFeedback.delete(k) } } } /** * @param {string} type * @param {string} message * @returns {boolean} */ export function wasFeedbackRecentlyShown(type, message) { const key = feedbackKey(type || 'info', message) const last = recentFeedback.get(key) || 0 return Date.now() - last < FEEDBACK_DEDUPE_MS } /** * True when bottom job drawer is visible or an activity job is in flight. */ export function isJobTrayActive() { if (typeof document === 'undefined') return false const drawer = document.getElementById('job-drawer') if (drawer?.classList.contains('job-drawer--visible')) return true if (typeof window !== 'undefined' && window.peardockOps?.getActivityJobId?.()) return true return false } /** * Close all open Bootstrap modals and clean up any lingering backdrops */ export function closeAllModals() { const modals = document.querySelectorAll('.modal.show, .modal[style*="display"]'); modals.forEach((modal) => { const modalInstance = bootstrap.Modal.getInstance(modal); if (modalInstance) { modalInstance.hide(); } else { const newInstance = new bootstrap.Modal(modal); newInstance.hide(); } modal.classList.remove('show'); modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); modal.removeAttribute('aria-modal'); }); document.querySelectorAll('.modal-backdrop').forEach((backdrop) => { backdrop.remove(); }); document.body.classList.remove('modal-open'); document.body.style.paddingRight = ''; document.body.style.overflow = ''; const terminalModal = document.getElementById('terminal-modal'); if (terminalModal && terminalModal.style.display !== 'none') { terminalModal.style.display = 'none'; } } /** * Activity / progress indicator — routes to the job/event tray (no full-screen spinner). * @param {string} message */ export function showStatusIndicator(message = 'Processing...') { removeLegacyStatusOverlay(); if (typeof window !== 'undefined' && window.peardockOps?.showActivity) { window.peardockOps.showActivity(message); markFeedbackShown('info', message); return; } try { notificationManager.add('info', message, { autoDismiss: false, key: 'status-activity' }); markFeedbackShown('info', message); } catch { // ignore } } /** * @param {string} message */ export function updateStatusIndicator(message) { if (typeof window !== 'undefined' && window.peardockOps?.updateActivity) { window.peardockOps.updateActivity(message); markFeedbackShown('info', message); return; } try { notificationManager.add('info', message, { autoDismiss: false, key: 'status-activity' }); markFeedbackShown('info', message); } catch { // ignore } } /** @type {{ ok: boolean, timer: ReturnType|null }|null} */ let pendingActivityFinish = null function clearPendingActivityFinish() { if (pendingActivityFinish?.timer) clearTimeout(pendingActivityFinish.timer) pendingActivityFinish = null } /** * If hideStatusIndicator() just ran without a message, the next showAlert in * the same turn supplies the final line to the bottom tray (one notification). * @param {string} type * @param {string} text * @returns {boolean} */ function claimPendingActivity(type, text) { if (!pendingActivityFinish) return false if (typeof window === 'undefined' || !window.peardockOps?.completeActivity) { clearPendingActivityFinish() return false } if (pendingActivityFinish.timer) clearTimeout(pendingActivityFinish.timer) pendingActivityFinish = null const ok = type === 'success' || type === 'info' window.peardockOps.completeActivity(ok, text) markFeedbackShown(type, text) clearTopToasts() return true } /** * Finish activity in the bottom tray. Prefer passing the final user message so * callers do not also need showAlert for the same outcome. * * Bare hideStatusIndicator() defers one tick so a following showAlert can * complete the same tray entry (avoids job + toast/tray double fire). * * @param {boolean} [ok=true] * @param {string} [message] */ export function hideStatusIndicator(ok = true, message) { const success = ok !== false removeLegacyStatusOverlay() if (typeof window === 'undefined' || !window.peardockOps?.completeActivity) { return } if (message != null && String(message).trim()) { clearPendingActivityFinish() window.peardockOps.completeActivity(success, String(message).trim()) markFeedbackShown(success ? 'success' : 'danger', message) return } // Defer bare complete so hideStatusIndicator(); showAlert(...) shares one tray slot clearPendingActivityFinish() pendingActivityFinish = { ok: success, timer: null } pendingActivityFinish.timer = setTimeout(() => { if (!pendingActivityFinish) return const p = pendingActivityFinish pendingActivityFinish = null window.peardockOps?.completeActivity?.(p.ok) }, 0) } function removeLegacyStatusOverlay() { const remove = () => { document.getElementById('status-indicator')?.remove(); }; if (typeof document === 'undefined') return; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', remove, { once: true }); } else { remove(); } } /** * Ensure toast host exists (top-center stack) — only used when toast: true is forced. * @returns {HTMLElement|null} */ function ensureToastStack() { if (typeof document === 'undefined') return null; let stack = document.getElementById('toast-stack'); if (stack) return stack; const legacy = document.getElementById('alert-container'); if (legacy) { legacy.id = 'toast-stack'; legacy.className = 'toast-stack'; legacy.removeAttribute('style'); legacy.setAttribute('aria-live', 'polite'); legacy.setAttribute('aria-atomic', 'false'); return legacy; } stack = document.createElement('div'); stack.id = 'toast-stack'; stack.className = 'toast-stack'; stack.setAttribute('aria-live', 'polite'); stack.setAttribute('aria-atomic', 'false'); document.body.appendChild(stack); return stack; } /** * Clear any leftover top-center toasts (e.g. after routing to job tray). */ export function clearTopToasts() { if (typeof document === 'undefined') return; const stack = document.getElementById('toast-stack'); if (stack) stack.innerHTML = ''; } /** * Try to finish / update the bottom job tray instead of a separate notification. * @param {string} type * @param {string} text * @returns {boolean} true if handled by job tray */ function routeToJobTrayIfActive(type, text) { if (typeof window === 'undefined' || !window.peardockOps) return false; const ops = window.peardockOps; const hasActivity = typeof ops.getActivityJobId === 'function' && ops.getActivityJobId(); const drawerActive = typeof document !== 'undefined' && document.getElementById('job-drawer')?.classList.contains('job-drawer--visible'); if (!hasActivity && !drawerActive) return false; // Active lightweight activity → complete with this message (single surface) if (hasActivity && typeof ops.completeActivity === 'function') { const ok = type === 'success' || type === 'info'; ops.completeActivity(ok, text); markFeedbackShown(type, text); clearTopToasts(); return true; } // Multi-step job already owns the drawer (deploy, etc.) — do not double-notify if (drawerActive) { markFeedbackShown(type, text); clearTopToasts(); return true; } return false; } /** * User feedback. Bottom job tray wins; top-center toasts are opt-in only. * * @param {string} type - success | danger | warning | info * @param {string} message * @param {{ * autoDismiss?: boolean, * duration?: number, * toast?: boolean, * tray?: boolean, * forceToast?: boolean, * job?: boolean, * key?: string, * }} [options] * toast / forceToast: show top-center toast (default false — avoid double UI) * tray: write notification history bell (default true when not handled by job) * job: allow routing into active job tray (default true) * badge: force (true) or suppress (false) bell badge; default danger/warning only */ export function showAlert(type, message, options = {}) { const text = String(message ?? ''); if (!text) return; const tone = ['success', 'danger', 'warning', 'info'].includes(type) ? type : 'info'; // Same message already shown (job tray / prior alert) — never spam a second channel if (options.force !== true && wasFeedbackRecentlyShown(tone, text)) { return; } // hideStatusIndicator(); showAlert(...) → one bottom-tray completion if (options.job !== false && claimPendingActivity(tone, text)) { return; } // Prefer bottom action tray when it is already handling this operation if (options.job !== false && routeToJobTrayIfActive(tone, text)) { return; } // Top-center toast: OFF unless requested or Settings → forceToast let settingsForceToast = false try { if (typeof window !== 'undefined' && window.__peardockSettings?.forceToast) { settingsForceToast = true } else if (typeof localStorage !== 'undefined') { const raw = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}') settingsForceToast = Boolean(raw.forceToast) } } catch { // ignore } const wantToast = options.forceToast === true || options.toast === true || (settingsForceToast && options.toast !== false && options.forceToast !== false) // Bell history: keep for non-job feedback unless caller disables const wantTray = options.tray !== false; markFeedbackShown(tone, text); if (wantTray) { try { notificationManager.add(tone, text, { autoDismiss: false, ...options, }); } catch { // ignore } } if (!wantToast) return; const stack = ensureToastStack(); if (!stack) return; const { autoDismiss = true, duration = 4500, } = options; const iconMap = { success: 'fa-circle-check', danger: 'fa-circle-xmark', warning: 'fa-triangle-exclamation', info: 'fa-circle-info', }; const el = document.createElement('div'); el.className = `pd-toast pd-toast--${tone}`; el.setAttribute('role', 'status'); el.innerHTML = `
${escapeToast(text)}
`; let timer = null; const dismiss = () => { if (timer) clearTimeout(timer); el.classList.add('pd-toast--leaving'); setTimeout(() => el.remove(), 220); }; el.querySelector('.pd-toast-close')?.addEventListener('click', dismiss); el.addEventListener('mouseenter', () => { if (timer) clearTimeout(timer); }); el.addEventListener('mouseleave', () => { if (autoDismiss !== false) { timer = setTimeout(dismiss, 2000); } }); stack.prepend(el); while (stack.children.length > 5) { stack.lastElementChild?.remove(); } if (autoDismiss !== false) { timer = setTimeout(dismiss, duration); } } function escapeToast(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** * Job-tray spinner (same markup as client/jobs.js active step). * Use for pending / loading UI instead of em-dash "—" placeholders. */ export const JOB_SPINNER_HTML = ''; /** * @param {string} [label] * @param {{ center?: boolean, className?: string }} [opts] * @returns {string} */ export function jobSpinnerHtml(label = 'Loading…', opts = {}) { const cls = [ 'pd-job-loading', 'd-inline-flex', 'align-items-center', 'gap-2', opts.center ? 'justify-content-center' : '', opts.className || '', ] .filter(Boolean) .join(' '); if (!label) { return `${JOB_SPINNER_HTML}`; } const safe = String(label) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); return `${JOB_SPINNER_HTML}${safe}`; } /** * Compact spinner for table cells / metric chips (no label). * @returns {string} */ export function jobSpinnerPending() { return JOB_SPINNER_HTML; } /** * Loading row for table bodies. * @param {number} colspan * @param {string} [label] * @returns {string} */ export function jobSpinnerLoadingRow(colspan, label = 'Loading…') { return `${jobSpinnerHtml(label)}`; } /** * Centered block for list containers / empty panes. * @param {string} [label] * @returns {string} */ export function jobSpinnerLoadingBlock(label = 'Loading…') { return `
${jobSpinnerHtml(label)}
`; } // Bridge for jobs.js (avoids circular import) — mark feedback when jobs complete if (typeof globalThis !== 'undefined') { globalThis.__peardockMarkFeedback = markFeedbackShown globalThis.__peardockJobSpinnerHtml = jobSpinnerHtml globalThis.__peardockJobSpinnerPending = jobSpinnerPending }