This commit is contained in:
eric
2026-03-10 01:35:37 -05:00
parent ff51622bb6
commit be7a298604
69 changed files with 11016 additions and 57 deletions

View File

@@ -0,0 +1,22 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
type Props = HTMLMotionProps<"div"> & {
delay?: number;
};
export function AnimateOnScroll({ children, delay = 0, className = "", ...rest }: Props) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.08, margin: "0px 0px -40px 0px" }}
transition={{ duration: 0.6, ease: "easeOut", delay: delay / 1000 }}
className={className}
{...rest}
>
{children}
</motion.div>
);
}

43
app/components/Button.tsx Normal file
View File

@@ -0,0 +1,43 @@
import Link from "next/link";
type ButtonProps = {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
className?: string;
};
const sizeClasses = {
sm: "px-4 py-2 text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-8 py-4 text-lg sm:px-10",
};
export function Button({
href,
children,
variant = "primary",
size = "md",
className = "",
}: ButtonProps) {
const base =
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition";
const variants = {
primary:
"bg-[var(--accent)] text-[var(--accent-foreground)] hover:opacity-90",
secondary:
"border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--muted)]",
};
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className={`${base} ${variants[variant]} ${sizeClasses[size]} ${className}`}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { COMMUNITY_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function CommunityButton() {
const [open, setOpen] = useState(false);
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPay = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPay);
return () => window.removeEventListener("pay:updated", onPay);
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
if (!COMMUNITY_CONFIG.enabled || !COMMUNITY_CONFIG.wechat) return null;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="text-sm text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
{COMMUNITY_CONFIG.wechat}
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={() => setOpen(false)}
>
<div
className="relative w-full max-w-[420px] overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--background)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[var(--accent)] via-cyan-400 to-[var(--accent)]" />
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 rounded-full p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{paid ? (
<div className="p-8 pt-10">
<div className="mb-6 text-center">
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)]/30 to-cyan-500/20 text-3xl shadow-lg">
💬
</div>
<h3 className="text-xl font-semibold">加入数字游民社群</h3>
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
与导师、同学一起交流,获取持续支持与灵感
</p>
</div>
<div className="flex justify-center">
<div className="relative flex h-56 w-56 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--card)] to-[var(--muted)]/30 p-5 shadow-inner ring-2 ring-[var(--accent)]/20">
<img
src={COMMUNITY_CONFIG.qrCodeUrl}
alt="社群二维码"
className="h-full w-full object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
const fallback = target.parentElement?.querySelector(".qr-fallback");
if (fallback) (fallback as HTMLElement).classList.remove("hidden");
}}
/>
<div className="qr-fallback hidden absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-2xl bg-[var(--muted)]/40 p-4 text-center text-sm text-[var(--muted-foreground)]">
<span className="text-4xl">📱</span>
<span>请配置社群二维码</span>
<span className="text-xs">config/course.ts → qrCodeUrl</span>
</div>
</div>
</div>
<p className="mt-5 text-center text-sm text-[var(--muted-foreground)]">
长按识别二维码加入
</p>
</div>
) : (
<div className="p-8 pt-10">
<div className="mb-6 text-center">
<div className="mb-3 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-500/20 to-orange-500/10 text-4xl shadow-lg">
🔒
</div>
<h3 className="text-xl font-semibold">报名后解锁社群</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted-foreground)]">
完成课程报名后,即可加入专属社群,与导师、同学交流答疑
</p>
</div>
<div className="flex flex-col gap-3">
<Link
href="/pay"
className="rounded-xl bg-[var(--accent)] px-6 py-4 text-center font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
onClick={() => setOpen(false)}
>
立即报名
</Link>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-xl border border-[var(--border)] px-6 py-3 text-sm transition hover:bg-[var(--muted)]"
>
稍后再说
</button>
</div>
</div>
)}
</div>
</div>
)}
</>
);
}

