This commit is contained in:
eric
2026-03-12 00:34:50 -05:00
parent 0c2ccadb6b
commit 5caf970c7e
26 changed files with 1528 additions and 288 deletions

View File

@@ -0,0 +1,294 @@
/* 电子书阅读器 - 支持深色主题,可复用到 digital/nomadvip/nomadlms */
.readingProgress {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, var(--accent) 0%, #00f2fe 100%);
z-index: 201;
transition: width 0.15s linear;
border-radius: 0 4px 4px 0;
}
.readingTime {
font-size: 12px;
font-weight: 400;
color: var(--muted-foreground);
margin-left: 4px;
}
.headerSpacer {
width: 100%;
height: 88px;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 88px;
background: var(--card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.headerLeft {
width: 100px;
display: flex;
align-items: center;
justify-content: center;
color: var(--foreground);
cursor: pointer;
}
.headerLeft:hover {
color: var(--accent);
}
.headerMiddle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: var(--foreground);
letter-spacing: 2px;
}
.headerRight {
width: 40px;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
cursor: pointer;
margin-right: 8px;
background: var(--accent-muted, rgba(245, 158, 11, 0.15));
border-radius: 50%;
transition: all 0.2s ease;
}
.headerRight:hover {
background: var(--accent-muted, rgba(245, 158, 11, 0.25));
}
.headerRightActive {
background: var(--accent) !important;
color: var(--accent-foreground) !important;
}
.headerRightText {
display: none;
margin-left: 4px;
}
.chunkEnter {
animation: chunkAppear 0.5s ease-out both;
}
@keyframes chunkAppear {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.chunkPlaceholder {
background: var(--muted);
background-size: 200% 100%;
animation: shimmer 1.8s ease infinite;
border-radius: 12px;
margin: 24px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.backToTop {
position: fixed;
bottom: 120px;
right: 30px;
width: 56px;
height: 56px;
background: var(--card);
backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px 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 24px rgba(0, 0, 0, 0.15);
}
.book {
padding: 30px;
max-width: 100%;
}
.markdownBody {
font-size: 16px !important;
line-height: 1.85 !important;
color: var(--foreground) !important;
word-wrap: break-word;
}
.markdownBody h1 {
font-size: 24px !important;
font-weight: 800;
color: var(--foreground);
margin: 28px 0 10px;
}
.markdownBody h1::after {
content: "";
display: block;
width: 40px;
height: 3px;
background: linear-gradient(90deg, var(--accent), #00f2fe);
border-radius: 3px;
margin-top: 8px;
}
.markdownBody h2 {
font-size: 21px !important;
font-weight: 700;
color: var(--foreground);
margin: 22px 0 9px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.markdownBody h3 { font-size: 18px !important; font-weight: 600; margin: 18px 0 7px; }
.markdownBody h4, .markdownBody h5, .markdownBody h6 { font-size: 16px !important; margin: 14px 0 6px; }
.markdownBody p { font-size: 16px !important; margin-bottom: 10px; line-height: 1.9; }
.markdownBody blockquote {
margin: 28px 0;
padding: 20px 24px 20px 30px;
background: var(--muted);
border-radius: 0 12px 12px 0;
color: var(--muted-foreground);
border-left: 4px solid var(--accent);
}
.markdownBody pre {
margin: 28px 0;
padding: 28px 24px;
background: var(--background);
border: 1px solid var(--border);
border-radius: 12px;
overflow-x: auto;
}
.markdownBody pre code {
font-family: var(--font-geist-mono), monospace;
font-size: 13px !important;
color: var(--foreground);
background: none !important;
}
.markdownBody code {
font-family: var(--font-geist-mono), monospace;
font-size: 14px !important;
padding: 2px 6px;
background: var(--muted);
color: var(--accent);
border-radius: 6px;
}
.markdownBody a { color: var(--accent); }
.markdownBody a:hover { text-decoration: underline; }
.markdownBody img { display: block; margin: 28px auto; max-width: 100%; border-radius: 8px; }
.markdownBody ul, .markdownBody ol { margin: 16px 0; padding-left: 40px; }
.markdownBody table { width: 100%; margin: 28px 0; border-collapse: collapse; border-radius: 8px; }
.markdownBody th { background: var(--muted); font-weight: 600; padding: 16px 20px; border: 1px solid var(--border); }
.markdownBody td { padding: 14px 20px; border: 1px solid var(--border); }
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: var(--card);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 150;
padding-top: 88px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawerOpen { transform: translateX(0); }
.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;
}
.drawerItem {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
color: var(--foreground);
cursor: pointer;
font-size: 15px;
transition: background 0.15s;
}
.drawerItem:hover {
background: var(--muted);
}
@media (min-width: 768px) {
.headerSpacer { height: 56px; }
.header { height: 56px; }
.progressBar { top: 56px; }
.headerMiddle { font-size: 18px; }
.headerRight {
width: auto;
height: 34px;
padding: 0 14px;
margin-right: 16px;
border-radius: 17px;
font-size: 13px;
}
.headerRightText { display: inline; }
.book { padding: 30px 60px; }
.markdownBody { max-width: 800px; margin: 0 auto; }
.markdownBody h1 { font-size: 28px !important; }
.markdownBody h2 { font-size: 23px !important; }
.drawer { padding-top: 56px; width: 300px; }
.backToTop { width: 44px; height: 44px; bottom: 40px; right: 40px; }
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import Link from "next/link";
import type { EbookData } from "@/lib/content/ebook";
import type { EbookConfig } from "@/lib/content/config";
import "github-markdown-css/github-markdown.css";
import styles from "./EbookViewer.module.css";
function LazyChunk({
html,
estimatedHeight,
forceVisible,
}: {
html: string;
estimatedHeight: number;
forceVisible: boolean;
}) {
const [visible, setVisible] = useState(forceVisible);
const ref = useRef<HTMLDivElement>(null);
const show = forceVisible || visible;
useEffect(() => {
if (forceVisible || visible) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: "1200px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [forceVisible, visible]);
if (show) {
return (
<div
className={`${styles.chunkEnter} animate__animated animate__fadeInUp`}
style={{ animationDuration: "0.5s", animationFillMode: "both" }}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<div ref={ref} className={styles.chunkPlaceholder} style={{ minHeight: estimatedHeight }} />
);
}
type EbookViewerProps = {
data: EbookData;
config: EbookConfig;
};
export function EbookViewer({ data, config }: EbookViewerProps) {
const [showDrawer, setShowDrawer] = useState(false);
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [forceShowAll, setForceShowAll] = useState(false);
const [readingTimeLeft, setReadingTimeLeft] = useState<number | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { chunks, headers } = data;
const estimatedMin = config.estimatedMinutes ?? null;
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 > 600);
if (estimatedMin && docHeight > 0) {
const remaining = Math.max(0, estimatedMin * (1 - scrollTop / docHeight));
setReadingTimeLeft(Math.ceil(remaining));
}
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [estimatedMin]);
const handleJump = useCallback((index: number) => {
setForceShowAll(true);
setShowDrawer(false);
const run = () => {
const root = contentRef.current;
if (!root) return;
const h1s = root.querySelectorAll("h1");
const h2s = root.querySelectorAll("h2");
const targets = h1s.length > 0 ? h1s : h2s;
const el = targets[index] as HTMLElement;
if (el) {
try {
el.scrollIntoView({ behavior: "smooth", block: "start" });
} catch {
const top =
el.getBoundingClientRect().top +
(window.pageYOffset || document.documentElement.scrollTop) -
60;
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
}
}
};
run();
setTimeout(run, 100);
setTimeout(run, 300);
setTimeout(run, 600);
}, []);
return (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div className={styles.headerSpacer} />
<header className={`${styles.header} animate__animated animate__fadeInDown`} style={{ animationFillMode: "both" }}>
<Link href={config.backHref} className={styles.headerLeft} aria-label="返回">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<div className={styles.headerMiddle}>
{config.title}
{readingTimeLeft !== null && readingTimeLeft > 0 && (
<span className={styles.readingTime}> · {readingTimeLeft} </span>
)}
</div>
<button
type="button"
className={`${styles.headerRight} ${showDrawer ? styles.headerRightActive : ""}`}
onClick={() => setShowDrawer(!showDrawer)}
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>
<span className={styles.headerRightText}></span>
</button>
</header>
<div className={styles.book}>
<div ref={contentRef} className={`markdown-body ${styles.markdownBody}`}>
{chunks.map((chunk, i) => (
<LazyChunk
key={i}
html={chunk.html}
estimatedHeight={chunk.estimatedHeight}
forceVisible={forceShowAll || i === 0}
/>
))}
</div>
</div>
<div
className={`${styles.drawerMask} ${showDrawer ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowDrawer(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showDrawer ? styles.drawerOpen : ""}`}>
{headers.map((titleItem, index) => (
<div
key={index}
role="button"
tabIndex={0}
className={styles.drawerItem}
onClick={() => handleJump(index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleJump(index);
}}
>
{titleItem}
</div>
))}
</aside>
{showBackTop && (
<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,151 @@
"use client";
import { useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { CourseConfig } from "@/lib/content/config";
type LessonLayoutProps = {
courseConfig: CourseConfig;
moduleIndex: number;
lessonIndex: number;
lessonName: string;
lessonDuration: string;
isFreeTrial: boolean;
unlocked: boolean;
totalLessons: number;
completedCount: number;
prevLesson: { moduleIndex: number; lessonIndex: number } | null;
nextLesson: { moduleIndex: number; lessonIndex: number } | null;
courseHomeHref: string;
payHref: string;
children: React.ReactNode;
};
export function LessonLayout({
courseConfig,
moduleIndex,
lessonIndex,
lessonName,
lessonDuration,
isFreeTrial,
unlocked,
totalLessons,
completedCount,
prevLesson,
nextLesson,
courseHomeHref,
payHref,
children,
}: LessonLayoutProps) {
const router = useRouter();
const courseBase = courseHomeHref.replace(/\/$/, "");
const lessonBase = courseBase.includes("/course") ? courseBase : `${courseBase}/course`;
const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.target && (e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "ArrowLeft" && prevLesson) {
router.push(`${lessonBase}/${prevLesson.moduleIndex}/${prevLesson.lessonIndex}`);
} else if (e.key === "ArrowRight" && nextLesson) {
router.push(`${lessonBase}/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`);
}
},
[prevLesson, nextLesson, lessonBase, router]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 进度条 */}
<div className="mb-6">
<div className="flex justify-between text-xs text-[var(--muted-foreground)] mb-1">
<span></span>
<span>{completedCount}/{totalLessons} · {progressPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* 导航 */}
<nav className="mb-6 flex items-center gap-2 text-sm">
<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={courseHomeHref} className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
</Link>
</nav>
{/* 标题 */}
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
{lessonName}
{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)]"> {lessonDuration}</p>
{/* 内容区 */}
{children}
{/* 上/下一节 */}
<div className="mt-12 flex flex-col gap-3 sm:flex-row sm:justify-between">
{prevLesson ? (
<Link
href={`${courseBase}/course/${prevLesson.moduleIndex}/${prevLesson.lessonIndex}`}
className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--accent)]/50"
>
<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>
</Link>
) : (
<div />
)}
{nextLesson ? (
<Link
href={`${courseBase}/course/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`}
className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--accent)]/50 ml-auto"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
) : (
<Link
href={courseHomeHref}
className="flex items-center gap-2 rounded-xl bg-[var(--accent)] px-4 py-3 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 ml-auto"
>
</Link>
)}
</div>
</div>
</main>
</div>
);
}