This commit is contained in:
eric
2026-03-10 11:48:50 -05:00
parent eb33ed9360
commit 296d703307
41 changed files with 9072 additions and 121 deletions

View File

@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { marked } from "marked";
import styles from "./lesson-markdown.module.css";
type TocItem = { level: number; text: string; id: string };
function slugify(text: string): string {
return text
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
function extractToc(content: string): TocItem[] {
const items: TocItem[] = [];
const lines = content.split("\n");
let idCounter = 0;
for (const line of lines) {
const h1 = line.match(/^# (.+)$/);
const h2 = line.match(/^## (.+)$/);
if (h1) {
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
} else if (h2) {
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
}
}
return items;
}
function markdownToHtml(content: string): string {
const raw = marked.parse(content, { breaks: true, gfm: true }) as string;
let html = raw
.replace(/<img /g, '<img loading="lazy" decoding="async" ')
.replace(/<a href=/g, '<a href=');
// 给 h1/h2 添加 id
html = html.replace(/<h1>([^<]*)<\/h1>/gi, (_, text) => {
const t = text.replace(/<[^>]+>/g, "").trim();
return `<h1 id="${slugify(t)}" class="scroll-mt-24">${text}</h1>`;
});
html = html.replace(/<h2>([^<]*)<\/h2>/gi, (_, text) => {
const t = text.replace(/<[^>]+>/g, "").trim();
return `<h2 id="${slugify(t)}" class="scroll-mt-24">${text}</h2>`;
});
html = html.replace(/<a href="([^"]+)"([^>]*)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer"$2>');
html = html.replace(/<pre>\s*<code([^>]*)>/g, `<pre class="${styles.codeBlock}"><code$1>`);
return html;
}
export function LessonMarkdown({ content }: { content: string }) {
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [showToc, setShowToc] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const toc = extractToc(content);
const html = markdownToHtml(content);
useEffect(() => {
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
setShowBackTop(scrollTop > 400);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleTocJump = useCallback((index: number) => {
setShowToc(false);
const root = contentRef.current;
if (!root) return;
const headings = root.querySelectorAll("h1, h2");
const el = headings[index] as HTMLElement;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div
ref={contentRef}
className={`${styles.markdownBody} markdown-body`}
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowToc(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
<div className={styles.drawerTitle}></div>
{toc.map((item, i) => (
<div
key={item.id}
role="button"
tabIndex={0}
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
onClick={() => handleTocJump(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
}}
>
{item.text}
</div>
))}
</aside>
{showBackTop && (
<>
{toc.length > 0 && (
<button
type="button"
className={styles.tocButton}
onClick={() => setShowToc(true)}
aria-label="打开目录"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
</>
)}
</>
);
}

View File

@@ -0,0 +1,333 @@
.readingProgress {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
z-index: 100;
transition: width 0.15s linear;
border-radius: 0 3px 3px 0;
}
.backToTop {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
color: var(--accent);
transition: transform 0.2s, box-shadow 0.2s;
}
.backToTop:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.tocButton {
position: fixed;
bottom: 24px;
right: 84px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
color: var(--accent);
transition: transform 0.2s, box-shadow 0.2s;
}
.tocButton:hover {
transform: translateY(-2px);
}
.drawerMask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 140;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.drawerMaskOpen {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: var(--background);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 150;
padding: 80px 0 24px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawerOpen {
transform: translateX(0);
}
.drawerTitle {
padding: 0 20px 12px;
font-size: 14px;
font-weight: 600;
color: var(--muted-foreground);
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.drawerItem {
padding: 10px 20px;
font-size: 14px;
color: var(--foreground);
cursor: pointer;
transition: background 0.15s;
}
.drawerItem:hover {
background: var(--muted);
}
.drawerItemH2 {
padding-left: 28px;
font-size: 13px;
color: var(--muted-foreground);
}
.markdownBody {
font-size: 16px;
line-height: 1.85;
color: var(--foreground);
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdownBody h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--foreground);
margin: 28px 0 12px;
padding-bottom: 0;
border-bottom: none;
line-height: 1.3;
}
.markdownBody h1:first-child {
margin-top: 0;
}
.markdownBody h1::after {
content: "";
display: block;
width: 40px;
height: 3px;
background: linear-gradient(90deg, var(--accent), #fbbf24);
border-radius: 3px;
margin-top: 8px;
}
.markdownBody h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--foreground);
margin: 24px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
line-height: 1.35;
}
.markdownBody h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--foreground);
margin: 20px 0 8px;
}
.markdownBody h4,
.markdownBody h5,
.markdownBody h6 {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
margin: 16px 0 6px;
}
.markdownBody p {
font-size: 1rem;
margin-bottom: 14px;
line-height: 1.9;
}
.markdownBody blockquote {
position: relative;
margin: 24px 0;
padding: 16px 20px 16px 24px;
border-left: none;
background: var(--muted);
border-radius: 0 12px 12px 0;
color: var(--muted-foreground);
}
.markdownBody blockquote::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.markdownBody pre.codeBlock {
margin: 24px 0;
padding: 0;
overflow-x: auto;
border-radius: 8px;
background: #282c34;
}
.markdownBody pre.codeBlock code {
display: block;
padding: 16px;
font-size: 0.875em;
line-height: 1.6;
color: #abb2bf;
}
.markdownBody p code,
.markdownBody li code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background: var(--muted);
color: var(--foreground);
border-radius: 6px;
}
.markdownBody a {
color: var(--accent);
text-decoration: none;
}
.markdownBody a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.markdownBody img {
display: block;
margin: 28px auto;
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.markdownBody ul,
.markdownBody ol {
margin: 16px 0;
padding-left: 28px;
}
.markdownBody li {
margin-bottom: 6px;
}
.markdownBody table {
width: 100%;
margin: 24px 0;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
}
.markdownBody th {
background: var(--muted);
font-weight: 600;
padding: 12px 16px;
text-align: left;
border: 1px solid var(--border);
}
.markdownBody td {
padding: 12px 16px;
border: 1px solid var(--border);
}
.markdownBody tr:nth-child(even) {
background: var(--muted);
}
.markdownBody hr {
border: none;
height: 1px;
background: var(--border);
margin: 28px 0;
}
.markdownBody strong {
font-weight: 700;
}
.markdownBody input[type="checkbox"] {
margin-right: 8px;
}
@media (min-width: 768px) {
.markdownBody {
font-size: 17px;
line-height: 1.8;
}
.markdownBody h1 {
font-size: 1.75rem;
margin: 32px 0 14px;
}
.markdownBody h2 {
font-size: 1.35rem;
margin: 28px 0 12px;
}
.markdownBody h3 {
font-size: 1.2rem;
margin: 24px 0 10px;
}
.backToTop,
.tocButton {
bottom: 32px;
right: 32px;
}
.tocButton {
right: 92px;
}
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Header } from "../../../components";
import { VideoPlayer } from "../../../components/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "../../../config/lessons";
import { canWatchLesson } from "../../../lib/payment";
import { setLastWatched, markCompleted } from "../../../lib/learning";
import { DOWNLOAD_CONFIG } from "../../../config/course";
export default function LessonPage() {
const params = useParams();
const router = useRouter();
const moduleIndex = Number(params.moduleIndex);
const lessonIndex = Number(params.lessonIndex);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<main className="pt-20 pb-16">
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<h1 className="mb-4 text-2xl font-bold"></h1>
<p className="mb-6 text-[var(--muted-foreground)]">
</p>
<Link
href="/cloudphone"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
<nav className="animate__animated animate__fadeInDown mb-6 flex items-center gap-2 text-sm" style={{ animationFillMode: "both" }}>
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-[var(--muted-foreground)]">/</span>
<Link href="/cloudphone" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
</Link>
</nav>
<h1 className="animate__animated animate__fadeInUp mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>
{lesson.name}
{isFreeTrial && (
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
</span>
)}
</h1>
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
{lesson.duration}
</p>
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`cloudphone-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
) : (
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90"></p>
<Link
href="/cloudphone/pay"
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
</div>
)}
</div>
{unlocked && (
<div className="animate__animated animate__fadeInUp relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8" style={{ animationDelay: "0.3s", animationFillMode: "both" }}>
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</h2>
<LessonMarkdown content={lesson.markdown} />
</div>
)}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">
</p>
<Link
href={DOWNLOAD_CONFIG.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
{DOWNLOAD_CONFIG.buttonText}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</div>
)}
</div>
</main>
</div>
);
}