308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
'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<LearnerCourse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [marks, setMarks] = useState<Record<string, { started: boolean; completed: boolean }>>({});
|
|
|
|
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<string, { started: boolean; completed: boolean }> = {};
|
|
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 (
|
|
<main className="min-h-screen bg-gradient-to-b from-violet-50/60 to-zinc-50 dark:from-zinc-950 dark:to-zinc-950">
|
|
<div className="mx-auto max-w-3xl px-6 py-10">
|
|
{loading && !course && (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{error && !course && (
|
|
<div className="py-16 text-center space-y-4">
|
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
<Button variant="outline" size="sm" onClick={() => router.push('/learn')}>
|
|
{t('course.learner.backToCatalog')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{course && (
|
|
<>
|
|
{/* Header */}
|
|
<header className="mb-8">
|
|
<button
|
|
onClick={() => router.push('/learn')}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mb-3"
|
|
>
|
|
<ArrowLeft className="w-3.5 h-3.5" />
|
|
{t('course.learner.backToCatalog')}
|
|
</button>
|
|
<div className="flex items-center gap-2 text-violet-600 dark:text-violet-400">
|
|
<GraduationCap className="w-5 h-5" />
|
|
<span className="text-sm font-medium tracking-wide">
|
|
{t('course.learner.brand')}
|
|
</span>
|
|
</div>
|
|
<h1 className="mt-2 text-2xl font-semibold flex items-center gap-2">
|
|
{course.title}
|
|
<Sparkles className="w-5 h-5 text-violet-400" />
|
|
</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground leading-relaxed max-w-2xl">
|
|
{course.summary}
|
|
</p>
|
|
|
|
{/* Progress */}
|
|
<div className="mt-5 rounded-2xl border border-border/60 bg-white dark:bg-zinc-900 p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium">{t('course.learner.progress')}</span>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{completedCount}/{course.modules.length}
|
|
</span>
|
|
</div>
|
|
<Progress value={progressPercent} className="h-2" />
|
|
<p className="mt-2 text-[11px] text-muted-foreground/70">
|
|
{t('course.learner.progressHint')}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Module list — learned one by one in framework order */}
|
|
<section className="space-y-3">
|
|
{course.modules.map((module) => {
|
|
const mark = marks[String(module.index)];
|
|
const status = mark?.completed
|
|
? 'completed'
|
|
: mark?.started
|
|
? 'inProgress'
|
|
: 'notStarted';
|
|
return (
|
|
<button
|
|
key={module.index}
|
|
onClick={() => enterModule(module)}
|
|
className="group w-full text-left rounded-2xl border border-border/60 bg-white dark:bg-zinc-900 p-5 shadow-sm hover:shadow-md hover:border-violet-300 dark:hover:border-violet-700 transition-all"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 shrink-0">
|
|
{status === 'completed' ? (
|
|
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
|
) : status === 'inProgress' ? (
|
|
<BookOpen className="w-5 h-5 text-violet-500" />
|
|
) : (
|
|
<Circle className="w-5 h-5 text-muted-foreground/40" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-[11px] font-semibold text-violet-500 tabular-nums">
|
|
{String(module.index).padStart(2, '0')}
|
|
</span>
|
|
<h3 className="font-medium text-sm group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
|
|
{module.title}
|
|
</h3>
|
|
<Badge
|
|
className={
|
|
status === 'completed'
|
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300'
|
|
: status === 'inProgress'
|
|
? 'bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300'
|
|
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'
|
|
}
|
|
>
|
|
{t(`course.learner.moduleStatus.${status}`)}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">
|
|
{module.description}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-3">
|
|
<span className="text-[11px] font-medium text-violet-600 dark:text-violet-400">
|
|
{t('course.learner.enterModule')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleMark(module, 'completed');
|
|
}}
|
|
className="text-[11px] text-muted-foreground/70 hover:text-foreground underline underline-offset-2 transition-colors"
|
|
>
|
|
{mark?.completed
|
|
? t('course.learner.undoDone')
|
|
: t('course.learner.markDone')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 mt-1 shrink-0 text-muted-foreground/60 group-hover:text-violet-500 group-hover:translate-x-0.5 transition-all" />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{!loading && !course && !error && (
|
|
<div className="py-16 text-center space-y-4">
|
|
<p className="text-sm text-muted-foreground">{t('course.learner.notFound')}</p>
|
|
<Button variant="outline" size="sm" onClick={() => router.push('/learn')}>
|
|
{t('course.learner.backToCatalog')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|