Skip to content

Commit a480312

Browse files
authored
Merge pull request #6 from Aeshus/main
Fix mobile UI for Imagine
2 parents e696d61 + 3e9b5b3 commit a480312

31 files changed

Lines changed: 2257 additions & 337 deletions

src/app/exercise/[id]/ExercisePageClient.tsx

Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use client';
22

3-
import { use, useEffect } from 'react';
3+
import { use, useEffect, useState } from 'react';
4+
import { usePathname, useRouter } from 'next/navigation';
45
import { useExerciseContext } from '@/state/ExerciseContext';
5-
import { getExercise } from '@/exercises/registry';
6+
import { getExercise, getAllExercises } from '@/exercises/registry';
7+
import { imagineRitExercises } from '@/exercises/imagine-rit';
68
import { StackSim } from '@/engine/simulators/StackSim';
79
import { HeapSim } from '@/engine/simulators/HeapSim';
810
import { WinHeapSim } from '@/engine/simulators/WinHeapSim';
@@ -13,9 +15,13 @@ import { BASE_SYMBOLS } from '@/exercises/shared/symbols';
1315
import SourcePanel from '@/components/panels/SourcePanel/SourcePanel';
1416
import VizPanel from '@/components/panels/VizPanel/VizPanel';
1517
import InputPanel from '@/components/panels/InputPanel/InputPanel';
18+
import Toolkit from '@/components/panels/InputPanel/Toolkit';
1619
import LogPanel from '@/components/panels/LogPanel/LogPanel';
1720
import { ErrorBoundary } from '@/components/ErrorBoundary';
1821

22+
const MOBILE_BREAKPOINT = '(max-width: 900px)';
23+
const MOBILE_SIDEBAR_TOGGLE_EVENT = '0xvrig:toggle-mobile-sidebar';
24+
1925
function retAddrInMain(symbols: Record<string, number>): number {
2026
return (symbols.main || BASE_SYMBOLS.main) + 0x25;
2127
}
@@ -56,9 +62,135 @@ function computeSymbols(exercise: ReturnType<typeof getExercise>): Record<string
5662
return symbols;
5763
}
5864

65+
function ExerciseDirectionsPanel() {
66+
const { currentExercise } = useExerciseContext();
67+
const [collapsed, setCollapsed] = useState(false);
68+
69+
return (
70+
<div className={`panel mobile-directions-panel${collapsed ? ' is-collapsed' : ''}`}>
71+
<div className="panel-hdr mobile-directions-header">
72+
<span>directions</span>
73+
<button
74+
type="button"
75+
className="mobile-directions-toggle"
76+
aria-expanded={!collapsed}
77+
onClick={() => setCollapsed((prev) => !prev)}
78+
>
79+
{collapsed ? 'Expand' : 'Minimize'}
80+
</button>
81+
</div>
82+
{!collapsed && (
83+
<div className="panel-body">
84+
{currentExercise ? (
85+
<div
86+
className="mobile-directions-content"
87+
dangerouslySetInnerHTML={{ __html: currentExercise.desc }}
88+
/>
89+
) : (
90+
<div className="mobile-directions-empty">Select an exercise to begin.</div>
91+
)}
92+
</div>
93+
)}
94+
</div>
95+
);
96+
}
97+
98+
function MobileExercisePager() {
99+
const router = useRouter();
100+
const pathname = usePathname();
101+
const { state } = useExerciseContext();
102+
const currentId = state.currentExerciseId;
103+
const isImagineRit = pathname?.startsWith('/imagine-rit/');
104+
const orderedExercises = isImagineRit ? imagineRitExercises : getAllExercises();
105+
const currentIndex = orderedExercises.findIndex((exercise) => exercise.id === currentId);
106+
const prevExercise = currentIndex > 0 ? orderedExercises[currentIndex - 1] : null;
107+
const nextExercise =
108+
currentIndex >= 0 && currentIndex < orderedExercises.length - 1
109+
? orderedExercises[currentIndex + 1]
110+
: null;
111+
const basePath = isImagineRit ? '/imagine-rit' : '/exercise';
112+
const nextHref = nextExercise
113+
? `${basePath}/${nextExercise.id}`
114+
: isImagineRit && currentId === 'rit-rop'
115+
? '/imagine-rit/congratulations'
116+
: null;
117+
118+
return (
119+
<div className="mobile-exercise-pager">
120+
<button
121+
type="button"
122+
className="link-button secondary"
123+
disabled={!prevExercise}
124+
onClick={() => prevExercise && router.push(`${basePath}/${prevExercise.id}`)}
125+
>
126+
← Previous
127+
</button>
128+
<button
129+
type="button"
130+
className="link-button secondary-accent"
131+
onClick={() => window.dispatchEvent(new Event(MOBILE_SIDEBAR_TOGGLE_EVENT))}
132+
>
133+
Contents
134+
</button>
135+
<button
136+
type="button"
137+
className="link-button primary"
138+
disabled={!nextHref}
139+
onClick={() => nextHref && router.push(nextHref)}
140+
>
141+
{nextExercise ? 'Next →' : isImagineRit && currentId === 'rit-rop' ? 'Finish →' : 'Next →'}
142+
</button>
143+
</div>
144+
);
145+
}
146+
59147
export default function ExercisePageClient({ params }: { params: Promise<{ id: string }> }) {
60148
const { id } = use(params);
61-
const { dispatch, stackSim, heapSim, asmEmulator } = useExerciseContext();
149+
const { dispatch, stackSim, heapSim, asmEmulator, currentExercise } = useExerciseContext();
150+
const pathname = usePathname();
151+
const [isMobile, setIsMobile] = useState(false);
152+
const [activeMobileTab, setActiveMobileTab] = useState<'source' | 'viz' | 'log' | 'misc'>('source');
153+
154+
useEffect(() => {
155+
if (typeof window === 'undefined') return;
156+
157+
const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT);
158+
const syncIsMobile = (event?: MediaQueryListEvent) => {
159+
setIsMobile(event?.matches ?? mediaQuery.matches);
160+
};
161+
162+
syncIsMobile();
163+
if (typeof mediaQuery.addEventListener === 'function') {
164+
mediaQuery.addEventListener('change', syncIsMobile);
165+
return () => mediaQuery.removeEventListener('change', syncIsMobile);
166+
}
167+
168+
mediaQuery.addListener(syncIsMobile);
169+
return () => mediaQuery.removeListener(syncIsMobile);
170+
}, []);
171+
172+
useEffect(() => {
173+
setActiveMobileTab('source');
174+
}, [id]);
175+
176+
useEffect(() => {
177+
const mainElement = document.querySelector('#app-body > main');
178+
const appElement = document.getElementById('app');
179+
if (!mainElement) return;
180+
181+
if (isMobile) {
182+
mainElement.classList.add('exercise-main-mobile-shell');
183+
appElement?.classList.add('exercise-mobile-nav-bottom');
184+
} else {
185+
mainElement.classList.remove('exercise-main-mobile-shell');
186+
appElement?.classList.remove('exercise-mobile-nav-bottom');
187+
}
188+
189+
return () => {
190+
mainElement.classList.remove('exercise-main-mobile-shell');
191+
appElement?.classList.remove('exercise-mobile-nav-bottom');
192+
};
193+
}, [isMobile, pathname]);
62194

