227 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|