Skip to content

Commit 4601cdb

Browse files
committed
confirm dialog on abort training
1 parent 31ee679 commit 4601cdb

3 files changed

Lines changed: 174 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { ConfirmDialog } from "./components/ConfirmDialog";
23
import { HomeScreen } from "./components/HomeScreen";
34
import { LevelOverview } from "./components/LevelOverview";
45
import { LevelSummaryScreen } from "./components/LevelSummaryScreen";
@@ -175,6 +176,10 @@ function getRecommendationLabel(
175176
return "Stay on same level" as const;
176177
}
177178

179+
function isActiveTrainingState(appState: AppState): boolean {
180+
return ["memorizing", "pause", "reconstructing", "problem-result"].includes(appState);
181+
}
182+
178183
export default function App() {
179184
const initialProgress = useMemo(() => loadProgress(), []);
180185
const initialRoute = useMemo(() => parseRoute(), []);
@@ -198,6 +203,9 @@ export default function App() {
198203
const [pauseCountdown, setPauseCountdown] = useState(3);
199204
const [lastResultIndex, setLastResultIndex] = useState<number | null>(null);
200205
const [summarySaved, setSummarySaved] = useState(false);
206+
const [pendingExitRoute, setPendingExitRoute] = useState<Route | null>(null);
207+
const sessionRef = useRef<TrainingSession | null>(null);
208+
const appStateRef = useRef<AppState>(appState);
201209

202210
useEffect(() => {
203211
const failures = runScoringSelfCheck();
@@ -210,6 +218,14 @@ export default function App() {
210218
saveProgress(progress);
211219
}, [progress]);
212220

221+
useEffect(() => {
222+
sessionRef.current = session;
223+
}, [session]);
224+
225+
useEffect(() => {
226+
appStateRef.current = appState;
227+
}, [appState]);
228+
213229
useEffect(() => {
214230
if (!window.location.hash) {
215231
replaceRoute({ screen: "home" });
@@ -223,6 +239,18 @@ export default function App() {
223239
useEffect(() => {
224240
function applyRoute(): void {
225241
const route = parseRoute();
242+
const activeSession = sessionRef.current;
243+
const leavingActiveTraining =
244+
activeSession !== null &&
245+
isActiveTrainingState(appStateRef.current) &&
246+
(route.screen !== "training" || route.level !== activeSession.level);
247+
248+
if (leavingActiveTraining) {
249+
replaceRoute({ screen: "training", level: activeSession.level });
250+
setPendingExitRoute(route);
251+
return;
252+
}
253+
226254
if (route.screen === "home") {
227255
setSession(null);
228256
setAppState("home");
@@ -517,11 +545,45 @@ export default function App() {
517545
}
518546

519547
function backHome(): void {
548+
if (session && isActiveTrainingState(appState)) {
549+
setPendingExitRoute({ screen: "home" });
550+
return;
551+
}
520552
setSession(null);
521553
setAppState("home");
522554
pushRoute({ screen: "home" });
523555
}
524556

557+
function cancelTrainingExit(): void {
558+
setPendingExitRoute(null);
559+
if (session && isActiveTrainingState(appState)) {
560+
replaceRoute({ screen: "training", level: session.level });
561+
}
562+
}
563+
564+
function confirmTrainingExit(): void {
565+
if (!pendingExitRoute) {
566+
return;
567+
}
568+
const nextRoute = pendingExitRoute;
569+
setPendingExitRoute(null);
570+
setSession(null);
571+
572+
if (nextRoute.screen === "home") {
573+
setAppState("home");
574+
pushRoute({ screen: "home" });
575+
return;
576+
}
577+
578+
setSelectedLevel(nextRoute.level);
579+
setProgress((current) => ({
580+
...current,
581+
lastSelectedLevel: nextRoute.level,
582+
}));
583+
setAppState("level-overview");
584+
pushRoute({ screen: "level-overview", level: nextRoute.level });
585+
}
586+
525587
const lastSession = progress.sessions[progress.sessions.length - 1];
526588
const showWorldChampionshipMessage =
527589
session?.level === (LEVELS[LEVELS.length - 1]?.level ?? null) &&
@@ -617,6 +679,15 @@ export default function App() {
617679
onBackHome={backHome}
618680
/>
619681
) : null}
682+
<ConfirmDialog
683+
open={pendingExitRoute !== null}
684+
title="Leave training?"
685+
message="Your current level progress will be lost."
686+
cancelLabel="Stay in training"
687+
confirmLabel="Leave training"
688+
onCancel={cancelTrainingExit}
689+
onConfirm={confirmTrainingExit}
690+
/>
620691
</main>
621692
);
622693
}

src/components/ConfirmDialog.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { FaTimes } from "react-icons/fa";
2+
3+
type ConfirmDialogProps = {
4+
open: boolean;
5+
title: string;
6+
message: string;
7+
confirmLabel: string;
8+
cancelLabel: string;
9+
onConfirm: () => void;
10+
onCancel: () => void;
11+
};
12+
13+
export function ConfirmDialog({
14+
open,
15+
title,
16+
message,
17+
confirmLabel,
18+
cancelLabel,
19+
onConfirm,
20+
onCancel,
21+
}: ConfirmDialogProps) {
22+
if (!open) {
23+
return null;
24+
}
25+
26+
return (
27+
<div className="modal-backdrop" role="presentation">
28+
<section
29+
className="modal-dialog"
30+
role="dialog"
31+
aria-modal="true"
32+
aria-labelledby="confirm-dialog-title"
33+
>
34+
<button
35+
type="button"
36+
className="modal-close"
37+
onClick={onCancel}
38+
aria-label="Close dialog"
39+
>
40+
<FaTimes aria-hidden="true" />
41+
</button>
42+
<h2 id="confirm-dialog-title">{title}</h2>
43+
<p>{message}</p>
44+
<div className="button-row modal-actions">
45+
<button type="button" onClick={onCancel}>
46+
{cancelLabel}
47+
</button>
48+
<button type="button" className="primary" onClick={onConfirm}>
49+
{confirmLabel}
50+
</button>
51+
</div>
52+
</section>
53+
</div>
54+
);
55+
}

src/styles.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,53 @@ a:hover {
778778
color: #b8c2d1;
779779
}
780780

781+
.modal-backdrop {
782+
position: fixed;
783+
inset: 0;
784+
z-index: 20;
785+
display: grid;
786+
place-items: center;
787+
padding: 18px;
788+
background: rgba(8, 11, 15, 0.72);
789+
animation: screen-enter 150ms ease-out both;
790+
}
791+
792+
.modal-dialog {
793+
position: relative;
794+
width: min(100%, 430px);
795+
padding: 20px;
796+
border: 1px solid #2c3644;
797+
border-radius: 8px;
798+
background:
799+
radial-gradient(circle at 92% 0%, rgba(224, 176, 109, 0.08), transparent 26%),
800+
#1a212b;
801+
box-shadow: 0 24px 54px rgba(0, 0, 0, 0.45);
802+
}
803+
804+
.modal-dialog h2 {
805+
margin-right: 36px;
806+
}
807+
808+
.modal-dialog p {
809+
color: #c9d3e2;
810+
}
811+
812+
.modal-close {
813+
position: absolute;
814+
top: 10px;
815+
right: 10px;
816+
display: inline-flex;
817+
align-items: center;
818+
justify-content: center;
819+
width: 34px;
820+
height: 34px;
821+
padding: 0;
822+
}
823+
824+
.modal-actions {
825+
justify-content: flex-end;
826+
}
827+
781828
.level-preview-grid {
782829
margin-top: 16px;
783830
display: grid;

0 commit comments

Comments
 (0)