s''
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { getTopicCurriculum } from "../config";
|
||||
import { canWatchLesson } from "@/app/cloudphone/lib/payment";
|
||||
import { canWatchLesson, isPaid } from "@/app/cloudphone/lib/payment";
|
||||
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/cloudphone/lib/learning";
|
||||
|
||||
type TopicCurriculumProps = {
|
||||
@@ -14,19 +14,26 @@ 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -43,6 +50,20 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
}
|
||||
}, [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] }));
|
||||
};
|
||||
@@ -50,6 +71,17 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
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">
|
||||
@@ -92,12 +124,50 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
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}>
|
||||
<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"
|
||||
>
|
||||
{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"
|
||||
@@ -111,13 +181,7 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
>
|
||||
{bookmarked ? "⭐" : "☆"}
|
||||
</button>
|
||||
<span
|
||||
className={
|
||||
unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"
|
||||
}
|
||||
>
|
||||
{lesson.name}
|
||||
</span>
|
||||
<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)]">
|
||||
试看
|
||||
@@ -136,7 +200,8 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
{lesson.duration}
|
||||
<span className="text-[var(--accent)]">→</span>
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -147,6 +212,49 @@ export function TopicCurriculum({ topicId }: TopicCurriculumProps) {
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
{ value: "100+", label: "分钟视频" },
|
||||
{ value: "2", label: "大模块" },
|
||||
],
|
||||
moduleIndices: [1, 2],
|
||||
payUrl: "/",
|
||||
},
|
||||
indie: {
|
||||
@@ -75,6 +74,36 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
const LIVESTREAM_CURRICULUM = [
|
||||
{
|
||||
emoji: "🎯",
|
||||
title: "策略篇 · 账号定位",
|
||||
lessons: [
|
||||
{ name: "01 赛道选择与账号人设设计", duration: "09:30" },
|
||||
{ name: "02 起号期内容与直播节奏", duration: "11:20" },
|
||||
],
|
||||
originalModuleIndex: 1,
|
||||
},
|
||||
{
|
||||
emoji: "🧩",
|
||||
title: "执行篇 · 素材与脚本",
|
||||
lessons: [
|
||||
{ name: "03 直播素材筛选与版权避坑", duration: "12:10" },
|
||||
{ name: "04 开播话术与互动脚本模板", duration: "10:45" },
|
||||
],
|
||||
originalModuleIndex: 2,
|
||||
},
|
||||
{
|
||||
emoji: "📈",
|
||||
title: "增长篇 · 复盘优化",
|
||||
lessons: [
|
||||
{ name: "05 流量数据复盘与调优闭环", duration: "13:15" },
|
||||
{ name: "06 多账号矩阵扩展方法", duration: "14:00" },
|
||||
],
|
||||
originalModuleIndex: 3,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getTopicConfig(topicId: string): TopicConfig | null {
|
||||
return TOPIC_CONFIGS[topicId] ?? null;
|
||||
}
|
||||
@@ -83,6 +112,9 @@ export function getTopicCurriculum(topicId: string) {
|
||||
const config = getTopicConfig(topicId);
|
||||
if (!config) return null;
|
||||
if (config.externalUrl) return { modules: [], hasContent: false };
|
||||
if (topicId === "livestream") {
|
||||
return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 };
|
||||
}
|
||||
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
|
||||
const modules = indices
|
||||
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
|
||||
|
||||
Reference in New Issue
Block a user