diff --git a/website/css/style.css b/website/css/style.css index bb9ebe9..4e4d9e8 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1900,6 +1900,23 @@ body.modal-open { border: 1px solid var(--border); } +.confirm-modal-message { + margin: 0 0 16px; + line-height: 1.5; +} +.confirm-modal-message p { + margin: 0 0 10px; +} +.confirm-modal-message p:last-child { + margin-bottom: 0; +} +.confirm-modal-choices { + margin-bottom: 16px; +} +.confirm-modal--danger .confirm-modal-dialog { + border-color: color-mix(in srgb, var(--danger-border) 40%, var(--border)); +} + /* Options builder (inside add-question form) */ .options-builder { border-top: 1px solid var(--border); diff --git a/website/js/client-archive.js b/website/js/client-archive.js index 97b90de..35bbd66 100644 --- a/website/js/client-archive.js +++ b/website/js/client-archive.js @@ -1,15 +1,20 @@ import { apiPatch } from './api.js'; import { showToast } from './app.js'; +import { confirmAction } from './confirm-modal.js'; export function isClientArchived(c) { return Number(c.archived) === 1; } export async function confirmAndPatchClientArchive(clientCode, archived) { - const msg = archived - ? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.` - : `Restore client "${clientCode}" to active lists?`; - if (!confirm(msg)) return false; + if (!(await confirmAction({ + title: archived ? 'Archive client' : 'Restore client', + message: archived + ? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.` + : `Restore client "${clientCode}" to active lists?`, + confirmLabel: archived ? 'Archive' : 'Restore', + variant: archived ? 'danger' : 'default', + }))) return false; try { await apiPatch('clients.php', { clientCode, archived }); diff --git a/website/js/confirm-modal.js b/website/js/confirm-modal.js new file mode 100644 index 0000000..cac0ad2 --- /dev/null +++ b/website/js/confirm-modal.js @@ -0,0 +1,148 @@ +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function formatMessageHTML(message) { + return String(message) + .split(/\n\n+/) + .map((block) => `

${esc(block).replace(/\n/g, '
')}

`) + .join(''); +} + +function renderChoicesHTML(choices) { + const name = 'confirm_modal_choice'; + return ` +
+
+ ${choices.map((choice, i) => ` + + `).join('')} +
+
`; +} + +/** @type {((value: boolean | string | null) => void) | null} */ +let resolveConfirm = null; + +function ensureConfirmModal() { + let modal = document.getElementById('confirmModal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'confirmModal'; + modal.className = 'modal-overlay'; + modal.hidden = true; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'confirmModalTitle'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) finishConfirm(null); + }); + modal.querySelector('[data-confirm-cancel]').addEventListener('click', () => finishConfirm(null)); + modal.querySelector('[data-confirm-submit]').addEventListener('click', () => { + const choicesEl = modal.querySelector('#confirmModalChoices'); + const hasChoices = choicesEl.childElementCount > 0; + if (hasChoices) { + const selected = modal.querySelector('input[name="confirm_modal_choice"]:checked'); + finishConfirm(selected ? selected.value : null); + } else { + finishConfirm(true); + } + }); + + document.addEventListener('keydown', (e) => { + const el = document.getElementById('confirmModal'); + if (!el || el.hidden) return; + if (e.key === 'Escape') finishConfirm(null); + }); + + return modal; +} + +function finishConfirm(value) { + const modal = document.getElementById('confirmModal'); + if (!modal || modal.hidden) return; + modal.hidden = true; + document.body.classList.remove('modal-open'); + const resolve = resolveConfirm; + resolveConfirm = null; + if (resolve) resolve(value); +} + +/** + * Show a confirmation dialog. + * + * Without `choices`: resolves to `true` (confirmed) or `false` (cancelled). + * With `choices`: resolves to the selected choice value (string) or `null` (cancelled). + * + * @param {Object} options + * @param {string} options.title + * @param {string} options.message + * @param {string} [options.confirmLabel='Confirm'] + * @param {string} [options.cancelLabel='Cancel'] + * @param {'default'|'danger'} [options.variant='default'] + * @param {{ value: string, label: string, description?: string }[]} [options.choices] + * @returns {Promise} + */ +export function confirmAction(options) { + return new Promise((resolve) => { + if (resolveConfirm) { + resolve(false); + return; + } + + const modal = ensureConfirmModal(); + const hasChoices = Array.isArray(options.choices) && options.choices.length > 0; + const variant = options.variant === 'danger' ? 'danger' : 'default'; + + modal.querySelector('#confirmModalTitle').textContent = options.title || 'Confirm'; + modal.querySelector('#confirmModalMessage').innerHTML = formatMessageHTML(options.message || ''); + + const choicesEl = modal.querySelector('#confirmModalChoices'); + choicesEl.innerHTML = hasChoices ? renderChoicesHTML(options.choices) : ''; + + const cancelBtn = modal.querySelector('[data-confirm-cancel]'); + const submitBtn = modal.querySelector('[data-confirm-submit]'); + cancelBtn.textContent = options.cancelLabel || 'Cancel'; + submitBtn.textContent = options.confirmLabel || (hasChoices ? 'Continue' : 'Confirm'); + submitBtn.className = `btn btn-sm ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`; + + modal.classList.toggle('confirm-modal--danger', variant === 'danger'); + + resolveConfirm = (value) => { + if (hasChoices) { + resolve(value); + } else { + resolve(value === true); + } + }; + + modal.hidden = false; + document.body.classList.add('modal-open'); + (hasChoices + ? modal.querySelector('input[name="confirm_modal_choice"]') + : submitBtn + )?.focus(); + }); +} diff --git a/website/js/pages/assignments.js b/website/js/pages/assignments.js index b7c3a96..9abe17b 100644 --- a/website/js/pages/assignments.js +++ b/website/js/pages/assignments.js @@ -1,5 +1,6 @@ import { apiGet, apiPost } from '../api.js'; import { pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; const CLIENT_PAGE_SIZE = 50; @@ -421,7 +422,11 @@ async function doAssign() { if (!coachID) { showToast('Select a target counselor', 'error'); return; } const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID; - if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return; + if (!(await confirmAction({ + title: 'Assign clients', + message: `Assign ${selected.length} client(s) to ${coachName}?`, + confirmLabel: 'Assign', + }))) return; const btn = document.getElementById('assignBtn'); btn.disabled = true; diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index 32887db..2eb0173 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { isClientArchived, confirmAndPatchClientArchive, @@ -818,7 +819,12 @@ async function setClientArchived(clientCode, archived) { } async function deleteClient(clientCode) { - if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return; + if (!(await confirmAction({ + title: 'Delete client', + message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; try { await apiDelete('clients.php', { clientCode }); clientsList = clientsList.filter(c => c.clientCode !== clientCode); diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index 639a8c0..25a8696 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { navigate } from '../router.js'; const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; @@ -166,11 +167,12 @@ export function devPage() { }); document.getElementById('devRemoveBtn').addEventListener('click', async () => { - if (!confirm( - 'Remove all dev test data?\n\n' - + 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n' - + 'Real accounts are not affected.' - )) { + if (!(await confirmAction({ + title: 'Remove dev test data', + message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.', + confirmLabel: 'Remove', + variant: 'danger', + }))) { return; } const btn = document.getElementById('devRemoveBtn'); @@ -206,11 +208,12 @@ export function devPage() { }); document.getElementById('devWipeBtn').addEventListener('click', async () => { - if (!confirm( - 'Remove ALL clients, completions, counselors, and supervisors?\n\n' - + 'Admin accounts and questionnaires will NOT be deleted.\n' - + 'This cannot be undone.' - )) { + if (!(await confirmAction({ + title: 'Wipe operational data', + message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.', + confirmLabel: 'Remove all', + variant: 'danger', + }))) { return; } const btn = document.getElementById('devWipeBtn'); @@ -533,10 +536,12 @@ function wireSecuritySettings() { revokeBtn?.addEventListener('click', async () => { if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return; - if (!confirm( - 'Revoke every active session?\n\n' - + 'All users (including you) will be signed out on website and mobile.' - )) { + if (!(await confirmAction({ + title: 'Revoke all sessions', + message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.', + confirmLabel: 'Revoke all', + variant: 'danger', + }))) { return; } revokeBtn.disabled = true; diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 3a9b349..cdf349d 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { canEdit, homeNavButton, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { navigate } from '../router.js'; import { SOURCE_LANG, @@ -1668,7 +1669,12 @@ async function deleteQuestion(q) { return; } const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.'; - if (!confirm(msg)) return; + if (!(await confirmAction({ + title: 'Remove question', + message: msg, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; try { const data = await apiDelete('questions.php', { questionID: q.questionID }); questions = questions.filter(x => x.questionID !== q.questionID); @@ -1702,7 +1708,12 @@ async function updateOption(q, answerOptionID, fields) { } async function deleteOption(q, answerOptionID) { - if (!confirm('Remove this answer option?')) return; + if (!(await confirmAction({ + title: 'Remove answer option', + message: 'Remove this answer option?', + confirmLabel: 'Remove', + variant: 'danger', + }))) return; try { const data = await apiDelete('answer_options.php', { answerOptionID }); if (!data.retired) { @@ -1976,7 +1987,12 @@ function bindLanguageManager(languages) { document.querySelectorAll('.lang-chip-remove').forEach(btn => { btn.addEventListener('click', async () => { const lc = btn.dataset.lc; - if (!confirm(`Remove language "${lc}" and ALL its translations?`)) return; + if (!(await confirmAction({ + title: 'Remove language', + message: `Remove language "${lc}" and ALL its translations?`, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; try { await apiDelete('translations.php', { type: 'language', languageCode: lc }); showToast(`Language "${lc}" removed`, 'success'); diff --git a/website/js/pages/questionnaires.js b/website/js/pages/questionnaires.js index 2ffbfe9..251f939 100644 --- a/website/js/pages/questionnaires.js +++ b/website/js/pages/questionnaires.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { navigate } from '../router.js'; export async function questionnairesPage() { @@ -111,7 +112,12 @@ function renderCategoryKeysPanel(categoryKeys) { if (count > 0) { msg += `\n\n${count} questionnaire(s) will have their category key cleared.`; } - if (!confirm(msg)) return; + if (!(await confirmAction({ + title: 'Delete category key', + message: msg, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; btn.disabled = true; try { const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key }); @@ -297,7 +303,12 @@ function renderGrid(questionnaires, categoryKeys = []) { grid.querySelectorAll('.delete-q-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); - if (!confirm('Delete this questionnaire and all its data?')) return; + if (!(await confirmAction({ + title: 'Delete questionnaire', + message: 'Delete this questionnaire and all its data?', + confirmLabel: 'Delete', + variant: 'danger', + }))) return; try { await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id }); showToast('Questionnaire deleted', 'success'); @@ -491,7 +502,12 @@ function renderScoringProfilesSection(profiles, questionnaires) { }); list.querySelectorAll('.delete-profile-btn').forEach(btn => { btn.addEventListener('click', async () => { - if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return; + if (!(await confirmAction({ + title: 'Delete scoring profile', + message: `Delete scoring profile "${btn.dataset.name}"?`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; try { await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id }); showToast('Profile deleted', 'success'); @@ -761,12 +777,32 @@ function bindImportBundle() { } const count = bundle.questionnaires.length; - if (!confirm(`Import ${count} questionnaire(s) from this file?`)) return; - const replace = confirm( - 'If a questionnaire ID from the file already exists:\n\n' + - 'OK — replace that questionnaire\n' + - 'Cancel — import as a new copy (new ID)' - ); + if (!(await confirmAction({ + title: 'Import questionnaires', + message: `Import ${count} questionnaire(s) from this file?`, + confirmLabel: 'Import', + }))) return; + + const mode = await confirmAction({ + title: 'Existing questionnaire IDs', + message: 'If a questionnaire ID from the file already exists, how should it be handled?', + choices: [ + { + value: 'replace', + label: 'Replace existing', + description: 'Overwrite the questionnaire with that ID.', + }, + { + value: 'copy', + label: 'Import as new copy', + description: 'Keep the existing questionnaire and import with a new ID.', + }, + ], + confirmLabel: 'Continue', + cancelLabel: 'Cancel import', + }); + if (!mode) return; + const replace = mode === 'replace'; btn.disabled = true; const prev = btn.textContent; diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js index 5714d5d..61a6ec6 100644 --- a/website/js/pages/translations.js +++ b/website/js/pages/translations.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { SOURCE_LANG, normalizeEntry, @@ -308,7 +309,12 @@ function renderLanguagePanel(languages) { panel.querySelectorAll('.lang-chip-remove').forEach(btn => { btn.addEventListener('click', async () => { const lc = btn.dataset.lc; - if (!confirm(`Remove "${lc}" and all its translations?`)) return; + if (!(await confirmAction({ + title: 'Remove language', + message: `Remove "${lc}" and all its translations?`, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; try { await apiDelete('translations.php', { type: 'language', languageCode: lc }); showToast(`Language "${lc}" removed`, 'success'); @@ -671,7 +677,11 @@ function bindTranslationBundleActions() { } const count = bundle.entries.length; - if (!confirm(`Import translations for ${count} entries from this file?`)) return; + if (!(await confirmAction({ + title: 'Import translations', + message: `Import translations for ${count} entries from this file?`, + confirmLabel: 'Import', + }))) return; importBtn.disabled = true; const prev = importBtn.textContent; diff --git a/website/js/pages/users.js b/website/js/pages/users.js index 44d16db..449c78a 100644 --- a/website/js/pages/users.js +++ b/website/js/pages/users.js @@ -1,5 +1,6 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; +import { confirmAction } from '../confirm-modal.js'; import { openAdminResetPasswordModal } from '../password-modal.js'; // Roles an admin can create; supervisors can only create coaches @@ -319,10 +320,12 @@ async function reassignCoachSupervisor(selectEl) { } async function revokeUserSessions(userID, username) { - if (!confirm( - `Sign out "${username}" on all devices?\n\n` - + 'They must log in again on the website and mobile app.' - )) { + if (!(await confirmAction({ + title: 'Sign out everywhere', + message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`, + confirmLabel: 'Sign out', + variant: 'danger', + }))) { return; } try { @@ -336,7 +339,12 @@ async function revokeUserSessions(userID, username) { } async function deleteUser(userID, username) { - if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; + if (!(await confirmAction({ + title: 'Delete user', + message: `Delete user "${username}"? This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; try { const data = await apiDelete('users.php', { userID }); if (data.success) {