This commit is contained in:
eric
2026-03-11 23:47:45 -05:00
parent 296d703307
commit 0c2ccadb6b
35 changed files with 2399 additions and 195 deletions

View File

@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTopicCurriculum } from "../config";
import { canWatchLesson } from "@/app/cloudphone/lib/payment";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/cloudphone/lib/learning";
type TopicCurriculumProps = {
topicId: string;
};
export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
const [completed, setCompleted] = useState<Set<string>>(new Set());
const curriculum = getTopicCurriculum(topicId);
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);
};
}, []);
useEffect(() => {
const curr = getTopicCurriculum(topicId);
if (curr?.modules?.length) {
setExpanded((prev) => {
const next = { ...prev };
curr.modules.forEach((_, i) => {
if (next[i] === undefined) next[i] = i === 0;
});
return next;
});
}
}, [topicId]);
const toggle = (i: number) => {
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
};
if (!curriculum?.hasContent || !curriculum.modules.length) return null;
const modules = curriculum.modules;
return (
<section className="border-t border-[var(--border)] py-12 md:py-16 lg:py-20">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl"></h2>
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
</p>
<div className="space-y-2 md:space-y-3">
{modules.map((module, i) => {
const isOpen = expanded[i] ?? true;
return (
<div
key={module.title}
className="animate__animated animate__fadeInUp overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-sm transition-all duration-200 hover:border-[var(--accent)]/30 hover:shadow-md md:rounded-2xl"
style={{ animationDelay: `${i * 0.06}s`, animationFillMode: "both" }}
>
<button
type="button"
onClick={() => toggle(i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)]/50 sm:px-5 sm:py-4"
>
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<span
className={`inline-block transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
</span>
<span>{module.emoji}</span>
{module.title}
</span>
<span className="text-sm text-[var(--muted-foreground)]">{module.lessons.length} </span>
</button>
{isOpen && (
<ul className="border-t border-[var(--border)]">
{module.lessons.map((lesson, li) => {
const origMod = module.originalModuleIndex;
const free = origMod === 0 && li < 2;
const unlocked = canWatchLesson(origMod, li);
const key = `${origMod}-${li}`;
const bookmarked = isBookmarked(origMod, li);
const isCompleted = completed.has(key);
return (
<li key={key}>
<Link
href={`/cloudphone/course/${origMod}/${li}`}
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)]/50 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(origMod, li);
}}
className="shrink-0 text-xs"
aria-label={bookmarked ? "取消收藏" : "收藏"}
>
{bookmarked ? "⭐" : "☆"}
</button>
<span
className={
unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"
}
>
{lesson.name}
</span>
{free && (
<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">
{lesson.duration}
<span className="text-[var(--accent)]"></span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</div>
</section>
);
}