Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 214 additions & 3 deletions src/app/exercise/[id]/ExercisePageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use client';

import { use, useEffect } from 'react';
import { use, useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useExerciseContext } from '@/state/ExerciseContext';
import { getExercise } from '@/exercises/registry';
import { getExercise, getAllExercises } from '@/exercises/registry';
import { imagineRitExercises } from '@/exercises/imagine-rit';
import { StackSim } from '@/engine/simulators/StackSim';
import { HeapSim } from '@/engine/simulators/HeapSim';
import { WinHeapSim } from '@/engine/simulators/WinHeapSim';
Expand All @@ -13,9 +15,13 @@ import { BASE_SYMBOLS } from '@/exercises/shared/symbols';
import SourcePanel from '@/components/panels/SourcePanel/SourcePanel';
import VizPanel from '@/components/panels/VizPanel/VizPanel';
import InputPanel from '@/components/panels/InputPanel/InputPanel';
import Toolkit from '@/components/panels/InputPanel/Toolkit';
import LogPanel from '@/components/panels/LogPanel/LogPanel';
import { ErrorBoundary } from '@/components/ErrorBoundary';

const MOBILE_BREAKPOINT = '(max-width: 900px)';
const MOBILE_SIDEBAR_TOGGLE_EVENT = '0xvrig:toggle-mobile-sidebar';

function retAddrInMain(symbols: Record<string, number>): number {
return (symbols.main || BASE_SYMBOLS.main) + 0x25;
}
Expand Down Expand Up @@ -56,9 +62,135 @@ function computeSymbols(exercise: ReturnType<typeof getExercise>): Record<string
return symbols;
}

function ExerciseDirectionsPanel() {
const { currentExercise } = useExerciseContext();
const [collapsed, setCollapsed] = useState(false);

return (
<div className={`panel mobile-directions-panel${collapsed ? ' is-collapsed' : ''}`}>
<div className="panel-hdr mobile-directions-header">
<span>directions</span>
<button
type="button"
className="mobile-directions-toggle"
aria-expanded={!collapsed}
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? 'Expand' : 'Minimize'}
</button>
</div>
{!collapsed && (
<div className="panel-body">
{currentExercise ? (
<div
className="mobile-directions-content"
dangerouslySetInnerHTML={{ __html: currentExercise.desc }}
/>
) : (
<div className="mobile-directions-empty">Select an exercise to begin.</div>
)}
</div>
)}
</div>
);
}

function MobileExercisePager() {
const router = useRouter();
const pathname = usePathname();
const { state } = useExerciseContext();
const currentId = state.currentExerciseId;
const isImagineRit = pathname?.startsWith('/imagine-rit/');
const orderedExercises = isImagineRit ? imagineRitExercises : getAllExercises();
const currentIndex = orderedExercises.findIndex((exercise) => exercise.id === currentId);
const prevExercise = currentIndex > 0 ? orderedExercises[currentIndex - 1] : null;
const nextExercise =
currentIndex >= 0 && currentIndex < orderedExercises.length - 1
? orderedExercises[currentIndex + 1]
: null;
const basePath = isImagineRit ? '/imagine-rit' : '/exercise';
const nextHref = nextExercise
? `${basePath}/${nextExercise.id}`
: isImagineRit && currentId === 'rit-rop'
? '/imagine-rit/congratulations'
: null;

return (
<div className="mobile-exercise-pager">
<button
type="button"
className="link-button secondary"
disabled={!prevExercise}
onClick={() => prevExercise && router.push(`${basePath}/${prevExercise.id}`)}
>
← Previous
</button>
<button
type="button"
className="link-button secondary-accent"
onClick={() => window.dispatchEvent(new Event(MOBILE_SIDEBAR_TOGGLE_EVENT))}
>
Contents
</button>
<button
type="button"
className="link-button primary"
disabled={!nextHref}
onClick={() => nextHref && router.push(nextHref)}
>
{nextExercise ? 'Next →' : isImagineRit && currentId === 'rit-rop' ? 'Finish →' : 'Next →'}
</button>
</div>
);
}

