diff --git a/app/course/VideoCard.tsx b/app/[locale]/course/VideoCard.tsx similarity index 100% rename from app/course/VideoCard.tsx rename to app/[locale]/course/VideoCard.tsx diff --git a/app/course/page.tsx b/app/[locale]/course/page.tsx similarity index 99% rename from app/course/page.tsx rename to app/[locale]/course/page.tsx index 6d3dde5..16551d5 100644 --- a/app/course/page.tsx +++ b/app/[locale]/course/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { VideoCard } from "./VideoCard"; import { useVideoProgress } from "./useVideoProgress"; -import AuthModal from "../components/AuthModal"; +import AuthModal from "../../components/AuthModal"; function getStoredUser(): string | null { if (typeof window === "undefined") return null; diff --git a/app/course/useVideoProgress.ts b/app/[locale]/course/useVideoProgress.ts similarity index 100% rename from app/course/useVideoProgress.ts rename to app/[locale]/course/useVideoProgress.ts diff --git a/app/course/videoPoster.ts b/app/[locale]/course/videoPoster.ts similarity index 100% rename from app/course/videoPoster.ts rename to app/[locale]/course/videoPoster.ts diff --git a/app/day/[id]/page.tsx b/app/[locale]/day/[id]/page.tsx similarity index 92% rename from app/day/[id]/page.tsx rename to app/[locale]/day/[id]/page.tsx index 2777803..faf0189 100644 --- a/app/day/[id]/page.tsx +++ b/app/[locale]/day/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; -import { getDayData, daysData } from "../../data/days"; -import DayContent from "../../components/DayContent"; +import { Link } from "@/i18n/navigation"; +import { getDayData, daysData } from "../../../data/days"; +import DayContent from "../../../components/DayContent"; interface PageProps { params: Promise<{ id: string }>; @@ -25,7 +26,7 @@ export default async function DayPage({ params }: PageProps) { {/* Top bar */}
- @@ -33,7 +34,7 @@ export default async function DayPage({ params }: PageProps) { 返回首页 - + Day {data.day} / 7 @@ -75,7 +76,7 @@ export default async function DayPage({ params }: PageProps) {
- + ) : (
)} {nextDay ? ( - @@ -106,7 +107,7 @@ export default async function DayPage({ params }: PageProps) { - + ) : (
)} @@ -115,9 +116,9 @@ export default async function DayPage({ params }: PageProps) { {/* Footer */} diff --git a/app/[locale]/ebook/EbookClient.tsx b/app/[locale]/ebook/EbookClient.tsx new file mode 100644 index 0000000..d6119b7 --- /dev/null +++ b/app/[locale]/ebook/EbookClient.tsx @@ -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(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 ( +
+ ); + } + return ( +
+ ); +} + +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(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 ( + <> +
+ +
+
+ + + + + +
数字游民
+ +
+ +
+
+ {chunks.map((chunk, i) => ( + + ))} +
+
+ + {/* 抽屉目录 */} +
setShowDrawer(false)} + aria-hidden="true" + /> + + + {showBackTop && ( + + )} + + ); +} diff --git a/app/[locale]/ebook/ebook.module.css b/app/[locale]/ebook/ebook.module.css new file mode 100644 index 0000000..f472704 --- /dev/null +++ b/app/[locale]/ebook/ebook.module.css @@ -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); + } +} diff --git a/app/[locale]/ebook/page.tsx b/app/[locale]/ebook/page.tsx new file mode 100644 index 0000000..7a1b272 --- /dev/null +++ b/app/[locale]/ebook/page.tsx @@ -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 ; +} diff --git a/app/join/page.tsx b/app/[locale]/join/page.tsx similarity index 98% rename from app/join/page.tsx rename to app/[locale]/join/page.tsx index 66352b7..b475ee6 100644 --- a/app/join/page.tsx +++ b/app/[locale]/join/page.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react"; -import { getPayEnv, getRecommendedChannel, mustUseWxPay, type PayChannel, type PayEnv } from "../lib/payEnv"; +import { Link } from "@/i18n/navigation"; +import { getPayEnv, getRecommendedChannel, mustUseWxPay, type PayChannel, type PayEnv } from "../../lib/payEnv"; const genderOptions = ["男", "女"]; const singleOptions = ["单身", "恋爱中", "已婚"]; @@ -85,7 +86,7 @@ export default function JoinPage() { } setSubmitting(false); setPayRedirecting(true); - const returnUrl = `${window.location.origin}/join?paid=1`; + const returnUrl = `${window.location.origin}${window.location.pathname}?paid=1`; const payForm = document.createElement("form"); payForm.method = "POST"; payForm.action = "/api/pay"; @@ -133,12 +134,12 @@ export default function JoinPage() {
审核通过后将通过微信邀请你入群

- ← 返回首页 - +
); @@ -501,22 +502,21 @@ export default function JoinPage() { {/* ── Back link (PC) ── */} - {/* ── Back link (H5, fixed bottom) ── */}
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..42a44b8 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..0cbb240 --- /dev/null +++ b/app/[locale]/page.tsx @@ -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 ( + <> +
+
+ + + + + +
+