261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { getTopicCurriculum } from "../config";
|
||
import { canWatchLesson, isPaid } 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 [paid, setPaid] = useState(false);
|
||
const [showUpdatingModal, setShowUpdatingModal] = useState(false);
|
||
const [updatingLessonName, setUpdatingLessonName] = useState("");
|
||
|
||
const curriculum = getTopicCurriculum(topicId);
|
||
|
||
useEffect(() => {
|
||
setBookmarks(getBookmarks());
|
||
setCompleted(getCompletedLessons());
|
||
setPaid(isPaid());
|
||
const onB = () => setBookmarks(getBookmarks());
|
||
const onP = () => setCompleted(getCompletedLessons());
|
||
const onPayUpdate = () => setPaid(isPaid());
|
||
window.addEventListener("learning:bookmarks", onB);
|
||
window.addEventListener("learning:progress", onP);
|
||
window.addEventListener("pay:updated", onPayUpdate);
|
||
return () => {
|
||
window.removeEventListener("learning:bookmarks", onB);
|
||
window.removeEventListener("learning:progress", onP);
|
||
window.removeEventListener("pay:updated", onPayUpdate);
|
||
};
|
||
}, []);
|
||
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!showUpdatingModal) return;
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") setShowUpdatingModal(false);
|
||
};
|
||
const prevOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => {
|
||
document.body.style.overflow = prevOverflow;
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
};
|
||
}, [showUpdatingModal]);
|
||
|
||
const toggle = (i: number) => {
|
||
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
||
};
|
||
|
||
if (!curriculum?.hasContent || !curriculum.modules.length) return null;
|
||
|
||
const modules = curriculum.modules;
|
||
const isLivestreamTopic = topicId === "livestream";
|
||
const isLivestreamUpdating = isLivestreamTopic && paid;
|
||
|
||
const openUpdatingModal = (lessonName: string) => {
|
||
setUpdatingLessonName(lessonName);
|
||
setShowUpdatingModal(true);
|
||
};
|
||
|
||
const closeUpdatingModal = () => {
|
||
setShowUpdatingModal(false);
|
||
};
|
||
|
||
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 = false; /* 暂时全部加锁 */
|
||
const unlocked = canWatchLesson(origMod, li);
|
||
const key = `${origMod}-${li}`;
|
||
const bookmarked = isBookmarked(origMod, li);
|
||
const isCompleted = completed.has(key);
|
||
const textClass = unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]";
|
||
return (
|
||
<li key={key}>
|
||
{isLivestreamUpdating ? (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => {
|
||
if (paid) openUpdatingModal(lesson.name);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
if (paid) openUpdatingModal(lesson.name);
|
||
}
|
||
}}
|
||
className="flex w-full flex-col gap-1 px-4 py-3 text-left 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={textClass}>{lesson.name}</span>
|
||
<span className="shrink-0 text-xs text-amber-500">🛠️</span>
|
||
</span>
|
||
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
||
{lesson.duration}
|
||
<span>···</span>
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<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={textClass}>{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>
|
||
{showUpdatingModal && (
|
||
<div
|
||
className="fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="课程更新提示"
|
||
onClick={closeUpdatingModal}
|
||
>
|
||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px] animate__animated animate__fadeIn" />
|
||
<div
|
||
className="animate__animated animate__fadeInUp relative w-full max-w-md rounded-2xl border border-amber-400/30 bg-[var(--card)] p-6 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/15 text-2xl text-amber-500">
|
||
🚧
|
||
</div>
|
||
<h3 className="mb-2 text-xl font-bold text-[var(--foreground)]">正在更新</h3>
|
||
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
|
||
<span className="font-medium text-[var(--foreground)]">{updatingLessonName}</span> 正在升级内容,
|
||
将在完成后第一时间开放。
|
||
</p>
|
||
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
|
||
现在可以先学习「云手机」课程,后续会自动同步最新章节。
|
||
</p>
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={closeUpdatingModal}
|
||
className="rounded-full border border-[var(--border)] px-4 py-2 text-sm text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]"
|
||
>
|
||
稍后再看
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={closeUpdatingModal}
|
||
className="rounded-full bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||
>
|
||
我知道了
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|