11'use client' ;
22
3- import { use , useEffect } from 'react' ;
3+ import { use , useEffect , useState } from 'react' ;
4+ import { usePathname , useRouter } from 'next/navigation' ;
45import { useExerciseContext } from '@/state/ExerciseContext' ;
5- import { getExercise } from '@/exercises/registry' ;
6+ import { getExercise , getAllExercises } from '@/exercises/registry' ;
7+ import { imagineRitExercises } from '@/exercises/imagine-rit' ;
68import { StackSim } from '@/engine/simulators/StackSim' ;
79import { HeapSim } from '@/engine/simulators/HeapSim' ;
810import { WinHeapSim } from '@/engine/simulators/WinHeapSim' ;
@@ -13,9 +15,13 @@ import { BASE_SYMBOLS } from '@/exercises/shared/symbols';
1315import SourcePanel from '@/components/panels/SourcePanel/SourcePanel' ;
1416import VizPanel from '@/components/panels/VizPanel/VizPanel' ;
1517import InputPanel from '@/components/panels/InputPanel/InputPanel' ;
18+ import Toolkit from '@/components/panels/InputPanel/Toolkit' ;
1619import LogPanel from '@/components/panels/LogPanel/LogPanel' ;
1720import { ErrorBoundary } from '@/components/ErrorBoundary' ;
1821
22+ const MOBILE_BREAKPOINT = '(max-width: 900px)' ;
23+ const MOBILE_SIDEBAR_TOGGLE_EVENT = '0xvrig:toggle-mobile-sidebar' ;
24+
1925function 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+
59147export 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 < >
0 commit comments