161 lines
6.9 KiB
TypeScript
161 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Link } from "@/i18n/navigation";
|
|
import { Section } from "./Section";
|
|
import { CURRICULUM_CONFIG } from "@/config/course";
|
|
import { canWatchLesson, isFreeTrial } from "@/app/lib/course-payment";
|
|
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
|
|
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
|
|
|
|
export function CurriculumSection() {
|
|
const { vip } = useAuthAndVip();
|
|
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
|
|
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
|
|
);
|
|
const [search, setSearch] = useState("");
|
|
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
|
|
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
|
|
|
useEffect(() => {
|
|
setBookmarks(getBookmarks());
|
|
setCompleted(getCompletedLessons());
|
|
const onB = () => setBookmarks(getBookmarks());
|
|
const onP = () => setCompleted(getCompletedLessons());
|
|
window.addEventListener("learning:bookmarks", onB);
|
|
window.addEventListener("learning:progress", onP);
|
|
return () => {
|
|
window.removeEventListener("learning:bookmarks", onB);
|
|
window.removeEventListener("learning:progress", onP);
|
|
};
|
|
}, []);
|
|
|
|
const toggle = (i: number) => {
|
|
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
|
};
|
|
|
|
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
|
|
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
|
|
);
|
|
const filtered = search.trim()
|
|
? allLessons.filter(
|
|
(l) =>
|
|
l.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: allLessons;
|
|
|
|
const filteredByModule = CURRICULUM_CONFIG.modules
|
|
.map((m, mi) => ({
|
|
...m,
|
|
lessons: filtered.filter((l) => l.moduleIndex === mi),
|
|
}))
|
|
.filter((m) => m.lessons.length > 0);
|
|
|
|
return (
|
|
<Section>
|
|
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
|
{CURRICULUM_CONFIG.title}
|
|
</h2>
|
|
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
|
|
{CURRICULUM_CONFIG.subtitle}
|
|
</p>
|
|
<div className="mx-auto mb-8 max-w-md">
|
|
<input
|
|
type="search"
|
|
placeholder="搜索课程..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2 md:space-y-3">
|
|
{filteredByModule.map((c) => {
|
|
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
|
|
const isOpen = expanded[moduleIndex] ?? true;
|
|
return (
|
|
<div
|
|
key={c.title}
|
|
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(moduleIndex)}
|
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
|
|
>
|
|
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
|
|
<span
|
|
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
|
|
>
|
|
▶
|
|
</span>
|
|
<span>{c.emoji}</span>
|
|
{c.title}
|
|
</span>
|
|
<span className="text-sm text-[var(--muted-foreground)]">
|
|
{c.lessons.length} 节
|
|
</span>
|
|
</button>
|
|
{isOpen && (
|
|
<ul className="border-t border-[var(--border)]">
|
|
{c.lessons.map((l) => {
|
|
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex, vip);
|
|
const key = `${l.moduleIndex}-${l.lessonIndex}`;
|
|
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
|
|
const isCompleted = completed.has(key);
|
|
return (
|
|
<li key={key}>
|
|
<Link
|
|
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
|
|
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
|
|
>
|
|
<span className="flex items-center gap-2 truncate sm:gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleBookmark(l.moduleIndex, l.lessonIndex);
|
|
}}
|
|
className="shrink-0 text-xs"
|
|
aria-label={bookmarked ? "取消收藏" : "收藏"}
|
|
>
|
|
{bookmarked ? "⭐" : "☆"}
|
|
</button>
|
|
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
|
|
{l.name}
|
|
</span>
|
|
{isFreeTrial(l.moduleIndex, l.lessonIndex) && (
|
|
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
|
|
试看
|
|
</span>
|
|
)}
|
|
{isCompleted && (
|
|
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
|
|
✓
|
|
</span>
|
|
)}
|
|
{!unlocked && (
|
|
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
|
|
🔒
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
|
{l.duration}
|
|
<span className="text-[var(--accent)]">→</span>
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|