25
app/components/Footer.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { SITE_CONFIG } from "@/config/course";
import { ShareButton } from "./ShareButton";
import { CommunityButton } from "./CommunityButton";
export function Footer() {
return (
<footer className="border-t border-[var(--border)] py-6 sm:py-8">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
<Link href="/certificate" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
我的证书
</Link>
<CommunityButton />
<ShareButton />
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
{SITE_CONFIG.footer}
</div>
</div>
</footer>
);
}

45
app/components/Header.tsx Normal file
View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import { SITE_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function Header() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-xl">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-3 sm:h-14 sm:gap-4 sm:px-4 md:h-16 md:px-6">
<Link href="/" className="min-w-0 truncate text-sm font-medium sm:text-base md:text-xl">
{SITE_CONFIG.brand}
</Link>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<ThemeToggle />
<Link
href={paid ? "/course/0/0" : "/pay"}
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
<Link
href={paid ? "/course/0/0" : "/pay"}
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label={paid ? "进入课程" : "立即报名"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</header>
);
}

111
app/components/Hero.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
import { getLastWatched, getProgress } from "@/app/lib/learning";
import { ParticleBackground } from "./ParticleBackground";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function Hero() {
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" />
<ParticleBackground />
<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 animate__animated animate__fadeInDown sm:text-4xl md:text-5xl lg:text-6xl"
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
>
{HERO_CONFIG.title}
</h1>
<p
className="mb-10 text-lg text-[var(--muted-foreground)] animate__animated animate__fadeInDown sm:mb-12 sm:text-xl md:text-2xl"
style={{ animationDelay: "0.25s", animationFillMode: "both" }}
>
{HERO_CONFIG.subtitle}
</p>
<div
className="mb-10 flex flex-wrap justify-center gap-6 animate__animated animate__fadeInDown sm:mb-12 sm:gap-8"
style={{ animationDelay: "0.4s", animationFillMode: "both" }}
>
{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 animate__animated animate__fadeInDown sm:gap-4"
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
>
{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" : "/pay"}
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 animate__animated animate__fadeInDown"
style={{ animationDelay: "0.7s", animationFillMode: "both" }}
>
<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>
{progress.percent >= 100 && (
<Link href="/certificate" className="mt-2 inline-block text-sm font-medium text-[var(--accent)] hover:underline">
查看证书 →
</Link>
)}
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState, useEffect } from "react";
import { getNote, setNote } from "@/app/lib/learning";
type Props = { moduleIndex: number; lessonIndex: number };
export function LessonNotes({ moduleIndex, lessonIndex }: Props) {
const [text, setText] = useState("");
const [saved, setSaved] = useState(false);
useEffect(() => {
setText(getNote(moduleIndex, lessonIndex));
}, [moduleIndex, lessonIndex]);
const handleSave = () => {
setNote(moduleIndex, lessonIndex, text);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6">
<h3 className="mb-3 flex items-center gap-2 font-semibold">
<span>📝</span> 学习笔记
</h3>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={handleSave}
placeholder="记录你的学习心得..."
className="mb-3 w-full min-h-[100px] rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
rows={4}
/>
<p className="text-xs text-[var(--muted-foreground)]">
{saved ? "✓ 已保存" : "失焦自动保存"}
</p>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type MarkdownContentProps = {
content: string;
className?: string;
};
export function MarkdownContent({ content, className = "" }: MarkdownContentProps) {
return (
<article
className={`markdown-content text-[var(--foreground)] ${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-4 mt-8 border-b border-[var(--border)] pb-2 text-2xl font-bold first:mt-0 sm:text-3xl">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 mt-6 text-xl font-semibold sm:text-2xl">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-4 text-lg font-semibold sm:text-xl">{children}</h3>
),
p: ({ children }) => <p className="mb-4 leading-7">{children}</p>,
ul: ({ children }) => (
<ul className="mb-4 list-disc space-y-1 pl-6">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 list-decimal space-y-1 pl-6">{children}</ol>
),
li: ({ children }) => <li className="leading-7">{children}</li>,
code: ({ className: codeClassName, children }) => {
const isBlock = codeClassName?.includes("language-");
if (isBlock) {
return (
<pre className="mb-4 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
<code className={codeClassName}>{children}</code>
</pre>
);
}
return (
<code className="rounded bg-[var(--muted)] px-1.5 py-0.5 font-mono text-sm">
{children}
</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--accent)] pl-4 italic text-[var(--muted-foreground)]">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] underline hover:opacity-80"
>
{children}
</a>
),
table: ({ children }) => (
<div className="mb-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-[var(--border)]">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-[var(--border)] bg-[var(--muted)] px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-[var(--border)] px-4 py-2">{children}</td>
),
}}
>
{content}
</ReactMarkdown>
</article>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import type { ISourceOptions } from "@tsparticles/engine";
export function ParticleBackground() {
const [init, setInit] = useState(false);
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => setInit(true));
}, []);
const options: ISourceOptions = {
background: { color: { value: "transparent" } },
fpsLimit: 60,
interactivity: {
events: { onHover: { enable: true, mode: "grab" } },
modes: { grab: { distance: 140, links: { opacity: 0.3 } } },
},
particles: {
color: { value: "#f59e0b" },
links: {
color: "#f59e0b",
distance: 120,
enable: true,
opacity: 0.15,
width: 0.5,
},
move: {
enable: true,
outModes: { default: "bounce" },
speed: 0.3,
},
number: { density: { enable: true, area: 800 }, value: 50 },
opacity: { value: { min: 0.1, max: 0.4 } },
shape: { type: "circle" },
size: { value: { min: 0.5, max: 2 } },
},
detectRetina: true,
};
if (!init) return null;
return (
<Particles
id="hero-particles"
className="pointer-events-none absolute inset-0"
options={options}
/>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
type PayButtonProps = {
children: React.ReactNode;
size?: "sm" | "md" | "lg";
className?: string;
channel?: "alipay" | "wxpay";
};
const sizeClasses = {
sm: "px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-8 py-4 text-lg sm:px-10",
};
export function PayButton({ children, size = "md", className = "", channel = "alipay" }: PayButtonProps) {
const [loading, setLoading] = useState(false);
const handlePay = () => {
if (loading) return;
setLoading(true);
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/pay";
form.style.display = "none";
const fields = {
user_id: `user_${Date.now()}`,
return_url: typeof window !== "undefined" ? `${window.location.origin}/pay/paid` : "",
total_fee: 80,
type: "meetup",
channel,
html: "1",
};
for (const [k, v] of Object.entries(fields)) {
const input = document.createElement("input");
input.name = k;
input.value = String(v);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
};
return (
<button
type="button"
onClick={handlePay}
disabled={loading}
className={`inline-flex items-center justify-center gap-2 rounded-full bg-[var(--accent)] font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70 ${sizeClasses[size]} ${className}`}
>
{loading ? "跳转中..." : children}
</button>
);
}

View File

@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
type SectionProps = {
children: ReactNode;
className?: string;
};
/** 通用区块容器 - 统一内边距与最大宽度,响应式 */
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>
);
}

View File

@@ -0,0 +1,379 @@
"use client";
import { useState, useRef, useEffect } from "react";
import {
HERO_CONFIG,
BENEFITS_CONFIG,
CURRICULUM_CONFIG,
CTA_CONFIG,
INSTRUCTORS_CONFIG,
} from "@/config/course";
type Props = { title?: string; url?: string; className?: string };
const POSTER_WIDTH = 750;
const POSTER_HEIGHT = 1334;
/** 使用公共 API 生成二维码图片,无需 npm 包 */
async function getQRImage(url: string, size: number): Promise<HTMLImageElement | null> {
const encoded = encodeURIComponent(url);
const apiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encoded}&format=png&bgcolor=0f172a&color=22d3ee`;
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = apiUrl;
});
}
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
): number {
const words = text.split("");
let line = "";
let lineCount = 0;
for (const char of words) {
const test = line + char;
const m = ctx.measureText(test);
if (m.width > maxWidth && line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
line = char;
} else {
line = test;
}
}
if (line) {
ctx.fillText(line, x, y + lineCount * lineHeight);
lineCount++;
}
return lineCount * lineHeight;
}
export function ShareButton({ title = HERO_CONFIG.title, url, className = "" }: Props) {
const [open, setOpen] = useState(false);
const [ready, setReady] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const getShareUrl = () => url || (typeof window !== "undefined" ? window.location.origin : "");
const drawPoster = async () => {
const canvas = canvasRef.current;
if (!canvas || typeof window === "undefined") return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const shareUrl = getShareUrl();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = POSTER_WIDTH * dpr;
canvas.height = POSTER_HEIGHT * dpr;
ctx.scale(dpr, dpr);
const w = POSTER_WIDTH;
const h = POSTER_HEIGHT;
const cyan = "#22d3ee";
const cyanDim = "rgba(34,211,238,0.6)";
const white = "#f8fafc";
const muted = "rgba(248,250,252,0.7)";
const dark = "#0f172a";
const cardBg = "rgba(15,23,42,0.8)";
// ========== 背景:深色科技感 ==========
const bgGrad = ctx.createLinearGradient(0, 0, w, h);
bgGrad.addColorStop(0, "#020617");
bgGrad.addColorStop(0.4, "#0f172a");
bgGrad.addColorStop(0.8, "#0c4a6e");
bgGrad.addColorStop(1, "#0e7490");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// 网格线
ctx.strokeStyle = "rgba(34,211,238,0.06)";
ctx.lineWidth = 1;
const gridStep = 40;
for (let i = 0; i <= w; i += gridStep) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, h);
ctx.stroke();
}
for (let i = 0; i <= h; i += gridStep) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(w, i);
ctx.stroke();
}
// 顶部光晕
const glow = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, 400);
glow.addColorStop(0, "rgba(34,211,238,0.15)");
glow.addColorStop(0.5, "rgba(34,211,238,0.04)");
glow.addColorStop(1, "transparent");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, 350);
// 底部光晕
const glowBot = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, 350);
glowBot.addColorStop(0, "rgba(34,211,238,0.12)");
glowBot.addColorStop(1, "transparent");
ctx.fillStyle = glowBot;
ctx.fillRect(0, h - 400, w, 400);
// ========== 顶部:标题区 ==========
ctx.fillStyle = cyan;
ctx.font = "bold 38px sans-serif";
ctx.textAlign = "center";
ctx.fillText("🌍 " + title, w / 2, 88);
ctx.font = "20px sans-serif";
ctx.fillStyle = muted;
ctx.textAlign = "left";
wrapText(ctx, HERO_CONFIG.subtitle, 48, 128, 654, 26);
ctx.textAlign = "center";
// 分隔线
ctx.strokeStyle = "rgba(34,211,238,0.3)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(60, 168);
ctx.lineTo(w - 60, 168);
ctx.stroke();
// ========== 数据卡片 ==========
const cardY = 192;
const cardW = 210;
const cardH = 68;
const cardGap = (w - cardW * 3) / 4;
HERO_CONFIG.stats.forEach((s, i) => {
const x = cardGap + i * (cardW + cardGap);
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.25)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, cardY, cardW, cardH, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyan;
ctx.font = "bold 26px sans-serif";
ctx.textAlign = "center";
ctx.fillText(s.value, x + cardW / 2, cardY + 36);
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(s.label, x + cardW / 2, cardY + 56);
});
ctx.textAlign = "center";
// ========== 学完你将获得 ==========
let y = 290;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(BENEFITS_CONFIG.title, w / 2, y);
y += 28;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(BENEFITS_CONFIG.subtitle, w / 2, y);
y += 32;
BENEFITS_CONFIG.items.forEach((item) => {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.15)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(48, y, w - 96, 40, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = cyanDim;
ctx.font = "15px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${item.num}. ${item.title}`, 64, y + 26);
ctx.textAlign = "center";
y += 48;
});
// ========== 课程模块 ==========
y += 16;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = white;
ctx.fillText(CURRICULUM_CONFIG.title, w / 2, y);
y += 26;
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(CURRICULUM_CONFIG.subtitle, w / 2, y);
y += 32;
const cols = 2;
const modW = (w - 96 - 12) / cols;
CURRICULUM_CONFIG.modules.forEach((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const mx = 48 + col * (modW + 12);
const my = y + row * 44;
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.12)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(mx, my, modW, 38, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = white;
ctx.font = "14px sans-serif";
ctx.textAlign = "left";
ctx.fillText(`${m.emoji} ${m.title}`, mx + 12, my + 24);
ctx.textAlign = "center";
});
y += Math.ceil(CURRICULUM_CONFIG.modules.length / cols) * 44 + 20;
// ========== 徽章 ==========
ctx.font = "15px sans-serif";
ctx.fillStyle = cyanDim;
ctx.fillText("✓ " + CTA_CONFIG.badges.join(" · "), w / 2, y);
y += 44;
// ========== 导师 ==========
ctx.font = "13px sans-serif";
ctx.fillStyle = muted;
ctx.fillText(INSTRUCTORS_CONFIG.subtitle, w / 2, y);
y += 32;
// ========== 二维码区 ==========
const qrSize = 200;
const qrX = (w - qrSize) / 2;
const qrY = y;
const qrImg = await getQRImage(shareUrl, qrSize);
if (qrImg) {
ctx.fillStyle = cardBg;
ctx.strokeStyle = "rgba(34,211,238,0.4)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 12);
ctx.fill();
ctx.stroke();
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
} else {
ctx.fillStyle = cardBg;
ctx.fillRect(qrX, qrY, qrSize, qrSize);
ctx.fillStyle = cyan;
ctx.font = "13px sans-serif";
ctx.fillText(shareUrl, w / 2, qrY + qrSize / 2 + 5);
}
y += qrSize + 24;
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = cyan;
ctx.fillText("扫码访问课程", w / 2, y);
y += 24;
ctx.font = "14px sans-serif";
ctx.fillStyle = muted;
ctx.fillText("长按识别二维码", w / 2, y);
y += 28;
ctx.font = "12px sans-serif";
ctx.fillStyle = "rgba(248,250,252,0.5)";
ctx.fillText("自由工作 · 自在生活", w / 2, y);
setReady(true);
};
useEffect(() => {
if (open && canvasRef.current) {
setReady(false);
drawPoster();
}
}, [open, url]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
const handleDownload = () => {
const canvas = canvasRef.current;
if (!canvas || !ready) return;
const link = document.createElement("a");
link.download = `分享-${title}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={`inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-sm transition hover:bg-[var(--muted)] ${className}`}
>
分享
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={() => setOpen(false)}
>
<div
className="relative max-h-[90vh] overflow-auto rounded-2xl bg-[var(--background)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-2 top-2 rounded-full p-2 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 className="mb-4 text-center font-semibold">分享海报</h3>
<div className="flex justify-center rounded-xl bg-[var(--muted)]/30 p-2">
<canvas
ref={canvasRef}
width={POSTER_WIDTH}
height={POSTER_HEIGHT}
className="max-w-full rounded-lg"
style={{ width: POSTER_WIDTH, height: POSTER_HEIGHT, maxWidth: "100%" }}
/>
</div>
<p className="mt-3 text-center text-sm text-[var(--muted-foreground)]">
下载海报后,可通过微信、朋友圈等渠道分享
</p>
<div className="mt-4 flex justify-center gap-3">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-[var(--border)] px-6 py-2 text-sm transition hover:bg-[var(--muted)]"
>
取消
</button>
<button
type="button"
onClick={handleDownload}
disabled={!ready}
className="rounded-lg bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-50"
>
{ready ? "下载海报" : "生成中..."}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useTheme } from "../providers/ThemeProvider";
const SunIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
);
const MoonIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const toggle = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return (
<button
type="button"
onClick={toggle}
className="flex items-center justify-center rounded-full p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label={resolvedTheme === "dark" ? "切换至日间模式" : "切换至夜间模式"}
title={resolvedTheme === "dark" ? "日间" : "夜间"}
>
{resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const STORAGE_PREFIX = "lms-video-";
const SAVE_INTERVAL = 5000; // 每 5 秒保存一次
const MIN_PROGRESS_TO_SHOW = 10; // 至少观看 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;
/** 用于存储观看进度的唯一 ID,如 course-0-1 */
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>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
/**
* 自实现滚动入场动画 - 封装待用
* 基于 IntersectionObserver,无第三方依赖
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
};
export function CustomAnimateOnScroll({ children, className = "", delay = 0 }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setVisible(true);
},
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`animate-on-scroll ${visible ? "animate-on-scroll-visible" : ""} ${className}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
/**
* 自实现 Canvas 粒子背景 - 封装待用
* 无第三方依赖,轻量级替代方案
*/
import { useEffect, useRef } from "react";
const PARTICLE_COUNT = 50;
const CONNECT_DISTANCE = 120;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
r: number;
opacity: number;
};
export function CustomParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationId: number;
let particles: Particle[] = [];
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
r: Math.random() * 2 + 0.5,
opacity: Math.random() * 0.4 + 0.1,
}));
};
const getAccentRgb = () => {
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue("--accent").trim() || "#f59e0b";
const m = accent.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (m) return `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}`;
return "245, 158, 11";
};
const draw = () => {
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
const rgb = getAccentRgb();
ctx.clearRect(0, 0, w, h);
particles.forEach((p, i) => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb}, ${p.opacity})`;
ctx.fill();
for (let j = i + 1; j < particles.length; j++) {
const q = particles[j];
const dx = p.x - q.x;
const dy = p.y - q.y;
const dist = Math.hypot(dx, dy);
if (dist < CONNECT_DISTANCE) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.strokeStyle = `rgba(${rgb}, ${0.08 * (1 - dist / CONNECT_DISTANCE)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
});
animationId = requestAnimationFrame(draw);
};
resize();
window.addEventListener("resize", resize);
draw();
return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(animationId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0"
aria-hidden
/>
);
}

