's'
This commit is contained in:
152
app/topic/components/TopicCurriculum.tsx
Normal file
152
app/topic/components/TopicCurriculum.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user