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 { useState, useRef } from 'react'
|
||||||
import styles from './BundleNav.module.css'
|
import styles from './BundleNav.module.css'
|
||||||
|
import { useConfirm } from './useConfirm'
|
||||||
|
|
||||||
async function resetProgress(scope, opts) {
|
async function resetProgress(scope, opts) {
|
||||||
await fetch('/api/progress/reset', {
|
await fetch('/api/progress/reset', {
|
||||||
@@ -17,6 +18,7 @@ export default function BundleNav({
|
|||||||
const [startX, setStartX] = useState(0)
|
const [startX, setStartX] = useState(0)
|
||||||
const [scrollLeft, setScrollLeft] = useState(0)
|
const [scrollLeft, setScrollLeft] = useState(0)
|
||||||
const [dragDist, setDragDist] = useState(0)
|
const [dragDist, setDragDist] = useState(0)
|
||||||
|
const { confirm, ConfirmUI } = useConfirm()
|
||||||
|
|
||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
if (!trackRef.current) return
|
if (!trackRef.current) return
|
||||||
@@ -41,6 +43,7 @@ export default function BundleNav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{ConfirmUI}
|
||||||
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
|
<nav className={`${styles.nav} ${collapsed ? styles.collapsed : ''}`} aria-label="Scenario bundles">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
@@ -117,7 +120,13 @@ export default function BundleNav({
|
|||||||
title={`Reset all progress in "${b.name}"`}
|
title={`Reset all progress in "${b.name}"`}
|
||||||
onClick={async e => {
|
onClick={async e => {
|
||||||
e.stopPropagation()
|
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 })
|
await resetProgress('bundle', { bundleId: b.id })
|
||||||
onProgressUpdate?.()
|
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 { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import styles from './ExamTimer.module.css'
|
import styles from './ExamTimer.module.css'
|
||||||
|
import { useConfirm } from './useConfirm'
|
||||||
|
|
||||||
function formatTime(secs) {
|
function formatTime(secs) {
|
||||||
if (secs < 0) secs = 0
|
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 pct = Math.min(100, (elapsed / durationSecs) * 100)
|
||||||
const urgent = remaining < 600 // < 10 min
|
const urgent = remaining < 600 // < 10 min
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const { confirm, ConfirmUI } = useConfirm()
|
||||||
if (!window.confirm(`Submit exam now?\n\n${session.completedCount || 0} of ${session.scenarioCount || '?'} scenarios completed.`)) return
|
|
||||||
onSubmit()
|
|
||||||
}, [session, onSubmit])
|
|
||||||
|
|
||||||
const handleAbandon = useCallback(() => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!window.confirm('Abandon this exam?\n\nYour progress will be saved but no score report will be generated.')) return
|
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])
|
}, [onAbandon, confirm])
|
||||||
|
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
|
<div className={`${styles.timer} ${urgent ? styles.urgent : ''}`}>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<span className={styles.icon}>⏱</span>
|
<span className={styles.icon}>⏱</span>
|
||||||
@@ -87,5 +102,7 @@ export default function ExamTimer({ session, bundle, onSubmit, onAbandon }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{ConfirmUI}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import styles from './ScenarioPanel.module.css'
|
import styles from './ScenarioPanel.module.css'
|
||||||
|
import { useConfirm } from './useConfirm'
|
||||||
|
|
||||||
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
// Inline markdown: renders without a wrapping <p> — safe for buttons/spans
|
||||||
const inlineComponents = {
|
const inlineComponents = {
|
||||||
@@ -28,6 +29,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
const [tab, setTab] = useState('problem')
|
const [tab, setTab] = useState('problem')
|
||||||
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
|
||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
|
const { confirm, ConfirmUI } = useConfirm()
|
||||||
const [validResult, setValidResult] = useState(null)
|
const [validResult, setValidResult] = useState(null)
|
||||||
const [selectedOption, setSelectedOption] = useState(null)
|
const [selectedOption, setSelectedOption] = useState(null)
|
||||||
const [mcqResult, setMcqResult] = useState(null)
|
const [mcqResult, setMcqResult] = useState(null)
|
||||||
@@ -197,6 +199,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
|
{ConfirmUI}
|
||||||
{/* Scenario header */}
|
{/* Scenario header */}
|
||||||
<div className={styles.scenarioHeader}>
|
<div className={styles.scenarioHeader}>
|
||||||
<div className={styles.scenarioMeta}>
|
<div className={styles.scenarioMeta}>
|
||||||
@@ -289,10 +292,14 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
|
|||||||
className={styles.resetBtn}
|
className={styles.resetBtn}
|
||||||
title="Reset progress for this scenario"
|
title="Reset progress for this scenario"
|
||||||
onClick={async () => {
|
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 and cluster state for "${scenario.title}"?\n\nThis will run teardown to clean the environment.`
|
||||||
: `Reset progress for "${scenario.title}"?`
|
: `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 })
|
await resetProgress('scenario', { scenarioId: scenario.id })
|
||||||
setSelectedOption(null)
|
setSelectedOption(null)
|
||||||
setMcqResult(null)
|
setMcqResult(null)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
import { useConfirm } from './useConfirm'
|
||||||
|
|
||||||
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
|
const DIFF_COLOR = { Easy: 'green', Medium: 'amber', Hard: 'red' }
|
||||||
const TYPE_ICON = { task: '⚙', mcq: '◉' }
|
const TYPE_ICON = { task: '⚙', mcq: '◉' }
|
||||||
@@ -20,6 +21,7 @@ export default function Sidebar({
|
|||||||
}) {
|
}) {
|
||||||
const [filterDiff, setFilterDiff] = useState('All')
|
const [filterDiff, setFilterDiff] = useState('All')
|
||||||
const [filterType, setFilterType] = useState('All')
|
const [filterType, setFilterType] = useState('All')
|
||||||
|
const { confirm, ConfirmUI } = useConfirm()
|
||||||
|
|
||||||
const filteredScenarios = useMemo(() => {
|
const filteredScenarios = useMemo(() => {
|
||||||
return scenarios.filter(s => {
|
return scenarios.filter(s => {
|
||||||
@@ -72,23 +74,37 @@ export default function Sidebar({
|
|||||||
|
|
||||||
const handleCategoryReset = async (e, cat) => {
|
const handleCategoryReset = async (e, cat) => {
|
||||||
e.stopPropagation()
|
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 })
|
await resetProgress('category', { category: cat })
|
||||||
onProgressUpdate?.()
|
onProgressUpdate?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScenarioReset = async (e, scenarioId, title) => {
|
const handleScenarioReset = async (e, scenarioId, title) => {
|
||||||
e.stopPropagation()
|
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 })
|
await resetProgress('scenario', { scenarioId })
|
||||||
onProgressUpdate?.()
|
onProgressUpdate?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
|
{ConfirmUI}
|
||||||
style={{ width, minWidth: width }}
|
<aside
|
||||||
>
|
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}
|
||||||
|
style={{ width, minWidth: width }}
|
||||||
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className={styles.sidebarTop}>
|
<div className={styles.sidebarTop}>
|
||||||
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
|
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
|
||||||
@@ -256,5 +272,6 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</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