export default function ExercisePageClient({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { dispatch, stackSim, heapSim, asmEmulator } = useExerciseContext();
const { dispatch, stackSim, heapSim, asmEmulator, currentExercise } = useExerciseContext();
const pathname = usePathname();
const [isMobile, setIsMobile] = useState(false);
const [activeMobileTab, setActiveMobileTab] = useState<'source' | 'viz' | 'log' | 'misc'>('source');

useEffect(() => {
if (typeof window === 'undefined') return;

const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT);
const syncIsMobile = (event?: MediaQueryListEvent) => {
setIsMobile(event?.matches ?? mediaQuery.matches);
};

syncIsMobile();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncIsMobile);
return () => mediaQuery.removeEventListener('change', syncIsMobile);
}

mediaQuery.addListener(syncIsMobile);
return () => mediaQuery.removeListener(syncIsMobile);
}, []);

useEffect(() => {
setActiveMobileTab('source');
}, [id]);

useEffect(() => {
const mainElement = document.querySelector('#app-body > main');
const appElement = document.getElementById('app');
if (!mainElement) return;

if (isMobile) {
mainElement.classList.add('exercise-main-mobile-shell');
appElement?.classList.add('exercise-mobile-nav-bottom');
} else {
mainElement.classList.remove('exercise-main-mobile-shell');
appElement?.classList.remove('exercise-mobile-nav-bottom');
}

return () => {
mainElement.classList.remove('exercise-main-mobile-shell');
appElement?.classList.remove('exercise-mobile-nav-bottom');
};
}, [isMobile, pathname]);

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

dispatch({ type: 'LOAD_EXERCISE', exerciseId: id });
if (id === 'rit-00') {
dispatch({ type: 'EXERCISE_COMPLETED', exerciseId: id });
}
}, [id]); // eslint-disable-line
const mobileVizLabel =
'Assembly';

if (isMobile) {
return (
<div className="mobile-exercise-shell">
<ExerciseDirectionsPanel />

<section className="mobile-workspace">
<div className="mobile-workspace-tabs" role="tablist" aria-label="Exercise workspace">
<button
type="button"
role="tab"
aria-selected={activeMobileTab === 'source'}
className={`mobile-workspace-tab${activeMobileTab === 'source' ? ' active' : ''}`}
onClick={() => setActiveMobileTab('source')}
>
Code
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileTab === 'viz'}
className={`mobile-workspace-tab${activeMobileTab === 'viz' ? ' active' : ''}`}
onClick={() => setActiveMobileTab('viz')}
>
{mobileVizLabel}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileTab === 'log'}
className={`mobile-workspace-tab${activeMobileTab === 'log' ? ' active' : ''}`}
onClick={() => setActiveMobileTab('log')}
>
Console
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileTab === 'misc'}
className={`mobile-workspace-tab${activeMobileTab === 'misc' ? ' active' : ''}`}
onClick={() => setActiveMobileTab('misc')}
>
Misc
</button>
</div>

<div className="mobile-workspace-panel">
{activeMobileTab === 'source' && <SourcePanel showDescription={false} />}
{activeMobileTab === 'viz' && (
<ErrorBoundary>
<VizPanel />
</ErrorBoundary>
)}
{activeMobileTab === 'log' && <LogPanel />}
{activeMobileTab === 'misc' && currentExercise && (
<div className="panel mobile-misc-panel">
<div className="panel-hdr">misc</div>
<div className="panel-body">
<Toolkit exercise={currentExercise} variant="stack" />
</div>
</div>
)}
</div>
</section>

<div className="mobile-bottom-dock">
<ErrorBoundary>
<InputPanel showToolkit={false} />
</ErrorBoundary>
<MobileExercisePager />
</div>
</div>
);
}

return (
<>
Expand Down
4 changes: 4 additions & 0 deletions src/app/exercise/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ExerciseContextProvider } from '@/state/ExerciseContext';
import Sidebar from '@/components/AppShell/Sidebar';
import SuccessBanner from '@/components/shared/SuccessBanner';
import BadgePopup from '@/components/shared/BadgePopup';
import ToastMessage from '@/components/shared/ToastMessage';
import SolutionGuideModal from '@/components/shared/SolutionGuideModal';

export default function ExerciseLayout({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -21,6 +23,8 @@ export default function ExerciseLayout({ children }: { children: React.ReactNode
</main>
</div>
<SuccessBanner />
<SolutionGuideModal />
<ToastMessage />
<BadgePopup />
</div>
</ExerciseContextProvider>
Expand Down
Loading
Loading