'init'
This commit is contained in:
22
app/components/AnimateOnScroll.tsx
Normal file
22
app/components/AnimateOnScroll.tsx
Normal 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
43
app/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
app/components/CommunityButton.tsx
Normal file
135
app/components/CommunityButton.tsx
Normal 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
25
app/components/Footer.tsx
Normal 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
45
app/components/Header.tsx
Normal 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
111
app/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
app/components/LessonNotes.tsx
Normal file
40
app/components/LessonNotes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
app/components/MarkdownContent.tsx
Normal file
89
app/components/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/components/ParticleBackground.tsx
Normal file
55
app/components/ParticleBackground.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
app/components/PayButton.tsx
Normal file
59
app/components/PayButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/Section.tsx
Normal file
17
app/components/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
379
app/components/ShareButton.tsx
Normal file
379
app/components/ShareButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
app/components/ThemeToggle.tsx
Normal file
36
app/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
app/components/VideoPlayer.tsx
Normal file
369
app/components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal file
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
app/components/custom/CustomParticleBackground.tsx
Normal file
117
app/components/custom/CustomParticleBackground.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
app/components/custom/index.ts
Normal file
2
app/components/custom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CustomParticleBackground } from "./CustomParticleBackground";
|
||||
export { CustomAnimateOnScroll } from "./CustomAnimateOnScroll";
|
||||
15
app/components/index.ts
Normal file
15
app/components/index.ts
Normal 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";
|
||||
27
app/components/sections/AudienceSection.tsx
Normal file
27
app/components/sections/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/sections/BenefitsSection.tsx
Normal file
31
app/components/sections/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/sections/CTASection.tsx
Normal file
46
app/components/sections/CTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
app/components/sections/CurriculumSection.tsx
Normal file
159
app/components/sections/CurriculumSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/sections/FAQSection.tsx
Normal file
43
app/components/sections/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/sections/InstructorsSection.tsx
Normal file
37
app/components/sections/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>
|
||||
);
|
||||
}
|
||||
67
app/components/sections/RatingSection.tsx
Normal file
67
app/components/sections/RatingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user