Files
openmaic/OpenMAIC/app/learn/page.tsx
2026-08-16 14:58:47 +08:00

227 lines
8.8 KiB
TypeScript

'use client';
// Learner course catalog — the user-end home. Lists every published
// courseware from the registry; clicking a card opens the learner player
// (/learn/[coursewareId] → static playback + AI assistant).
//
// Read path only (GET /api/coursewares) — zero LLM, matches the "high-quality
// original courseware, directly consumable" value proposition of the 3-tier
// design.
import { useCallback, useEffect, useState } from 'react';
import {
BookOpen,
ChevronRight,
GraduationCap,
Languages,
Layers,
ListChecks,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useRouter } from 'next/navigation';
export interface CatalogCourseware {
coursewareId: string;
version: number;
title: string;
language?: string;
status: string;
publishedAt: string;
contentHash: string;
sceneCount: number;
quizSceneCount: number;
bundleUrl: string;
}
/** Published large-course (large-course mode) — learned module by module. */
export interface CatalogCourse {
courseId: string;
version: number;
title: string;
summary: string;
moduleCount: number;
publishedAt: string;
}
export default function LearnCatalogPage() {
const { t } = useI18n();
const router = useRouter();
const [items, setItems] = useState<CatalogCourseware[]>([]);
const [courses, setCourses] = useState<CatalogCourse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [coursewaresResponse, coursesResponse] = await Promise.all([
fetch('/api/coursewares'),
fetch('/api/learn/courses'),
]);
const coursewaresBody = (await coursewaresResponse.json()) as { items?: CatalogCourseware[] };
const coursesBody = (await coursesResponse.json()) as { items?: CatalogCourse[] };
if (
!coursewaresResponse.ok ||
!Array.isArray(coursewaresBody.items) ||
!coursesResponse.ok ||
!Array.isArray(coursesBody.items)
) {
throw new Error('HTTP error');
}
setItems(coursewaresBody.items);
setCourses(coursesBody.items);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
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-4xl px-6 py-10">
{/* Hero */}
<header className="mb-8">
<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('learner.catalogBrand')}</span>
</div>
<h1 className="mt-2 text-2xl font-semibold flex items-center gap-2">
{t('learner.catalogTitle')}
<Sparkles className="w-5 h-5 text-violet-400" />
</h1>
<p className="mt-1.5 text-sm text-muted-foreground max-w-xl">
{t('learner.catalogDesc')}
</p>
</header>
{/* Toolbar */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-muted-foreground">
{t('learner.catalogCount', { count: items.length })}
</span>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
{t('learner.refresh')}
</Button>
</div>
{loading && (
<p className="text-sm text-muted-foreground py-8 text-center">{t('learner.loading')}</p>
)}
{error && (
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/40 p-4 text-sm text-red-800 dark:text-red-200">
{t('learner.loadFailed')}: {error}
</div>
)}
{!loading && !error && items.length === 0 && courses.length === 0 && (
<div className="py-16 text-center space-y-3">
<BookOpen className="w-10 h-10 mx-auto text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t('learner.catalogEmpty')}</p>
</div>
)}
{/* Large courses (large-course mode) — learned module by module */}
{courses.length > 0 && (
<section className="mb-10">
<h2 className="flex items-center gap-2 text-sm font-semibold mb-3">
<Layers className="w-4 h-4 text-violet-500" />
{t('course.learner.catalogCourses')}
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{courses.map((course) => (
<button
key={course.courseId}
onClick={() =>
router.push(
`/learn/course/${encodeURIComponent(course.courseId)}?version=${course.version}`,
)
}
className="group text-left rounded-2xl border border-violet-200/70 dark:border-violet-900/50 bg-gradient-to-br from-violet-50/80 to-white dark:from-violet-950/30 dark:to-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 justify-between gap-3">
<h3 className="font-semibold leading-snug group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
{course.title}
</h3>
<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>
<p className="mt-2 text-xs text-muted-foreground leading-relaxed line-clamp-2">
{course.summary}
</p>
<div className="mt-3 text-[11px] text-muted-foreground/70">
{t('course.learner.moduleCount', { count: course.moduleCount })}
</div>
</button>
))}
</div>
</section>
)}
{items.length > 0 && (
<h2 className="flex items-center gap-2 text-sm font-semibold mb-3">
<GraduationCap className="w-4 h-4 text-violet-500" />
{t('course.learner.catalogCoursewares')}
</h2>
)}
{/* Course cards */}
<div className="grid gap-4 sm:grid-cols-2">
{items.map((item) => (
<button
key={`${item.coursewareId}@${item.version}`}
onClick={() => {
const query = new URLSearchParams({
version: String(item.version),
hash: item.contentHash,
});
router.push(`/learn/${encodeURIComponent(item.coursewareId)}?${query.toString()}`);
}}
className="group 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 justify-between gap-3">
<h2 className="font-semibold leading-snug group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
{item.title}
</h2>
<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>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{item.language && (
<span className="inline-flex items-center gap-1">
<Languages className="w-3 h-3" />
{item.language}
</span>
)}
<span>{t('learner.sceneCount', { count: item.sceneCount })}</span>
{item.quizSceneCount > 0 && (
<span className="inline-flex items-center gap-1">
<ListChecks className="w-3 h-3" />
{t('learner.quizCount', { count: item.quizSceneCount })}
</span>
)}
</div>
<div className="mt-3 text-[11px] text-muted-foreground/70">
{t('learner.publishedAt')} · {formatDate(item.publishedAt)}
</div>
</button>
))}
</div>
</div>
</main>
);
}