's'
This commit is contained in:
294
app/components/content/EbookViewer.module.css
Normal file
294
app/components/content/EbookViewer.module.css
Normal 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; }
|
||||
}
|
||||
200
app/components/content/EbookViewer.tsx
Normal file
200
app/components/content/EbookViewer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
app/components/content/LessonLayout.tsx
Normal file
151
app/components/content/LessonLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user