'use client'; // Learner large-course mode — course framework page. // // A published large course is learned module by module, in framework order: // this page lists the modules with their local progress marks, and each entry // opens the module's courseware in the existing learner player // (/learn/[coursewareId] → static playback + AI assistant). Progress is kept // locally per device (started / completed marks); the manifest itself is static // data from L2 (GET /api/learn/courses/:id) — zero LLM. import { useCallback, useEffect, useState } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { ArrowLeft, BookOpen, CheckCircle2, ChevronRight, Circle, GraduationCap, Loader2, Sparkles, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { useI18n } from '@/lib/hooks/use-i18n'; interface CourseModule { index: number; title: string; description: string; coursewareId: string; coursewareVersion: number; contentHash: string; } interface LearnerCourse { courseId: string; version: number; title: string; summary: string; language?: string; publishedAt: string; modules: CourseModule[]; } function storageKey( courseId: string, courseVersion: number, moduleIndex: number, kind: 'started' | 'completed', ) { return `milo.course.${courseId}.v${courseVersion}.module.${moduleIndex}.${kind}`; } export default function LearnerCoursePage() { const params = useParams(); const router = useRouter(); const searchParams = useSearchParams(); const { t } = useI18n(); const courseId = String(params?.courseId ?? ''); const requestedVersion = searchParams.get('version'); const [course, setCourse] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [marks, setMarks] = useState>({}); const load = useCallback(async () => { if (!courseId) return; setLoading(true); setError(null); try { const versionQuery = requestedVersion ? `?version=${encodeURIComponent(requestedVersion)}` : ''; const response = await fetch(`/api/learn/courses/${courseId}${versionQuery}`); const body = (await response.json()) as { course?: LearnerCourse }; if (!response.ok || !body.course) { throw new Error(`HTTP ${response.status}`); } setCourse(body.course); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [courseId, requestedVersion]); // Read local progress marks for every module. useEffect(() => { if (!courseId) return; const next: Record = {}; if (!course) return; const prefix = `milo.course.${courseId}.v${course.version}.module.`; try { for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i); if (!key || !key.startsWith(prefix)) continue; const rest = key.slice(prefix.length); // "3.started" | "3.completed" const dot = rest.lastIndexOf('.'); if (dot < 0) continue; const index = rest.slice(0, dot); const kind = rest.slice(dot + 1) as 'started' | 'completed'; if (kind !== 'started' && kind !== 'completed') continue; const entry = next[index] ?? { started: false, completed: false }; entry[kind] = localStorage.getItem(key) === '1'; next[index] = entry; } } catch { /* localStorage unavailable */ } setMarks(next); }, [courseId, course]); useEffect(() => { void load(); }, [load]); const toggleMark = (module: CourseModule, kind: 'started' | 'completed') => { if (!course) return; const key = storageKey(courseId, course.version, module.index, kind); const current = marks[String(module.index)]?.[kind] ?? false; const nextValue = !current; try { if (nextValue) localStorage.setItem(key, '1'); else localStorage.removeItem(key); } catch { /* ignore */ } setMarks((prev) => ({ ...prev, [String(module.index)]: { started: kind === 'started' ? nextValue : (prev[String(module.index)]?.started ?? false), completed: kind === 'completed' ? nextValue : (prev[String(module.index)]?.completed ?? false), }, })); }; const enterModule = (module: CourseModule) => { toggleMark(module, 'started'); if (!course) return; const query = new URLSearchParams({ version: String(module.coursewareVersion), hash: module.contentHash, course: courseId, courseVersion: String(course.version), module: String(module.index), }); router.push(`/learn/${encodeURIComponent(module.coursewareId)}?${query.toString()}`); }; const completedCount = course ? course.modules.filter((m) => marks[String(m.index)]?.completed).length : 0; const progressPercent = course && course.modules.length > 0 ? Math.round((completedCount / course.modules.length) * 100) : 0; return (
{loading && !course && (
)} {error && !course && (

{error}

)} {course && ( <> {/* Header */}
{t('course.learner.brand')}

{course.title}

{course.summary}

{/* Progress */}
{t('course.learner.progress')} {completedCount}/{course.modules.length}

{t('course.learner.progressHint')}

{/* Module list — learned one by one in framework order */}
{course.modules.map((module) => { const mark = marks[String(module.index)]; const status = mark?.completed ? 'completed' : mark?.started ? 'inProgress' : 'notStarted'; return (
); })} )} {!loading && !course && !error && (

{t('course.learner.notFound')}

)}
); }