'多语言'
This commit is contained in:
@@ -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;
|
||||
@@ -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 */}
|
||||
<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">
|
||||
<a
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-sky-600"
|
||||
>
|
||||
@@ -33,7 +34,7 @@ export default async function DayPage({ params }: PageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回首页
|
||||
</a>
|
||||
</Link>
|
||||
<span className="text-sm text-slate-400">
|
||||
Day {data.day} / 7
|
||||
</span>
|
||||
@@ -75,7 +76,7 @@ export default async function DayPage({ params }: PageProps) {
|
||||
<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 ? (
|
||||
<a
|
||||
<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"
|
||||
>
|
||||
@@ -88,12 +89,12 @@ export default async function DayPage({ params }: PageProps) {
|
||||
Day {prevDay.day}: {prevDay.title}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="sm:flex-1" />
|
||||
)}
|
||||
{nextDay ? (
|
||||
<a
|
||||
<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"
|
||||
>
|
||||
@@ -106,7 +107,7 @@ export default async function DayPage({ params }: PageProps) {
|
||||
<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>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="sm:flex-1" />
|
||||
)}
|
||||
@@ -115,9 +116,9 @@ export default async function DayPage({ params }: PageProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-100 bg-slate-50 py-8 text-center text-sm text-slate-400">
|
||||
<a href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
|
||||
<Link href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
|
||||
🌍 数字游民指南
|
||||
</a>
|
||||
</Link>
|
||||
<span className="mx-2">·</span>
|
||||
开源免费 · 社区驱动
|
||||
</footer>
|
||||
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} />;
|
||||
}
|
||||
@@ -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() {
|
||||
<br />
|
||||
审核通过后将通过微信邀请你入群
|
||||
</p>
|
||||
<a
|
||||
<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"
|
||||
>
|
||||
← 返回首页
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -501,22 +502,21 @@ export default function JoinPage() {
|
||||
|
||||
{/* ── Back link (PC) ── */}
|
||||
<div className="mt-6 hidden text-center sm:block">
|
||||
<a
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
|
||||
>
|
||||
← 返回首页
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── Back link (H5, fixed bottom) ── */}
|
||||
<div className="mt-4 pb-6 text-center sm:hidden">
|
||||
<a
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
|
||||
>
|
||||
← 返回首页
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +1,94 @@
|
||||
const links = [
|
||||
{
|
||||
icon: "📖",
|
||||
title: "入门指南",
|
||||
desc: "完整的数字游民入门教程",
|
||||
label: "开始学习 ↗",
|
||||
},
|
||||
{
|
||||
icon: "💬",
|
||||
title: "Discord 社区",
|
||||
desc: "与全球数字游民实时交流",
|
||||
label: "加入社区 ↗",
|
||||
},
|
||||
{
|
||||
icon: "🛒",
|
||||
title: "工具集市",
|
||||
desc: "发现、评测和分享远程工具",
|
||||
label: "浏览工具 ↗",
|
||||
},
|
||||
{
|
||||
icon: "📦",
|
||||
title: "GitHub",
|
||||
desc: "开源项目,欢迎 Star 和 PR",
|
||||
label: "访问仓库 ↗",
|
||||
},
|
||||
{
|
||||
icon: "📝",
|
||||
title: "飞书知识库",
|
||||
desc: "7天入门指南 · 中文图文教程",
|
||||
label: "查看文档 ↗",
|
||||
},
|
||||
{
|
||||
icon: "🗺️",
|
||||
title: "目的地数据库",
|
||||
desc: "全球数字游民城市信息",
|
||||
label: "探索城市 ↗",
|
||||
},
|
||||
];
|
||||
"use client";
|
||||
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const linkKeys = ["guide", "discord", "tools", "github", "feishu", "destinations"] as const;
|
||||
const linkIcons: Record<(typeof linkKeys)[number], string> = {
|
||||
guide: "📖",
|
||||
discord: "💬",
|
||||
tools: "🛒",
|
||||
github: "📦",
|
||||
feishu: "📝",
|
||||
destinations: "🗺️",
|
||||
};
|
||||
|
||||
export default function Community() {
|
||||
const { t } = useTranslation("community");
|
||||
return (
|
||||
<section id="community" className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{/* Community cards */}
|
||||
<div className="mb-20 grid gap-6 md:grid-cols-2">
|
||||
<a
|
||||
{/* 数字游民社区 - 优化布局 */}
|
||||
<div className="mb-20">
|
||||
<Link
|
||||
href="/join"
|
||||
className="block rounded-2xl border border-sky-200 bg-gradient-to-br from-sky-50 to-cyan-50 p-8 transition-all hover:shadow-lg"
|
||||
className="group block overflow-hidden rounded-2xl border border-sky-200/80 bg-gradient-to-br from-sky-50 via-white to-cyan-50/60 p-8 shadow-sm transition-all duration-300 hover:border-sky-300 hover:shadow-xl hover:shadow-sky-100/50 sm:p-10"
|
||||
>
|
||||
<div className="mb-2 text-sm font-semibold text-sky-600">
|
||||
👥 数字游民社区
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900">
|
||||
加入全球华人数字游民网络
|
||||
</h3>
|
||||
<p className="mb-6 text-sm leading-relaxed text-slate-600">
|
||||
填写申请表加入社区
|
||||
<br />
|
||||
1000+ 数字游民 · 每周线上分享 · 城市攻略
|
||||
</p>
|
||||
<div className="flex items-center justify-center rounded-xl bg-white/80 p-6 shadow-sm backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-sky-100 text-3xl">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-sky-100 px-4 py-1.5 text-sm font-semibold text-sky-700">
|
||||
<span className="text-lg">👥</span>
|
||||
{t("nomadCommunity")}
|
||||
</div>
|
||||
<h3 className="mb-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
{t("joinTitle")}
|
||||
</h3>
|
||||
<p className="mb-4 max-w-xl text-base leading-relaxed text-slate-600">
|
||||
{t("joinDesc")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm">
|
||||
{t("applyForm")}
|
||||
</span>
|
||||
<span className="rounded-lg bg-white/90 px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm">
|
||||
{t("approveJoin")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-4 rounded-2xl bg-white/90 p-6 shadow-md backdrop-blur-sm transition-transform group-hover:scale-[1.02] sm:p-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-3xl shadow-lg shadow-sky-200/50">
|
||||
📝
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-bold text-slate-800">
|
||||
立即申请加入 →
|
||||
{t("applyNow")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
填写简单表单,审核通过即可入群
|
||||
<div className="mt-1 text-sm text-slate-500">
|
||||
{t("applyHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/course"
|
||||
className="block rounded-2xl border border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50 p-8 transition-all hover:shadow-lg"
|
||||
>
|
||||
<div className="mb-2 text-sm font-semibold text-amber-600">
|
||||
🎬 数字游民实战课
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900">
|
||||
打造 7×24 小时自动化收入系统
|
||||
</h3>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<span className="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-slate-600">
|
||||
🔥 实战训练营
|
||||
</span>
|
||||
<span className="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-slate-600">
|
||||
📹 视频教程
|
||||
</span>
|
||||
<span className="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-slate-600">
|
||||
¥199
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-slate-600">
|
||||
7 天打造你的远程工作体系 · 从入门到精通
|
||||
</p>
|
||||
<div className="flex items-center justify-center rounded-xl bg-white/80 p-6 shadow-sm backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl">🎓</div>
|
||||
<div className="mt-3 text-sm font-medium text-slate-600">
|
||||
点击查看课程详情 →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Open source contribution */}
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-sm font-semibold text-slate-400">
|
||||
🤝 开源共建
|
||||
{t("openSource")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
社区与<span className="gradient-text">贡献</span>
|
||||
{t("contributeTitle")} <span className="gradient-text">{t("contributeHighlight")}</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||
数字游民指南是开源项目,致力于做最好的中文数字游民资源聚合站。
|
||||
{t("contributeSubtitle")}
|
||||
<br />
|
||||
欢迎补充资源、改进内容、分享经验。
|
||||
{t("contributeSubtitle2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{links.map((l) => (
|
||||
{linkKeys.map((key) => (
|
||||
<div
|
||||
key={l.title}
|
||||
key={key}
|
||||
className="card-hover flex items-start gap-4 rounded-xl border border-slate-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-xl">
|
||||
{l.icon}
|
||||
{linkIcons[key]}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900">{l.title}</h4>
|
||||
<p className="mt-1 text-sm text-slate-500">{l.desc}</p>
|
||||
<h4 className="font-bold text-slate-900">{t(`links.${key}.title`)}</h4>
|
||||
<p className="mt-1 text-sm text-slate-500">{t(`links.${key}.desc`)}</p>
|
||||
<span className="mt-2 inline-block text-sm font-medium text-sky-600">
|
||||
{l.label}
|
||||
{t(`links.${key}.label`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,15 +97,15 @@ export default function Community() {
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<div className="mb-4 text-lg font-bold text-slate-900">
|
||||
🌟 一起让数字游民指南变得更好
|
||||
{t("betterTitle")}
|
||||
</div>
|
||||
<p className="mb-6 text-slate-600">
|
||||
发现了好资源?写了旅居心得?有工具推荐?
|
||||
{t("betterDesc")}
|
||||
<br />
|
||||
提一个 PR,你的贡献将帮助更多人。
|
||||
{t("betterDesc2")}
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-8 py-3 font-semibold text-white transition-colors hover:bg-slate-800">
|
||||
⭐ Star & Fork on GitHub
|
||||
{t("starFork")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
const features = [
|
||||
{
|
||||
icon: "🌏",
|
||||
title: "地点自由",
|
||||
desc: "在世界任何角落工作——咖啡馆、海滩、共享空间,你的办公室就是整个地球。告别固定工位,拥抱无限可能。",
|
||||
},
|
||||
{
|
||||
icon: "⚡",
|
||||
title: "技能变现",
|
||||
desc: "编程、设计、写作、营销、咨询……用你的专业技能换取全球收入,构建多元化的收入体系。",
|
||||
},
|
||||
{
|
||||
icon: "🔄",
|
||||
title: "工作生活平衡",
|
||||
desc: "自主安排时间,工作与旅行完美融合。按照自己的节奏生活,找到真正属于你的生活方式。",
|
||||
},
|
||||
];
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const featureKeys = ["location", "skill", "balance"] as const;
|
||||
|
||||
export default function Features() {
|
||||
const { t } = useTranslation("features");
|
||||
const features = featureKeys.map((key) => ({
|
||||
icon: key === "location" ? "🌏" : key === "skill" ? "⚡" : "🔄",
|
||||
key,
|
||||
}));
|
||||
return (
|
||||
<section id="features" className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
什么是<span className="gradient-text">数字游民</span>?
|
||||
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>{t("titleSuffix")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||
数字游民是一种利用互联网技术远程工作、不受地理位置限制的生活方式。
|
||||
{t("subtitle")}
|
||||
<br className="hidden sm:block" />
|
||||
它让你在探索世界的同时,持续创造价值。
|
||||
{t("subtitle2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
key={f.key}
|
||||
className="card-hover rounded-2xl border border-slate-100 bg-white p-8 shadow-sm"
|
||||
>
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl">
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900">
|
||||
{f.title}
|
||||
{t(`items.${f.key}.title`)}
|
||||
</h3>
|
||||
<p className="leading-relaxed text-slate-600">{f.desc}</p>
|
||||
<p className="leading-relaxed text-slate-600">{t(`items.${f.key}.desc`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -51,7 +44,7 @@ export default function Features() {
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-5 py-2 text-sm font-medium text-amber-700">
|
||||
<span>🔥</span>
|
||||
全球已有超过 <span className="font-bold">3500 万</span> 数字游民
|
||||
{t("stat")} <span className="font-bold">{t("statCount")}</span> {t("statSuffix")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,72 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const footerSections = [
|
||||
{
|
||||
title: "指南",
|
||||
titleKey: "guide" as const,
|
||||
links: [
|
||||
{ label: "7天学习路径", href: "#roadmap" },
|
||||
{ label: "全部资源", href: "#resources" },
|
||||
{ label: "工具推荐", href: "#tools" },
|
||||
{ label: "目的地数据库", href: "#" },
|
||||
{ labelKey: "roadmap" as const, href: "#roadmap" },
|
||||
{ labelKey: "tools" as const, href: "#tools" },
|
||||
{ labelKey: "destinations" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "资源",
|
||||
titleKey: "resources" as const,
|
||||
links: [
|
||||
{ label: "远程工作平台", href: "#" },
|
||||
{ label: "签证政策汇总", href: "#" },
|
||||
{ label: "共居空间推荐", href: "#" },
|
||||
{ label: "数字游民保险", href: "#" },
|
||||
{ labelKey: "remoteJobs" as const, href: "#" },
|
||||
{ labelKey: "visa" as const, href: "#" },
|
||||
{ labelKey: "coliving" as const, href: "#" },
|
||||
{ labelKey: "insurance" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "社区",
|
||||
titleKey: "community" as const,
|
||||
links: [
|
||||
{ label: "Discord", href: "#" },
|
||||
{ label: "微信群", href: "#community" },
|
||||
{ label: "即刻", href: "#" },
|
||||
{ label: "GitHub", href: "#" },
|
||||
{ labelKey: "discord" as const, href: "#" },
|
||||
{ labelKey: "wechat" as const, href: "#community" },
|
||||
{ labelKey: "jike" as const, href: "#" },
|
||||
{ labelKey: "github" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "关于",
|
||||
titleKey: "about" as const,
|
||||
links: [
|
||||
{ label: "关于我们", href: "#" },
|
||||
{ label: "贡献指南", href: "#" },
|
||||
{ label: "隐私政策", href: "#" },
|
||||
{ label: "联系我们", href: "#" },
|
||||
{ labelKey: "aboutUs" as const, href: "#" },
|
||||
{ labelKey: "contribute" as const, href: "#" },
|
||||
{ labelKey: "privacy" as const, href: "#" },
|
||||
{ labelKey: "contact" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation("footer");
|
||||
return (
|
||||
<footer className="border-t border-slate-100 bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="lg:col-span-1">
|
||||
<a href="#" className="flex items-center gap-2 text-lg font-bold">
|
||||
<Link href="/" className="flex items-center gap-2 text-lg font-bold">
|
||||
<span className="text-2xl">🌍</span>
|
||||
<span>数字游民指南</span>
|
||||
</a>
|
||||
<span>{t("siteName")}</span>
|
||||
</Link>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-500">
|
||||
开源免费的数字游民资源平台
|
||||
{t("tagline")}
|
||||
<br />
|
||||
助你开启自由工作与旅行的生活方式
|
||||
{t("tagline2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{footerSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div key={section.titleKey}>
|
||||
<h4 className="mb-4 text-sm font-bold text-slate-900">
|
||||
{section.title}
|
||||
{t(`sections.${section.titleKey}`)}
|
||||
</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{section.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<li key={link.labelKey}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-sm text-slate-500 transition-colors hover:text-slate-700"
|
||||
>
|
||||
{link.label}
|
||||
{t(`links.${link.labelKey}`)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -77,9 +81,9 @@ export default function Footer() {
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-slate-100 pt-8 sm:flex-row">
|
||||
<p className="text-sm text-slate-400">
|
||||
Made with 🌍 by 数字游民社区 | Digital Nomad Guide
|
||||
{t("madeBy")}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">开源共享 · 社区驱动</p>
|
||||
<p className="text-sm text-slate-400">{t("openSource")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||
import AuthModal from "./AuthModal";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "学习路径", href: "#roadmap" },
|
||||
{ label: "工具推荐", href: "#tools" },
|
||||
{ label: "精选资源", href: "#resources" },
|
||||
{ label: "社区", href: "#community" },
|
||||
{ labelKey: "roadmap" as const, href: "#roadmap" },
|
||||
{ labelKey: "tools" as const, href: "#tools" },
|
||||
{ labelKey: "community" as const, href: "#community" },
|
||||
];
|
||||
|
||||
function getStoredUser(): string | null {
|
||||
@@ -23,6 +23,11 @@ function getStoredUser(): string | null {
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tNav } = useTranslation("nav");
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
@@ -48,11 +53,11 @@ export default function Header() {
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<a href="#" className="flex items-center gap-2 text-lg font-bold">
|
||||
<Link href="/" className="flex items-center gap-2 text-lg font-bold">
|
||||
<span className="text-2xl">🌍</span>
|
||||
<span className="hidden sm:inline">数字游民指南</span>
|
||||
<span className="sm:hidden">DN Guide</span>
|
||||
</a>
|
||||
<span className="hidden sm:inline">{t("siteName")}</span>
|
||||
<span className="sm:hidden">{t("siteNameShort")}</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{navLinks.map((link) => (
|
||||
@@ -61,9 +66,16 @@ export default function Header() {
|
||||
href={link.href}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{link.label}
|
||||
{tNav(link.labelKey)}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -76,7 +88,7 @@ export default function Header() {
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
退出
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -84,20 +96,20 @@ export default function Header() {
|
||||
onClick={() => setAuthOpen(true)}
|
||||
className="hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 sm:inline-flex"
|
||||
>
|
||||
登录 / 注册
|
||||
{t("loginRegister")}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="#roadmap"
|
||||
className="hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 sm:inline-flex"
|
||||
>
|
||||
开始探索 →
|
||||
{t("explore")}
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 md:hidden"
|
||||
aria-label="切换菜单"
|
||||
aria-label={t("toggleMenu")}
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -123,9 +135,19 @@ export default function Header() {
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{link.label}
|
||||
{tNav(link.labelKey)}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" });
|
||||
}}
|
||||
className="block w-full rounded-lg px-3 py-2.5 text-left text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
{userEmail ? (
|
||||
<div className="mt-2 flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5">
|
||||
<span className="truncate text-sm text-slate-600">{userEmail}</span>
|
||||
@@ -136,7 +158,7 @@ export default function Header() {
|
||||
}}
|
||||
className="text-sm font-medium text-sky-500"
|
||||
>
|
||||
退出
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -147,7 +169,7 @@ export default function Header() {
|
||||
}}
|
||||
className="mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600"
|
||||
>
|
||||
登录 / 注册
|
||||
{t("loginRegister")}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
@@ -155,7 +177,7 @@ export default function Header() {
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
开始探索 →
|
||||
{t("explore")}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
const stats = [
|
||||
{ value: "200+", label: "精选资源" },
|
||||
{ value: "7 天", label: "入门路径" },
|
||||
{ value: "50+", label: "远程工具" },
|
||||
{ value: "30+", label: "目的地" },
|
||||
{ value: "100%", label: "免费开源" },
|
||||
];
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
export default function Hero() {
|
||||
const { t } = useTranslation("hero");
|
||||
const stats = [
|
||||
{ value: "200+", labelKey: "resources" as const },
|
||||
{ value: "7 天", labelKey: "path" as const },
|
||||
{ value: "50+", labelKey: "tools" as const },
|
||||
{ value: "30+", labelKey: "destinations" as const },
|
||||
{ value: "100%", labelKey: "free" as const },
|
||||
];
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-32 sm:pb-20">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-sky-50/80 via-white to-white" />
|
||||
@@ -16,20 +20,20 @@ export default function Hero() {
|
||||
<div className="text-center">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700">
|
||||
<span>🎒</span>
|
||||
<span>开源免费 · 收录 200+ 篇实用资源</span>
|
||||
<span>{t("badge")}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mx-auto max-w-4xl text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-7xl">
|
||||
<span className="gradient-text">数字游民</span>
|
||||
<span className="gradient-text">{t("title")}</span>
|
||||
<br className="sm:hidden" />
|
||||
生活指南
|
||||
{t("titleSuffix")}
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-slate-600 sm:text-xl">
|
||||
从零开始,7天开启你的数字游民生活
|
||||
{t("subtitle")}
|
||||
<br className="hidden sm:block" />
|
||||
<span className="text-slate-400 sm:text-slate-600">
|
||||
The open-source guide to becoming a digital nomad
|
||||
{t("subtitleEn")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -38,14 +42,14 @@ export default function Hero() {
|
||||
href="#roadmap"
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-sky-500 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30 sm:w-auto"
|
||||
>
|
||||
🚀 开始学习
|
||||
{t("ctaStart")}
|
||||
<span>→</span>
|
||||
</a>
|
||||
<a
|
||||
href="#resources"
|
||||
href="#community"
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-8 py-3.5 text-base font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 sm:w-auto"
|
||||
>
|
||||
📚 浏览资源
|
||||
{t("ctaResources")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,7 +66,7 @@ export default function Hero() {
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-500">
|
||||
{s.label}
|
||||
{t(`stats.${s.labelKey}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -80,7 +84,7 @@ export default function Hero() {
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-500">
|
||||
{s.label}
|
||||
{t(`stats.${s.labelKey}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,72 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const days = [
|
||||
{
|
||||
day: 1,
|
||||
icon: "👋",
|
||||
title: "认识数字游民",
|
||||
desc: "了解数字游民的真正含义,评估这种生活方式是否适合你,破除常见误解。",
|
||||
color: "bg-sky-500",
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
icon: "💡",
|
||||
title: "发现远程技能",
|
||||
desc: "掌握最受欢迎的远程工作技能,从编程、设计到内容创作,找到你的方向。",
|
||||
color: "bg-cyan-500",
|
||||
},
|
||||
{
|
||||
day: 3,
|
||||
icon: "💰",
|
||||
title: "构建收入体系",
|
||||
desc: "自由职业、远程全职、被动收入、在线课程,构建可持续的多元收入来源。",
|
||||
color: "bg-teal-500",
|
||||
},
|
||||
{
|
||||
day: 4,
|
||||
icon: "🛠️",
|
||||
title: "工具武装",
|
||||
desc: "生产力工具、通讯协作、项目管理、云服务,打造你的移动办公装备库。",
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
day: 5,
|
||||
icon: "📋",
|
||||
title: "签证与法律",
|
||||
desc: "数字游民签证政策、税务规划、海外保险、银行账户,搞定法律合规问题。",
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
day: 6,
|
||||
icon: "🗺️",
|
||||
title: "目的地指南",
|
||||
desc: "热门数字游民城市推荐,生活成本对比、网速测评、社区活跃度一目了然。",
|
||||
color: "bg-orange-500",
|
||||
},
|
||||
{
|
||||
day: 7,
|
||||
icon: "🚀",
|
||||
title: "启程出发",
|
||||
desc: "打包清单、过渡计划、第一站选择建议,从规划到行动的最后一步。",
|
||||
color: "bg-rose-500",
|
||||
},
|
||||
{ day: 1, icon: "👋", color: "bg-sky-500" },
|
||||
{ day: 2, icon: "💡", color: "bg-cyan-500" },
|
||||
{ day: 3, icon: "💰", color: "bg-teal-500" },
|
||||
{ day: 4, icon: "🛠️", color: "bg-emerald-500" },
|
||||
{ day: 5, icon: "📋", color: "bg-amber-500" },
|
||||
{ day: 6, icon: "🗺️", color: "bg-orange-500" },
|
||||
{ day: 7, icon: "🚀", color: "bg-rose-500" },
|
||||
];
|
||||
|
||||
export default function Roadmap() {
|
||||
const { t } = useTranslation("roadmap");
|
||||
return (
|
||||
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
7天<span className="gradient-text">学习路径</span>
|
||||
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||
从入门到出发,每天一个主题,循序渐进开启你的数字游民之旅。
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 参考 openclaw101.dev:卡片网格,非时间轴 */}
|
||||
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{days.map((d) => (
|
||||
<a
|
||||
<Link
|
||||
key={d.day}
|
||||
href={`/day/${d.day}`}
|
||||
className="card-hover group flex flex-col rounded-2xl border border-slate-100 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md"
|
||||
@@ -79,36 +41,54 @@ export default function Roadmap() {
|
||||
</div>
|
||||
<span className="text-xl">{d.icon}</span>
|
||||
<h3 className="text-base font-bold text-slate-900 group-hover:text-sky-600">
|
||||
{d.title}
|
||||
{t(`days.${d.day}.title`)}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-600">
|
||||
{d.desc}
|
||||
{t(`days.${d.day}.desc`)}
|
||||
</p>
|
||||
<span className="mt-3 inline-flex items-center text-sm font-medium text-sky-600">
|
||||
查看详情 →
|
||||
{t("viewDetails")}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/ebook"
|
||||
className="card-hover group flex flex-col rounded-2xl border border-slate-100 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gradient-to-r from-sky-500 to-cyan-500 text-xs font-bold text-white">
|
||||
📖
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-900 group-hover:text-sky-600">
|
||||
{t("ebook.title")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-600">
|
||||
{t("ebook.desc")}
|
||||
</p>
|
||||
<span className="mt-3 inline-flex items-center text-sm font-medium text-sky-600">
|
||||
{t("ebook.cta")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Training camp CTA */}
|
||||
<div className="mt-16 overflow-hidden rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-8 text-center sm:p-10">
|
||||
<div className="mb-2 text-sm font-semibold text-amber-600">
|
||||
🎓 实战训练营
|
||||
{t("course.tag")}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-slate-900">
|
||||
数字游民实战课
|
||||
{t("course.title")}
|
||||
</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
21节视频课程,手把手带你从零规划专属数字游民路线,打通远程工作全流程
|
||||
{t("course.desc")}
|
||||
</p>
|
||||
<a
|
||||
<Link
|
||||
href="/course"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-amber-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-amber-600"
|
||||
>
|
||||
了解详情 →
|
||||
</a>
|
||||
{t("course.cta")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import toolsData from "../data/tools.json";
|
||||
|
||||
const categories = toolsData.categories as Array<{
|
||||
@@ -14,29 +17,27 @@ function truncateDesc(desc: string, maxLen = 5) {
|
||||
return desc.slice(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
const toolStats = (() => {
|
||||
const total = categories.reduce((s, c) => s + c.tools.length, 0);
|
||||
return [
|
||||
{ value: `${total}+`, label: "精选工具" },
|
||||
{ value: String(categories.length), label: "分类" },
|
||||
{ value: "数字游民", label: "出海" },
|
||||
{ value: "佣金", label: "推荐" },
|
||||
];
|
||||
})();
|
||||
|
||||
export default function Tools() {
|
||||
const { t } = useTranslation("tools");
|
||||
const total = categories.reduce((s, c) => s + c.tools.length, 0);
|
||||
const toolStats = [
|
||||
{ value: `${total}+`, key: "tools" as const },
|
||||
{ value: String(categories.length), key: "categories" as const },
|
||||
{ value: "数字游民", key: "nomad" as const },
|
||||
{ value: "佣金", key: "affiliate" as const },
|
||||
];
|
||||
return (
|
||||
<section id="tools" className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700">
|
||||
🚀 50+ 精选工具
|
||||
{t("badge")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
<span className="gradient-text">工具推荐</span>
|
||||
<span className="gradient-text">{t("title")}</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
|
||||
数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -83,17 +84,16 @@ export default function Tools() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{toolStats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
key={s.key}
|
||||
className="rounded-2xl border border-slate-100 bg-white p-5 text-center shadow-sm"
|
||||
>
|
||||
<div className="text-3xl font-extrabold text-sky-600">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-500">{s.label}</div>
|
||||
<div className="mt-1 text-sm text-slate-500">{t(`stats.${s.key}`)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
100
app/context/LocaleContext.tsx
Normal file
100
app/context/LocaleContext.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
const LOCALES: Locale[] = ["zh", "en"];
|
||||
const DEFAULT_LOCALE: Locale = "zh";
|
||||
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
messages: Messages;
|
||||
t: (key: string) => string;
|
||||
setLocale: (locale: Locale) => void;
|
||||
};
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
function getNested(obj: unknown, path: string): string | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
messages,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale;
|
||||
messages: Messages;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const t = useCallback(
|
||||
(key: string) => {
|
||||
const value = getNested(messages, key);
|
||||
return value ?? key;
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
const path = window.location.pathname;
|
||||
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
|
||||
window.location.href = newPath || `/${newLocale}`;
|
||||
}, []);
|
||||
|
||||
const value: LocaleContextValue = {
|
||||
locale,
|
||||
messages,
|
||||
t,
|
||||
setLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
||||
return ctx.locale;
|
||||
}
|
||||
|
||||
export function useTranslation(namespace?: string) {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useTranslation must be used within LocaleProvider");
|
||||
return {
|
||||
t: (key: string) => {
|
||||
const fullKey = namespace ? `${namespace}.${key}` : key;
|
||||
return ctx.t(fullKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocaleRouter() {
|
||||
const locale = useLocale();
|
||||
return {
|
||||
replace: (pathname: string, opts?: { locale?: Locale }) => {
|
||||
const newLocale = opts?.locale ?? locale;
|
||||
const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/";
|
||||
const newPath = `/${newLocale}${cleanPath === "/" ? "" : cleanPath}`;
|
||||
window.location.href = newPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { LOCALES, DEFAULT_LOCALE };
|
||||
4446
app/data/ebook.md
Normal file
4446
app/data/ebook.md
Normal file
File diff suppressed because it is too large
Load Diff
43
app/lib/ebook.ts
Normal file
43
app/lib/ebook.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
export interface EbookChunk {
|
||||
html: string;
|
||||
estimatedHeight: number;
|
||||
}
|
||||
|
||||
export interface EbookData {
|
||||
chunks: EbookChunk[];
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
export function parseEbookMarkdown(source: string): EbookData {
|
||||
const html = marked.parse(source, { breaks: true, gfm: true }) as string;
|
||||
const lazyHtml = html.replace(/<img /g, '<img loading="lazy" decoding="async" ');
|
||||
|
||||
let rawChunks = lazyHtml.split(/(?=<h1[\s>])/i).filter((c) => c.trim());
|
||||
if (rawChunks.length <= 1) {
|
||||
rawChunks = lazyHtml.split(/(?=<h2[\s>])/i).filter((c) => c.trim());
|
||||
}
|
||||
|
||||
const headers: string[] = [];
|
||||
const headerRegex = /<h1[^>]*>(.*?)<\/h1>/gi;
|
||||
let m;
|
||||
while ((m = headerRegex.exec(lazyHtml)) !== null) {
|
||||
headers.push(m[1].replace(/<[^>]+>/g, ""));
|
||||
}
|
||||
if (headers.length === 0) {
|
||||
const h2Regex = /<h2[^>]*>(.*?)<\/h2>/gi;
|
||||
while ((m = h2Regex.exec(lazyHtml)) !== null) {
|
||||
headers.push(m[1].replace(/<[^>]+>/g, ""));
|
||||
}
|
||||
}
|
||||
|
||||
const chunks: EbookChunk[] = rawChunks.map((chunkHtml) => {
|
||||
const imgCount = (chunkHtml.match(/<img /g) || []).length;
|
||||
const textLen = chunkHtml.replace(/<[^>]+>/g, "").length;
|
||||
const estimatedHeight = Math.max(300, Math.round(textLen * 0.6 + imgCount * 350));
|
||||
return { html: chunkHtml, estimatedHeight };
|
||||
});
|
||||
|
||||
return { chunks, headers };
|
||||
}
|
||||
22
app/lib/locale-link.tsx
Normal file
22
app/lib/locale-link.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import NextLink from "next/link";
|
||||
import { usePathname as useNextPathname } from "next/navigation";
|
||||
import { useLocale } from "../context/LocaleContext";
|
||||
|
||||
type LocaleLinkProps = React.ComponentProps<typeof NextLink>;
|
||||
|
||||
export function LocaleLink({ href, ...props }: LocaleLinkProps) {
|
||||
const locale = useLocale();
|
||||
const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
|
||||
const localeHref = hrefStr.startsWith("/")
|
||||
? `/${locale}${hrefStr === "/" ? "" : hrefStr}`
|
||||
: hrefStr;
|
||||
return <NextLink href={localeHref} {...props} />;
|
||||
}
|
||||
|
||||
export function usePathname() {
|
||||
const path = useNextPathname();
|
||||
const match = path?.match(/^\/(zh|en)(\/.*)?$/);
|
||||
return match ? (match[2] ?? "/") : path ?? "/";
|
||||
}
|
||||
25
app/page.tsx
25
app/page.tsx
@@ -1,25 +0,0 @@
|
||||
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 Resources from "./components/Resources";
|
||||
import Community from "./components/Community";
|
||||
import Footer from "./components/Footer";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Roadmap />
|
||||
<Tools />
|
||||
<Resources />
|
||||
<Community />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
i18n/navigation.ts
Normal file
9
i18n/navigation.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export { LocaleLink as Link } from "@/app/lib/locale-link";
|
||||
export { usePathname } from "@/app/lib/locale-link";
|
||||
export {
|
||||
useLocale,
|
||||
useLocaleRouter as useRouter,
|
||||
useTranslation,
|
||||
} from "@/app/context/LocaleContext";
|
||||
5
i18n/routing.ts
Normal file
5
i18n/routing.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const routing = {
|
||||
locales: ["zh", "en"] as const,
|
||||
defaultLocale: "zh" as const,
|
||||
localePrefix: "always" as const,
|
||||
};
|
||||
155
messages/en.json
Normal file
155
messages/en.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"common": {
|
||||
"siteName": "Digital Nomad Guide",
|
||||
"siteNameShort": "DN Guide",
|
||||
"logout": "Logout",
|
||||
"loginRegister": "Login / Sign up",
|
||||
"explore": "Explore →",
|
||||
"toggleMenu": "Toggle menu"
|
||||
},
|
||||
"nav": {
|
||||
"roadmap": "Learning Path",
|
||||
"tools": "Tools",
|
||||
"community": "Community"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "Open source · 200+ curated resources",
|
||||
"title": "Digital Nomad",
|
||||
"titleSuffix": "Life Guide",
|
||||
"subtitle": "Start your digital nomad journey in 7 days from scratch",
|
||||
"subtitleEn": "The open-source guide to becoming a digital nomad",
|
||||
"ctaStart": "🚀 Start Learning",
|
||||
"ctaResources": "📚 Browse Resources",
|
||||
"stats": {
|
||||
"resources": "Resources",
|
||||
"path": "Learning Path",
|
||||
"tools": "Remote Tools",
|
||||
"destinations": "Destinations",
|
||||
"free": "Free & Open"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"title": "What is a",
|
||||
"titleHighlight": "Digital Nomad",
|
||||
"titleSuffix": "?",
|
||||
"subtitle": "A digital nomad leverages internet technology to work remotely without geographic constraints.",
|
||||
"subtitle2": "It lets you explore the world while creating value.",
|
||||
"stat": "Over",
|
||||
"statCount": "35 million",
|
||||
"statSuffix": "digital nomads worldwide",
|
||||
"items": {
|
||||
"location": {
|
||||
"title": "Location Freedom",
|
||||
"desc": "Work from anywhere—cafés, beaches, coworking spaces. Your office is the whole world. Say goodbye to fixed desks and embrace infinite possibilities."
|
||||
},
|
||||
"skill": {
|
||||
"title": "Monetize Skills",
|
||||
"desc": "Programming, design, writing, marketing, consulting… Exchange your expertise for global income and build a diversified revenue stream."
|
||||
},
|
||||
"balance": {
|
||||
"title": "Work-Life Balance",
|
||||
"desc": "Set your own schedule. Work and travel blend seamlessly. Live at your own pace and find the lifestyle that truly fits you."
|
||||
}
|
||||
}
|
||||
},
|
||||
"roadmap": {
|
||||
"title": "7-Day",
|
||||
"titleHighlight": "Learning Path",
|
||||
"subtitle": "One theme per day, from basics to launch—start your digital nomad journey step by step.",
|
||||
"viewDetails": "View details →",
|
||||
"days": {
|
||||
"1": { "title": "Meet Digital Nomads", "desc": "Understand what digital nomadism really means, assess if it fits you, and debunk common myths." },
|
||||
"2": { "title": "Discover Remote Skills", "desc": "Master the most in-demand remote skills—from coding and design to content creation—and find your direction." },
|
||||
"3": { "title": "Build Income Streams", "desc": "Freelancing, remote jobs, passive income, online courses—build a sustainable, diversified income." },
|
||||
"4": { "title": "Tool Up", "desc": "Productivity tools, communication, project management, cloud services—build your mobile office toolkit." },
|
||||
"5": { "title": "Visa & Legal", "desc": "Digital nomad visas, tax planning, overseas insurance, bank accounts—get legal and compliant." },
|
||||
"6": { "title": "Destination Guide", "desc": "Top digital nomad cities, cost of living, internet speed, community vibes—all at a glance." },
|
||||
"7": { "title": "Launch", "desc": "Packing list, transition plan, first-stop tips—from planning to action." }
|
||||
},
|
||||
"ebook": {
|
||||
"title": "Ebook",
|
||||
"desc": "Digital nomad: geo-arbitrage & automation leverage. Low-cost studio, cloud phones, unmanned live streaming, overseas gold rush.",
|
||||
"cta": "Read online →"
|
||||
},
|
||||
"course": {
|
||||
"tag": "🎓 Bootcamp",
|
||||
"title": "Digital Nomad Bootcamp",
|
||||
"desc": "21 video lessons to plan your digital nomad route from zero and master the full remote workflow.",
|
||||
"cta": "Learn more →"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"badge": "🚀 50+ Curated Tools",
|
||||
"title": "Tool Recommendations",
|
||||
"subtitle": "Essential tools for digital nomads: community, VPS, payments, overseas ventures—every link is clickable.",
|
||||
"stats": {
|
||||
"tools": "Tools",
|
||||
"categories": "Categories",
|
||||
"nomad": "Nomad",
|
||||
"affiliate": "Affiliate"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"nomadCommunity": "Digital Nomad Community",
|
||||
"joinTitle": "Join the Global Chinese Digital Nomad Network",
|
||||
"joinDesc": "Apply to join · 1000+ nomads · Weekly online shares · City guides",
|
||||
"applyForm": "Apply via form",
|
||||
"approveJoin": "Join after approval",
|
||||
"applyNow": "Apply now →",
|
||||
"applyHint": "Simple form, quick review",
|
||||
"openSource": "🤝 Open Source",
|
||||
"contributeTitle": "Community &",
|
||||
"contributeHighlight": "Contribution",
|
||||
"contributeSubtitle": "Digital Nomad Guide is open source, building the best Chinese digital nomad resource hub.",
|
||||
"contributeSubtitle2": "Welcome to add resources, improve content, and share experience.",
|
||||
"links": {
|
||||
"guide": { "title": "Getting Started", "desc": "Complete digital nomad tutorial", "label": "Start learning ↗" },
|
||||
"discord": { "title": "Discord", "desc": "Chat with global digital nomads", "label": "Join community ↗" },
|
||||
"tools": { "title": "Tool Marketplace", "desc": "Discover, review, and share remote tools", "label": "Browse tools ↗" },
|
||||
"github": { "title": "GitHub", "desc": "Open source—Star and PR welcome", "label": "View repo ↗" },
|
||||
"feishu": { "title": "Feishu Docs", "desc": "7-day guide · Chinese tutorials", "label": "View docs ↗" },
|
||||
"destinations": { "title": "Destinations", "desc": "Global digital nomad city info", "label": "Explore cities ↗" }
|
||||
},
|
||||
"betterTitle": "🌟 Help Make Digital Nomad Guide Better",
|
||||
"betterDesc": "Found a great resource? Wrote travel notes? Have tool recommendations?",
|
||||
"betterDesc2": "Open a PR—your contribution helps more people.",
|
||||
"starFork": "⭐ Star & Fork on GitHub"
|
||||
},
|
||||
"footer": {
|
||||
"siteName": "Digital Nomad Guide",
|
||||
"tagline": "Open-source digital nomad resource platform",
|
||||
"tagline2": "Start your free work and travel lifestyle",
|
||||
"sections": {
|
||||
"guide": "Guide",
|
||||
"resources": "Resources",
|
||||
"community": "Community",
|
||||
"about": "About"
|
||||
},
|
||||
"links": {
|
||||
"roadmap": "7-Day Learning Path",
|
||||
"tools": "Tools",
|
||||
"destinations": "Destinations",
|
||||
"remoteJobs": "Remote Jobs",
|
||||
"visa": "Visa Policies",
|
||||
"coliving": "Coliving Spaces",
|
||||
"insurance": "Nomad Insurance",
|
||||
"discord": "Discord",
|
||||
"wechat": "WeChat",
|
||||
"jike": "Jike",
|
||||
"github": "GitHub",
|
||||
"aboutUs": "About Us",
|
||||
"contribute": "Contribute",
|
||||
"privacy": "Privacy",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"madeBy": "Made with 🌍 by Digital Nomad Community | DN Guide",
|
||||
"openSource": "Open source · Community driven"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Login / Sign up",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Login",
|
||||
"close": "Close"
|
||||
}
|
||||
}
|
||||
155
messages/zh.json
Normal file
155
messages/zh.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"common": {
|
||||
"siteName": "数字游民指南",
|
||||
"siteNameShort": "DN Guide",
|
||||
"logout": "退出",
|
||||
"loginRegister": "登录 / 注册",
|
||||
"explore": "开始探索 →",
|
||||
"toggleMenu": "切换菜单"
|
||||
},
|
||||
"nav": {
|
||||
"roadmap": "学习路径",
|
||||
"tools": "工具推荐",
|
||||
"community": "社区"
|
||||
},
|
||||
"hero": {
|
||||
"badge": "开源免费 · 收录 200+ 篇实用资源",
|
||||
"title": "数字游民",
|
||||
"titleSuffix": "生活指南",
|
||||
"subtitle": "从零开始,7天开启你的数字游民生活",
|
||||
"subtitleEn": "The open-source guide to becoming a digital nomad",
|
||||
"ctaStart": "🚀 开始学习",
|
||||
"ctaResources": "📚 浏览资源",
|
||||
"stats": {
|
||||
"resources": "精选资源",
|
||||
"path": "入门路径",
|
||||
"tools": "远程工具",
|
||||
"destinations": "目的地",
|
||||
"free": "免费开源"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"title": "什么是",
|
||||
"titleHighlight": "数字游民",
|
||||
"titleSuffix": "?",
|
||||
"subtitle": "数字游民是一种利用互联网技术远程工作、不受地理位置限制的生活方式。",
|
||||
"subtitle2": "它让你在探索世界的同时,持续创造价值。",
|
||||
"stat": "全球已有超过",
|
||||
"statCount": "3500 万",
|
||||
"statSuffix": "数字游民",
|
||||
"items": {
|
||||
"location": {
|
||||
"title": "地点自由",
|
||||
"desc": "在世界任何角落工作——咖啡馆、海滩、共享空间,你的办公室就是整个地球。告别固定工位,拥抱无限可能。"
|
||||
},
|
||||
"skill": {
|
||||
"title": "技能变现",
|
||||
"desc": "编程、设计、写作、营销、咨询……用你的专业技能换取全球收入,构建多元化的收入体系。"
|
||||
},
|
||||
"balance": {
|
||||
"title": "工作生活平衡",
|
||||
"desc": "自主安排时间,工作与旅行完美融合。按照自己的节奏生活,找到真正属于你的生活方式。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"roadmap": {
|
||||
"title": "7天",
|
||||
"titleHighlight": "学习路径",
|
||||
"subtitle": "从入门到出发,每天一个主题,循序渐进开启你的数字游民之旅。",
|
||||
"viewDetails": "查看详情 →",
|
||||
"days": {
|
||||
"1": { "title": "认识数字游民", "desc": "了解数字游民的真正含义,评估这种生活方式是否适合你,破除常见误解。" },
|
||||
"2": { "title": "发现远程技能", "desc": "掌握最受欢迎的远程工作技能,从编程、设计到内容创作,找到你的方向。" },
|
||||
"3": { "title": "构建收入体系", "desc": "自由职业、远程全职、被动收入、在线课程,构建可持续的多元收入来源。" },
|
||||
"4": { "title": "工具武装", "desc": "生产力工具、通讯协作、项目管理、云服务,打造你的移动办公装备库。" },
|
||||
"5": { "title": "签证与法律", "desc": "数字游民签证政策、税务规划、海外保险、银行账户,搞定法律合规问题。" },
|
||||
"6": { "title": "目的地指南", "desc": "热门数字游民城市推荐,生活成本对比、网速测评、社区活跃度一目了然。" },
|
||||
"7": { "title": "启程出发", "desc": "打包清单、过渡计划、第一站选择建议,从规划到行动的最后一步。" }
|
||||
},
|
||||
"ebook": {
|
||||
"title": "电子书",
|
||||
"desc": "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
|
||||
"cta": "在线阅读 →"
|
||||
},
|
||||
"course": {
|
||||
"tag": "🎓 实战训练营",
|
||||
"title": "数字游民实战课",
|
||||
"desc": "21节视频课程,手把手带你从零规划专属数字游民路线,打通远程工作全流程",
|
||||
"cta": "了解详情 →"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"badge": "🚀 50+ 精选工具",
|
||||
"title": "工具推荐",
|
||||
"subtitle": "数字游民出海必备工具精选,含游民社区、VPS、金融收款、出海掘金等,每个链接可点击跳转。",
|
||||
"stats": {
|
||||
"tools": "精选工具",
|
||||
"categories": "分类",
|
||||
"nomad": "出海",
|
||||
"affiliate": "推荐"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
"nomadCommunity": "数字游民社区",
|
||||
"joinTitle": "加入全球华人数字游民网络",
|
||||
"joinDesc": "填写申请表加入社区 · 1000+ 数字游民 · 每周线上分享 · 城市攻略",
|
||||
"applyForm": "填写表单即可申请",
|
||||
"approveJoin": "审核通过入群",
|
||||
"applyNow": "立即申请加入 →",
|
||||
"applyHint": "简单表单,快速审核",
|
||||
"openSource": "🤝 开源共建",
|
||||
"contributeTitle": "社区与",
|
||||
"contributeHighlight": "贡献",
|
||||
"contributeSubtitle": "数字游民指南是开源项目,致力于做最好的中文数字游民资源聚合站。",
|
||||
"contributeSubtitle2": "欢迎补充资源、改进内容、分享经验。",
|
||||
"links": {
|
||||
"guide": { "title": "入门指南", "desc": "完整的数字游民入门教程", "label": "开始学习 ↗" },
|
||||
"discord": { "title": "Discord 社区", "desc": "与全球数字游民实时交流", "label": "加入社区 ↗" },
|
||||
"tools": { "title": "工具集市", "desc": "发现、评测和分享远程工具", "label": "浏览工具 ↗" },
|
||||
"github": { "title": "GitHub", "desc": "开源项目,欢迎 Star 和 PR", "label": "访问仓库 ↗" },
|
||||
"feishu": { "title": "飞书知识库", "desc": "7天入门指南 · 中文图文教程", "label": "查看文档 ↗" },
|
||||
"destinations": { "title": "目的地数据库", "desc": "全球数字游民城市信息", "label": "探索城市 ↗" }
|
||||
},
|
||||
"betterTitle": "🌟 一起让数字游民指南变得更好",
|
||||
"betterDesc": "发现了好资源?写了旅居心得?有工具推荐?",
|
||||
"betterDesc2": "提一个 PR,你的贡献将帮助更多人。",
|
||||
"starFork": "⭐ Star & Fork on GitHub"
|
||||
},
|
||||
"footer": {
|
||||
"siteName": "数字游民指南",
|
||||
"tagline": "开源免费的数字游民资源平台",
|
||||
"tagline2": "助你开启自由工作与旅行的生活方式",
|
||||
"sections": {
|
||||
"guide": "指南",
|
||||
"resources": "资源",
|
||||
"community": "社区",
|
||||
"about": "关于"
|
||||
},
|
||||
"links": {
|
||||
"roadmap": "7天学习路径",
|
||||
"tools": "工具推荐",
|
||||
"destinations": "目的地数据库",
|
||||
"remoteJobs": "远程工作平台",
|
||||
"visa": "签证政策汇总",
|
||||
"coliving": "共居空间推荐",
|
||||
"insurance": "数字游民保险",
|
||||
"discord": "Discord",
|
||||
"wechat": "微信群",
|
||||
"jike": "即刻",
|
||||
"github": "GitHub",
|
||||
"aboutUs": "关于我们",
|
||||
"contribute": "贡献指南",
|
||||
"privacy": "隐私政策",
|
||||
"contact": "联系我们"
|
||||
},
|
||||
"madeBy": "Made with 🌍 by 数字游民社区 | Digital Nomad Guide",
|
||||
"openSource": "开源共享 · 社区驱动"
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录 / 注册",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"submit": "登录",
|
||||
"close": "关闭"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"marked": "^14.1.2",
|
||||
"github-markdown-css": "^5.6.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1004.0
|
||||
version: 3.1004.0
|
||||
github-markdown-css:
|
||||
specifier: ^5.6.1
|
||||
version: 5.9.0
|
||||
marked:
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.4
|
||||
minio:
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
@@ -1624,6 +1630,10 @@ packages:
|
||||
get-tsconfig@4.13.6:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
github-markdown-css@5.9.0:
|
||||
resolution: {integrity: sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2000,6 +2010,11 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
marked@14.1.4:
|
||||
resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4812,6 +4827,8 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
github-markdown-css@5.9.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -5165,6 +5182,8 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@14.1.4: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
|
||||
28
proxy.ts
Normal file
28
proxy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const LOCALES = ["zh", "en"] as const;
|
||||
const DEFAULT_LOCALE = "zh";
|
||||
|
||||
function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// 检查路径是否已有 locale 前缀
|
||||
const hasLocale = LOCALES.some(
|
||||
(loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)
|
||||
);
|
||||
|
||||
if (hasLocale) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 根路径或无效路径,重定向到默认 locale
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/${DEFAULT_LOCALE}${pathname === "/" ? "" : pathname}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export { proxy };
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user