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
139 changes: 123 additions & 16 deletions Frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,49 @@ import { Profile } from './components/Profile';
import { ChallengePlay } from './components/ChallengePlay';
import { CodeReviewPlay } from './components/CodeReviewPlay';
import { Landing } from './components/Landing';
import { getCodeReviewChallenge } from './data/codeReviewChallenges';
import { api, type ChallengeSummary, type CurrentUser } from './lib/api';

type View = 'home' | 'auth' | 'dashboard' | 'profile' | 'play';
type ChallengeMode = 'overview' | 'attack' | 'defend';
type Route =
| { view: Exclude<View, 'play'> }
| { view: 'play'; challengeId: string; mode: ChallengeMode };

function readRoute(): Route {
const pathname = window.location.pathname.replace(/\/+$/, '') || '/';
const challengeMatch = pathname.match(/^\/challenges\/([^/]+)(?:\/(attack|defend))?$/);

if (challengeMatch) {
return {
view: 'play',
challengeId: decodeURIComponent(challengeMatch[1]),
mode: (challengeMatch[2] as ChallengeMode | undefined) ?? 'overview',
};
}
if (pathname === '/login' || pathname === '/auth') return { view: 'auth' };
if (pathname === '/dashboard') return { view: 'dashboard' };
if (pathname === '/profile') return { view: 'profile' };
return { view: 'home' };
}

function routePath(route: Route): string {
if (route.view === 'play') {
const base = `/challenges/${encodeURIComponent(route.challengeId)}`;
return route.mode === 'overview' ? base : `${base}/${route.mode}`;
}
if (route.view === 'auth') return '/login';
if (route.view === 'dashboard') return '/dashboard';
if (route.view === 'profile') return '/profile';
return '/';
}

