refactor: replace window alerts with modals

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-18 01:49:10 +05:30
parent 35dfeb7208
commit ec27b01531
7 changed files with 270 additions and 16 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react'
import styles from './BundleNav.module.css'
import { useConfirm } from './useConfirm'
async function resetProgress(scope, opts) {
await fetch('/api/progress/reset', {
@@ -17,6 +18,7 @@ export default function BundleNav({
const [startX, setStartX] = useState(0)
const [scrollLeft, setScrollLeft] = useState(0)
const [dragDist, setDragDist] = useState(0)
const { confirm, ConfirmUI } = useConfirm()
const handleMouseDown = (e) => {
if (!trackRef.current) return
@@ -41,6 +43,7 @@ export default function BundleNav({
return (
<>
{ConfirmUI}
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
{!collapsed && (
<div
@@ -117,7 +120,13 @@ export default function BundleNav({
title={`Reset all progress in "${b.name}"`}
onClick={async e => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${b.name}"?`)) return
const ok = await confirm({
title: 'Reset Bundle Progress',
message: `Reset all progress in "${b.name}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('bundle', { bundleId: b.id })
onProgressUpdate?.()
}}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react'
import styles from './ConfirmModal.module.css'
/**
* ConfirmModal — drop-in replacement for window.confirm.
*
* Usage via the useConfirm hook (see useConfirm.js).
* Props:
* open boolean
* title string
* message string
* confirmLabel string (default "Confirm")
* cancelLabel string (default "Cancel")
* danger boolean (red confirm button)
* onConfirm () => void
* onCancel () => void
*/
export default function ConfirmModal({
open, title, message,
confirmLabel = 'Confirm', cancelLabel = 'Cancel',
danger = false,
onConfirm, onCancel,
}) {
const confirmRef = useRef(null)
useEffect(() => {
if (open) confirmRef.current?.focus()
}, [open])
useEffect(() => {
if (!open) return
const onKey = e => {
if (e.key === 'Escape') onCancel()
if (e.key === 'Enter') onConfirm()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onConfirm, onCancel])
if (!open) return null
return (
<div className={styles.overlay} onMouseDown={e => e.target === e.currentTarget && onCancel()}>
<div className={styles.modal} role="dialog" aria-modal="true">
{title && <div className={styles.header}>{title}</div>}
{message && <div className={styles.body}>{message}</div>}
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={onCancel}>{cancelLabel}</button>
<button
ref={confirmRef}
className={`${styles.confirmBtn} ${danger ? styles.danger : ''}`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(3px);
animation: fadeIn 0.12s ease;
}
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: 400px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideUp 0.15s ease;
overflow: hidden;
}
.header {
padding: 18px 20px 0;
font-size: 15px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.2px;
}
.body {
padding: 10px 20px 18px;
font-size: 13px;
color: var(--text-2);
line-height: 1.65;
white-space: pre-line;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--surface2);
}
.cancelBtn {
background: none;
border: 1px solid var(--border2);
border-radius: 6px;
color: var(--text-2);
padding: 7px 16px;
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: all 0.12s;
}
.cancelBtn:hover {
color: var(--text);
border-color: var(--text-3);
background: var(--surface3);
}
.confirmBtn {
background: var(--green);
color: #000;
border: none;
border-radius: 6px;
padding: 7px 18px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.12s;
}
.confirmBtn:hover { opacity: 0.85; }
.confirmBtn.danger {
background: var(--red);
color: #fff;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes slideUp { from { opacity: 0; transform: translateY(12px) } to { opacity: 1; transform: none } }
+24 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import styles from './ExamTimer.module.css'
import { useConfirm } from './useConfirm'
function formatTime(secs) {
if (secs < 0) secs = 0
@@ -41,19 +42,33 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
const pct = Math.min(100, (elapsed / durationSecs) * 100)
const urgent = remaining < 600 // < 10 min
const handleSubmit = useCallback(async () => {
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
onSubmit()
}, [session, onSubmit])
const { confirm, ConfirmUI } = useConfirm()
const handleAbandon = useCallback(() => {
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return
const handleSubmit = useCallback(async () => {
const ok = await confirm({
title: 'Submit Exam',
message: `Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`,
confirmLabel: 'Submit',
})
if (!ok) return
onSubmit()
}, [session, onSubmit, confirm])
const handleAbandon = useCallback(async () => {
const ok = await confirm({
title: 'Abandon Exam',
message: 'Abandon this exam?\n\nYour progress will be saved but no score report will be generated.',
confirmLabel: 'Abandon',
danger: true,
})
if (!ok) return
onAbandon()
}, [onAbandon])
}, [onAbandon, confirm])
if (!session) return null
return (
<>
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
<div className={styles.left}>
<span className={styles.icon}></span>
@@ -87,5 +102,7 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
</button>
</div>
</div>
{ConfirmUI}
</>
)
}
+9 -2
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
import { useConfirm } from './useConfirm'
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
const inlineComponents = {
@@ -28,6 +29,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false)
const { confirm, ConfirmUI } = useConfirm()
const [validResult, setValidResult] = useState(null)
const [selectedOption, setSelectedOption] = useState(null)
const [mcqResult, setMcqResult] = useState(null)
@@ -197,6 +199,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
return (
<div className={styles.panel}>
{ConfirmUI}
{/* Scenario header */}
<div className={styles.scenarioHeader}>
<div className={styles.scenarioMeta}>
@@ -289,10 +292,14 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
className={styles.resetBtn}
title="Reset progress for this scenario"
onClick={async () => {
const msg = scenario.type === 'task'
const title = scenario.type === 'task'
? 'Reset Scenario & Environment'
: 'Reset Scenario Progress'
const message = scenario.type === 'task'
? `Reset progress and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.`
: `Reset progress for "${scenario.title}"?`
if (!window.confirm(msg)) return
const ok = await confirm({ title, message, confirmLabel: 'Reset', danger: true })
if (!ok) return
await resetProgress('scenario', { scenarioId: scenario.id })
setSelectedOption(null)
setMcqResult(null)
+23 -6
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'
import styles from './Sidebar.module.css'
import { useConfirm } from './useConfirm'
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
const TYPE_ICON = { task: '⚙', mcq: '◉' }
@@ -20,6 +21,7 @@ export default function Sidebar({
}) {
const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All')
const { confirm, ConfirmUI } = useConfirm()
const filteredScenarios = useMemo(() => {
return scenarios.filter(s => {
@@ -72,23 +74,37 @@ export default function Sidebar({
const handleCategoryReset = async (e, cat) => {
e.stopPropagation()
if (!window.confirm(`Reset all progress in "${cat}"?`)) return
const ok = await confirm({
title: 'Reset Category Progress',
message: `Reset all progress in "${cat}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('category', { category: cat })
onProgressUpdate?.()
}
const handleScenarioReset = async (e, scenarioId, title) => {
e.stopPropagation()
if (!window.confirm(`Reset progress for "${title}"?`)) return
const ok = await confirm({
title: 'Reset Scenario Progress',
message: `Reset progress for "${title}"?`,
confirmLabel: 'Reset',
danger: true,
})
if (!ok) return
await resetProgress('scenario', { scenarioId })
onProgressUpdate?.()
}
return (
<aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }}
>
<>
{ConfirmUI}
<aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
style={{ width, minWidth: width }}
>
{/* Top bar */}
<div className={styles.sidebarTop}>
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
@@ -256,5 +272,6 @@ export default function Sidebar({
</div>
)}
</aside>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useState, useCallback, useRef } from 'react'
import ConfirmModal from './ConfirmModal'
/**
* useConfirm — returns { confirm, ConfirmUI }
*
* Usage:
* const { confirm, ConfirmUI } = useConfirm()
*
* // Inside JSX:
* {ConfirmUI}
*
* // Imperatively (awaitable):
* const ok = await confirm({
* title: 'Delete item',
* message: 'This cannot be undone.',
* confirmLabel: 'Delete',
* danger: true,
* })
* if (ok) { ... }
*/
export function useConfirm() {
const [state, setState] = useState({ open: false })
const resolveRef = useRef(null)
const confirm = useCallback((options) => {
return new Promise(resolve => {
resolveRef.current = resolve
setState({ open: true, ...options })
})
}, [])
const handleConfirm = useCallback(() => {
setState(s => ({ ...s, open: false }))
resolveRef.current?.(true)
}, [])
const handleCancel = useCallback(() => {
setState(s => ({ ...s, open: false }))
resolveRef.current?.(false)
}, [])
const ConfirmUI = (
<ConfirmModal
open={state.open}
title={state.title}
message={state.message}
confirmLabel={state.confirmLabel}
cancelLabel={state.cancelLabel}
danger={state.danger}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)
return { confirm, ConfirmUI }
}