View File

@@ -0,0 +1,2 @@
export { CustomParticleBackground } from "./CustomParticleBackground";
export { CustomAnimateOnScroll } from "./CustomAnimateOnScroll";

15
app/components/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export { Header } from "./Header";
export { Hero } from "./Hero";
export { Footer } from "./Footer";
export { Section } from "./Section";
export { AnimateOnScroll } from "./AnimateOnScroll";
export { CustomParticleBackground, CustomAnimateOnScroll } from "./custom";
export { Button } from "./Button";
export { ThemeToggle } from "./ThemeToggle";
export { AudienceSection } from "./sections/AudienceSection";
export { BenefitsSection } from "./sections/BenefitsSection";
export { CurriculumSection } from "./sections/CurriculumSection";
export { InstructorsSection } from "./sections/InstructorsSection";
export { FAQSection } from "./sections/FAQSection";
export { RatingSection } from "./sections/RatingSection";
export { CTASection } from "./sections/CTASection";

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { CTA_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/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" : "/pay"}
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>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Section } from "../Section";
import { CURRICULUM_CONFIG } from "@/config/course";
import { canWatchLesson } from "@/app/lib/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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { getRating, setRating } from "@/app/lib/learning";
export function RatingSection() {
const [stars, setStars] = useState(0);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
const r = getRating();
if (r) {
setStars(r.stars);
setComment(r.comment);
setSubmitted(true);
}
}, []);
const handleSubmit = () => {
setRating(stars, comment);
setSubmitted(true);
};
return (
<section className="border-t border-[var(--border)] py-12 sm:py-16">
<div className="mx-auto max-w-2xl px-4 text-center sm:px-6">
<h2 className="mb-2 text-xl font-bold sm:text-2xl">课程评价</h2>
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
你的反馈对我们很重要
</p>
{submitted ? (
<p className="text-[var(--muted-foreground)]">感谢你的评价 ✓</p>
) : (
<>
<div className="mb-4 flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setStars(n)}
className="text-2xl transition hover:scale-110"
>
{n <= stars ? "⭐" : "☆"}
</button>
))}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="选填:一句话评价"
className="mb-4 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
rows={2}
/>
<button
type="button"
onClick={handleSubmit}
className="rounded-full bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:opacity-90"
>
提交评价
</button>
</>
)}
</div>
</section>
);
}