120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
// Learner player entry — L3 user end.
|
|
//
|
|
// Resolves a published courseware from the registry, materializes (or reuses
|
|
// the cached copy of) its frozen bundle, then opens the classroom in pure
|
|
// playback. No course generation is ever triggered: the materialized document
|
|
// carries no generation outlines, so scene/media generation stays silent. The
|
|
// original classroom teacher/assistant, dynamic grading, and PBL runtime remain
|
|
// available and may use their normal teaching-time model calls.
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|
import { BookOpen, Loader2, RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useI18n } from '@/lib/hooks/use-i18n';
|
|
import { loadLearnerCourseware } from '@/lib/bundle/learner-load';
|
|
|
|
type LoadPhase = 'resolving' | 'downloading' | 'opening' | 'error';
|
|
|
|
export default function LearnPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { t } = useI18n();
|
|
const coursewareId = String(params?.coursewareId ?? '');
|
|
// Large-course mode pass-through: when the learner entered through a course
|
|
// framework page, carry the course context into the classroom so it can show
|
|
// a "back to course" affordance.
|
|
const courseId = searchParams.get('course') ?? '';
|
|
const moduleIndex = searchParams.get('module') ?? '';
|
|
const courseVersion = searchParams.get('courseVersion') ?? '';
|
|
const versionParam = searchParams.get('version');
|
|
const expectedContentHash = searchParams.get('hash');
|
|
|
|
const [phase, setPhase] = useState<LoadPhase>('resolving');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const startedRef = useRef(false);
|
|
|
|
const start = useCallback(async () => {
|
|
if (!coursewareId) return;
|
|
startedRef.current = true;
|
|
setPhase('resolving');
|
|
setError(null);
|
|
try {
|
|
const version = versionParam === null ? undefined : Number(versionParam);
|
|
const result = await loadLearnerCourseware(coursewareId, {
|
|
...(version !== undefined ? { version } : {}),
|
|
...(expectedContentHash !== null ? { expectedContentHash } : {}),
|
|
});
|
|
setPhase('opening');
|
|
const query = new URLSearchParams({ learner: '1', courseware: coursewareId });
|
|
if (courseId) query.set('course', courseId);
|
|
if (moduleIndex) query.set('module', moduleIndex);
|
|
if (courseVersion) query.set('courseVersion', courseVersion);
|
|
router.replace(`/classroom/${result.stageId}?${query.toString()}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setPhase('error');
|
|
}
|
|
}, [
|
|
coursewareId,
|
|
courseId,
|
|
moduleIndex,
|
|
courseVersion,
|
|
versionParam,
|
|
expectedContentHash,
|
|
router,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (startedRef.current || !coursewareId) return;
|
|
// Entry redirect must show its own loading state before the async load
|
|
// settles; the effect's synchronous call into `start` is intentional.
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
void start();
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
}, [coursewareId, start]);
|
|
|
|
return (
|
|
<main className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
|
<div className="max-w-sm w-full px-6 text-center space-y-6">
|
|
<div className="mx-auto w-14 h-14 rounded-2xl bg-violet-100 dark:bg-violet-950/50 flex items-center justify-center">
|
|
<BookOpen className="w-7 h-7 text-violet-600 dark:text-violet-400" />
|
|
</div>
|
|
|
|
{phase === 'error' ? (
|
|
<>
|
|
<div>
|
|
<h1 className="text-lg font-semibold">{t('learner.loadFailed')}</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground break-all">{error}</p>
|
|
</div>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Button variant="outline" onClick={() => router.push('/')}>
|
|
{t('learner.backHome')}
|
|
</Button>
|
|
<Button onClick={() => void start()}>
|
|
<RefreshCw className="w-4 h-4" />
|
|
{t('learner.retry')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{phase === 'downloading'
|
|
? t('learner.downloading')
|
|
: phase === 'opening'
|
|
? t('learner.opening')
|
|
: t('learner.resolving')}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground/70">{t('learner.onceHint')}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|