'优化课程'
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { generateVideoPoster } from "./videoPoster";
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
videoUrl?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
lesson: Lesson;
|
||||
completed?: boolean;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export function VideoCard({ lesson, completed = false, onComplete }: Props) {
|
||||
const [poster, setPoster] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPoster(generateVideoPoster(lesson.title, lesson.id));
|
||||
}, [lesson.title, lesson.id]);
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group overflow-hidden rounded-2xl border shadow-sm transition-all duration-200 hover:shadow-md ${
|
||||
completed
|
||||
? "border-emerald-200 bg-emerald-50/50 hover:border-emerald-300"
|
||||
: "border-slate-100 bg-white hover:border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{/* 三级标题:视频上方,突出显示 */}
|
||||
<div className="flex items-center gap-3 border-b border-slate-100 bg-white/80 px-4 py-3">
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
|
||||
completed ? "bg-emerald-100 text-emerald-600" : "bg-sky-50 text-sky-600"
|
||||
}`}
|
||||
>
|
||||
{lesson.id}
|
||||
</span>
|
||||
<h4 className="min-w-0 flex-1 text-base font-semibold leading-snug text-slate-800 line-clamp-2">
|
||||
{lesson.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden bg-slate-900">
|
||||
<video
|
||||
src={lesson.videoUrl}
|
||||
poster={poster ?? undefined}
|
||||
controls
|
||||
playsInline
|
||||
onEnded={handleEnded}
|
||||
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
|
||||
preload="metadata"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<span className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-0.5 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm">
|
||||
{lesson.duration}
|
||||
</span>
|
||||
{completed && (
|
||||
<span className="absolute top-2 right-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-md">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/atom-one-dark.min.css";
|
||||
import styles from "./lesson-markdown.module.css";
|
||||
|
||||
type TocItem = { level: number; text: string; id: string };
|
||||
|
||||
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 slugify(text: string): string {
|
||||
return text
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
|
||||
components={{
|
||||
h1: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h1 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h2 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
|
||||
h4: ({ children }) => <h4>{children}</h4>,
|
||||
h5: ({ children }) => <h5>{children}</h5>,
|
||||
h6: ({ children }) => <h6>{children}</h6>,
|
||||
p: ({ children }) => <p>{children}</p>,
|
||||
ul: ({ children }) => <ul>{children}</ul>,
|
||||
ol: ({ children }) => <ol>{children}</ol>,
|
||||
li: ({ children, className }) => (
|
||||
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className={styles.codeBlock}>{children}</pre>
|
||||
),
|
||||
code: ({ className, children }) => (
|
||||
<code className={className}>{children}</code>
|
||||
),
|
||||
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th>{children}</th>,
|
||||
td: ({ children }) => <td>{children}</td>,
|
||||
hr: () => <hr />,
|
||||
strong: ({ children }) => <strong>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/* 参考 nomadlms,适配日间/夜间主题 */
|
||||
|
||||
.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: transparent;
|
||||
}
|
||||
|
||||
.markdownBody pre.codeBlock code {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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 li > ul,
|
||||
.markdownBody li > ol {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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 h1::after {
|
||||
width: 50px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.35rem;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 24px 0 10px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.backToTop,
|
||||
.tocButton {
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
right: 92px;
|
||||
}
|
||||
}
|
||||
154
app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
154
app/[locale]/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { VideoPlayer } from "@/app/components/course/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
import { getLesson } from "@/config/lessons";
|
||||
import { canWatchLesson } from "@/app/lib/course-payment";
|
||||
import { setLastWatched, markCompleted } from "@/app/lib/learning";
|
||||
import { DOWNLOAD_CONFIG } from "@/config/course";
|
||||
import Header from "@/app/components/Header";
|
||||
|
||||
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="/course"
|
||||
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="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="/course" 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">
|
||||
{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>
|
||||
|
||||
{/* 视频播放器 / 锁定 overlay */}
|
||||
<div className="relative mb-12">
|
||||
{unlocked ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${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="/join"
|
||||
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="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<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>
|
||||
<a
|
||||
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>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,558 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { VideoCard } from "./VideoCard";
|
||||
import { useVideoProgress } from "./useVideoProgress";
|
||||
import AuthModal from "../../components/AuthModal";
|
||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||
|
||||
/* ─── data ─── */
|
||||
|
||||
const stats = [
|
||||
{ value: "21", label: "节实战课程" },
|
||||
{ value: "150+", label: "分钟视频" },
|
||||
{ value: "3", label: "位实战讲师" },
|
||||
];
|
||||
|
||||
const audiences = [
|
||||
{
|
||||
icon: "💼",
|
||||
title: "职场人想转型",
|
||||
desc: "受够了 996?学会远程工作技能,平滑过渡到自由生活方式",
|
||||
},
|
||||
{
|
||||
icon: "👨💻",
|
||||
title: "开发者 / 设计师",
|
||||
desc: "你的技能天然适合远程,学会如何接全球客户、拿美金报酬",
|
||||
},
|
||||
{
|
||||
icon: "📱",
|
||||
title: "自媒体 / 内容创作者",
|
||||
desc: "边旅行边创作,掌握数字游民内容变现的全套方法论",
|
||||
},
|
||||
{
|
||||
icon: "🌍",
|
||||
title: "想出国旅居的人",
|
||||
desc: "签证、税务、保险、目的地一站搞定,少走弯路直接出发",
|
||||
},
|
||||
];
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
num: 1,
|
||||
title: "完整的远程工作体系",
|
||||
desc: "从技能评估到客户获取,建立可持续的远程收入系统",
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: "打通 3+ 收入渠道",
|
||||
desc: "远程全职、自由职业、数字产品、内容创作多管齐下",
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: "掌握 50+ 远程工具",
|
||||
desc: "协作、会议、项目管理、财务,一个背包装下整个办公室",
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
title: "签证 + 税务 + 保险全通关",
|
||||
desc: "55 国数字游民签证政策、合规税务方案、保险选购指南",
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
title: "跑通第一次数字游民旅程",
|
||||
desc: "从选目的地到打包出发,手把手带你完成首次数字游民体验",
|
||||
},
|
||||
];
|
||||
|
||||
// 示例视频:w3schools 公开样本,可替换为 MinIO CDN URL(如 https://minioweb.hackrobot.cn/hackrobot/course/xxx.mp4)
|
||||
const SAMPLE_VIDEO = "https://www.w3schools.com/html/mov_bbb.mp4";
|
||||
|
||||
// 一级:part 二级:section 三级:lesson
|
||||
const curriculum = [
|
||||
{
|
||||
emoji: "🚀",
|
||||
part: "基础篇",
|
||||
sections: [
|
||||
{ name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💡",
|
||||
part: "技能篇",
|
||||
sections: [
|
||||
{ name: "远程工作能力", lessons: [{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20", videoUrl: SAMPLE_VIDEO }, { id: "04", title: "30天技能升级路线:从评估到接单", duration: "11:15", videoUrl: SAMPLE_VIDEO }, { id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💰",
|
||||
part: "收入篇",
|
||||
sections: [
|
||||
{ name: "构建收入体系", lessons: [{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05", videoUrl: SAMPLE_VIDEO }, { id: "07", title: "自由职业平台实操:Upwork / Toptal / 电鸭", duration: "13:50", videoUrl: SAMPLE_VIDEO }, { id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20", videoUrl: SAMPLE_VIDEO }, { id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🛠️",
|
||||
part: "工具篇",
|
||||
sections: [
|
||||
{ name: "移动办公装备", lessons: [{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45", videoUrl: SAMPLE_VIDEO }, { id: "11", title: "远程协作工具栈:Slack / Notion / Linear", duration: "08:30", videoUrl: SAMPLE_VIDEO }, { id: "12", title: "安全必修课:VPN / 密码管理 / 2FA", duration: "05:55", videoUrl: SAMPLE_VIDEO }, { id: "13", title: "跨境财务工具:Wise / Stripe / Revolut", duration: "07:10", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "📋",
|
||||
part: "合规篇",
|
||||
sections: [
|
||||
{ name: "签证税务保险", lessons: [{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30", videoUrl: SAMPLE_VIDEO }, { id: "15", title: "税务居民身份与合规规划", duration: "11:40", videoUrl: SAMPLE_VIDEO }, { id: "16", title: "数字游民保险选购指南", duration: "06:20", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🗺️",
|
||||
part: "目的地篇",
|
||||
sections: [
|
||||
{ name: "选择你的城市", lessons: [{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50", videoUrl: SAMPLE_VIDEO }, { id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25", videoUrl: SAMPLE_VIDEO }, { id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🏆",
|
||||
part: "实战篇",
|
||||
sections: [
|
||||
{ name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const instructors = [
|
||||
{
|
||||
name: "Alex",
|
||||
avatar: "🧑💻",
|
||||
role: "全栈开发者 / 5年数字游民",
|
||||
tag: "技术导师",
|
||||
desc: "前字节跳动高级工程师,2021年开始数字游民生活,足迹遍布 20+ 国家,远程服务硅谷客户",
|
||||
},
|
||||
{
|
||||
name: "Lisa",
|
||||
avatar: "👩🎨",
|
||||
role: "品牌设计师 / 自由职业教练",
|
||||
tag: "变现专家",
|
||||
desc: "Toptal 认证设计师,年收入 $120K+,帮助 500+ 人成功转型远程自由职业",
|
||||
},
|
||||
{
|
||||
name: "Marco",
|
||||
avatar: "🧳",
|
||||
role: "旅居作家 / 签证顾问",
|
||||
tag: "旅居达人",
|
||||
desc: "常驻里斯本,持有 3 国数字游民签证,《数字游民签证手册》作者,深谙各国政策",
|
||||
},
|
||||
];
|
||||
|
||||
/* ─── component ─── */
|
||||
import Header from "@/app/components/Header";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import {
|
||||
CourseHero,
|
||||
AudienceSection,
|
||||
BenefitsSection,
|
||||
CurriculumSection,
|
||||
InstructorsSection,
|
||||
FAQSection,
|
||||
CTASection,
|
||||
} from "@/app/components/course";
|
||||
|
||||
export default function CoursePage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [expandedPart, setExpandedPart] = useState<string | null>(null);
|
||||
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
|
||||
|
||||
useEffect(() => {
|
||||
setUserEmail(getStoredUserEmail());
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
pbLogout();
|
||||
setUserEmail(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900">
|
||||
{/* ── Nav ── */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||
<a href="/" className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-lg">🌍</span>
|
||||
数字游民指南
|
||||
</a>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{userEmail ? (
|
||||
<>
|
||||
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
|
||||
{userEmail}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthOpen(true)}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
登录 / 注册
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
立即报名
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 sm:py-28">
|
||||
<div className="pointer-events-none absolute top-0 left-1/2 h-[500px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/50 via-cyan-50/30 to-amber-50/20 blur-3xl" />
|
||||
<div className="relative mx-auto max-w-3xl px-4 text-center sm:px-6">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
<span className="gradient-text">数字游民</span>实战课
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-slate-600 sm:text-xl">
|
||||
7天打造你的远程工作体系与自由生活方式
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-6 sm:gap-10">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="text-center">
|
||||
<div className="text-3xl font-extrabold text-sky-600 sm:text-4xl">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-500">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
|
||||
>
|
||||
立即报名
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Audience ── */}
|
||||
<section className="py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-5xl px-4 sm:px-6">
|
||||
<h2 className="text-center text-2xl font-bold sm:text-3xl">
|
||||
这门课适合谁?
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
|
||||
不管你是什么背景,只要想拥有地点自由的生活
|
||||
</p>
|
||||
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{audiences.map((a) => (
|
||||
<div
|
||||
key={a.title}
|
||||
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
|
||||
>
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl">
|
||||
{a.icon}
|
||||
</div>
|
||||
<h3 className="mb-2 font-bold">{a.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-slate-500">
|
||||
{a.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Benefits ── */}
|
||||
<section className="bg-slate-50 py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-5xl px-4 sm:px-6">
|
||||
<h2 className="text-center text-2xl font-bold sm:text-3xl">
|
||||
学完你将获得
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
|
||||
5 大核心收益,助你顺利开启数字游民生活
|
||||
</p>
|
||||
|
||||
<div className="mt-14 space-y-5">
|
||||
{benefits.map((b) => (
|
||||
<div
|
||||
key={b.num}
|
||||
className="card-hover flex items-start gap-5 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm sm:items-center"
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 to-cyan-500 text-xl font-extrabold text-white shadow-md shadow-sky-500/20">
|
||||
{b.num}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900">{b.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{b.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/50 to-slate-50 py-24 sm:py-28">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
||||
{/* 标题区:大数字 + 学习路径感 */}
|
||||
<div className="mb-16 text-center">
|
||||
<div className="inline-flex items-baseline gap-2">
|
||||
<span className="gradient-text text-6xl font-extrabold tabular-nums tracking-tight sm:text-7xl">
|
||||
21
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-slate-800 sm:text-3xl">
|
||||
节实战课程
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-slate-500">
|
||||
从入门到启程 · 完整的学习路径
|
||||
</p>
|
||||
<div className="mx-auto mt-6 h-px w-24 rounded-full bg-gradient-to-r from-transparent via-sky-300/60 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* 垂直时间线:左侧竖线 + 节点 */}
|
||||
<div className="relative">
|
||||
{/* 中央竖线 */}
|
||||
<div
|
||||
className="absolute left-[19px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-sky-200/80 via-sky-300/60 to-sky-200/80 sm:left-[23px]"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="space-y-0">
|
||||
{curriculum.map((ch, idx) => {
|
||||
const allLessons = ch.sections.flatMap((s) => s.lessons);
|
||||
const partCompleted = isPartCompleted(allLessons.map((l) => l.id));
|
||||
const isExpanded = expandedPart === ch.part;
|
||||
const totalLessons = allLessons.length;
|
||||
return (
|
||||
<div key={ch.part} className="relative flex gap-6 pb-10 last:pb-0">
|
||||
{/* 左侧节点圆:完成时显示 ✓ */}
|
||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold shadow-sm transition-colors sm:h-12 sm:w-12 ${
|
||||
partCompleted
|
||||
? "border-2 border-emerald-300 bg-emerald-50 text-emerald-600"
|
||||
: "border-2 border-sky-200 bg-white text-sky-600"
|
||||
}`}
|
||||
>
|
||||
{partCompleted ? (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容卡片 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border shadow-sm transition-colors ${
|
||||
partCompleted ? "border-emerald-200/80 bg-emerald-50/30" : "border-slate-200/80 bg-white"
|
||||
}`}
|
||||
>
|
||||
{/* 一级:Part 标题,点击展开/收起 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedPart(isExpanded ? null : ch.part)}
|
||||
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50/80 sm:px-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{ch.emoji}</span>
|
||||
<h3 className="font-bold text-slate-800">{ch.part}</h3>
|
||||
{partCompleted && (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
已完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span>{totalLessons} 节</span>
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 展开后:二级 section + 三级 lesson */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/40">
|
||||
{ch.sections.map((sec) => (
|
||||
<div key={sec.name} className="border-b border-slate-100 last:border-b-0">
|
||||
{/* 二级标题 */}
|
||||
<div className="flex items-center gap-3 border-b border-slate-100/80 bg-slate-50/60 px-5 py-3.5 sm:px-6">
|
||||
<span className="flex h-6 w-1 shrink-0 rounded-full bg-gradient-to-b from-sky-400 to-sky-500" />
|
||||
<h4 className="text-sm font-semibold text-slate-700">
|
||||
{sec.name}
|
||||
</h4>
|
||||
<span className="rounded-full bg-slate-200/60 px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||
{sec.lessons.length} 节
|
||||
</span>
|
||||
</div>
|
||||
{/* 三级:视频卡片网格 */}
|
||||
<div className="grid gap-5 p-5 sm:grid-cols-2 sm:p-6 lg:grid-cols-3">
|
||||
{sec.lessons.map((lesson) => (
|
||||
<VideoCard
|
||||
key={lesson.id}
|
||||
lesson={lesson as { id: string; title: string; duration: string; videoUrl?: string }}
|
||||
completed={isCompleted(lesson.id)}
|
||||
onComplete={() => markCompleted(lesson.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Instructors ── */}
|
||||
<section className="bg-slate-50 py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-5xl px-4 sm:px-6">
|
||||
<h2 className="text-center text-2xl font-bold sm:text-3xl">
|
||||
三位实战讲师联合授课
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
|
||||
真正的数字游民亲自带你上手
|
||||
</p>
|
||||
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-3">
|
||||
{instructors.map((ins) => (
|
||||
<div
|
||||
key={ins.name}
|
||||
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
|
||||
>
|
||||
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-sky-100 to-cyan-50 text-5xl">
|
||||
{ins.avatar}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">{ins.name}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{ins.role}</p>
|
||||
<span className="mt-3 inline-block rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-600">
|
||||
{ins.tag}
|
||||
</span>
|
||||
<p className="mt-4 text-sm leading-relaxed text-slate-500">
|
||||
{ins.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Final CTA ── */}
|
||||
<section className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">
|
||||
准备好开启你的数字游民生活了吗?
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-lg text-slate-500">
|
||||
加入我们,跟着实战讲师一起,从零开始规划属于你的自由生活
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
|
||||
21节实战视频
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
|
||||
永久回看
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
|
||||
社群答疑
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
|
||||
>
|
||||
立即加入
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="border-t border-slate-100 bg-white py-8 text-center text-sm text-slate-400">
|
||||
<a href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
|
||||
🌍 数字游民指南
|
||||
</a>
|
||||
<span className="mx-2">·</span>
|
||||
开源免费 · 社区驱动
|
||||
</footer>
|
||||
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={(email) => setUserEmail(email)}
|
||||
/>
|
||||
|
||||
{/* ── 报名 Modal ── */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-sm animate-fade-in rounded-2xl bg-white p-8 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => setModalOpen(false)}
|
||||
className="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h3 className="text-center text-lg font-bold">
|
||||
数字游民实战课
|
||||
</h3>
|
||||
<p className="mt-1 text-center text-sm text-slate-500">
|
||||
7天打造你的远程工作体系
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50 p-6 text-center">
|
||||
<div className="text-6xl">🌍</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-600">
|
||||
扫码报名
|
||||
</p>
|
||||
<p className="mt-4 text-3xl font-extrabold text-sky-600">¥199</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
微信扫码 · 小程序购买
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50 p-4 text-center">
|
||||
<div className="text-4xl">💬</div>
|
||||
<p className="mt-2 text-sm font-medium text-slate-600">
|
||||
添加微信进群
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
交流答疑 · 文档资料 · 权益通知
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main>
|
||||
<CourseHero />
|
||||
<AudienceSection />
|
||||
<BenefitsSection />
|
||||
<CurriculumSection />
|
||||
<InstructorsSection />
|
||||
<FAQSection />
|
||||
<CTASection />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "digital-course-video-completed";
|
||||
|
||||
function loadCompleted(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
return new Set(Array.isArray(arr) ? arr : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveCompleted(ids: Set<string>) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useVideoProgress() {
|
||||
const [completed, setCompleted] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setCompleted(loadCompleted());
|
||||
}, []);
|
||||
|
||||
const markCompleted = useCallback((lessonId: string) => {
|
||||
setCompleted((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(lessonId);
|
||||
saveCompleted(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isCompleted = useCallback(
|
||||
(lessonId: string) => completed.has(lessonId),
|
||||
[completed]
|
||||
);
|
||||
|
||||
const isPartCompleted = useCallback(
|
||||
(lessonIds: string[]) =>
|
||||
lessonIds.length > 0 && lessonIds.every((id) => completed.has(id)),
|
||||
[completed]
|
||||
);
|
||||
|
||||
return { completed, markCompleted, isCompleted, isPartCompleted };
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* 生成视频封面图(数字游民插画风格,无序号)
|
||||
* 返回 data URL,可直接用作 video poster
|
||||
*/
|
||||
export function generateVideoPoster(
|
||||
title: string,
|
||||
_lessonId: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
bgGradient?: [string, string];
|
||||
accentColor?: string;
|
||||
}
|
||||
): string {
|
||||
const width = options?.width ?? 640;
|
||||
const height = options?.height ?? 360;
|
||||
const [from, to] = options?.bgGradient ?? ["#0ea5e9", "#06b6d4"];
|
||||
const accent = options?.accentColor ?? "#ffffff";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return "";
|
||||
|
||||
// 数字游民插画:天空渐变背景
|
||||
const skyGrad = ctx.createLinearGradient(0, 0, 0, height);
|
||||
skyGrad.addColorStop(0, "#87CEEB");
|
||||
skyGrad.addColorStop(0.5, "#B8E0F0");
|
||||
skyGrad.addColorStop(0.75, "#E8F4F8");
|
||||
skyGrad.addColorStop(1, "#F5E6D3");
|
||||
ctx.fillStyle = skyGrad;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 海洋
|
||||
ctx.fillStyle = "rgba(14, 165, 233, 0.25)";
|
||||
ctx.fillRect(0, height * 0.65, width, height * 0.35);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.4)";
|
||||
ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ctx.beginPath();
|
||||
const x = (i * width) / 5;
|
||||
ctx.moveTo(x, height * 0.72);
|
||||
ctx.quadraticCurveTo(x + 50, height * 0.68, x + 100, height * 0.72);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 太阳
|
||||
const sunGrad = ctx.createRadialGradient(width * 0.85, height * 0.15, 0, width * 0.85, height * 0.15, 50);
|
||||
sunGrad.addColorStop(0, "#FEF3C7");
|
||||
sunGrad.addColorStop(0.6, "#FCD34D");
|
||||
sunGrad.addColorStop(1, "rgba(252, 211, 77, 0)");
|
||||
ctx.fillStyle = sunGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(width * 0.85, height * 0.15, 50, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#FCD34D";
|
||||
ctx.beginPath();
|
||||
ctx.arc(width * 0.85, height * 0.15, 18, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 椰子树(简化插画)
|
||||
const palmX = width * 0.12;
|
||||
const palmY = height * 0.55;
|
||||
ctx.fillStyle = "#8B7355";
|
||||
ctx.fillRect(palmX - 4, palmY, 8, height * 0.35);
|
||||
ctx.fillStyle = "#22C55E";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const a = (i / 5) * Math.PI * 0.8 - Math.PI * 0.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(palmX, palmY);
|
||||
ctx.quadraticCurveTo(palmX + Math.cos(a) * 35, palmY - 25, palmX + Math.cos(a) * 55, palmY - 45);
|
||||
ctx.strokeStyle = "#22C55E";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 地球/ globe
|
||||
const globeX = width * 0.78;
|
||||
const globeY = height * 0.35;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(globeX, globeY, 45, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(globeX, globeY, 45, 15, 0.3, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(globeX, globeY, 15, 45, -0.2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "rgba(14, 165, 233, 0.15)";
|
||||
ctx.fill();
|
||||
|
||||
// 笔记本电脑(数字游民工作象征)
|
||||
const lapX = width * 0.25;
|
||||
const lapY = height * 0.5;
|
||||
ctx.fillStyle = "#E5E7EB";
|
||||
ctx.fillRect(lapX - 35, lapY - 20, 70, 45);
|
||||
ctx.fillStyle = "#94A3B8";
|
||||
ctx.fillRect(lapX - 32, lapY - 18, 64, 35);
|
||||
ctx.fillStyle = "#0ea5e9";
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillRect(lapX - 28, lapY - 14, 56, 27);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = "#64748B";
|
||||
ctx.fillRect(lapX - 38, lapY + 22, 76, 6);
|
||||
ctx.fillRect(lapX - 2, lapY + 18, 4, 8);
|
||||
|
||||
// 云朵
|
||||
ctx.fillStyle = "rgba(255,255,255,0.7)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(width * 0.5, height * 0.2, 25, 0, Math.PI * 2);
|
||||
ctx.arc(width * 0.55, height * 0.18, 20, 0, Math.PI * 2);
|
||||
ctx.arc(width * 0.6, height * 0.22, 22, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 底部半透明遮罩,让标题更清晰
|
||||
const overlay = ctx.createLinearGradient(0, height * 0.5, 0, height);
|
||||
overlay.addColorStop(0, "rgba(0,0,0,0)");
|
||||
overlay.addColorStop(0.5, "rgba(0,0,0,0.1)");
|
||||
overlay.addColorStop(1, "rgba(0,0,0,0.4)");
|
||||
ctx.fillStyle = overlay;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 标题:多行自动换行
|
||||
ctx.fillStyle = accent;
|
||||
ctx.font = `600 ${Math.min(width * 0.045, 28)}px system-ui, "PingFang SC", sans-serif`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
const maxWidth = width * 0.9;
|
||||
const lineHeight = Math.min(width * 0.05, 32);
|
||||
const paddingX = width * 0.06;
|
||||
const paddingY = height * 0.68;
|
||||
|
||||
const words = title.split("");
|
||||
let line = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const char of words) {
|
||||
const testLine = line + char;
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > maxWidth && line) {
|
||||
lines.push(line);
|
||||
line = char;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
|
||||
ctx.shadowColor = "rgba(0,0,0,0.35)";
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowOffsetY = 1;
|
||||
|
||||
const maxLines = 2;
|
||||
const startY = paddingY;
|
||||
lines.slice(0, maxLines).forEach((ln, i) => {
|
||||
ctx.fillText(ln, paddingX, startY + i * lineHeight);
|
||||
});
|
||||
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 播放图标(右下角)
|
||||
const playSize = Math.min(width, height) * 0.1;
|
||||
const playX = width - width * 0.12;
|
||||
const playY = height - height * 0.15;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(playX, playY, playSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = from;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playX - playSize * 0.3, playY - playSize * 0.5);
|
||||
ctx.lineTo(playX - playSize * 0.3, playY + playSize * 0.5);
|
||||
ctx.lineTo(playX + playSize * 0.5, playY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
return canvas.toDataURL("image/jpeg", 0.92);
|
||||
}
|
||||
@@ -11,13 +11,15 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
{ cache: "no-store", signal: controller.signal }
|
||||
).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json({ ok: true, paid: !!data?.paid });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true, paid: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ type ToolsProps = {
|
||||
data: { categories: ToolsCategory[] };
|
||||
};
|
||||
|
||||
function truncateDesc(desc: string, maxLen = 5) {
|
||||
if (!desc || desc.length <= maxLen) return desc;
|
||||
return desc.slice(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
export default function Tools({ data }: ToolsProps) {
|
||||
const categories = data.categories;
|
||||
const { t } = useTranslation("tools");
|
||||
@@ -64,14 +59,13 @@ export default function Tools({ data }: ToolsProps) {
|
||||
className="group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:hover:text-sky-400"
|
||||
>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{tool.name}</span>
|
||||
<span className="relative text-slate-400">
|
||||
{truncateDesc(tool.desc)}
|
||||
{tool.desc && tool.desc.length > 5 && (
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 w-max max-w-[90vw] -translate-x-1/2 whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
|
||||
{tool.desc && (
|
||||
<span className="relative shrink-0">
|
||||
<span className="pointer-events-none absolute bottom-full right-0 z-10 mb-1 w-max max-w-[90vw] whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
|
||||
{tool.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
27
app/components/course/AudienceSection.tsx
Normal file
27
app/components/course/AudienceSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Section } from "./Section";
|
||||
import { AUDIENCE_CONFIG } from "@/config/course";
|
||||
|
||||
export function AudienceSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{AUDIENCE_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{AUDIENCE_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
|
||||
{AUDIENCE_CONFIG.items.map((a) => (
|
||||
<div
|
||||
key={a.title}
|
||||
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
|
||||
<h3 className="mb-2 font-semibold">{a.title}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
31
app/components/course/BenefitsSection.tsx
Normal file
31
app/components/course/BenefitsSection.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Section } from "./Section";
|
||||
import { BENEFITS_CONFIG } from "@/config/course";
|
||||
|
||||
export function BenefitsSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{BENEFITS_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{BENEFITS_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{BENEFITS_CONFIG.items.map((b) => (
|
||||
<div
|
||||
key={b.num}
|
||||
className="hover-shimmer flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
|
||||
{b.num}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-1 font-semibold">{b.title}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
46
app/components/course/CTASection.tsx
Normal file
46
app/components/course/CTASection.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { CTA_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/course-payment";
|
||||
|
||||
export function CTASection() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
|
||||
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
|
||||
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{CTA_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
|
||||
{CTA_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
|
||||
{CTA_CONFIG.badges.map((b, i) => (
|
||||
<span
|
||||
key={b}
|
||||
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
|
||||
style={{ animationDelay: `${i * 0.2}s` }}
|
||||
>
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/join"}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
|
||||
>
|
||||
{paid ? "开始学习 →" : "立即加入 →"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
89
app/components/course/CourseHero.tsx
Normal file
89
app/components/course/CourseHero.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/course-payment";
|
||||
import { getLastWatched, getProgress } from "@/app/lib/learning";
|
||||
|
||||
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
|
||||
export function CourseHero() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
|
||||
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
setLast(getLastWatched());
|
||||
setProgress(getProgress(TOTAL_LESSONS));
|
||||
const onPay = () => setPaid(isPaid());
|
||||
const onProgress = () => {
|
||||
setLast(getLastWatched());
|
||||
setProgress(getProgress(TOTAL_LESSONS));
|
||||
};
|
||||
window.addEventListener("pay:updated", onPay);
|
||||
window.addEventListener("learning:progress", onProgress);
|
||||
return () => {
|
||||
window.removeEventListener("pay:updated", onPay);
|
||||
window.removeEventListener("learning:progress", onProgress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
||||
<div className="relative mx-auto max-w-4xl px-4 text-center sm:px-6">
|
||||
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl lg:text-6xl">
|
||||
{HERO_CONFIG.title}
|
||||
</h1>
|
||||
<p className="mb-10 text-lg text-[var(--muted-foreground)] sm:mb-12 sm:text-xl md:text-2xl">
|
||||
{HERO_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="mb-10 flex flex-wrap justify-center gap-6 sm:mb-12 sm:gap-8">
|
||||
{HERO_CONFIG.stats.map((s) => (
|
||||
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
|
||||
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
{paid && last ? (
|
||||
<Link
|
||||
href={`/course/${last.moduleIndex}/${last.lessonIndex}`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
继续学习 →
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/join"}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
{paid ? "进入课程 →" : "立即报名 →"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{paid && progress.completed > 0 && (
|
||||
<div className="mt-6 w-full max-w-xs">
|
||||
<div className="mb-1 flex justify-between text-xs text-[var(--muted-foreground)]">
|
||||
<span>学习进度</span>
|
||||
<span>{progress.completed}/{TOTAL_LESSONS} 节</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
159
app/components/course/CurriculumSection.tsx
Normal file
159
app/components/course/CurriculumSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Section } from "./Section";
|
||||
import { CURRICULUM_CONFIG } from "@/config/course";
|
||||
import { canWatchLesson } from "@/app/lib/course-payment";
|
||||
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
|
||||
|
||||
export function CurriculumSection() {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
|
||||
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
|
||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarks(getBookmarks());
|
||||
setCompleted(getCompletedLessons());
|
||||
const onB = () => setBookmarks(getBookmarks());
|
||||
const onP = () => setCompleted(getCompletedLessons());
|
||||
window.addEventListener("learning:bookmarks", onB);
|
||||
window.addEventListener("learning:progress", onP);
|
||||
return () => {
|
||||
window.removeEventListener("learning:bookmarks", onB);
|
||||
window.removeEventListener("learning:progress", onP);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = (i: number) => {
|
||||
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
||||
};
|
||||
|
||||
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
|
||||
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
|
||||
);
|
||||
const filtered = search.trim()
|
||||
? allLessons.filter(
|
||||
(l) =>
|
||||
l.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allLessons;
|
||||
|
||||
const filteredByModule = CURRICULUM_CONFIG.modules
|
||||
.map((m, mi) => ({
|
||||
...m,
|
||||
lessons: filtered.filter((l) => l.moduleIndex === mi),
|
||||
}))
|
||||
.filter((m) => m.lessons.length > 0);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{CURRICULUM_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
|
||||
{CURRICULUM_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="mx-auto mb-8 max-w-md">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索课程..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
{filteredByModule.map((c) => {
|
||||
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
|
||||
const isOpen = expanded[moduleIndex] ?? true;
|
||||
return (
|
||||
<div
|
||||
key={c.title}
|
||||
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(moduleIndex)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
|
||||
<span
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span>{c.emoji}</span>
|
||||
{c.title}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{c.lessons.length} 节
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className="border-t border-[var(--border)]">
|
||||
{c.lessons.map((l) => {
|
||||
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
|
||||
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
|
||||
const key = `${l.moduleIndex}-${l.lessonIndex}`;
|
||||
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
|
||||
const isCompleted = completed.has(key);
|
||||
return (
|
||||
<li key={key}>
|
||||
<Link
|
||||
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
|
||||
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] 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(l.moduleIndex, l.lessonIndex);
|
||||
}}
|
||||
className="shrink-0 text-xs"
|
||||
aria-label={bookmarked ? "取消收藏" : "收藏"}
|
||||
>
|
||||
{bookmarked ? "⭐" : "☆"}
|
||||
</button>
|
||||
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
|
||||
{l.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">
|
||||
{l.duration}
|
||||
<span className="text-[var(--accent)]">→</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
43
app/components/course/FAQSection.tsx
Normal file
43
app/components/course/FAQSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "./Section";
|
||||
import { FAQ_CONFIG } from "@/config/course";
|
||||
|
||||
export function FAQSection() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{FAQ_CONFIG.title}
|
||||
</h2>
|
||||
<div className="mx-auto max-w-2xl space-y-2">
|
||||
{FAQ_CONFIG.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
|
||||
>
|
||||
<span className="font-medium">{item.q}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
{openIndex === i && (
|
||||
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
|
||||
{item.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
37
app/components/course/InstructorsSection.tsx
Normal file
37
app/components/course/InstructorsSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Section } from "./Section";
|
||||
import { INSTRUCTORS_CONFIG } from "@/config/course";
|
||||
|
||||
export function InstructorsSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{INSTRUCTORS_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{INSTRUCTORS_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
|
||||
{INSTRUCTORS_CONFIG.items.map((i) => (
|
||||
<div
|
||||
key={i.name}
|
||||
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="mb-3 flex justify-center sm:mb-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
|
||||
{i.name.slice(0, 1)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-1 font-semibold">{i.name}</h3>
|
||||
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
|
||||
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
|
||||
{i.desc}
|
||||
</p>
|
||||
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
|
||||
{i.tag}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
17
app/components/course/Section.tsx
Normal file
17
app/components/course/Section.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type SectionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 通用区块容器 - 统一内边距与最大宽度,响应式(来自 nomadlms) */
|
||||
export function Section({ children, className = "" }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={`border-t border-[var(--border)] py-12 md:py-16 lg:py-20 ${className}`}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
362
app/components/course/VideoPlayer.tsx
Normal file
362
app/components/course/VideoPlayer.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STORAGE_PREFIX = "lms-video-";
|
||||
const SAVE_INTERVAL = 5000;
|
||||
const MIN_PROGRESS_TO_SHOW = 10;
|
||||
|
||||
function getStoredProgress(videoId: string): number {
|
||||
if (typeof window === "undefined") return 0;
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
|
||||
if (!raw) return 0;
|
||||
const data = JSON.parse(raw);
|
||||
return typeof data?.time === "number" ? data.time : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress(videoId: string, time: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string;
|
||||
poster?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
videoId?: string;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [savedTime, setSavedTime] = useState(0);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSaveRef = useRef(0);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play();
|
||||
setPlaying(true);
|
||||
} else {
|
||||
v.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const time = v.currentTime;
|
||||
const dur = v.duration;
|
||||
setCurrentTime(time);
|
||||
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
|
||||
|
||||
if (videoId && v.currentTime > 0) {
|
||||
const now = Date.now();
|
||||
if (now - lastSaveRef.current > SAVE_INTERVAL) {
|
||||
lastSaveRef.current = now;
|
||||
saveProgress(videoId, v.currentTime);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
setDuration(dur);
|
||||
|
||||
if (videoId) {
|
||||
const stored = getStoredProgress(videoId);
|
||||
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
|
||||
setSavedTime(stored);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
if (videoId) saveProgress(videoId, 0);
|
||||
onComplete?.();
|
||||
}, [videoId, onComplete]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (videoId && v && v.currentTime > 0) {
|
||||
saveProgress(videoId, v.currentTime);
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || savedTime <= 0) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
const time = Math.min(savedTime, dur - 0.1);
|
||||
v.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
setProgress((time / dur) * 100);
|
||||
v.play();
|
||||
setPlaying(true);
|
||||
setShowResumePrompt(false);
|
||||
}, [savedTime]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (videoId) saveProgress(videoId, 0);
|
||||
v.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
setProgress(0);
|
||||
setShowResumePrompt(false);
|
||||
}, [videoId]);
|
||||
|
||||
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = p * dur;
|
||||
v.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
setProgress(p * 100);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setMuted(v.muted);
|
||||
}, []);
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const val = parseFloat(e.target.value);
|
||||
v.volume = val;
|
||||
setVolume(val);
|
||||
setMuted(val === 0);
|
||||
}, []);
|
||||
|
||||
const cycleSpeed = useCallback(() => {
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const i = speeds.indexOf(speed);
|
||||
const next = speeds[(i + 1) % speeds.length];
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = next;
|
||||
setSpeed(next);
|
||||
}, [speed]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const container = videoRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showControlsTemporarily = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
if (!Number.isFinite(s) || s < 0) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatResumeTime = (s: number) => {
|
||||
if (!Number.isFinite(s) || s < 0) return "0秒";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
if (m > 0) return `${m}分${sec}秒`;
|
||||
return `${sec}秒`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
|
||||
onMouseMove={showControlsTemporarily}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
title={title}
|
||||
className="aspect-video w-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
onPause={handlePause}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{showResumePrompt && (
|
||||
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
|
||||
<p className="mb-3 text-sm text-white/90">
|
||||
上次看到 <span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
|
||||
>
|
||||
从上次继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
|
||||
>
|
||||
重新观看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!playing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
|
||||
aria-label="播放"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
|
||||
<svg
|
||||
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
|
||||
onClick={handleSeek}
|
||||
role="slider"
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label={playing ? "暂停" : "播放"}
|
||||
>
|
||||
{playing ? (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
className="rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleSpeed}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label="全屏"
|
||||
>
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/components/course/index.ts
Normal file
9
app/components/course/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { Section } from "./Section";
|
||||
export { VideoPlayer } from "./VideoPlayer";
|
||||
export { CurriculumSection } from "./CurriculumSection";
|
||||
export { AudienceSection } from "./AudienceSection";
|
||||
export { BenefitsSection } from "./BenefitsSection";
|
||||
export { InstructorsSection } from "./InstructorsSection";
|
||||
export { FAQSection } from "./FAQSection";
|
||||
export { CTASection } from "./CTASection";
|
||||
export { CourseHero } from "./CourseHero";
|
||||
@@ -17,14 +17,33 @@
|
||||
--animate-float: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
:root,
|
||||
.light {
|
||||
--background: #ffffff;
|
||||
--foreground: #0f172a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0f172a;
|
||||
--muted: #f1f5f9;
|
||||
--muted-foreground: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--accent: #f59e0b;
|
||||
--accent-foreground: #1c1917;
|
||||
--accent-muted: rgba(245, 158, 11, 0.2);
|
||||
--gradient-accent: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: rgba(255, 255, 255, 0.05);
|
||||
--card-foreground: #f8fafc;
|
||||
--muted: rgba(255, 255, 255, 0.05);
|
||||
--muted-foreground: #94a3b8;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #f59e0b;
|
||||
--accent-foreground: #1c1917;
|
||||
--accent-muted: rgba(245, 158, 11, 0.2);
|
||||
--gradient-accent: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
@@ -42,6 +61,57 @@
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* 课程页 - 渐变背景动画 */
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient-shift 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 卡片悬停光效 */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.hover-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-shimmer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.hover-shimmer:hover::after {
|
||||
opacity: 1;
|
||||
animation: shimmer 1.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* CTA 徽章轻微浮动 */
|
||||
@keyframes float-badge {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
.animate-float-badge {
|
||||
animation: float-badge 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
|
||||
31
app/lib/course-payment.ts
Normal file
31
app/lib/course-payment.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 课程支付状态 - localStorage(来自 nomadlms)
|
||||
* 与 join 报名支付分离,课程解锁用此模块
|
||||
*/
|
||||
|
||||
const PAY_STORAGE_KEY = "lms-pay";
|
||||
|
||||
/** 是否已支付(课程已解锁) */
|
||||
export function isPaid(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(PAY_STORAGE_KEY) === "ok";
|
||||
}
|
||||
|
||||
/** 设置已支付(支付成功后调用) */
|
||||
export function setPaid(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(PAY_STORAGE_KEY, "ok");
|
||||
window.dispatchEvent(new CustomEvent("pay:updated"));
|
||||
}
|
||||
|
||||
/** 前 2 章为试看,无需支付 */
|
||||
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (moduleIndex === 0 && lessonIndex < 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 章节是否可观看 */
|
||||
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
|
||||
return isPaid();
|
||||
}
|
||||
141
app/lib/learning.ts
Normal file
141
app/lib/learning.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 学习进度、继续学习、收藏、笔记 - localStorage(来自 nomadlms)
|
||||
*/
|
||||
|
||||
const PREFIX = "lms-";
|
||||
const KEY_LAST = `${PREFIX}last`;
|
||||
const KEY_COMPLETED = `${PREFIX}completed`;
|
||||
const KEY_BOOKMARKS = `${PREFIX}bookmarks`;
|
||||
const KEY_NOTES = `${PREFIX}notes`;
|
||||
const KEY_RATING = `${PREFIX}rating`;
|
||||
|
||||
export type LastWatched = { moduleIndex: number; lessonIndex: number; name: string };
|
||||
|
||||
export function getLastWatched(): LastWatched | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_LAST);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
if (typeof d?.moduleIndex !== "number" || typeof d?.lessonIndex !== "number") return null;
|
||||
return { moduleIndex: d.moduleIndex, lessonIndex: d.lessonIndex, name: d.name || "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastWatched(m: number, l: number, name: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(KEY_LAST, JSON.stringify({ moduleIndex: m, lessonIndex: l, name }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompletedLessons(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_COMPLETED);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw);
|
||||
return new Set(Array.isArray(arr) ? arr : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function markCompleted(moduleIndex: number, lessonIndex: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const set = getCompletedLessons();
|
||||
set.add(`${moduleIndex}-${lessonIndex}`);
|
||||
localStorage.setItem(KEY_COMPLETED, JSON.stringify([...set]));
|
||||
window.dispatchEvent(new CustomEvent("learning:progress"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getProgress(totalLessons: number): { completed: number; percent: number } {
|
||||
const set = getCompletedLessons();
|
||||
const completed = set.size;
|
||||
return { completed, percent: totalLessons > 0 ? Math.round((completed / totalLessons) * 100) : 0 };
|
||||
}
|
||||
|
||||
export function getBookmarks(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_BOOKMARKS);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw);
|
||||
return new Set(Array.isArray(arr) ? arr : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleBookmark(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const set = getBookmarks();
|
||||
const key = `${moduleIndex}-${lessonIndex}`;
|
||||
if (set.has(key)) set.delete(key);
|
||||
else set.add(key);
|
||||
localStorage.setItem(KEY_BOOKMARKS, JSON.stringify([...set]));
|
||||
window.dispatchEvent(new CustomEvent("learning:bookmarks"));
|
||||
return set.has(key);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBookmarked(moduleIndex: number, lessonIndex: number): boolean {
|
||||
return getBookmarks().has(`${moduleIndex}-${lessonIndex}`);
|
||||
}
|
||||
|
||||
export function getNote(moduleIndex: number, lessonIndex: number): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_NOTES);
|
||||
if (!raw) return "";
|
||||
const map = JSON.parse(raw);
|
||||
return map[`${moduleIndex}-${lessonIndex}`] ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function setNote(moduleIndex: number, lessonIndex: number, text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_NOTES);
|
||||
const map = raw ? JSON.parse(raw) : {};
|
||||
map[`${moduleIndex}-${lessonIndex}`] = text;
|
||||
localStorage.setItem(KEY_NOTES, JSON.stringify(map));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getRating(): { stars: number; comment: string } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_RATING);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
return { stars: d?.stars ?? 0, comment: d?.comment ?? "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRating(stars: number, comment: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(KEY_RATING, JSON.stringify({ stars, comment }));
|
||||
window.dispatchEvent(new CustomEvent("learning:rating"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
209
config/course.ts
Normal file
209
config/course.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 课程落地页配置 - 数字游民主题(来自 nomadlms)
|
||||
*/
|
||||
|
||||
export const SITE_CONFIG = {
|
||||
brand: "🌍 数字游民",
|
||||
ctaUrl: "#",
|
||||
footer: "🌍 数字游民 © 2024 | 自由工作,自在生活",
|
||||
} as const;
|
||||
|
||||
export const HERO_CONFIG = {
|
||||
title: "数字游民实战课",
|
||||
subtitle: "从朝九晚五到边旅行边赚钱,开启你的自由人生",
|
||||
stats: [
|
||||
{ value: "21", label: "节实战课程" },
|
||||
{ value: "170+", label: "分钟视频" },
|
||||
{ value: "3", label: "位资深导师" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const AUDIENCE_CONFIG = {
|
||||
title: "这门课适合谁?",
|
||||
subtitle: "向往自由,想用技能换时间与地点自由的人",
|
||||
items: [
|
||||
{
|
||||
emoji: "💻",
|
||||
title: "远程打工人",
|
||||
desc: "厌倦通勤,想在家或任何地方高效工作",
|
||||
},
|
||||
{
|
||||
emoji: "✈️",
|
||||
title: "旅行爱好者",
|
||||
desc: "想边旅行边赚钱,不再为假期发愁",
|
||||
},
|
||||
{
|
||||
emoji: "📱",
|
||||
title: "自由职业者",
|
||||
desc: "想建立被动收入,减少接单焦虑",
|
||||
},
|
||||
{
|
||||
emoji: "🌴",
|
||||
title: "生活方式探索者",
|
||||
desc: "想摆脱固定地点,体验不同城市与国家",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const BENEFITS_CONFIG = {
|
||||
title: "学完你将获得",
|
||||
subtitle: "5大核心能力,助你成为真正的数字游民",
|
||||
items: [
|
||||
{
|
||||
num: "1",
|
||||
title: "远程工作技能体系",
|
||||
desc: "从技能选择到接单渠道,系统掌握远程变现能力",
|
||||
},
|
||||
{
|
||||
num: "2",
|
||||
title: "被动收入搭建方法",
|
||||
desc: "产品化你的技能,让收入不依赖时间",
|
||||
},
|
||||
{
|
||||
num: "3",
|
||||
title: "全球旅居实操指南",
|
||||
desc: "签证、住宿、网络、保险,一站式搞定",
|
||||
},
|
||||
{
|
||||
num: "4",
|
||||
title: "一人公司运营心法",
|
||||
desc: "从个人到团队,可持续的自由商业模式",
|
||||
},
|
||||
{
|
||||
num: "5",
|
||||
title: "数字游民社群与资源",
|
||||
desc: "加入同频社群,获取持续支持与灵感",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const CURRICULUM_CONFIG = {
|
||||
title: "21节实战课程",
|
||||
subtitle: "从入门到进阶,完整成为数字游民",
|
||||
modules: [
|
||||
{
|
||||
emoji: "🚀",
|
||||
title: "基础篇 · 数字游民入门",
|
||||
lessons: [
|
||||
{ name: "01 什么是数字游民?你适合吗?", duration: "11:58" },
|
||||
{ name: "02 从朝九晚五到边旅行边赚钱的路径", duration: "14:32" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💼",
|
||||
title: "技能篇 · 远程变现能力",
|
||||
lessons: [
|
||||
{ name: "3.1 最适合远程的5大技能方向", duration: "06:25" },
|
||||
{ name: "3.2 如何打造你的技能组合与作品集", duration: "07:34" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💰",
|
||||
title: "收入篇 · 被动收入与副业",
|
||||
lessons: [
|
||||
{ name: "04 从接单到产品的思维转变", duration: "04:16" },
|
||||
{ name: "05 打造你的第一个数字产品", duration: "06:18" },
|
||||
{ name: "06 海外平台接单与变现", duration: "02:30" },
|
||||
{ name: "07 被动收入组合:课程+会员+咨询", duration: "06:25" },
|
||||
{ name: "08 如何定价才能既赚又自由", duration: "03:09" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🧠",
|
||||
title: "心态篇 · 自由与自律",
|
||||
lessons: [
|
||||
{ name: "09 数字游民的时间管理心法", duration: "07:49" },
|
||||
{ name: "09-1 独处与社交的平衡", duration: "01:24" },
|
||||
{ name: "10 如何应对家人的不理解", duration: "04:07" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "⚡",
|
||||
title: "落地篇 · 旅居实操",
|
||||
lessons: [
|
||||
{ name: "11 签证与长期居留规划", duration: "04:12" },
|
||||
{ name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19" },
|
||||
{ name: "13 网络、保险与紧急预案", duration: "04:11" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🎯",
|
||||
title: "实战篇 · 真实案例",
|
||||
lessons: [
|
||||
{ name: "14 巴厘岛数字游民社区探访", duration: "12:02" },
|
||||
{ name: "15 曼谷/清迈生活成本与体验", duration: "09:39" },
|
||||
{ name: "16 欧洲数字游民签证攻略", duration: "09:04" },
|
||||
{ name: "17 一人公司如何合法合规运营", duration: "05:12" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🏆",
|
||||
title: "进阶篇 · 高阶玩法",
|
||||
lessons: [
|
||||
{ name: "18 从一人到小团队的进化", duration: "11:25" },
|
||||
{ name: "19 建立你的数字游民品牌", duration: "25:57" },
|
||||
{ name: "20 年度旅居计划与预算规划", duration: "12:34" },
|
||||
{ name: "21 长期数字游民的可持续之道", duration: "08:53" },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const INSTRUCTORS_CONFIG = {
|
||||
title: "三位资深导师联合授课",
|
||||
subtitle: "异度世界、大师兄、小美带你开启数字游民之旅",
|
||||
items: [
|
||||
{
|
||||
name: "异度世界",
|
||||
role: "数字游民先行者",
|
||||
desc: "5年旅居经验,走过20+国家",
|
||||
tag: "课程主理",
|
||||
},
|
||||
{
|
||||
name: "大师兄",
|
||||
role: "一人公司实践者",
|
||||
desc: "远程自由职业转型教练",
|
||||
tag: "变现专家",
|
||||
},
|
||||
{
|
||||
name: "小美",
|
||||
role: "生活方式博主",
|
||||
desc: "边旅行边创作的斜杠青年",
|
||||
tag: "旅居达人",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const CTA_CONFIG = {
|
||||
title: "准备好开启你的数字游民之旅了吗?",
|
||||
subtitle: "加入我们,跟着异度世界、大师兄、小美一起,从零开始实现自由人生",
|
||||
badges: ["21节实战视频", "永久回看", "社群答疑"],
|
||||
} as const;
|
||||
|
||||
/** FAQ 常见问题 */
|
||||
export const FAQ_CONFIG = {
|
||||
title: "常见问题",
|
||||
items: [
|
||||
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
|
||||
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
|
||||
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** 社群入口 */
|
||||
export const COMMUNITY_CONFIG = {
|
||||
enabled: true,
|
||||
wechat: "扫码加入数字游民社群",
|
||||
qrCodeUrl: "/qrcode-community.png",
|
||||
qq: "",
|
||||
link: "",
|
||||
} as const;
|
||||
|
||||
/** 章节底部网盘下载模块 */
|
||||
export const DOWNLOAD_CONFIG = {
|
||||
enabled: true,
|
||||
url: "https://pan.baidu.com/s/example",
|
||||
title: "课程资料下载",
|
||||
buttonText: "前往网盘下载",
|
||||
} as const;
|
||||
112
config/lessons.ts
Normal file
112
config/lessons.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 课程章节详情配置 - 数字游民主题(来自 nomadlms)
|
||||
*/
|
||||
|
||||
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
const SAMPLE_MARKDOWN = `# 课程概述
|
||||
|
||||
本小节将带你理解数字游民的核心概念,掌握关键技能。
|
||||
|
||||
## 学习目标
|
||||
|
||||
- 理解数字游民的本质与生活方式
|
||||
- 掌握远程变现的基础路径
|
||||
- 完成个人定位与规划
|
||||
|
||||
## 核心要点
|
||||
|
||||
1. **定义**:数字游民 = 远程工作 + 自由旅行 + 地点独立
|
||||
2. **技能**:选择可远程交付的技能方向
|
||||
3. **心态**:自律与自由是相辅相成的
|
||||
|
||||
## 行动清单
|
||||
|
||||
- [ ] 评估自己的技能是否可远程交付
|
||||
- [ ] 列出3个你想旅居的城市
|
||||
- [ ] 设定6个月内的转型目标
|
||||
|
||||
### 代码示例
|
||||
|
||||
\`\`\`bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
行内代码示例:\`npm run build\` 可生成生产版本。
|
||||
|
||||
## 注意事项
|
||||
|
||||
> 数字游民不是逃避,而是主动选择。建议先有稳定收入再考虑长期旅居。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 收入 | 建议月收入稳定在 1 万以上 |
|
||||
| 技能 | 至少 1 项可远程交付 |
|
||||
| 心态 | 自律与自由平衡 |
|
||||
`;
|
||||
|
||||
export type LessonDetail = {
|
||||
name: string;
|
||||
duration: string;
|
||||
videoUrl: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
/** 扁平化课程数据:moduleIndex-lessonIndex 作为 key */
|
||||
export const LESSONS_MAP: Record<string, LessonDetail> = {
|
||||
"0-0": {
|
||||
name: "01 什么是数字游民?你适合吗?",
|
||||
duration: "11:58",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "数字游民的定义与自我评估"),
|
||||
},
|
||||
"0-1": {
|
||||
name: "02 从朝九晚五到边旅行边赚钱的路径",
|
||||
duration: "14:32",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "从上班族到数字游民的转型路径"),
|
||||
},
|
||||
"1-0": {
|
||||
name: "3.1 最适合远程的5大技能方向",
|
||||
duration: "06:25",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "编程、设计、写作、营销、咨询等远程技能"),
|
||||
},
|
||||
"1-1": {
|
||||
name: "3.2 如何打造你的技能组合与作品集",
|
||||
duration: "07:34",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "作品集搭建与个人品牌展示"),
|
||||
},
|
||||
"2-0": { name: "04 从接单到产品的思维转变", duration: "04:16", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-1": { name: "05 打造你的第一个数字产品", duration: "06:18", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-2": { name: "06 海外平台接单与变现", duration: "02:30", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-3": { name: "07 被动收入组合:课程+会员+咨询", duration: "06:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-4": { name: "08 如何定价才能既赚又自由", duration: "03:09", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-0": { name: "09 数字游民的时间管理心法", duration: "07:49", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-1": { name: "09-1 独处与社交的平衡", duration: "01:24", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-2": { name: "10 如何应对家人的不理解", duration: "04:07", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-0": { name: "11 签证与长期居留规划", duration: "04:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-1": { name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-2": { name: "13 网络、保险与紧急预案", duration: "04:11", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-0": { name: "14 巴厘岛数字游民社区探访", duration: "12:02", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-1": { name: "15 曼谷/清迈生活成本与体验", duration: "09:39", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-2": { name: "16 欧洲数字游民签证攻略", duration: "09:04", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-3": { name: "17 一人公司如何合法合规运营", duration: "05:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-0": { name: "18 从一人到小团队的进化", duration: "11:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-1": { name: "19 建立你的数字游民品牌", duration: "25:57", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-2": { name: "20 年度旅居计划与预算规划", duration: "12:34", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-3": { name: "21 长期数字游民的可持续之道", duration: "08:53", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
};
|
||||
|
||||
export function getLessonKey(moduleIndex: number, lessonIndex: number): string {
|
||||
return `${moduleIndex}-${lessonIndex}`;
|
||||
}
|
||||
|
||||
export function getLesson(moduleIndex: number, lessonIndex: number): LessonDetail | null {
|
||||
return LESSONS_MAP[getLessonKey(moduleIndex, lessonIndex)] ?? null;
|
||||
}
|
||||
@@ -10,14 +10,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1004.0",
|
||||
"github-markdown-css": "^5.6.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^14.1.2",
|
||||
"minio": "^8.0.7",
|
||||
"next": "16.1.6",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"marked": "^14.1.2",
|
||||
"github-markdown-css": "^5.6.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -29,5 +31,6 @@
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
|
||||
}
|
||||
|
||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
github-markdown-css:
|
||||
specifier: ^5.6.1
|
||||
version: 5.9.0
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
marked:
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.4
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.3)
|
||||
rehype-highlight:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -1688,9 +1694,15 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
@@ -1700,6 +1712,10 @@ packages:
|
||||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
@@ -2001,6 +2017,9 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lowlight@3.3.0:
|
||||
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -2365,6 +2384,9 @@ packages:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -2645,6 +2667,9 @@ packages:
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
@@ -4872,6 +4897,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -4892,6 +4921,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
hast-util-is-element: 3.0.0
|
||||
unist-util-find-after: 5.0.0
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -4902,6 +4938,8 @@ snapshots:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@@ -5172,6 +5210,12 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lowlight@3.3.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.11.1
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -5791,6 +5835,14 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-text: 4.0.2
|
||||
lowlight: 3.3.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -6177,6 +6229,11 @@ snapshots:
|
||||
trough: 2.2.0
|
||||
vfile: 6.0.3
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
Reference in New Issue
Block a user