63195
useEffect(() => {
64196
const exercise = getExercise(id);
@@ -266,7 +398,86 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
266398
}
267399

268400
dispatch({ type: 'LOAD_EXERCISE', exerciseId: id });
401+
if (id === 'rit-00') {
402+
dispatch({ type: 'EXERCISE_COMPLETED', exerciseId: id });
403+
}
269404
}, [id]); // eslint-disable-line
405+
const mobileVizLabel =
406+
'Assembly';
407+
408+
if (isMobile) {
409+
return (
410+
<div className="mobile-exercise-shell">
411+
<ExerciseDirectionsPanel />
412+
413+
<section className="mobile-workspace">
414+
<div className="mobile-workspace-tabs" role="tablist" aria-label="Exercise workspace">
415+
<button
416+
type="button"
417+
role="tab"
418+
aria-selected={activeMobileTab === 'source'}
419+
className={`mobile-workspace-tab${activeMobileTab === 'source' ? ' active' : ''}`}
420+
onClick={() => setActiveMobileTab('source')}
421+
>
422+
Code
423+
</button>
424+
<button
425+
type="button"
426+
role="tab"
427+
aria-selected={activeMobileTab === 'viz'}
428+
className={`mobile-workspace-tab${activeMobileTab === 'viz' ? ' active' : ''}`}
429+
onClick={() => setActiveMobileTab('viz')}
430+
>
431+
{mobileVizLabel}
432+
</button>
433+
<button
434+
type="button"
435+
role="tab"
436+
aria-selected={activeMobileTab === 'log'}
437+
className={`mobile-workspace-tab${activeMobileTab === 'log' ? ' active' : ''}`}
438+
onClick={() => setActiveMobileTab('log')}
439+
>
440+
Console
441+
</button>
442+
<button
443+
type="button"
444+
role="tab"
445+
aria-selected={activeMobileTab === 'misc'}
446+
className={`mobile-workspace-tab${activeMobileTab === 'misc' ? ' active' : ''}`}
447+
onClick={() => setActiveMobileTab('misc')}
448+
>
449+
Misc
450+
</button>
451+
</div>
452+
453+
<div className="mobile-workspace-panel">
454+
{activeMobileTab === 'source' && <SourcePanel showDescription={false} />}
455+
{activeMobileTab === 'viz' && (
456+
<ErrorBoundary>
457+
<VizPanel />
458+
</ErrorBoundary>
459+
)}
460+
{activeMobileTab === 'log' && <LogPanel />}
461+
{activeMobileTab === 'misc' && currentExercise && (
462+
<div className="panel mobile-misc-panel">
463+
<div className="panel-hdr">misc</div>
464+
<div className="panel-body">
465+
<Toolkit exercise={currentExercise} variant="stack" />
466+
</div>
467+
</div>
468+
)}
469+
</div>
470+
</section>
471+
472+
<div className="mobile-bottom-dock">
473+
<ErrorBoundary>
474+
<InputPanel showToolkit={false} />
475+
</ErrorBoundary>
476+
<MobileExercisePager />
477+
</div>
478+
</div>
479+
);
480+
}
270481

271482
return (
272483
<>

src/app/exercise/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ExerciseContextProvider } from '@/state/ExerciseContext';
55
import Sidebar from '@/components/AppShell/Sidebar';
66
import SuccessBanner from '@/components/shared/SuccessBanner';
77
import BadgePopup from '@/components/shared/BadgePopup';
8+
import ToastMessage from '@/components/shared/ToastMessage';
9+
import SolutionGuideModal from '@/components/shared/SolutionGuideModal';
810

911
export default function ExerciseLayout({ children }: { children: React.ReactNode }) {
1012
return (
@@ -21,6 +23,8 @@ export default function ExerciseLayout({ children }: { children: React.ReactNode
2123
</main>
2224
</div>
2325
<SuccessBanner />
26+
<SolutionGuideModal />
27+
<ToastMessage />
2428
<BadgePopup />
2529
</div>
2630
</ExerciseContextProvider>

0 commit comments

Comments
 (0)