'多语言'

This commit is contained in:
eric
2026-03-08 04:30:57 -05:00
parent 51eeea3be3
commit def36bf3aa
30 changed files with 6058 additions and 323 deletions

View File

@@ -0,0 +1,76 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { generateVideoPoster } from "./videoPoster";
type Lesson = {
id: string;
title: string;
duration: string;
videoUrl?: string;
};
type Props = {
lesson: Lesson;
completed?: boolean;
onComplete?: () => void;
};
export function VideoCard({ lesson, completed = false, onComplete }: Props) {
const [poster, setPoster] = useState<string | null>(null);
useEffect(() => {
setPoster(generateVideoPoster(lesson.title, lesson.id));
}, [lesson.title, lesson.id]);
const handleEnded = useCallback(() => {
onComplete?.();
}, [onComplete]);
return (
<article
className={`group overflow-hidden rounded-2xl border shadow-sm transition-all duration-200 hover:shadow-md ${
completed
? "border-emerald-200 bg-emerald-50/50 hover:border-emerald-300"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
{/* 三级标题:视频上方,突出显示 */}
<div className="flex items-center gap-3 border-b border-slate-100 bg-white/80 px-4 py-3">
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
completed ? "bg-emerald-100 text-emerald-600" : "bg-sky-50 text-sky-600"
}`}
>
{lesson.id}
</span>
<h4 className="min-w-0 flex-1 text-base font-semibold leading-snug text-slate-800 line-clamp-2">
{lesson.title}
</h4>
</div>
<div className="relative aspect-video overflow-hidden bg-slate-900">
<video
src={lesson.videoUrl}
poster={poster ?? undefined}
controls
playsInline
onEnded={handleEnded}
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
preload="metadata"
>
</video>
<span className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-0.5 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm">
{lesson.duration}
</span>
{completed && (
<span className="absolute top-2 right-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-md">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,570 @@
"use client";
import { useState, useEffect } from "react";
import { VideoCard } from "./VideoCard";
import { useVideoProgress } from "./useVideoProgress";
import AuthModal from "../../components/AuthModal";
function getStoredUser(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("pb_user");
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
/* ─── data ─── */
const stats = [
{ value: "21", label: "节实战课程" },
{ value: "150+", label: "分钟视频" },
{ value: "3", label: "位实战讲师" },
];
const audiences = [
{
icon: "💼",
title: "职场人想转型",
desc: "受够了 996学会远程工作技能平滑过渡到自由生活方式",
},
{
icon: "👨‍💻",
title: "开发者 / 设计师",
desc: "你的技能天然适合远程,学会如何接全球客户、拿美金报酬",
},
{
icon: "📱",
title: "自媒体 / 内容创作者",
desc: "边旅行边创作,掌握数字游民内容变现的全套方法论",
},
{
icon: "🌍",
title: "想出国旅居的人",
desc: "签证、税务、保险、目的地一站搞定,少走弯路直接出发",
},
];
const benefits = [
{
num: 1,
title: "完整的远程工作体系",
desc: "从技能评估到客户获取,建立可持续的远程收入系统",
},
{
num: 2,
title: "打通 3+ 收入渠道",
desc: "远程全职、自由职业、数字产品、内容创作多管齐下",
},
{
num: 3,
title: "掌握 50+ 远程工具",
desc: "协作、会议、项目管理、财务,一个背包装下整个办公室",
},
{
num: 4,
title: "签证 + 税务 + 保险全通关",
desc: "55 国数字游民签证政策、合规税务方案、保险选购指南",
},
{
num: 5,
title: "跑通第一次数字游民旅程",
desc: "从选目的地到打包出发,手把手带你完成首次数字游民体验",
},
];
// 示例视频w3schools 公开样本,可替换为 MinIO CDN URL如 https://minioweb.hackrobot.cn/hackrobot/course/xxx.mp4
const SAMPLE_VIDEO = "https://www.w3schools.com/html/mov_bbb.mp4";
// 一级part 二级section 三级lesson
const curriculum = [
{
emoji: "🚀",
part: "基础篇",
sections: [
{ name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💡",
part: "技能篇",
sections: [
{ name: "远程工作能力", lessons: [{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20", videoUrl: SAMPLE_VIDEO }, { id: "04", title: "30天技能升级路线从评估到接单", duration: "11:15", videoUrl: SAMPLE_VIDEO }, { id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💰",
part: "收入篇",
sections: [
{ name: "构建收入体系", lessons: [{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05", videoUrl: SAMPLE_VIDEO }, { id: "07", title: "自由职业平台实操Upwork / Toptal / 电鸭", duration: "13:50", videoUrl: SAMPLE_VIDEO }, { id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20", videoUrl: SAMPLE_VIDEO }, { id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🛠️",
part: "工具篇",
sections: [
{ name: "移动办公装备", lessons: [{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45", videoUrl: SAMPLE_VIDEO }, { id: "11", title: "远程协作工具栈Slack / Notion / Linear", duration: "08:30", videoUrl: SAMPLE_VIDEO }, { id: "12", title: "安全必修课VPN / 密码管理 / 2FA", duration: "05:55", videoUrl: SAMPLE_VIDEO }, { id: "13", title: "跨境财务工具Wise / Stripe / Revolut", duration: "07:10", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "📋",
part: "合规篇",
sections: [
{ name: "签证税务保险", lessons: [{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30", videoUrl: SAMPLE_VIDEO }, { id: "15", title: "税务居民身份与合规规划", duration: "11:40", videoUrl: SAMPLE_VIDEO }, { id: "16", title: "数字游民保险选购指南", duration: "06:20", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🗺️",
part: "目的地篇",
sections: [
{ name: "选择你的城市", lessons: [{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50", videoUrl: SAMPLE_VIDEO }, { id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25", videoUrl: SAMPLE_VIDEO }, { id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🏆",
part: "实战篇",
sections: [
{ name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] },
],
},
];
const instructors = [
{
name: "Alex",
avatar: "🧑‍💻",
role: "全栈开发者 / 5年数字游民",
tag: "技术导师",
desc: "前字节跳动高级工程师2021年开始数字游民生活足迹遍布 20+ 国家,远程服务硅谷客户",
},
{
name: "Lisa",
avatar: "👩‍🎨",
role: "品牌设计师 / 自由职业教练",
tag: "变现专家",
desc: "Toptal 认证设计师,年收入 $120K+,帮助 500+ 人成功转型远程自由职业",
},
{
name: "Marco",
avatar: "🧳",
role: "旅居作家 / 签证顾问",
tag: "旅居达人",
desc: "常驻里斯本,持有 3 国数字游民签证,《数字游民签证手册》作者,深谙各国政策",
},
];
/* ─── component ─── */
export default function CoursePage() {
const [modalOpen, setModalOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [expandedPart, setExpandedPart] = useState<string | null>(null);
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
useEffect(() => {
setUserEmail(getStoredUser());
}, []);
const handleLogout = () => {
localStorage.removeItem("pb_token");
localStorage.removeItem("pb_user");
setUserEmail(null);
};
return (
<div className="min-h-screen bg-white text-slate-900">
{/* ── Nav ── */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3 px-4 sm:px-6">
<a href="/" className="flex items-center gap-2 text-sm font-bold">
<span className="text-lg">🌍</span>
</a>
<div className="flex items-center gap-2 sm:gap-3">
{userEmail ? (
<>
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
退
</button>
</>
) : (
<button
onClick={() => setAuthOpen(true)}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
/
</button>
)}
<button
onClick={() => setModalOpen(true)}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
>
</button>
</div>
</div>
</header>
{/* ── Hero ── */}
<section className="relative overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 sm:py-28">
<div className="pointer-events-none absolute top-0 left-1/2 h-[500px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/50 via-cyan-50/30 to-amber-50/20 blur-3xl" />
<div className="relative mx-auto max-w-3xl px-4 text-center sm:px-6">
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-6xl">
<span className="gradient-text"></span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-slate-600 sm:text-xl">
7
</p>
<div className="mt-10 flex items-center justify-center gap-6 sm:gap-10">
{stats.map((s) => (
<div key={s.label} className="text-center">
<div className="text-3xl font-extrabold text-sky-600 sm:text-4xl">
{s.value}
</div>
<div className="mt-1 text-sm text-slate-500">{s.label}</div>
</div>
))}
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Audience ── */}
<section className="py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{audiences.map((a) => (
<div
key={a.title}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl">
{a.icon}
</div>
<h3 className="mb-2 font-bold">{a.title}</h3>
<p className="text-sm leading-relaxed text-slate-500">
{a.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Benefits ── */}
<section className="bg-slate-50 py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
5
</p>
<div className="mt-14 space-y-5">
{benefits.map((b) => (
<div
key={b.num}
className="card-hover flex items-start gap-5 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm sm:items-center"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 to-cyan-500 text-xl font-extrabold text-white shadow-md shadow-sky-500/20">
{b.num}
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{b.title}</h3>
<p className="mt-1 text-sm text-slate-500">{b.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */}
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/50 to-slate-50 py-24 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 标题区:大数字 + 学习路径感 */}
<div className="mb-16 text-center">
<div className="inline-flex items-baseline gap-2">
<span className="gradient-text text-6xl font-extrabold tabular-nums tracking-tight sm:text-7xl">
21
</span>
<span className="text-2xl font-bold text-slate-800 sm:text-3xl">
</span>
</div>
<p className="mt-4 text-slate-500">
·
</p>
<div className="mx-auto mt-6 h-px w-24 rounded-full bg-gradient-to-r from-transparent via-sky-300/60 to-transparent" />
</div>
{/* 垂直时间线:左侧竖线 + 节点 */}
<div className="relative">
{/* 中央竖线 */}
<div
className="absolute left-[19px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-sky-200/80 via-sky-300/60 to-sky-200/80 sm:left-[23px]"
aria-hidden
/>
<div className="space-y-0">
{curriculum.map((ch, idx) => {
const allLessons = ch.sections.flatMap((s) => s.lessons);
const partCompleted = isPartCompleted(allLessons.map((l) => l.id));
const isExpanded = expandedPart === ch.part;
const totalLessons = allLessons.length;
return (
<div key={ch.part} className="relative flex gap-6 pb-10 last:pb-0">
{/* 左侧节点圆:完成时显示 ✓ */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold shadow-sm transition-colors sm:h-12 sm:w-12 ${
partCompleted
? "border-2 border-emerald-300 bg-emerald-50 text-emerald-600"
: "border-2 border-sky-200 bg-white text-sky-600"
}`}
>
{partCompleted ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
idx + 1
)}
</div>
</div>
{/* 右侧内容卡片 */}
<div className="min-w-0 flex-1">
<div
className={`overflow-hidden rounded-2xl border shadow-sm transition-colors ${
partCompleted ? "border-emerald-200/80 bg-emerald-50/30" : "border-slate-200/80 bg-white"
}`}
>
{/* 一级Part 标题,点击展开/收起 */}
<button
type="button"
onClick={() => setExpandedPart(isExpanded ? null : ch.part)}
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50/80 sm:px-6"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{ch.emoji}</span>
<h3 className="font-bold text-slate-800">{ch.part}</h3>
{partCompleted && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
</span>
)}
</div>
<span className="flex items-center gap-2 text-sm text-slate-400">
<span>{totalLessons} </span>
<svg
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{/* 展开后:二级 section + 三级 lesson */}
{isExpanded && (
<div className="border-t border-slate-100 bg-slate-50/40">
{ch.sections.map((sec) => (
<div key={sec.name} className="border-b border-slate-100 last:border-b-0">
{/* 二级标题 */}
<div className="flex items-center gap-3 border-b border-slate-100/80 bg-slate-50/60 px-5 py-3.5 sm:px-6">
<span className="flex h-6 w-1 shrink-0 rounded-full bg-gradient-to-b from-sky-400 to-sky-500" />
<h4 className="text-sm font-semibold text-slate-700">
{sec.name}
</h4>
<span className="rounded-full bg-slate-200/60 px-2 py-0.5 text-xs font-medium text-slate-500">
{sec.lessons.length}
</span>
</div>
{/* 三级:视频卡片网格 */}
<div className="grid gap-5 p-5 sm:grid-cols-2 sm:p-6 lg:grid-cols-3">
{sec.lessons.map((lesson) => (
<VideoCard
key={lesson.id}
lesson={lesson as { id: string; title: string; duration: string; videoUrl?: string }}
completed={isCompleted(lesson.id)}
onComplete={() => markCompleted(lesson.id)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
{/* ── Instructors ── */}
<section className="bg-slate-50 py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{instructors.map((ins) => (
<div
key={ins.name}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-sky-100 to-cyan-50 text-5xl">
{ins.avatar}
</div>
<h3 className="text-lg font-bold">{ins.name}</h3>
<p className="mt-1 text-sm text-slate-500">{ins.role}</p>
<span className="mt-3 inline-block rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-600">
{ins.tag}
</span>
<p className="mt-4 text-sm leading-relaxed text-slate-500">
{ins.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Final CTA ── */}
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-4 max-w-lg text-slate-500">
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500">
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
21
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-slate-100 bg-white py-8 text-center text-sm text-slate-400">
<a href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
🌍
</a>
<span className="mx-2">·</span>
·
</footer>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
{/* ── 报名 Modal ── */}
{modalOpen && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={() => setModalOpen(false)}
>
<div
className="relative w-full max-w-sm animate-fade-in rounded-2xl bg-white p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setModalOpen(false)}
className="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 className="text-center text-lg font-bold">
</h3>
<p className="mt-1 text-center text-sm text-slate-500">
7
</p>
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50 p-6 text-center">
<div className="text-6xl">🌍</div>
<p className="mt-3 text-sm font-medium text-slate-600">
</p>
<p className="mt-4 text-3xl font-extrabold text-sky-600">¥199</p>
<p className="mt-1 text-xs text-slate-400">
·
</p>
</div>
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50 p-4 text-center">
<div className="text-4xl">💬</div>
<p className="mt-2 text-sm font-medium text-slate-600">
</p>
<p className="mt-1 text-xs text-slate-400">
· ·
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "digital-course-video-completed";
function loadCompleted(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw) as string[];
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
function saveCompleted(ids: Set<string>) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch {
// ignore
}
}
export function useVideoProgress() {
const [completed, setCompleted] = useState<Set<string>>(() => new Set());
useEffect(() => {
setCompleted(loadCompleted());
}, []);
const markCompleted = useCallback((lessonId: string) => {
setCompleted((prev) => {
const next = new Set(prev);
next.add(lessonId);
saveCompleted(next);
return next;
});
}, []);
const isCompleted = useCallback(
(lessonId: string) => completed.has(lessonId),
[completed]
);
const isPartCompleted = useCallback(
(lessonIds: string[]) =>
lessonIds.length > 0 && lessonIds.every((id) => completed.has(id)),
[completed]
);
return { completed, markCompleted, isCompleted, isPartCompleted };
}

View File

@@ -0,0 +1,184 @@
/**
* 生成视频封面图(数字游民插画风格,无序号)
* 返回 data URL可直接用作 video poster
*/
export function generateVideoPoster(
title: string,
_lessonId: string,
options?: {
width?: number;
height?: number;
bgGradient?: [string, string];
accentColor?: string;
}
): string {
const width = options?.width ?? 640;
const height = options?.height ?? 360;
const [from, to] = options?.bgGradient ?? ["#0ea5e9", "#06b6d4"];
const accent = options?.accentColor ?? "#ffffff";
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
// 数字游民插画:天空渐变背景
const skyGrad = ctx.createLinearGradient(0, 0, 0, height);
skyGrad.addColorStop(0, "#87CEEB");
skyGrad.addColorStop(0.5, "#B8E0F0");
skyGrad.addColorStop(0.75, "#E8F4F8");
skyGrad.addColorStop(1, "#F5E6D3");
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height);
// 海洋
ctx.fillStyle = "rgba(14, 165, 233, 0.25)";
ctx.fillRect(0, height * 0.65, width, height * 0.35);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = 2;
for (let i = 0; i < 6; i++) {
ctx.beginPath();
const x = (i * width) / 5;
ctx.moveTo(x, height * 0.72);
ctx.quadraticCurveTo(x + 50, height * 0.68, x + 100, height * 0.72);
ctx.stroke();
}
// 太阳
const sunGrad = ctx.createRadialGradient(width * 0.85, height * 0.15, 0, width * 0.85, height * 0.15, 50);
sunGrad.addColorStop(0, "#FEF3C7");
sunGrad.addColorStop(0.6, "#FCD34D");
sunGrad.addColorStop(1, "rgba(252, 211, 77, 0)");
ctx.fillStyle = sunGrad;
ctx.beginPath();
ctx.arc(width * 0.85, height * 0.15, 50, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#FCD34D";
ctx.beginPath();
ctx.arc(width * 0.85, height * 0.15, 18, 0, Math.PI * 2);
ctx.fill();
// 椰子树(简化插画)
const palmX = width * 0.12;
const palmY = height * 0.55;
ctx.fillStyle = "#8B7355";
ctx.fillRect(palmX - 4, palmY, 8, height * 0.35);
ctx.fillStyle = "#22C55E";
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 0.8 - Math.PI * 0.2;
ctx.beginPath();
ctx.moveTo(palmX, palmY);
ctx.quadraticCurveTo(palmX + Math.cos(a) * 35, palmY - 25, palmX + Math.cos(a) * 55, palmY - 45);
ctx.strokeStyle = "#22C55E";
ctx.lineWidth = 6;
ctx.lineCap = "round";
ctx.stroke();
}
// 地球/ globe
const globeX = width * 0.78;
const globeY = height * 0.35;
ctx.strokeStyle = "rgba(255,255,255,0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(globeX, globeY, 45, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(globeX, globeY, 45, 15, 0.3, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(globeX, globeY, 15, 45, -0.2, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "rgba(14, 165, 233, 0.15)";
ctx.fill();
// 笔记本电脑(数字游民工作象征)
const lapX = width * 0.25;
const lapY = height * 0.5;
ctx.fillStyle = "#E5E7EB";
ctx.fillRect(lapX - 35, lapY - 20, 70, 45);
ctx.fillStyle = "#94A3B8";
ctx.fillRect(lapX - 32, lapY - 18, 64, 35);
ctx.fillStyle = "#0ea5e9";
ctx.globalAlpha = 0.6;
ctx.fillRect(lapX - 28, lapY - 14, 56, 27);
ctx.globalAlpha = 1;
ctx.fillStyle = "#64748B";
ctx.fillRect(lapX - 38, lapY + 22, 76, 6);
ctx.fillRect(lapX - 2, lapY + 18, 4, 8);
// 云朵
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.beginPath();
ctx.arc(width * 0.5, height * 0.2, 25, 0, Math.PI * 2);
ctx.arc(width * 0.55, height * 0.18, 20, 0, Math.PI * 2);
ctx.arc(width * 0.6, height * 0.22, 22, 0, Math.PI * 2);
ctx.fill();
// 底部半透明遮罩,让标题更清晰
const overlay = ctx.createLinearGradient(0, height * 0.5, 0, height);
overlay.addColorStop(0, "rgba(0,0,0,0)");
overlay.addColorStop(0.5, "rgba(0,0,0,0.1)");
overlay.addColorStop(1, "rgba(0,0,0,0.4)");
ctx.fillStyle = overlay;
ctx.fillRect(0, 0, width, height);
// 标题:多行自动换行
ctx.fillStyle = accent;
ctx.font = `600 ${Math.min(width * 0.045, 28)}px system-ui, "PingFang SC", sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const maxWidth = width * 0.9;
const lineHeight = Math.min(width * 0.05, 32);
const paddingX = width * 0.06;
const paddingY = height * 0.68;
const words = title.split("");
let line = "";
const lines: string[] = [];
for (const char of words) {
const testLine = line + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line) {
lines.push(line);
line = char;
} else {
line = testLine;
}
}
if (line) lines.push(line);
ctx.shadowColor = "rgba(0,0,0,0.35)";
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 1;
const maxLines = 2;
const startY = paddingY;
lines.slice(0, maxLines).forEach((ln, i) => {
ctx.fillText(ln, paddingX, startY + i * lineHeight);
});
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
// 播放图标(右下角)
const playSize = Math.min(width, height) * 0.1;
const playX = width - width * 0.12;
const playY = height - height * 0.15;
ctx.fillStyle = "rgba(255,255,255,0.9)";
ctx.beginPath();
ctx.arc(playX, playY, playSize, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = from;
ctx.beginPath();
ctx.moveTo(playX - playSize * 0.3, playY - playSize * 0.5);
ctx.lineTo(playX - playSize * 0.3, playY + playSize * 0.5);
ctx.lineTo(playX + playSize * 0.5, playY);
ctx.closePath();
ctx.fill();
return canvas.toDataURL("image/jpeg", 0.92);
}

View File

@@ -0,0 +1,127 @@
import { notFound } from "next/navigation";
import { Link } from "@/i18n/navigation";
import { getDayData, daysData } from "../../../data/days";
import DayContent from "../../../components/DayContent";
interface PageProps {
params: Promise<{ id: string }>;
}
export function generateStaticParams() {
return daysData.map((d) => ({ id: String(d.day) }));
}
export default async function DayPage({ params }: PageProps) {
const { id } = await params;
const dayId = parseInt(id, 10);
const data = getDayData(dayId);
if (!data) return notFound();
const prevDay = getDayData(dayId - 1);
const nextDay = getDayData(dayId + 1);
return (
<div className="min-h-screen bg-white">
{/* Top bar */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4 sm:px-6">
<Link
href="/"
className="flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-sky-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<span className="text-sm text-slate-400">
Day {data.day} / 7
</span>
</div>
</header>
{/* Article header */}
<div className="mx-auto max-w-4xl px-4 pt-10 pb-6 sm:px-6 sm:pt-16 sm:pb-10">
<div className="mb-6 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span className="rounded-full bg-slate-100 px-3 py-1 font-medium">
{data.readingTime}
</span>
<span className="rounded-full bg-sky-50 px-3 py-1 font-medium text-sky-600">
{data.day}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-500 to-cyan-500 text-3xl text-white shadow-lg shadow-sky-500/20 sm:h-20 sm:w-20 sm:text-4xl">
{data.icon}
</div>
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl">
{data.title}
</h1>
</div>
</div>
</div>
<div className="mx-auto max-w-4xl px-4 sm:px-6">
<hr className="border-slate-100" />
</div>
{/* Markdown body */}
<article className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">
<DayContent content={data.content} />
</article>
{/* Prev / Next navigation */}
<nav className="mx-auto max-w-4xl px-4 pb-20 sm:px-6">
<div className="flex flex-col gap-4 border-t border-slate-100 pt-8 sm:flex-row sm:justify-between">
{prevDay ? (
<Link
href={`/day/${prevDay.day}`}
className="group flex items-center gap-3 rounded-xl border border-slate-100 px-5 py-4 transition-all hover:border-sky-200 hover:bg-sky-50/50 sm:flex-1"
>
<svg className="h-5 w-5 shrink-0 text-slate-400 transition-colors group-hover:text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<div>
<div className="text-xs text-slate-400"></div>
<div className="text-sm font-semibold text-slate-700 group-hover:text-sky-700">
Day {prevDay.day}: {prevDay.title}
</div>
</div>
</Link>
) : (
<div className="sm:flex-1" />
)}
{nextDay ? (
<Link
href={`/day/${nextDay.day}`}
className="group flex items-center justify-end gap-3 rounded-xl border border-slate-100 px-5 py-4 text-right transition-all hover:border-sky-200 hover:bg-sky-50/50 sm:flex-1"
>
<div>
<div className="text-xs text-slate-400"></div>
<div className="text-sm font-semibold text-slate-700 group-hover:text-sky-700">
Day {nextDay.day}: {nextDay.title}
</div>
</div>
<svg className="h-5 w-5 shrink-0 text-slate-400 transition-colors group-hover:text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
) : (
<div className="sm:flex-1" />
)}
</div>
</nav>
{/* Footer */}
<footer className="border-t border-slate-100 bg-slate-50 py-8 text-center text-sm text-slate-400">
<Link href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
🌍
</Link>
<span className="mx-2">·</span>
·
</footer>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { Link } from "@/i18n/navigation";
import type { EbookData } from "../lib/ebook";
import "github-markdown-css/github-markdown.css";
import styles from "./ebook.module.css";
function LazyChunk({
html,
estimatedHeight,
forceVisible,
}: {
html: string;
estimatedHeight: number;
forceVisible: boolean;
}) {
const [visible, setVisible] = useState(forceVisible);
const ref = useRef<HTMLDivElement>(null);
const show = forceVisible || visible;
useEffect(() => {
if (forceVisible || visible) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: "1200px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [forceVisible, visible]);
if (show) {
return (
<div
className={styles.chunkEnter}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<div
ref={ref}
className={styles.chunkPlaceholder}
style={{ minHeight: estimatedHeight }}
/>
);
}
export default function EbookClient({ data }: { data: EbookData }) {
const [showDrawer, setShowDrawer] = useState(false);
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [forceShowAll, setForceShowAll] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const { chunks, headers } = data;
useEffect(() => {
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop =
window.scrollY || document.documentElement.scrollTop;
const docHeight =
document.documentElement.scrollHeight - window.innerHeight;
setReadProgress(
docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0
);
setShowBackTop(scrollTop > 600);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const onImgLoad = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === "IMG") target.classList.add("loaded");
};
el.addEventListener("load", onImgLoad, true);
const checkCached = () => {
el.querySelectorAll("img").forEach((img) => {
if (img.complete) img.classList.add("loaded");
});
};
const mo = new MutationObserver(checkCached);
mo.observe(el, { childList: true, subtree: true });
checkCached();
return () => {
el.removeEventListener("load", onImgLoad, true);
mo.disconnect();
};
}, []);
const handleJump = useCallback(
(index: number) => {
setForceShowAll(true);
setShowDrawer(false);
const run = () => {
const root = contentRef.current;
if (!root) return;
const h1s = root.querySelectorAll("h1");
const h2s = root.querySelectorAll("h2");
const targets = h1s.length > 0 ? h1s : h2s;
const el = targets[index] as HTMLElement;
if (el) {
try {
el.scrollIntoView({ behavior: "smooth", block: "start" });
} catch {
const top =
el.getBoundingClientRect().top +
(window.pageYOffset || document.documentElement.scrollTop) -
60;
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
}
}
};
run();
setTimeout(run, 100);
setTimeout(run, 300);
setTimeout(run, 600);
},
[]
);
return (
<>
<div
className={styles.readingProgress}
style={{ width: `${readProgress}%` }}
/>
<div className={styles.headerSpacer} />
<header className={styles.header}>
<Link href="/" className={styles.headerLeft} aria-label="返回首页">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<div className={styles.headerMiddle}></div>
<button
type="button"
className={`${styles.headerRight} ${showDrawer ? styles.headerRightActive : ""}`}
onClick={() => setShowDrawer(!showDrawer)}
aria-label="目录"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
<span className="hidden sm:inline sm:ml-1"></span>
</button>
</header>
<div className={styles.book}>
<div ref={contentRef} className={`markdown-body ${styles.markdownBody}`}>
{chunks.map((chunk, i) => (
<LazyChunk
key={i}
html={chunk.html}
estimatedHeight={chunk.estimatedHeight}
forceVisible={forceShowAll || i === 0}
/>
))}
</div>
</div>
{/* 抽屉目录 */}
<div
className={`${styles.drawerMask} ${showDrawer ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowDrawer(false)}
aria-hidden="true"
/>
<aside
className={`${styles.drawer} ${showDrawer ? styles.drawerOpen : ""}`}
>
{headers.map((title, index) => (
<div
key={index}
role="button"
tabIndex={0}
className={styles.drawerItem}
onClick={() => handleJump(index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleJump(index);
}}
>
{title}
</div>
))}
</aside>
{showBackTop && (
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
)}
</>
);
}

View File

@@ -0,0 +1,501 @@
/* 复刻 nomadbook 电子书样式 */
.readingProgress {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
z-index: 201;
transition: width 0.15s linear;
border-radius: 0 4px 4px 0;
}
.headerSpacer {
width: 100%;
height: 88px;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 88px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.headerLeft {
width: 100px;
display: flex;
align-items: center;
justify-content: center;
color: #1a1a2e;
cursor: pointer;
}
.headerMiddle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 600;
color: #1a1a2e;
letter-spacing: 2px;
}
.headerRight {
width: 72px;
height: 72px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: #4facfe;
cursor: pointer;
margin-right: 10px;
background: #f0f7ff;
border-radius: 50%;
transition: all 0.2s ease;
}
.headerRight:hover {
background: #dceeff;
}
.headerRightActive {
background: #4facfe !important;
color: #fff !important;
}
.chunkEnter {
animation: chunkAppear 0.5s ease-out both;
}
@keyframes chunkAppear {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chunkPlaceholder {
background: linear-gradient(90deg, #f6f7f8 25%, #edeef1 37%, #f6f7f8 63%);
background-size: 400% 100%;
animation: shimmer 1.8s ease infinite;
border-radius: 12px;
margin: 24px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loadingSpinner {
width: 56px;
height: 56px;
border: 5px solid #f0f0f0;
border-top-color: #4facfe;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.backToTop {
position: fixed;
bottom: 120px;
right: 30px;
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
animation: fadeInUp 0.3s ease;
color: #4facfe;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.backToTop:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.book {
padding: 30px;
max-width: 100%;
}
.markdownBody {
font-size: 32px !important;
line-height: 1.85 !important;
color: #2c3e50 !important;
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdownBody h1 {
font-size: 48px !important;
font-weight: 800;
color: #1a1a2e;
margin: 56px 0 20px;
padding-bottom: 0;
border-bottom: none;
line-height: 1.3;
}
.markdownBody h1::after {
content: "";
display: block;
width: 80px;
height: 6px;
background: linear-gradient(90deg, #4facfe, #00f2fe);
border-radius: 3px;
margin-top: 16px;
}
.markdownBody h2 {
font-size: 42px !important;
font-weight: 700;
color: #2c3e50;
margin: 44px 0 18px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
line-height: 1.3;
}
.markdownBody h3 {
font-size: 36px !important;
font-weight: 600;
color: #34495e;
margin: 36px 0 14px;
}
.markdownBody h4,
.markdownBody h5,
.markdownBody h6 {
font-size: 32px !important;
font-weight: 600;
color: #4a5568;
margin: 28px 0 12px;
}
.markdownBody p {
font-size: 32px !important;
margin-bottom: 20px;
line-height: 1.9;
}
.markdownBody blockquote {
position: relative;
margin: 28px 0;
padding: 20px 24px 20px 30px;
border-left: none;
background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
border-radius: 0 12px 12px 0;
color: #4a5568;
}
.markdownBody blockquote::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
background: linear-gradient(180deg, #4facfe, #00f2fe);
border-radius: 3px;
}
.markdownBody pre {
position: relative;
margin: 28px 0;
padding: 28px 24px 24px;
background: #1e1e2e;
border-radius: 12px;
overflow-x: auto;
}
.markdownBody pre::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #4facfe, #00f2fe);
border-radius: 12px 12px 0 0;
}
.markdownBody pre code {
font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
font-size: 26px !important;
line-height: 1.6;
color: #e2e8f0;
background: none !important;
padding: 0;
}
.markdownBody code {
font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
font-size: 28px !important;
padding: 4px 10px;
background: #f1f5f9;
color: #e53e3e;
border-radius: 6px;
}
.markdownBody a {
color: #4facfe;
text-decoration: none;
}
.markdownBody a:hover {
color: #2b8de3;
border-bottom: 1px solid #4facfe;
}
.markdownBody img {
display: block;
margin: 28px auto;
max-width: 100%;
height: auto;
border-radius: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.markdownBody img.loaded {
opacity: 1;
transform: translateY(0);
}
.markdownBody ul,
.markdownBody ol {
margin: 16px 0;
padding-left: 40px;
}
.markdownBody table {
width: 100%;
margin: 28px 0;
border-collapse: collapse;
border-radius: 8px;
}
.markdownBody th {
background: linear-gradient(135deg, #f8fafc, #edf2f7);
font-weight: 600;
padding: 16px 20px;
text-align: left;
border: 1px solid #e2e8f0;
}
.markdownBody td {
padding: 14px 20px;
border: 1px solid #e2e8f0;
}
.markdownBody tr:nth-child(even) {
background: #f8fafc;
}
/* 抽屉目录 */
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: #fff;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
z-index: 150;
padding-top: 88px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawerOpen {
transform: translateX(0);
}
.drawerMask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 140;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.drawerMaskOpen {
opacity: 1;
pointer-events: auto;
}
.drawerItem {
padding: 16px 20px;
border-bottom: 1px solid #f5f5f5;
color: #2c3e50;
cursor: pointer;
font-size: 15px;
transition: background 0.15s;
}
.drawerItem:hover {
background: #f0f7ff;
}
/* PC 适配 */
@media (min-width: 768px) {
.headerSpacer {
height: 56px;
}
.header {
height: 56px;
}
.headerMiddle {
font-size: 18px;
}
.headerRight {
width: auto;
height: 34px;
padding: 0 14px;
margin-right: 16px;
gap: 5px;
border-radius: 17px;
font-size: 13px;
}
.book {
padding: 30px 60px;
}
.markdownBody {
font-size: 16px !important;
line-height: 1.8 !important;
max-width: 800px;
margin: 0 auto;
}
.markdownBody h1 {
font-size: 28px !important;
margin: 36px 0 14px;
}
.markdownBody h1::after {
width: 50px;
height: 3px;
margin-top: 10px;
}
.markdownBody h2 {
font-size: 23px !important;
margin: 28px 0 12px;
padding-bottom: 8px;
}
.markdownBody h3 {
font-size: 19px !important;
margin: 22px 0 10px;
}
.markdownBody p {
font-size: 16px !important;
margin-bottom: 14px;
}
.markdownBody pre code {
font-size: 14px !important;
}
.markdownBody code {
font-size: 14px !important;
padding: 2px 6px;
}
.backToTop {
width: 44px;
height: 44px;
bottom: 40px;
right: 40px;
}
.drawer {
padding-top: 56px;
width: 300px;
}
.drawerItem {
font-size: 14px;
padding: 14px 18px;
}
}
@media (min-width: 1024px) {
.book {
padding: 30px 80px;
}
.markdownBody {
width: 720px;
font-size: 17px !important;
line-height: 1.85 !important;
}
.markdownBody h1 {
font-size: 30px !important;
}
.markdownBody h2 {
font-size: 25px !important;
}
.markdownBody h3 {
font-size: 20px !important;
}
.markdownBody p {
font-size: 17px !important;
}
.markdownBody img.loaded:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}

View File

@@ -0,0 +1,17 @@
import { readFileSync } from "fs";
import path from "path";
import { parseEbookMarkdown } from "../../lib/ebook";
import EbookClient from "./EbookClient";
export const metadata = {
title: "电子书 | 数字游民",
description: "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
};
export default function EbookPage() {
const mdPath = path.join(process.cwd(), "app/data/ebook.md");
const source = readFileSync(mdPath, "utf-8");
const data = parseEbookMarkdown(source);
return <EbookClient data={data} />;
}

567
app/[locale]/join/page.tsx Normal file
View File

@@ -0,0 +1,567 @@
"use client";
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
import { Link } from "@/i18n/navigation";
import { getPayEnv, getRecommendedChannel, mustUseWxPay, type PayChannel, type PayEnv } from "../../lib/payEnv";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
interface FormData {
nickname: string;
gender: string;
single: string;
profession: string;
intro: string;
wechat: string;
education: string;
graduationYear: string;
phone: string;
}
interface MediaInfo {
preview: string;
url: string;
type: "image" | "video";
}
export default function JoinPage() {
const [form, setForm] = useState<FormData>({
nickname: "",
gender: "",
single: "",
profession: "",
intro: "",
wechat: "",
education: "",
graduationYear: "",
phone: "",
});
const [media, setMedia] = useState<MediaInfo | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
setSubmitted(true);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const env = getPayEnv();
setPayEnv(env);
setPayChannel(getRecommendedChannel(env));
}, []);
const set = (key: keyof FormData, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await fetch("/api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, media: media?.url }),
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
const userId = data.user_id;
if (!userId) {
setSubmitted(true);
return;
}
setSubmitting(false);
setPayRedirecting(true);
const returnUrl = `${window.location.origin}${window.location.pathname}?paid=1`;
const payForm = document.createElement("form");
payForm.method = "POST";
payForm.action = "/api/pay";
payForm.target = "_self";
payForm.style.display = "none";
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
const device = env === "pc" ? "pc" : "h5"; // PC 跳转 payurlH5/微信 用 payurl/payurl2
const fields: [string, string][] = [
["user_id", userId],
["total_fee", "80"],
["type", "meetup"],
["channel", channel],
["device", device],
["return_url", returnUrl],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
payForm.appendChild(input);
});
document.body.appendChild(payForm);
payForm.submit();
return;
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="mt-3 text-slate-500">
<br />
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#f0f4f8]">
{/* ── PC: center column / H5: full width ── */}
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
{/* ── Banner ── */}
<div className="overflow-hidden bg-white shadow-sm sm:rounded-t-2xl">
{/* Hero image area */}
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-sky-400 via-cyan-500 to-teal-400 sm:h-52">
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
<div className="text-6xl sm:text-7xl">🌍</div>
<div className="mt-2 text-sm font-medium opacity-80">
Digital Nomad Community
</div>
</div>
{/* Decorative blobs */}
<div className="absolute -top-10 -left-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
</div>
{/* Title block */}
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
<h1 className="text-xl font-bold text-slate-800 sm:text-2xl">
🌍
</h1>
<p className="mt-3 whitespace-pre-line text-sm leading-relaxed text-slate-400 sm:text-base">
{"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
</p>
</div>
</div>
{/* ── Form card ── */}
<form
onSubmit={handleSubmit}
className="mt-0 bg-white px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md"
>
{/* ---- Row group: nickname + gender (PC side-by-side) ---- */}
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🌟 昵称">
<input
type="text"
maxLength={140}
placeholder="请输入您的昵称"
value={form.nickname}
onChange={(e) => set("nickname", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="👤 性别">
<select
value={form.gender}
onChange={(e) => set("gender", e.target.value)}
required
className={`form-input appearance-none ${
form.gender ? "text-slate-800" : "text-slate-400"
}`}
>
<option value="" disabled>
</option>
{genderOptions.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<ChevronDown />
</Field>
</div>
{/* ---- Row group: single + profession ---- */}
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💕 感情状态">
<select
value={form.single}
onChange={(e) => set("single", e.target.value)}
required
className={`form-input appearance-none ${
form.single ? "text-slate-800" : "text-slate-400"
}`}
>
<option value="" disabled>
</option>
{singleOptions.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="💼 职业">
<input
type="text"
maxLength={140}
placeholder="您的职业或专业"
value={form.profession}
onChange={(e) => set("profession", e.target.value)}
required
className="form-input"
/>
</Field>
</div>
{/* ---- wechat + phone ---- */}
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💬 微信号">
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={form.wechat}
onChange={(e) => set("wechat", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="📱 手机号">
<input
type="tel"
maxLength={11}
pattern="^1[3-9]\d{9}$"
placeholder="请输入11位中国手机号"
value={form.phone}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, "").slice(0, 11);
set("phone", v);
}}
className="form-input"
/>
</Field>
</div>
{/* ---- Intro (full width) ---- */}
<div className="border-b border-slate-100 py-4 sm:border-b-0 sm:py-5">
<label className="mb-2 block text-base font-bold text-slate-700 sm:text-[17px]">
📝
</label>
<div className="relative">
<textarea
maxLength={500}
rows={5}
placeholder="分享您的经历、兴趣或加入原因,越详细越好哦~"
value={form.intro}
onChange={(e) => set("intro", e.target.value)}
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
/>
<span className="absolute right-3 bottom-3 text-xs text-slate-300">
{form.intro.length}/500
</span>
</div>
</div>
{/* ---- Row group: education + graduation ---- */}
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🎓 学历">
<select
value={form.education}
onChange={(e) => set("education", e.target.value)}
className={`form-input appearance-none ${
form.education ? "text-slate-800" : "text-slate-400"
}`}
>
<option value="" disabled>
</option>
{educationOptions.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="📅 毕业时间">
<select
value={form.graduationYear}
onChange={(e) => set("graduationYear", e.target.value)}
className={`form-input appearance-none ${
form.graduationYear ? "text-slate-800" : "text-slate-400"
}`}
>
<option value="" disabled>
</option>
{graduationYears.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<ChevronDown />
</Field>
</div>
{/* ---- Media upload (optional) ---- */}
<div className="py-4 sm:py-5">
<label className="mb-1 block text-[15px] font-bold text-slate-700 sm:text-[17px]">
📷 /
<span className="ml-2 text-xs font-normal text-slate-400">
</span>
</label>
<p className="mb-3 text-xs text-slate-400">
JPG/PNGMP4 20MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
className="hidden"
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 20 * 1024 * 1024) {
alert("文件大小不能超过 20MB");
return;
}
const isVideo = file.type.startsWith("video/");
const preview = URL.createObjectURL(file);
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload-media", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "上传失败");
}
setMedia((m) => (m ? { ...m, url: data.url } : null));
} catch (err) {
alert(err instanceof Error ? err.message : "上传失败");
URL.revokeObjectURL(preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
} finally {
setUploading(false);
}
}}
/>
{media ? (
<div className="relative inline-block">
{uploading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
</div>
)}
{media.type === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={media.preview}
alt="preview"
className="h-32 w-32 rounded-xl border border-slate-200 object-cover sm:h-40 sm:w-40"
/>
) : (
<video
src={media.preview}
className="h-32 w-32 rounded-xl border border-slate-200 object-cover sm:h-40 sm:w-40"
muted
playsInline
controls
/>
)}
<button
type="button"
disabled={uploading}
onClick={() => {
URL.revokeObjectURL(media.preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
}}
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
>
</button>
</div>
) : (
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
>
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
</button>
)}
</div>
{/* ── 支付通道选择PC/H5 可选,微信内强制微信支付)── */}
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50/50 px-4 py-4 sm:px-5">
<p className="mb-3 text-sm font-bold text-slate-700">💳 </p>
{payEnv === "wechat" ? (
<p className="text-sm text-slate-500">
使 <span className="font-medium text-emerald-600"></span>
</p>
) : (
<div className="flex gap-3">
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50">
<input
type="radio"
name="payChannel"
value="alipay"
checked={payChannel === "alipay"}
onChange={() => setPayChannel("alipay")}
className="h-4 w-4 border-slate-300 text-sky-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50">
<input
type="radio"
name="payChannel"
value="wxpay"
checked={payChannel === "wxpay"}
onChange={() => setPayChannel("wxpay")}
className="h-4 w-4 border-slate-300 text-emerald-500"
/>
<span className="text-sm font-medium text-slate-700"></span>
</label>
</div>
)}
</div>
{/* ── Submit ── */}
<div className="mt-8 sm:mt-10">
{error && (
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-center text-sm text-red-600">
{error}
</p>
)}
<button
type="submit"
disabled={submitting || uploading || payRedirecting}
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
>
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
</button>
</div>
</form>
{/* ── Back link (PC) ── */}
<div className="mt-6 hidden text-center sm:block">
<Link
href="/"
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
>
</Link>
</div>
<div className="mt-4 pb-6 text-center sm:hidden">
<Link
href="/"
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
>
</Link>
</div>
</div>
</div>
);
}
/* ─── Reusable field wrapper ─── */
function Field({
label,
children,
noBorder,
}: {
label: string;
children: React.ReactNode;
noBorder?: boolean;
}) {
return (
<div
className={`relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 ${
noBorder ? "" : "border-b border-slate-100 sm:border-b-0"
}`}
>
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 sm:mb-2 sm:w-auto sm:text-[17px]">
{label}
</label>
<div className="relative min-w-0 flex-1">{children}</div>
</div>
);
}
/* ─── Chevron icon for selects ─── */
function ChevronDown() {
return (
<svg
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-slate-400 sm:right-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
);
}

37
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { LocaleProvider } from "../context/LocaleContext";
import type { Locale } from "../context/LocaleContext";
const LOCALES: Locale[] = ["zh", "en"];
export const metadata: Metadata = {
title: "数字游民指南 | Digital Nomad Guide",
description:
"从零开始7天开启你的数字游民生活。远程工作、地点自由、技能变现 —— 一站式数字游民资源平台。",
};
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!LOCALES.includes(locale as Locale)) {
notFound();
}
const messages = (await import(`../../messages/${locale}.json`)).default;
return (
<LocaleProvider locale={locale as Locale} messages={messages}>
{children}
</LocaleProvider>
);
}

23
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import Header from "../components/Header";
import Hero from "../components/Hero";
import Features from "../components/Features";
import Roadmap from "../components/Roadmap";
import Tools from "../components/Tools";
import Community from "../components/Community";
import Footer from "../components/Footer";
export default function HomePage() {
return (
<>
<Header />
<main>
<Hero />
<Features />
<Roadmap />
<Tools />
<Community />
</main>
<Footer />
</>
);
}