'多语言'
This commit is contained in:
76
app/[locale]/course/VideoCard.tsx
Normal file
76
app/[locale]/course/VideoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
570
app/[locale]/course/page.tsx
Normal file
570
app/[locale]/course/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
app/[locale]/course/useVideoProgress.ts
Normal file
56
app/[locale]/course/useVideoProgress.ts
Normal 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 };
|
||||
}
|
||||
184
app/[locale]/course/videoPoster.ts
Normal file
184
app/[locale]/course/videoPoster.ts
Normal 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);
|
||||
}
|
||||
127
app/[locale]/day/[id]/page.tsx
Normal file
127
app/[locale]/day/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
app/[locale]/ebook/EbookClient.tsx
Normal file
244
app/[locale]/ebook/EbookClient.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
501
app/[locale]/ebook/ebook.module.css
Normal file
501
app/[locale]/ebook/ebook.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
17
app/[locale]/ebook/page.tsx
Normal file
17
app/[locale]/ebook/page.tsx
Normal 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
567
app/[locale]/join/page.tsx
Normal 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 跳转 payurl,H5/微信 用 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/PNG)或短视频(MP4),最大 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
37
app/[locale]/layout.tsx
Normal 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
23
app/[locale]/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user