refactor: replace window alerts with modals
Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
@@ -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?.()
|
||||
}}
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,19 +74,33 @@ 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 (
|
||||
<>
|
||||
{ConfirmUI}
|
||||
<aside
|
||||
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
|
||||
style={{ width, minWidth: width }}
|
||||
@@ -256,5 +272,6 @@ export default function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user