Files
openmaic/OpenMAIC/app/learn/course/[courseId]/page.tsx
T

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>
);
}