export default function App() {
const [view, setView] = useState<View>('home');
const [route, setRoute] = useState<Route>(() => readRoute());
const [user, setUser] = useState<CurrentUser | null>(null);
const [bootChecked, setBootChecked] = useState(false);
const [activeChallenge, setActiveChallenge] = useState<ChallengeSummary | null>(null);
const view = route.view;

const refreshUser = useCallback(async () => {
try {
Expand All @@ -29,12 +63,64 @@ export default function App() {
return null;
}, []);

const navigate = useCallback((nextRoute: Route, options: { replace?: boolean } = {}) => {
const nextPath = routePath(nextRoute);
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (currentPath !== nextPath) {
const method = options.replace ? 'replaceState' : 'pushState';
window.history[method]({}, '', nextPath);
}
setRoute(nextRoute);
}, []);

useEffect(() => {
const onPopState = () => setRoute(readRoute());
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);

useEffect(() => {
refreshUser().then((me) => {
if (me) setView('dashboard');
const currentRoute = readRoute();
if (me) {
setRoute(currentRoute);
} else if (currentRoute.view !== 'home' && currentRoute.view !== 'auth') {
navigate({ view: 'home' }, { replace: true });
} else {
setRoute(currentRoute);
}
setBootChecked(true);
});
}, [refreshUser]);
}, [navigate, refreshUser]);

useEffect(() => {
if (!bootChecked || !user || route.view !== 'play') {
setActiveChallenge(null);
return;
}

if (activeChallenge?.id === route.challengeId) return;

const codeReviewChallenge = getCodeReviewChallenge(route.challengeId);
if (codeReviewChallenge) {
setActiveChallenge(codeReviewChallenge.summary);
return;
}

let cancelled = false;
setActiveChallenge(null);
api.challenge(route.challengeId)
.then((challenge) => {
if (!cancelled) setActiveChallenge(challenge);
})
.catch(() => {
if (!cancelled) navigate({ view: 'dashboard' }, { replace: true });
});

return () => {
cancelled = true;
};
}, [activeChallenge?.id, bootChecked, navigate, route, user]);

if (!bootChecked) {
return (
Expand All @@ -47,10 +133,10 @@ export default function App() {
if (view === 'auth') {
return (
<Auth
onBackToHome={() => setView('home')}
onBackToHome={() => navigate({ view: 'home' })}
onAuthenticated={async (authenticatedUser) => {
setUser({ authenticated: true, ...authenticatedUser });
setView('dashboard');
navigate({ view: 'dashboard' }, { replace: true });
void refreshUser();
}}
/>
Expand All @@ -61,15 +147,23 @@ export default function App() {
return (
<Dashboard
user={user}
onProfileClick={() => setView('profile')}
onProfileClick={() => navigate({ view: 'profile' })}
onSelectChallenge={(c) => {
setActiveChallenge(c);
setView('play');
navigate({ view: 'play', challengeId: c.id, mode: 'overview' });
}}
/>
);
}

if (view === 'play' && user && !activeChallenge) {
return (
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<span className="text-muted-foreground text-sm">Loading challenge...</span>
</div>
);
}

if (view === 'play' && user && activeChallenge) {
if (activeChallenge.track === 'code-review') {
return (
Expand All @@ -78,19 +172,19 @@ export default function App() {
user={user}
onExit={() => {
setActiveChallenge(null);
setView('dashboard');
navigate({ view: 'dashboard' });
}}
onCompleted={() => {
void refreshUser();
}}
onProfileClick={() => {
setActiveChallenge(null);
setView('profile');
navigate({ view: 'profile' });
}}
onLoggedOut={() => {
setActiveChallenge(null);
setUser(null);
setView('home');
navigate({ view: 'home' }, { replace: true });
}}
/>
);
Expand All @@ -99,22 +193,30 @@ export default function App() {
<ChallengePlay
challenge={activeChallenge}
challengeId={activeChallenge.id}
routeWorkspaceMode={route.view === 'play' && route.mode !== 'overview' ? route.mode : null}
user={user}
onWorkspaceModeChange={(mode) => {
navigate({
view: 'play',
challengeId: activeChallenge.id,
mode: mode ?? 'overview',
});
}}
onExit={() => {
setActiveChallenge(null);
setView('dashboard');
navigate({ view: 'dashboard' });
}}
onCompleted={() => {
void refreshUser();
}}
onProfileClick={() => {
setActiveChallenge(null);
setView('profile');
navigate({ view: 'profile' });
}}
onLoggedOut={() => {
setActiveChallenge(null);
setUser(null);
setView('home');
navigate({ view: 'home' }, { replace: true });
}}
/>
);
Expand All @@ -124,14 +226,19 @@ export default function App() {
return (
<Profile
user={user}
onBack={() => setView('dashboard')}
onBack={() => navigate({ view: 'dashboard' })}
onLoggedOut={() => {
setUser(null);
setView('home');
navigate({ view: 'home' }, { replace: true });
}}
/>
);
}

return <Landing user={user} onPrimaryClick={() => setView(user ? 'dashboard' : 'auth')} />;
return (
<Landing
user={user}
onPrimaryClick={() => navigate(user ? { view: 'dashboard' } : { view: 'auth' })}
/>
);
}
69 changes: 61 additions & 8 deletions Frontend/src/app/components/ChallengePlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ import {
} from '../lib/api';
import { CodeSnippet } from './CodeSnippet';

type WorkspaceMode = 'attack' | 'defend';

interface ChallengePlayProps {
challenge: ChallengeSummary;
challengeId: string;
routeWorkspaceMode: WorkspaceMode | null;
user: CurrentUser;
onWorkspaceModeChange: (mode: WorkspaceMode | null) => void;
onExit: () => void;
onCompleted: () => void;
onProfileClick: () => void;
Expand All @@ -29,15 +33,15 @@ type Status =
| { kind: 'ready' }
| { kind: 'error'; message: string };

type WorkspaceMode = 'attack' | 'defend';

const MIN_PANE_PERCENT = 20;
const MAX_PANE_PERCENT = 80;

export function ChallengePlay({
challenge: challengeSummary,
challengeId,
routeWorkspaceMode,
user,
onWorkspaceModeChange,
onExit,
onCompleted,
onProfileClick,
Expand Down Expand Up @@ -66,6 +70,7 @@ export function ChallengePlay({
const [editedFiles, setEditedFiles] = useState<Record<string, string>>({});
const [submittingPatch, setSubmittingPatch] = useState(false);
const [patchResult, setPatchResult] = useState<PatchResult | null>(null);
const summaryPassed = Boolean(history?.progress.summary_passed);

const splitContainerRef = useRef<HTMLDivElement | null>(null);
const stoppedRef = useRef(false);
Expand Down Expand Up @@ -184,12 +189,16 @@ export function ChallengePlay({
};
}, [challengeId, stopSession]);

const handleLaunchWorkspace = useCallback(async () => {
const handleLaunchWorkspace = useCallback(async (options: { syncRoute?: boolean } = {}) => {
if (options.syncRoute ?? true) {
onWorkspaceModeChange('attack');
}
setWorkspaceMode('attack');
setHint(null);
setFlagFeedback(null);
setStatus({ kind: 'starting' });
try {
stoppedRef.current = false;
await api.startAttack(challengeId);
setTargetPath('/');
setTargetCanGoBack(false);
Expand All @@ -205,7 +214,7 @@ export function ChallengePlay({
message: err instanceof Error ? err.message : 'Failed to start challenge',
});
}
}, [challengeId]);
}, [challengeId, onWorkspaceModeChange]);

const handleNavigateTarget = useCallback(() => {
setTargetLocation(targetPath);
Expand Down Expand Up @@ -314,12 +323,57 @@ export function ChallengePlay({
setStatus({ kind: 'ready' });
}, [refreshOverviewData, stopSession]);

const handleOpenDefendWorkspace = useCallback(() => {
const handleOpenDefendWorkspace = useCallback((options: { syncRoute?: boolean } = {}) => {
if (options.syncRoute ?? true) {
onWorkspaceModeChange('defend');
}
setPatchResult(null);
setProxyUrl(null);
setWorkspaceMode('defend');
setStatus({ kind: 'ready' });
}, []);
}, [onWorkspaceModeChange]);

useEffect(() => {
if (routeWorkspaceMode === workspaceMode) return;

if (routeWorkspaceMode && !history) return;

if (routeWorkspaceMode && !summaryPassed) {
onWorkspaceModeChange(null);
setWorkspaceMode(null);
setProxyUrl(null);
setPatchResult(null);
setStatus((current) => (current.kind === 'loading' ? current : { kind: 'overview' }));
return;
}

if (routeWorkspaceMode === 'attack') {
void handleLaunchWorkspace({ syncRoute: false });
return;
}

if (routeWorkspaceMode === 'defend') {
handleOpenDefendWorkspace({ syncRoute: false });
return;
}

if (workspaceMode === 'attack') {
void stopSession();
}
setWorkspaceMode(null);
setProxyUrl(null);
setPatchResult(null);
setStatus((current) => (current.kind === 'loading' ? current : { kind: 'overview' }));
}, [
handleLaunchWorkspace,
handleOpenDefendWorkspace,
history,
onWorkspaceModeChange,
routeWorkspaceMode,
summaryPassed,
stopSession,
workspaceMode,
]);

const handleSubmitPatch = useCallback(async () => {
if (!challenge) return;
Expand Down Expand Up @@ -410,8 +464,7 @@ export function ChallengePlay({
}
setPatchResult(null);
}
setWorkspaceMode(null);
setStatus({ kind: 'overview' });
onWorkspaceModeChange(null);
}}
className="text-xs uppercase tracking-wider px-3 py-1.5 border border-border rounded hover:border-accent hover:text-accent transition-colors"
title="Back to challenge overview"
Expand Down
Loading
Loading