This commit is contained in:
root
2026-06-07 18:06:52 +08:00
parent 9de2465793
commit a1daa040ef
33 changed files with 1917 additions and 439 deletions

View File

@@ -17,6 +17,22 @@ interface ContentItem {
chapters?: { title: string; icon?: string }[];
}
function normalizeDownloadUrl(url?: string): string {
const fallback = "/api/downloads/nomadcna-app-demo.zip";
if (!url) return fallback;
if (url.startsWith("/")) return url;
try {
const parsed = new URL(url);
if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.pathname.startsWith("/api/")) {
return `${parsed.pathname}${parsed.search}`;
}
return url;
} catch {
return fallback;
}
}
export default function AppLandingPage() {
const locale = useLocale();
const [item, setItem] = useState<ContentItem | null>(null);
@@ -30,6 +46,7 @@ export default function AppLandingPage() {
const title = item ? (locale === "zh" ? item.title : item.titleEn || item.title) : "NomadCNA App";
const subtitle = item ? (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle) : "";
const description = item ? (locale === "zh" ? item.description : item.descriptionEn || item.description) : "";
const downloadUrl = normalizeDownloadUrl(item?.mediaUrl);
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
@@ -41,7 +58,7 @@ export default function AppLandingPage() {
<p className="mt-4 text-xl text-gray-500 dark:text-gray-400">{subtitle}</p>
<p className="mt-6 leading-8 text-gray-700 dark:text-gray-300">{description}</p>
<div className="mt-8 flex flex-wrap gap-3">
<a href={item?.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-6 py-3 font-semibold text-white hover:bg-[#ff7a45]">
<a href={downloadUrl} download className="rounded-full bg-[#ff4d4f] px-6 py-3 font-semibold text-white hover:bg-[#ff7a45]">
</a>
<Link href="/map" className="rounded-full border border-gray-300 px-6 py-3 font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">

View File

@@ -0,0 +1,81 @@
import Footer from "@/app/components/Footer";
const entries = [
{
date: "2026-06-07",
title: "数字游民指南视觉统一",
items: [
"将数字指南页对齐首页品牌色、导航、按钮与卡片风格。",
"修复全量 lint 中的 React hook error。",
"补齐主菜单与数字页 footer 的占位链接。",
],
},
{
date: "2026-06-01",
title: "社区工作流联通",
items: [
"上线站内消息、活动报名、反馈、赏金任务与服务咨询入口。",
"完善城市详情弹窗的活动、讨论、成本和成员信息模块。",
],
},
{
date: "2026-05-20",
title: "数据化下一站",
items: [
"新增下一站推荐、城市数据、成员地图和年度报告页面。",
"接入后端 API 与 PocketBase 数据源,保留前端降级数据。",
],
},
];
export const metadata = {
title: "更新日志 | 游牧中国",
description: "游牧中国产品更新、修复和功能上线记录。",
};
export default function ChangelogPage() {
return (
<>
<main className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<section className="mx-auto max-w-4xl px-4 py-12 sm:px-6 sm:py-20">
<div className="mb-10">
<h1 className="flex items-center gap-2 text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl">
<span className="h-10 w-1 rounded-full bg-[#ff4d4f]" />
</h1>
<p className="mt-3 text-gray-600 dark:text-gray-400">
线
</p>
</div>
<div className="space-y-5">
{entries.map((entry) => (
<article
key={entry.date}
className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-6"
>
<div className="mb-4 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
{entry.title}
</h2>
<time className="text-sm text-gray-400 dark:text-gray-500" dateTime={entry.date}>
{entry.date}
</time>
</div>
<ul className="space-y-2">
{entry.items.map((item) => (
<li key={item} className="flex gap-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-[#ff4d4f]" />
<span>{item}</span>
</li>
))}
</ul>
</article>
))}
</div>
</section>
</main>
<Footer />
</>
);
}

View File

@@ -91,7 +91,6 @@ export default function DashboardPage() {
const [recommendedCities, setRecommendedCities] = useState<RecommendedCity[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [routes, setRoutes] = useState<NomadRoute[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
apiFetch<{ items: RecommendedCity[] }>("/api/cities")
@@ -129,7 +128,6 @@ export default function DashboardPage() {
useEffect(() => {
let cancelled = false;
setLoading(true);
apiFetch<{ items: RecommendedCity[] }>("/api/recommendations/next-stop", {
method: "POST",
body: JSON.stringify({ budget, internet, climate, tags: selectedTags, limit: 12 }),
@@ -141,9 +139,6 @@ export default function DashboardPage() {
})
.catch(() => {
if (!cancelled) setRecommendedCities(fallbackRecommendations);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
@@ -299,9 +294,6 @@ export default function DashboardPage() {
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("search")}
</h2>
{loading && (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3" style={{ gridAutoRows: "160px" }}>
{displayCities.map((city, index) => (

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/i18n/navigation";
import { Link, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
@@ -258,7 +258,12 @@ export default function DatingPage() {
</div>
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">{badgeText}</span>
<a href="#" className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium">{t("report")}</a>
<Link
href={`/feedback?type=report&target=${encodeURIComponent(currentProfile.id)}`}
className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium"
>
{t("report")}
</Link>
</div>
<div className="flex justify-center mt-12 mb-6">
<img src={currentProfile.photo} alt={currentProfile.name} className="w-28 h-28 rounded-full shadow-lg border-4 border-white/50 object-cover" />

View File

@@ -1,10 +1,18 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import { useEffect, useState } from "react";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import DigitalThemeIcon, { type IconName } from "@/app/digital/components/DigitalThemeIcon";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
export default function AppDownloadContent() {
const { t } = useTranslation("appDownload");
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installState, setInstallState] = useState<"idle" | "available" | "installed" | "manual">("idle");
useEffect(() => {
window.history.scrollRestoration = "manual";
@@ -14,55 +22,150 @@ export default function AppDownloadContent() {
};
}, []);
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setInstallPrompt(event as BeforeInstallPromptEvent);
setInstallState("available");
};
const onInstalled = () => {
setInstallPrompt(null);
setInstallState("installed");
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const features = [
{ key: "resources", icon: "📚" },
{ key: "community", icon: "👥" },
{ key: "tools", icon: "🛠️" },
{ key: "resources", icon: "book", emoji: "📚" },
{ key: "community", icon: "globe", emoji: "👥" },
{ key: "tools", icon: "tool", emoji: "🛠️" },
] as const;
const installApp = async () => {
if (!installPrompt) {
setInstallState("manual");
return;
}
await installPrompt.prompt();
const result = await installPrompt.userChoice;
setInstallPrompt(null);
setInstallState(result.outcome === "accepted" ? "installed" : "manual");
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-6 inline-flex h-24 w-24 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-5xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
📱
<section className="pt-24 pb-20 sm:pt-28 sm:pb-28">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
<div>
<div className="dn-badge mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<DigitalThemeIcon name="phone" emoji="📱" className="h-4 w-4" />
{t("badge")}
</div>
<h1 className="dn-section-title text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
{t("title")}
</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-400">
{t("subtitle")}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href="/"
className="dn-button-primary inline-flex items-center justify-center gap-2 rounded-full bg-[var(--dn-primary)] px-6 py-3 font-semibold text-white transition hover:bg-[var(--dn-primary-strong)]"
>
<DigitalThemeIcon name="app" emoji="📱" className="h-5 w-5" />
{t("openWeb")}
</Link>
<button
type="button"
onClick={installApp}
className="dn-button-secondary inline-flex cursor-pointer items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 font-semibold text-slate-700 transition hover:border-[var(--dn-primary)] hover:text-[var(--dn-primary)] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
>
<DigitalThemeIcon name="phone" emoji="📱" className="h-5 w-5" />
{installState === "available" ? t("installReady") : t("install")}
</button>
</div>
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
{installState === "installed"
? t("installed")
: installState === "manual"
? t("manualInstall")
: t("installHint")}
</p>
</div>
<div className="mx-auto w-full max-w-sm">
<div
className="dn-card relative overflow-hidden rounded-[2rem] border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-800 dark:bg-slate-900"
aria-label={t("previewLabel")}
>
<div className="rounded-[1.5rem] bg-slate-950 p-3">
<div className="overflow-hidden rounded-[1.15rem] bg-slate-50 dark:bg-slate-900">
<div className="border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-950">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{t("previewTitle")}</span>
<span className="h-2 w-2 rounded-full bg-[var(--dn-primary)]" />
</div>
</div>
<div className="space-y-3 p-4">
<div className="rounded-xl bg-[var(--dn-primary)] p-4 text-white">
<p className="text-xs font-medium text-white/75">{t("previewKicker")}</p>
<p className="mt-1 text-lg font-bold">{t("previewRoute")}</p>
</div>
{features.map(({ key, icon, emoji }) => (
<div
key={key}
className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-950"
>
<span className="dn-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-[var(--dn-primary)] dark:bg-slate-800">
<DigitalThemeIcon name={icon as IconName} emoji={emoji} className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-bold text-slate-900 dark:text-slate-100">
{t(`features.${key}.title`)}
</span>
<span className="mt-0.5 block text-xs leading-5 text-slate-500 dark:text-slate-400">
{t(`features.${key}.desc`)}
</span>
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
{t("title")}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-3">
{features.map(({ key, icon }) => (
<div className="mt-12 grid gap-4 sm:grid-cols-3">
{features.map(({ key, icon, emoji }) => (
<div
key={key}
className="card-hover flex flex-col items-center rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900"
className="dn-card flex items-start gap-4 rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-slate-50 text-2xl dark:bg-slate-800">
{icon}
<span className="dn-icon-box flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-[var(--dn-primary)] dark:bg-slate-800">
<DigitalThemeIcon name={icon as IconName} emoji={emoji} className="h-5 w-5" />
</span>
<span>
<span className="block font-bold text-slate-900 dark:text-slate-100">
{t(`features.${key}.title`)}
</span>
<span className="mt-1 block text-sm leading-6 text-slate-500 dark:text-slate-400">
{t(`features.${key}.desc`)}
</span>
</span>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{t(`features.${key}.title`)}
</h3>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{t(`features.${key}.desc`)}
</p>
</div>
))}
</div>
<div className="mt-16 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-amber-100 px-4 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/50 dark:text-amber-200">
{t("comingSoon")}
</div>
<p className="mt-4 text-slate-500 dark:text-slate-400">
{t("comingSoonDesc")}
</p>
</div>
</div>
</section>
</div>

View File

@@ -2,15 +2,23 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { Link } from "@/app/digital/i18n/navigation";
import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "@/config/digital/lessons";
import { getLesson, type LessonDetail } from "@/config/digital/lessons";
import { CURRICULUM_CONFIG, DOWNLOAD_CONFIG } from "@/config/digital/course";
import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
import { setLastWatched, markCompleted } from "@/app/digital/lib/learning";
import { DOWNLOAD_CONFIG } from "@/config/digital/course";
import {
getCompletedLessons,
getNote,
isBookmarked,
markCompleted,
setLastWatched,
setNote as saveNote,
toggleBookmark,
} from "@/app/digital/lib/learning";
import Header from "@/app/digital/components/Header";
import AuthModal from "@/app/digital/components/AuthModal";
import {
@@ -18,23 +26,191 @@ import {
redirectToPay,
getDeviceFromEnv,
} from "@/app/digital/lib/payment";
const COURSE_LESSONS = CURRICULUM_CONFIG.modules.flatMap(({ lessons }, moduleIndex) =>
lessons.map((_, lessonIndex) => ({ moduleIndex, lessonIndex }))
);
type DeckSlide = {
title: string;
body: string;
};
function buildDeckSlides(lesson: LessonDetail): DeckSlide[] {
const sections = lesson.markdown
.split("\n## ")
.slice(1)
.map((section) => {
const [rawTitle, ...bodyLines] = section.split("\n");
return {
title: rawTitle.trim(),
body: bodyLines
.filter((line) => line.trim())
.slice(0, 5)
.join("\n")
.replace(/^- /gm, "")
.replace(/^\d+\. /gm, ""),
};
})
.filter((slide) => slide.title && slide.body);
return [
{
title: "本节导览",
body: `${lesson.name}\n预计学习 ${lesson.duration}\n完成课件后可标记进度并保存笔记。`,
},
...sections,
];
}
function LessonDeck({
lesson,
onComplete,
}: {
lesson: LessonDetail;
onComplete: () => void;
}) {
const slides = buildDeckSlides(lesson);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const activeSlide = slides[activeIndex] ?? slides[0];
const progress = slides.length > 0 ? Math.round(((activeIndex + 1) / slides.length) * 100) : 0;
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
setActiveIndex((current) => {
if (current >= slides.length - 1) {
setPlaying(false);
onComplete();
return current;
}
return current + 1;
});
}, 6500);
return () => window.clearTimeout(timer);
}, [activeIndex, onComplete, playing, slides.length]);
return (
<div className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-xl md:rounded-2xl">
<div className="flex min-h-[min(58vw,420px)] flex-col bg-slate-950 text-white">
<div className="flex items-center justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-white/50">Interactive Lesson</p>
<h2 className="truncate text-base font-semibold sm:text-lg">{activeSlide.title}</h2>
</div>
<span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs text-white/70">
{activeIndex + 1} / {slides.length}
</span>
</div>
<div className="flex flex-1 items-center px-5 py-8 sm:px-8">
<p className="whitespace-pre-line text-base leading-8 text-white/85 sm:text-lg sm:leading-9">
{activeSlide.body}
</p>
</div>
<div className="border-t border-white/10 px-5 py-4">
<div className="mb-4 h-1.5 overflow-hidden rounded-full bg-white/15">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-2">
<button
type="button"
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
disabled={activeIndex === 0}
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => setActiveIndex((index) => Math.min(slides.length - 1, index + 1))}
disabled={activeIndex === slides.length - 1}
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPlaying((value) => !value)}
className="rounded-lg bg-white px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-white/90"
>
{playing ? "暂停" : "播放课件"}
</button>
<button
type="button"
onClick={onComplete}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default function LessonPage() {
const params = useParams();
const router = useRouter();
const { t } = useTranslation("community");
const { user, vip, refetch } = useAuthAndVip();
const [toast, setToast] = useState(false);
const { vip, refetch } = useAuthAndVip();
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
const [bookmarked, setBookmarked] = useState(false);
const [completed, setCompleted] = useState(false);
const [lessonNote, setLessonNote] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const moduleIndex = Number(params.moduleIndex);
const lessonIndex = Number(params.lessonIndex);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
const lessonName = lesson?.name;
const currentLessonIndex = COURSE_LESSONS.findIndex(
(item) => item.moduleIndex === moduleIndex && item.lessonIndex === lessonIndex
);
const previousLesson = currentLessonIndex > 0 ? COURSE_LESSONS[currentLessonIndex - 1] : null;
const nextLesson =
currentLessonIndex >= 0 && currentLessonIndex < COURSE_LESSONS.length - 1
? COURSE_LESSONS[currentLessonIndex + 1]
: null;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
if (lessonName && unlocked) setLastWatched(moduleIndex, lessonIndex, lessonName);
}, [moduleIndex, lessonIndex, lessonName, unlocked]);
useEffect(() => {
if (!lesson) return;
const key = `${moduleIndex}-${lessonIndex}`;
setBookmarked(isBookmarked(moduleIndex, lessonIndex));
setCompleted(getCompletedLessons().has(key));
setLessonNote(getNote(moduleIndex, lessonIndex));
setStatusMessage("");
}, [moduleIndex, lessonIndex, lesson]);
const handleBookmark = () => {
const active = toggleBookmark(moduleIndex, lessonIndex);
setBookmarked(active);
setStatusMessage(active ? "已收藏本节" : "已取消收藏");
};
const handleMarkCompleted = () => {
markCompleted(moduleIndex, lessonIndex);
setCompleted(true);
setStatusMessage("本节已完成");
};
const handleSaveNote = () => {
saveNote(moduleIndex, lessonIndex, lessonNote);
setStatusMessage("笔记已保存");
};
const startPay = (userId: string) => {
const env = getPayEnv();
@@ -155,17 +331,24 @@ export default function LessonPage() {
<div className="relative mb-12">
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`course-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
lesson.videoUrl ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`course-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={handleMarkCompleted}
/>
) : (
<LessonDeck key={`${moduleIndex}-${lessonIndex}`} lesson={lesson} onComplete={handleMarkCompleted} />
)
) : (
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<svg className="h-14 w-14 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 11V7a4 4 0 0 1 8 0v4" />
<rect x="5" y="11" width="14" height="10" rx="2" strokeWidth={1.8} />
</svg>
<p className="text-lg text-white/90"></p>
<button
type="button"
@@ -180,6 +363,55 @@ export default function LessonPage() {
)}
</div>
{unlocked && (
<div className="mb-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<button
type="button"
onClick={handleBookmark}
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
<svg className="h-4 w-4" fill={bookmarked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m12 3 2.9 5.88 6.5.95-4.7 4.58 1.1 6.47L12 17.83l-5.8 3.05 1.1-6.47-4.7-4.58 6.5-.95L12 3z" />
</svg>
{bookmarked ? "已收藏" : "收藏本节"}
</button>
<button
type="button"
onClick={handleMarkCompleted}
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 6 9 17l-5-5" />
</svg>
{completed ? "已完成" : "标记完成"}
</button>
<div className="flex items-center justify-center gap-2">
{previousLesson && (
<Link
href={`/course/${previousLesson.moduleIndex}/${previousLesson.lessonIndex}`}
className="inline-flex items-center justify-center rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
</Link>
)}
{nextLesson && (
<Link
href={`/course/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`}
className="inline-flex items-center justify-center rounded-xl bg-[var(--accent)] px-4 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
)}
</div>
</div>
)}
{statusMessage && (
<p className="mb-6 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm text-[var(--muted-foreground)]" role="status">
{statusMessage}
</p>
)}
{unlocked && (
<div className="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
@@ -192,6 +424,33 @@ export default function LessonPage() {
</div>
)}
{unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 20h9" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</h2>
<textarea
value={lessonNote}
onChange={(event) => setLessonNote(event.target.value)}
className="min-h-32 w-full rounded-xl border border-[var(--border)] bg-[var(--background)] px-4 py-3 text-sm leading-6 outline-none transition focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
placeholder="记录本节重点、行动清单或自己的实践结果"
/>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSaveNote}
className="inline-flex cursor-pointer items-center justify-center rounded-full bg-[var(--accent)] px-6 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
>
</button>
</div>
</div>
)}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
@@ -200,7 +459,7 @@ export default function LessonPage() {
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]"></p>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">便</p>
<a
href={DOWNLOAD_CONFIG.url}
target="_blank"
@@ -216,14 +475,6 @@ export default function LessonPage() {
)}
</div>
</main>
{toast && (
<div
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
role="alert"
>
{t("updating")}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}

View File

@@ -11,7 +11,7 @@ const positionOptions = [
{ value: "other", labelZh: "其他", labelEn: "Other" },
];
interface FormData {
interface JobFormData {
position: string;
name: string;
email: string;
@@ -52,7 +52,7 @@ function ChevronDown() {
export default function JobsContent() {
const { t } = useTranslation("jobs");
const locale = useLocale();
const [form, setForm] = useState<FormData>({
const [form, setForm] = useState<JobFormData>({
position: "",
name: "",
email: "",
@@ -61,23 +61,51 @@ export default function JobsContent() {
});
const [resume, setResume] = useState<File | null>(null);
const [resumeError, setResumeError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const set = (key: keyof FormData, value: string) =>
const set = (key: keyof JobFormData, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = (e: FormEvent) => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setResumeError(null);
setSubmitMessage("");
if (!resume) {
setResumeError(locale === "zh" ? "请上传简历PDF 或 Word" : "Please upload your resume (PDF or Word)");
return;
}
// 暂不提交,无业务逻辑
if (locale === "zh") {
alert("表单已填写完成,提交功能即将上线。");
} else {
alert("Form completed. Submit feature coming soon.");
setSubmitting(true);
try {
const uploadForm = new window.FormData();
uploadForm.append("file", resume);
const uploadRes = await fetch("/api/digital/upload-media", {
method: "POST",
body: uploadForm,
});
const uploadData = await uploadRes.json().catch(() => ({}));
if (!uploadRes.ok || !uploadData?.url) {
throw new Error(uploadData?.error || (locale === "zh" ? "简历上传失败" : "Resume upload failed"));
}
const submitRes = await fetch("/api/digital/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, resumeUrl: uploadData.url }),
});
const submitData = await submitRes.json().catch(() => ({}));
if (!submitRes.ok || !submitData?.ok) {
throw new Error(submitData?.error || (locale === "zh" ? "提交失败" : "Submit failed"));
}
setForm({ position: "", name: "", email: "", phone: "", intro: "" });
removeResume();
setSubmitMessage(locale === "zh" ? "申请已提交,我们会尽快联系你。" : "Application submitted. We will contact you soon.");
} catch (error) {
setResumeError(error instanceof Error ? error.message : (locale === "zh" ? "提交失败" : "Submit failed"));
} finally {
setSubmitting(false);
}
};
@@ -137,6 +165,12 @@ export default function JobsContent() {
onSubmit={handleSubmit}
className="mt-0 bg-white px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md"
>
{submitMessage && (
<div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{submitMessage}
</div>
)}
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label={t("position")}>
<select
@@ -270,9 +304,10 @@ export default function JobsContent() {
<div className="mt-8 sm:mt-10">
<button
type="submit"
className="w-full rounded-full bg-amber-500 px-8 py-4 text-base font-semibold text-white transition-colors hover:bg-amber-600"
disabled={submitting}
className="w-full rounded-full bg-amber-500 px-8 py-4 text-base font-semibold text-white transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{t("submit")}
{submitting ? (locale === "zh" ? "提交中..." : "Submitting...") : t("submit")}
</button>
</div>
</form>

View File

@@ -77,9 +77,15 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
</div>
<div className="mt-8 flex flex-wrap gap-3">
<a href={item.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
</a>
{item.mediaUrl ? (
<a href={item.mediaUrl} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
</a>
) : (
<span className="rounded-full bg-gray-200 px-5 py-3 text-sm font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-400">
</span>
)}
<Link href="/dashboard" className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
线
</Link>

View File

@@ -8,6 +8,7 @@ import FilterBar from "../components/FilterBar";
import CityCard from "../components/CityCard";
import CityModal from "../components/CityModal";
import Footer from "@/app/components/Footer";
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
import { cities, type City } from "../data/cities";
import { HOME_CONFIG } from "@/config";
import { apiFetch } from "@/app/lib/api-client";
@@ -157,6 +158,7 @@ export default function Home() {
const [hotTopics, setHotTopics] = useState(fallbackHotTopics);
const [routes, setRoutes] = useState<RouteItem[]>([]);
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
const [submissionOpen, setSubmissionOpen] = useState(false);
const locale = useLocale();
const router = useRouter();
const { t } = useTranslation("home");
@@ -289,6 +291,11 @@ export default function Home() {
} as City)),
}));
const communityContent = useMemo(
() => homeContent.filter((item) => item.type === "ebook" || item.type === "video"),
[homeContent]
);
const cityTags = [
{ key: "dali", emoji: "🏯", zh: "大理", en: "Dali" },
{ key: "chengdu", emoji: "🐼", zh: "成都", en: "Chengdu" },
@@ -387,7 +394,30 @@ export default function Home() {
);
})}
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 5 }}>
<button
type="button"
onClick={() => setSubmissionOpen(true)}
className="right-item block cursor-pointer text-left"
style={{ gridColumn: "-2 / -1", gridRow: 5 }}
>
<div className="right-item-header">稿</div>
<div className="flex h-[calc(100%-38px)] flex-col justify-center p-3 sm:h-[calc(100%-42px)] sm:p-5">
<span className="mb-2 inline-flex w-fit rounded-full bg-[#ff4d4f]/10 px-2.5 py-1 text-[10px] font-bold text-[#ff4d4f] sm:text-xs">
UGC
</span>
<p className="text-xs font-bold leading-snug text-gray-900 dark:text-gray-100 sm:text-sm">
稿 / 访
</p>
<p className="mt-2 line-clamp-3 text-[10px] leading-relaxed text-gray-500 dark:text-gray-400 sm:text-xs">
</p>
<span className="mt-3 inline-flex w-fit rounded-full bg-[#ff4d4f] px-3 py-1.5 text-[10px] font-semibold text-white sm:text-xs">
</span>
</div>
</button>
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 6 }}>
<div className="right-item-header">{t("joinCommunity")}</div>
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
@@ -403,7 +433,7 @@ export default function Home() {
</div>
</Link>
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 6 }}>
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 7 }}>
<div className="right-item-header">{t("newMembers")}</div>
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
{memberPhotos.map((m) => (
@@ -421,7 +451,7 @@ export default function Home() {
</div>
</Link>
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 7 }}>
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 8 }}>
<div className="right-item-header">{t("latestEvents")}</div>
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
{homeMeetups.slice(0, 3).map((meetup) => (
@@ -471,6 +501,49 @@ export default function Home() {
</div>
)}
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
<div className="mb-5 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
/ 访
</h2>
<button
type="button"
onClick={() => setSubmissionOpen(true)}
className="w-fit rounded-lg bg-[#ff4d4f] px-4 py-2 text-sm font-bold text-white transition-colors hover:bg-[#ff7a45]"
>
稿
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{communityContent.slice(0, 8).map((item) => {
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
const href = item.type === "video" ? `/videos/${item.slug}` : `/ebooks/${item.slug}`;
return (
<Link
key={`${item.type}-${item.slug}`}
href={href}
className="group overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-100 transition-transform hover:-translate-y-0.5 dark:bg-gray-900 dark:ring-gray-800"
>
<div className="relative aspect-[4/3] overflow-hidden bg-gray-100 dark:bg-gray-800">
<img src={item.coverImage} alt={title} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" />
<span className="absolute left-3 top-3 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-gray-900">
{item.type === "ebook" ? "电子书" : "访谈视频"}
</span>
</div>
<div className="p-4">
<h3 className="line-clamp-2 text-sm font-bold leading-snug text-gray-900 dark:text-gray-100">{title}</h3>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{subtitle}</p>
<span className="mt-4 inline-flex text-xs font-bold text-[#ff4d4f]">{cta}</span>
</div>
</Link>
);
})}
</div>
</section>
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
@@ -695,6 +768,7 @@ export default function Home() {
isOpen={!!cityModal}
onClose={() => setCityModal(null)}
/>
<ContentSubmissionModal open={submissionOpen} locale={locale} onClose={() => setSubmissionOpen(false)} />
<Footer />
</div>
);

View File

@@ -14,7 +14,7 @@ interface ContentItem {
description: string;
descriptionEn?: string;
coverImage: string;
mediaUrl: string;
mediaUrl?: string;
chapters?: { title: string; time?: string }[];
relatedProfiles?: string[];
durationMinutes?: number;
@@ -56,7 +56,13 @@ export default function VideoPage({ params }: { params: Promise<{ slug: string }
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
<Link href="/" className="text-sm text-white/60 hover:text-white"> </Link>
<div className="mt-6 overflow-hidden rounded-2xl bg-black shadow-2xl">
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
{item.mediaUrl ? (
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
) : (
<div className="flex aspect-video w-full items-center justify-center bg-black text-sm text-white/60">
</div>
)}
</div>
<div className="mt-8 grid gap-8 lg:grid-cols-[1fr_320px]">
<section>

View File

@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/digital/services.config";
import { COOKIE_NAME } from "@/app/digital/lib/auth-cookie";
type JobPayload = {
position?: string;
name?: string;
email?: string;
phone?: string;
intro?: string;
resumeUrl?: string;
};
function getSessionUserId(request: NextRequest): string {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) return "";
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
return typeof payload?.userId === "string" ? payload.userId : "";
} catch {
return "";
}
}
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
for (const path of paths) {
const res = await fetch(`${pb.url}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
if (data?.token) return data.token;
}
}
return null;
}
function clean(value: unknown): string {
return String(value ?? "").trim();
}
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as JobPayload;
const position = clean(body.position);
const name = clean(body.name);
const email = clean(body.email).toLowerCase();
const phone = clean(body.phone);
const intro = clean(body.intro);
const resumeUrl = clean(body.resumeUrl);
if (!position) {
return NextResponse.json({ ok: false, error: "请选择职位" }, { status: 400 });
}
if (!name) {
return NextResponse.json({ ok: false, error: "请填写姓名" }, { status: 400 });
}
if (!email || !email.includes("@")) {
return NextResponse.json({ ok: false, error: "请填写有效邮箱" }, { status: 400 });
}
if (!resumeUrl) {
return NextResponse.json({ ok: false, error: "请先上传简历" }, { status: 400 });
}
const token = await getAdminToken();
if (!token) {
return NextResponse.json({ ok: false, error: "招聘提交服务未配置" }, { status: 503 });
}
const pb = getPocketBaseConfig();
const note = [
`Position: ${position}`,
`Name: ${name}`,
phone ? `Phone: ${phone}` : "",
intro ? `Intro: ${intro}` : "",
`Resume: ${resumeUrl}`,
].filter(Boolean).join("\n");
const record = {
serviceSlug: `digital-job-${position}`,
userId: getSessionUserId(request),
email,
note,
status: "new",
};
const res = await fetch(`${pb.url}/api/collections/service_leads/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(record),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return NextResponse.json(
{ ok: false, error: data?.message || "提交失败,请稍后重试" },
{ status: res.status || 500 }
);
}
return NextResponse.json({ ok: true, record: data });
}

View File

@@ -74,12 +74,9 @@ interface CityDetailData {
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
const [activeTab, setActiveTab] = useState("scores");
const [mounted, setMounted] = useState(false);
const [favorited, setFavorited] = useState(false);
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!city || !isOpen) return;
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
@@ -88,7 +85,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
.catch(() => setDetailData(null));
}, [city, isOpen]);
if (!isOpen || !city || !mounted) return null;
if (!isOpen || !city || typeof document === "undefined") return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);

View File

@@ -0,0 +1,352 @@
"use client";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import { apiFetch, apiUrl } from "@/app/lib/api-client";
type SubmissionType = "ebook" | "video";
interface FormState {
type: SubmissionType;
title: string;
subtitle: string;
description: string;
coverImage: string;
mediaUrl: string;
authorName: string;
authorEmail: string;
chaptersText: string;
}
interface ContentSubmissionModalProps {
open: boolean;
locale: string;
onClose: () => void;
}
const MB = 1024 * 1024;
const EMPTY_FORM: FormState = {
type: "ebook",
title: "",
subtitle: "",
description: "",
coverImage: "",
mediaUrl: "",
authorName: "",
authorEmail: "",
chaptersText: "",
};
export default function ContentSubmissionModal({ open, locale, onClose }: ContentSubmissionModalProps) {
const isZh = locale === "zh";
const copy = useMemo(
() => ({
title: isZh ? "投稿电子书 / 访谈视频" : "Submit ebook / interview",
close: isZh ? "关闭" : "Close",
ebook: isZh ? "电子书" : "Ebook",
video: isZh ? "访谈视频" : "Interview video",
name: isZh ? "作者昵称" : "Author name",
email: isZh ? "联系邮箱" : "Contact email",
contentTitle: isZh ? "标题" : "Title",
subtitle: isZh ? "一句话简介" : "Short subtitle",
description: isZh ? "内容简介" : "Description",
cover: isZh ? "封面图" : "Cover image",
coverUrl: isZh ? "封面图 URL" : "Cover image URL",
file: isZh ? "内容文件" : "Content file",
fileUrl: isZh ? "电子书 PDF/EPUB 或视频直链" : "PDF/EPUB or direct video URL",
chapters: isZh ? "目录 / 章节" : "Chapters",
chaptersHint: isZh ? "每行一个章节,可选" : "One chapter per line, optional",
uploadCover: isZh ? "上传封面" : "Upload cover",
uploadFile: isZh ? "上传文件" : "Upload file",
uploading: isZh ? "上传中..." : "Uploading...",
submit: isZh ? "提交审核" : "Submit for review",
submitting: isZh ? "提交中..." : "Submitting...",
success: isZh ? "已提交,审核通过后会展示在首页。" : "Submitted. It will appear after review.",
required: isZh ? "请填写标题、简介、封面、内容文件和联系邮箱。" : "Please complete title, description, cover, file and email.",
uploadFailed: isZh ? "上传失败" : "Upload failed",
fileTooLarge: isZh ? "文件过大" : "File too large",
review: isZh ? "状态:待审核" : "Status: pending review",
}),
[isZh]
);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [uploading, setUploading] = useState<"coverImage" | "mediaUrl" | null>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setMessage("");
setError("");
}
}, [open]);
if (!open) return null;
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setError("");
};
const mediaMaxSize = form.type === "video" ? 200 * MB : 100 * MB;
const contentAccept =
form.type === "video"
? "video/mp4,video/webm,video/quicktime"
: "application/pdf,application/epub+zip,.pdf,.epub,.mobi,.azw3";
async function uploadFile(event: ChangeEvent<HTMLInputElement>, target: "coverImage" | "mediaUrl") {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
const limit = target === "coverImage" ? 20 * MB : mediaMaxSize;
if (file.size > limit) {
setError(`${copy.fileTooLarge},最大 ${Math.round(limit / MB)}MB`);
return;
}
setUploading(target);
setError("");
try {
const data = new FormData();
data.append("file", file);
const res = await fetch(apiUrl("/api/upload-media?purpose=content"), {
method: "POST",
body: data,
credentials: "include",
});
const json = (await res.json().catch(() => ({}))) as { ok?: boolean; url?: string; detail?: string; error?: string };
if (!res.ok || !json.ok || !json.url) {
throw new Error(json.detail || json.error || copy.uploadFailed);
}
setForm((prev) => ({ ...prev, [target]: json.url || "" }));
} catch (err) {
setError(err instanceof Error ? err.message : copy.uploadFailed);
} finally {
setUploading(null);
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const title = form.title.trim();
const description = form.description.trim();
const coverImage = form.coverImage.trim();
const mediaUrl = form.mediaUrl.trim();
const authorEmail = form.authorEmail.trim();
if (!title || !description || !coverImage || !mediaUrl || !authorEmail) {
setError(copy.required);
return;
}
setSubmitting(true);
setError("");
setMessage("");
try {
await apiFetch("/api/content/submissions", {
method: "POST",
body: JSON.stringify({
type: form.type,
title,
subtitle: form.subtitle.trim(),
description,
coverImage,
mediaUrl,
authorName: form.authorName.trim(),
authorEmail,
chapters: form.chaptersText
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => ({ title: line })),
}),
});
setMessage(copy.success);
setForm({ ...EMPTY_FORM, type: form.type });
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
const busy = submitting || Boolean(uploading);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-3 py-5 backdrop-blur-sm" role="dialog" aria-modal="true">
<div className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg bg-white shadow-2xl dark:bg-gray-950">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950 sm:px-6">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 sm:text-lg">{copy.title}</h2>
<p className="mt-0.5 text-xs font-medium text-[#ff4d4f]">{copy.review}</p>
</div>
<button
type="button"
onClick={onClose}
disabled={busy}
aria-label={copy.close}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 text-xl leading-none text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
x
</button>
</div>
<form onSubmit={submit} className="space-y-5 px-4 py-5 sm:px-6">
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-gray-900">
{(["ebook", "video"] as SubmissionType[]).map((type) => (
<button
key={type}
type="button"
onClick={() => set("type", type)}
className={`rounded-md px-3 py-2 text-sm font-bold transition-colors ${
form.type === type
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
{type === "ebook" ? copy.ebook : copy.video}
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.name}</span>
<input
value={form.authorName}
onChange={(event) => set("authorName", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder="Nomad"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.email}</span>
<input
type="email"
value={form.authorEmail}
onChange={(event) => set("authorEmail", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder="you@example.com"
required
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.contentTitle}</span>
<input
value={form.title}
onChange={(event) => set("title", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.subtitle}</span>
<input
value={form.subtitle}
onChange={(event) => set("subtitle", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.description}</span>
<textarea
value={form.description}
onChange={(event) => set("description", event.target.value)}
className="min-h-28 w-full resize-y rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm leading-6 text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.cover}</span>
<label className="cursor-pointer rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-bold text-white transition-colors hover:bg-[#ff4d4f] dark:bg-gray-100 dark:text-gray-900">
{uploading === "coverImage" ? copy.uploading : copy.uploadCover}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
disabled={busy}
onChange={(event) => void uploadFile(event, "coverImage")}
/>
</label>
</div>
<input
value={form.coverImage}
onChange={(event) => set("coverImage", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.coverUrl}
required
/>
{form.coverImage && (
<img src={form.coverImage} alt="" className="mt-3 h-32 w-full rounded-lg object-cover ring-1 ring-gray-100 dark:ring-gray-800" />
)}
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.file}</span>
<label className="cursor-pointer rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-bold text-white transition-colors hover:bg-[#ff4d4f] dark:bg-gray-100 dark:text-gray-900">
{uploading === "mediaUrl" ? copy.uploading : copy.uploadFile}
<input
type="file"
accept={contentAccept}
className="hidden"
disabled={busy}
onChange={(event) => void uploadFile(event, "mediaUrl")}
/>
</label>
</div>
<input
value={form.mediaUrl}
onChange={(event) => set("mediaUrl", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.fileUrl}
required
/>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">Max {Math.round(mediaMaxSize / MB)}MB</p>
</div>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.chapters}</span>
<textarea
value={form.chaptersText}
onChange={(event) => set("chaptersText", event.target.value)}
className="min-h-20 w-full resize-y rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm leading-6 text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.chaptersHint}
/>
</label>
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-200">{error}</div>}
{message && <div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">{message}</div>}
<div className="flex flex-col-reverse gap-3 border-t border-gray-100 pt-4 dark:border-gray-800 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-lg border border-gray-200 px-5 py-2.5 text-sm font-bold text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-900"
>
{copy.close}
</button>
<button
type="submit"
disabled={busy}
className="rounded-lg bg-[#ff4d4f] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[#ff7a45] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? copy.submitting : copy.submit}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { Link } from "@/i18n/navigation";
const meetups = [
{ city: "清迈", emoji: "🇹🇭", date: "3月9日 周一", attendees: 12 },
{ city: "曼谷", emoji: "🇹🇭", date: "3月12日 周四", attendees: 8 },
@@ -74,9 +76,9 @@ export default function Sidebar() {
<p className="text-xs text-emerald-700 bg-emerald-100/50 rounded-lg px-3 py-2 mb-3">
195+ ¥15/
</p>
<button className="w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white border border-emerald-200 rounded-full py-2 transition-colors hover:bg-emerald-50">
<Link href="/services" className="block w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white border border-emerald-200 rounded-full py-2 transition-colors hover:bg-emerald-50">
</button>
</Link>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
@@ -109,9 +111,9 @@ export default function Sidebar() {
</div>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
<Link href="/meetups" className="block w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</Link>
</div>
{/* Traveling Now */}
@@ -167,9 +169,9 @@ export default function Sidebar() {
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a
<Link
key={t.title}
href="#"
href={`/discuss?q=${encodeURIComponent(t.title)}`}
className="flex items-start gap-2.5 group"
>
<span className="text-xs font-bold text-gray-300 mt-0.5 w-4 shrink-0">
@@ -183,12 +185,12 @@ export default function Sidebar() {
{t.views.toLocaleString()}
</p>
</div>
</a>
</Link>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
<Link href="/discuss" className="block w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</Link>
</div>
{/* Chat CTA */}
@@ -204,9 +206,9 @@ export default function Sidebar() {
</p>
</div>
</div>
<button className="w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white border border-violet-200 rounded-full py-2 transition-colors hover:bg-violet-50">
<Link href="/join" className="block w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white border border-violet-200 rounded-full py-2 transition-colors hover:bg-violet-50">
</button>
</Link>
</div>
{/* Visa Service */}
@@ -234,9 +236,9 @@ export default function Sidebar() {
🇮🇩
</span>
</div>
<button className="w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white border border-amber-200 rounded-full py-2 transition-colors hover:bg-amber-50">
<Link href="/services" className="block w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white border border-amber-200 rounded-full py-2 transition-colors hover:bg-amber-50">
</button>
</Link>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
</div>

View File

@@ -80,17 +80,17 @@ export default memo(function SiteMenu({
titleKey: "help",
items: [
{ labelKey: "ideasBugs", href: "/feedback", icon: "💡" },
{ labelKey: "faq", href: "#", icon: "🆘" },
{ labelKey: "terms", href: "#", icon: "📄" },
{ labelKey: "changelog", href: "#", icon: "🚀" },
{ labelKey: "faq", href: "/help", icon: "🆘" },
{ labelKey: "terms", href: "/terms", icon: "📄" },
{ labelKey: "changelog", href: "/changelog", icon: "🚀" },
],
},
{
titleKey: "other",
items: [
{ labelKey: "digitalNomadGuide", href: "/digital", icon: "📖" },
{ labelKey: "remoteJobs", href: "#", icon: "💼" },
{ labelKey: "coworking", href: "#", icon: "🏢" },
{ labelKey: "remoteJobs", href: "/jobs", icon: "💼" },
{ labelKey: "coworking", href: "/services", icon: "🏢" },
],
},
];
@@ -100,7 +100,7 @@ export default memo(function SiteMenu({
{ labelKey: "settings", href: "/settings/notifications", icon: "⚙️" },
{ labelKey: "favorites", href: "/", icon: "❤️" },
{ labelKey: "pricing", href: "/pricing", icon: "👑" },
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
{ labelKey: "nomadInsurance", href: "/services", icon: "🛡️", ad: true },
];
const filteredSections = sections;

View File

@@ -1,30 +1,12 @@
"use client";
import { useTheme } from "@/app/context/ThemeContext";
import { useState, useEffect } from "react";
import { useTranslation } from "@/i18n/navigation";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation("common");
const isDark = theme === "dark";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
type="button"
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t("themeLight")}
>
<div className="h-5 w-5" />
</button>
);
}
return (
<button

View File

@@ -34,16 +34,9 @@ function getInitialTheme(): Theme {
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
useEffect(() => {
setThemeState(getInitialTheme());
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
@@ -55,7 +48,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
} catch {
/* ignore */
}
}, [theme, mounted]);
}, [theme]);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);

View File

@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
@@ -30,13 +29,19 @@ const linkIconNames: Record<string, IconName> = {
unmannedLive: "video",
};
const topicHrefs: Record<(typeof topicKeys)[number], string> = {
indieDev: "/ebook",
aiAgent: "/#tools",
cloudPhone: "/ebook",
unmannedLive: "/ebook",
};
type CommunityProps = {
resourceData?: ResourceData;
};
export default function Community({ resourceData }: CommunityProps) {
const { t } = useTranslation("community");
const [toast, setToast] = useState(false);
const { meetupUrl, vipUrl } = useCrossSiteUrls();
const linkHrefs: Record<string, { href: string; newTab?: boolean }> = {
destinations: { href: meetupUrl, newTab: true },
@@ -44,11 +49,6 @@ export default function Community({ resourceData }: CommunityProps) {
appDownload: { href: "/app" },
};
const showUpdating = () => {
setToast(true);
setTimeout(() => setToast(false), 2000);
};
const renderCardContent = (key: string) => (
<>
<span className="dn-icon-box flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-xl dark:bg-slate-800">
@@ -101,18 +101,17 @@ export default function Community({ resourceData }: CommunityProps) {
})}
</div>
{/* 4 个专题 - 小卡片,暂不可点击 */}
{/* 4 个专题 */}
<div className="mt-8">
<h3 className="mb-4 text-center text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{t("topicTag")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{topicKeys.map((key) => (
<button
<Link
key={key}
type="button"
onClick={showUpdating}
className="dn-card dn-muted-card flex cursor-not-allowed items-start gap-4 rounded-xl border border-slate-200 bg-slate-50/80 p-4 opacity-90 transition-opacity hover:opacity-100 dark:border-slate-700 dark:bg-slate-800/60"
href={topicHrefs[key]}
className="dn-card dn-muted-card flex items-start gap-4 rounded-xl border border-slate-200 bg-slate-50/80 p-4 opacity-90 transition-all hover:opacity-100 dark:border-slate-700 dark:bg-slate-800/60"
>
<span className="dn-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-200 text-lg dark:bg-slate-700">
<DigitalThemeIcon
@@ -124,24 +123,14 @@ export default function Community({ resourceData }: CommunityProps) {
<div className="min-w-0 flex-1 text-left">
<h4 className="font-bold text-slate-700 dark:text-slate-300">{t(`links.${key}.title`)}</h4>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{t(`links.${key}.desc`)}</p>
<span className="mt-1.5 inline-block text-xs font-medium text-amber-600 dark:text-amber-400">
{t("updating")}
<span className="dn-text-link mt-1.5 inline-block text-xs font-medium text-sky-600">
{t(`links.${key}.label`)}
</span>
</div>
</button>
</Link>
))}
</div>
</div>
{/* 更新中 Toast */}
{toast && (
<div
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
role="alert"
>
{t("updating")}
</div>
)}
</div>
</section>
);

View File

@@ -19,18 +19,18 @@ export default function Footer() {
{
titleKey: "resources" as const,
links: [
{ labelKey: "remoteJobs" as const, href: "#" },
{ labelKey: "visa" as const, href: "#" },
{ labelKey: "coliving" as const, href: "#" },
{ labelKey: "insurance" as const, href: "#" },
{ labelKey: "remoteJobs" as const, href: "/jobs", internal: true },
{ labelKey: "visa" as const, href: "/services", internal: true },
{ labelKey: "coliving" as const, href: "/services", internal: true },
{ labelKey: "insurance" as const, href: "/services", internal: true },
],
},
{
titleKey: "community" as const,
links: [
{ labelKey: "discord" as const, href: "#" },
{ labelKey: "discord" as const, href: "/join", internal: true },
{ labelKey: "wechat" as const, href: vipUrl, openInNewTab: true },
{ labelKey: "jike" as const, href: "#" },
{ labelKey: "jike" as const, href: "/discuss", internal: true },
],
},
{
@@ -39,7 +39,7 @@ export default function Footer() {
{ labelKey: "aboutUs" as const, href: "/about", internal: true },
{ labelKey: "recruit" as const, href: "/jobs", internal: true },
{ labelKey: "privacy" as const, href: "/privacy", internal: true, openInNewTab: true },
{ labelKey: "contact" as const, href: "#" },
{ labelKey: "contact" as const, href: "/contact", internal: true },
],
},
];

View File

@@ -49,12 +49,6 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
const [activePanCategory, setActivePanCategory] = useState(firstCategoryId);
const [booksPage, setBooksPage] = useState(0);
const [productsPage, setProductsPage] = useState(0);
const [toast, setToast] = useState(false);
const showUpdating = () => {
setToast(true);
setTimeout(() => setToast(false), 2000);
};
const activeCategory = data.pan.categories.find((c) => c.id === activePanCategory);
@@ -92,11 +86,12 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedBooks.map((book, i) => (
<button
<a
key={`book-${booksPage}-${i}`}
type="button"
onClick={showUpdating}
className="dn-resource-tile group flex cursor-not-allowed flex-col text-left"
href={book.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="dn-resource-tile group flex cursor-pointer flex-col text-left focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950"
>
<div
className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
@@ -111,7 +106,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{book.title}
</p>
</button>
</a>
))}
</div>
{booksTotalPages > 1 && (
@@ -160,11 +155,12 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedProducts.map((product, i) => (
<button
<a
key={`product-${productsPage}-${i}`}
type="button"
onClick={showUpdating}
className="dn-resource-tile group flex cursor-not-allowed flex-col text-left"
href={product.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="dn-resource-tile group flex cursor-pointer flex-col text-left focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950"
>
<div
className="overflow-hidden rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-900"
@@ -179,7 +175,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{product.name}
</p>
</button>
</a>
))}
</div>
{productsTotalPages > 1 && (
@@ -277,14 +273,6 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
</div>
</div>
{toast && (
<div
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
role="alert"
>
{t("updating")}
</div>
)}
</div>
);
}

View File

@@ -3,9 +3,9 @@
* 与 join 报名支付分离,课程解锁用 VIP 状态
*/
/** 前 2 章试看(暂时关闭,全部需 VIP 解锁 */
export function isFreeTrial(_moduleIndex: number, _lessonIndex: number): boolean {
return false;
/** 第一模块前 2 节可试看,其余章节需 VIP 解锁 */
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
return moduleIndex === 0 && lessonIndex < 2;
}
/** 章节是否可观看(需传入 vip 状态,来自 useAuthAndVip 或 /api/digital/vip/check */

View File

@@ -530,7 +530,16 @@ html {
}
.digital-scope .gradient-text {
background: linear-gradient(135deg, #0ea5e9, #06b6d4, #f59e0b);
background: linear-gradient(
135deg,
#f97316,
#ec4899,
#8b5cf6,
#3b82f6,
#f97316
);
background-size: 300% 300%;
animation: gradient-shift 6s ease infinite;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -864,66 +873,67 @@ html {
--border: var(--dn-border);
--accent: var(--dn-accent);
--accent-foreground: #ffffff;
--accent-muted: rgba(249, 115, 91, 0.14);
--gradient-accent: rgba(15, 159, 122, 0.16);
background:
linear-gradient(90deg, rgba(79, 70, 229, 0.035) 1px, transparent 1px),
linear-gradient(0deg, rgba(15, 159, 122, 0.035) 1px, transparent 1px),
linear-gradient(180deg, #fbfaf6 0%, var(--dn-bg) 34%, #eef7f3 100%);
background-size: 44px 44px, 44px 44px, auto;
--accent-muted: rgba(255, 77, 79, 0.1);
--gradient-accent: rgba(249, 115, 22, 0.16);
background: linear-gradient(180deg, #fff7ed 0%, #ffffff 18%, var(--dn-bg) 100%);
color: var(--dn-text);
font-family: var(--dn-font-body), system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
.dark .digital-scope.digital-theme-theme2 {
--dn-bg: #0f1724 !important;
--dn-bg-soft: #162235 !important;
--dn-surface: #111c2c !important;
--dn-surface-raised: #172235 !important;
--dn-text: #f8fafc !important;
--dn-text-muted: #a8b4c5 !important;
--dn-border: rgba(226, 232, 240, 0.16) !important;
--dn-primary: #8b93ff !important;
--dn-primary-strong: #c4c9ff !important;
--dn-accent: #ff967f !important;
--dn-cta: #34d399 !important;
--dn-cta-strong: #7ee7bf !important;
--dn-shadow: 0 16px 42px rgba(0, 0, 0, 0.28) !important;
--dn-shadow-hover: 0 22px 58px rgba(0, 0, 0, 0.34) !important;
background:
linear-gradient(90deg, rgba(139, 147, 255, 0.055) 1px, transparent 1px),
linear-gradient(0deg, rgba(52, 211, 153, 0.04) 1px, transparent 1px),
linear-gradient(180deg, #0b1220 0%, #0f1724 48%, #122333 100%);
background-size: 44px 44px, 44px 44px, auto;
--dn-bg: #111827 !important;
--dn-bg-soft: #1f2937 !important;
--dn-surface: #111827 !important;
--dn-surface-raised: #1f2937 !important;
--dn-text: #f9fafb !important;
--dn-text-muted: #9ca3af !important;
--dn-border: #1f2937 !important;
--dn-primary: #ff5c5e !important;
--dn-primary-strong: #ff7a45 !important;
--dn-accent: #f97316 !important;
--dn-cta: #ff4d4f !important;
--dn-cta-strong: #ff7a45 !important;
--dn-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05) !important;
--dn-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.08) !important;
background: linear-gradient(180deg, #030712 0%, #111827 28%, #111827 100%);
}
.digital-theme-theme2 .dn-container {
max-width: 1180px;
max-width: 1400px;
}
.digital-theme-theme2 .dn-header {
top: 12px;
pointer-events: none;
}
.digital-theme-theme2 .dn-header > .dn-container {
height: 60px;
border: 1px solid rgba(219, 228, 220, 0.86);
border-radius: var(--dn-radius);
background: rgba(255, 253, 248, 0.9);
box-shadow: var(--dn-shadow);
position: sticky;
top: 0;
border-bottom: 1px solid #f3f4f6;
background: rgba(255, 255, 255, 0.8);
box-shadow: none;
backdrop-filter: blur(18px);
pointer-events: auto;
}
.dark .digital-theme-theme2 .dn-header {
border-bottom-color: #1f2937;
background: rgba(17, 24, 39, 0.8);
}
.digital-theme-theme2 .dn-header > .dn-container {
height: 4rem;
max-width: 1600px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
pointer-events: auto;
}
.dark .digital-theme-theme2 .dn-header > .dn-container {
border-color: var(--dn-border);
background: rgba(17, 28, 44, 0.9);
background: transparent;
}
.digital-theme-theme2 .dn-header-scrolled {
background: transparent;
box-shadow: none;
}
@@ -946,7 +956,7 @@ html {
.digital-theme-theme2 .dn-nav-link,
.digital-theme-theme2 .dn-header-control,
.digital-theme-theme2 .dn-icon-button {
border-radius: var(--dn-radius-sm);
border-radius: 9999px;
color: var(--dn-text-muted);
cursor: pointer;
}
@@ -954,39 +964,59 @@ html {
.digital-theme-theme2 .dn-nav-link:hover,
.digital-theme-theme2 .dn-header-control:hover,
.digital-theme-theme2 .dn-icon-button:hover {
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
background: #f3f4f6;
color: var(--dn-text);
}
.dark .digital-theme-theme2 .dn-nav-link:hover,
.dark .digital-theme-theme2 .dn-header-control:hover,
.dark .digital-theme-theme2 .dn-icon-button:hover {
background: #1f2937;
color: var(--dn-text);
}
.digital-theme-theme2 .dn-mobile-menu {
width: min(100% - 2rem, 1180px);
margin: 0.5rem auto 0;
border: 1px solid var(--dn-border);
border-radius: var(--dn-radius);
background: var(--dn-surface);
box-shadow: var(--dn-shadow);
width: 100%;
max-width: 1600px;
margin: 0 auto;
border-top: 1px solid var(--dn-border);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
background: rgba(255, 255, 255, 0.95);
box-shadow: none;
}
.dark .digital-theme-theme2 .dn-mobile-menu {
background: rgba(17, 24, 39, 0.95);
}
.digital-theme-theme2 .dn-hero {
min-height: min(760px, 95vh);
padding-top: 8.75rem;
min-height: auto;
padding-top: 4rem;
padding-bottom: 5rem;
}
.digital-theme-theme2 .dn-hero-bg {
background:
radial-gradient(circle at 50% 0%, rgba(15, 159, 122, 0.16), transparent 34%),
linear-gradient(180deg, rgba(255, 253, 248, 0.72), rgba(247, 245, 239, 0.96));
radial-gradient(circle at 83% 0%, rgba(249, 115, 22, 0.24), transparent 32%),
radial-gradient(circle at 4% 100%, rgba(139, 92, 246, 0.18), transparent 28%),
linear-gradient(180deg, rgba(255, 247, 237, 0.72), #ffffff 58%, #fafafa 100%);
}
.dark .digital-theme-theme2 .dn-hero-bg {
background:
radial-gradient(circle at 50% 0%, rgba(52, 211, 153, 0.14), transparent 34%),
linear-gradient(180deg, rgba(15, 23, 36, 0.82), rgba(15, 23, 36, 0.98));
radial-gradient(circle at 83% 0%, rgba(249, 115, 22, 0.16), transparent 32%),
radial-gradient(circle at 4% 100%, rgba(139, 92, 246, 0.16), transparent 28%),
linear-gradient(180deg, #030712 0%, #111827 58%, #111827 100%);
}
.digital-theme-theme2 .dn-hero-glow {
display: none;
display: block;
background:
radial-gradient(circle, rgba(249, 115, 22, 0.16) 0%, transparent 70%),
radial-gradient(circle at 70% 70%, rgba(236, 72, 153, 0.12) 0%, transparent 56%);
}
.digital-theme-theme2 .dn-hero-title,
@@ -998,8 +1028,8 @@ html {
}
.digital-theme-theme2 .dn-hero-title {
max-width: 760px;
line-height: 1.02;
max-width: 56rem;
line-height: 1.05;
}
.digital-theme-theme2 .dn-hero-copy,
@@ -1010,42 +1040,52 @@ html {
}
.digital-theme-theme2 .gradient-text {
background: linear-gradient(120deg, var(--dn-primary), var(--dn-cta), var(--dn-accent));
background: linear-gradient(
135deg,
#f97316,
#ec4899,
#8b5cf6,
#3b82f6,
#f97316
);
background-size: 300% 300%;
animation: gradient-shift 6s ease infinite;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.digital-theme-theme2 .dn-badge {
border-color: rgba(79, 70, 229, 0.18);
border-radius: var(--dn-radius);
background: rgba(255, 255, 255, 0.74);
color: var(--dn-primary-strong);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.7) inset;
border-color: rgba(255, 77, 79, 0.14);
border-radius: 9999px;
background: rgba(255, 237, 213, 0.85);
color: #c2410c;
box-shadow: none;
}
.dark .digital-theme-theme2 .dn-badge {
border-color: rgba(139, 147, 255, 0.24);
background: rgba(17, 28, 44, 0.76);
border-color: rgba(255, 77, 79, 0.22);
background: rgba(127, 29, 29, 0.28);
color: #fb923c;
}
.digital-theme-theme2 .dn-button-primary {
border: 1px solid transparent;
border-radius: var(--dn-radius);
border-radius: 9999px;
background: var(--dn-cta);
color: #ffffff;
box-shadow: 0 14px 30px rgba(15, 159, 122, 0.24);
box-shadow: 0 10px 22px rgba(255, 77, 79, 0.24);
cursor: pointer;
}
.digital-theme-theme2 .dn-button-primary:hover {
background: var(--dn-cta-strong);
box-shadow: 0 16px 36px rgba(15, 159, 122, 0.3);
box-shadow: 0 12px 28px rgba(255, 122, 69, 0.28);
}
.digital-theme-theme2 .dn-button-secondary {
border-color: var(--dn-border);
border-radius: var(--dn-radius);
border-radius: 9999px;
background: var(--dn-surface);
color: var(--dn-text);
box-shadow: none;
@@ -1053,29 +1093,29 @@ html {
}
.digital-theme-theme2 .dn-button-secondary:hover {
border-color: rgba(79, 70, 229, 0.28);
background: rgba(79, 70, 229, 0.07);
color: var(--dn-primary-strong);
border-color: rgba(255, 77, 79, 0.22);
background: rgba(255, 77, 79, 0.06);
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-button-accent {
border-radius: var(--dn-radius);
border-radius: 9999px;
background: var(--dn-accent);
color: #ffffff;
}
.digital-theme-theme2 .dn-section {
background: transparent;
padding-top: 5.5rem;
padding-bottom: 5.5rem;
padding-top: 5rem;
padding-bottom: 5rem;
}
.digital-theme-theme2 .dn-roadmap {
background: linear-gradient(180deg, rgba(238, 247, 243, 0.72), rgba(247, 245, 239, 0.78));
background: rgba(255, 255, 255, 0.72);
}
.dark .digital-theme-theme2 .dn-roadmap {
background: linear-gradient(180deg, rgba(22, 34, 53, 0.72), rgba(15, 23, 36, 0.76));
background: rgba(31, 41, 55, 0.36);
}
.digital-theme-theme2 .dn-card,
@@ -1102,7 +1142,7 @@ html {
.digital-theme-theme2 .community-card:hover,
.digital-theme-theme2 .resource-card:hover,
.digital-theme-theme2 .dn-action-card:hover {
border-color: rgba(79, 70, 229, 0.28);
border-color: rgba(255, 77, 79, 0.22);
box-shadow: var(--dn-shadow-hover);
transform: translateY(-2px);
}
@@ -1122,8 +1162,8 @@ html {
}
.digital-theme-theme2 .dn-profile-menu-item:hover {
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
background: rgba(255, 77, 79, 0.08);
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-avatar-button {
@@ -1135,9 +1175,9 @@ html {
}
.digital-theme-theme2 .dn-status-pill {
border: 1px solid rgba(79, 70, 229, 0.12);
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
border: 1px solid rgba(255, 77, 79, 0.14);
background: rgba(255, 77, 79, 0.08);
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-muted-card {
@@ -1155,25 +1195,27 @@ html {
}
.digital-theme-theme2 .dn-icon-box {
border: 1px solid rgba(79, 70, 229, 0.12);
border: 1px solid rgba(255, 77, 79, 0.12);
border-radius: var(--dn-radius-sm);
background: rgba(79, 70, 229, 0.08);
background: rgba(255, 77, 79, 0.08);
background-image: none;
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-card-accent {
background: linear-gradient(90deg, var(--dn-primary), var(--dn-cta), var(--dn-accent));
background: linear-gradient(90deg, #ff4d4f, #ff7a45);
}
.digital-theme-theme2 .dn-counter {
border: 1px solid rgba(79, 70, 229, 0.12);
border: 1px solid rgba(255, 77, 79, 0.12);
border-radius: 999px;
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
background: rgba(255, 77, 79, 0.08);
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-text-link,
.digital-theme-theme2 .text-sky-600,
.digital-theme-theme2 .text-sky-700,
.digital-theme-theme2 .dark\:text-sky-400 {
color: var(--dn-primary);
}
@@ -1182,21 +1224,21 @@ html {
.digital-theme-theme2 .dn-resource-link {
border: 1px solid transparent;
border-radius: var(--dn-radius-sm);
background: rgba(23, 32, 51, 0.035);
background: #f5f6f7;
color: var(--dn-text);
cursor: pointer;
}
.digital-theme-theme2 .dn-tool-link:hover,
.digital-theme-theme2 .dn-resource-link:hover {
border-color: rgba(79, 70, 229, 0.22);
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
border-color: rgba(255, 77, 79, 0.22);
background: rgba(255, 77, 79, 0.06);
color: var(--dn-primary);
}
.dark .digital-theme-theme2 .dn-tool-link,
.dark .digital-theme-theme2 .dn-resource-link {
background: rgba(255, 255, 255, 0.045);
background: rgba(255, 255, 255, 0.05);
}
.digital-theme-theme2 .dn-subsection-title {
@@ -1243,8 +1285,8 @@ html {
}
.digital-theme-theme2 .dn-tab-active {
background: rgba(79, 70, 229, 0.12);
color: var(--dn-primary-strong);
background: rgba(255, 77, 79, 0.12);
color: var(--dn-primary);
}
.digital-theme-theme2 .form-input,
@@ -1264,7 +1306,7 @@ html {
.digital-theme-theme2 .form-input:focus {
border-color: var(--dn-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.12);
}
}
@@ -1278,23 +1320,58 @@ html {
.digital-theme-theme2 input:focus-visible,
.digital-theme-theme2 textarea:focus-visible,
.digital-theme-theme2 select:focus-visible {
outline: 3px solid rgba(79, 70, 229, 0.28);
outline: 3px solid rgba(255, 77, 79, 0.28);
outline-offset: 2px;
}
.digital-theme-theme2 .bg-sky-500,
.digital-theme-theme2 .hover\:bg-sky-500:hover,
.digital-theme-theme2 .dark\:bg-sky-600 {
background-color: var(--dn-cta);
}
.digital-theme-theme2 .hover\:bg-sky-600:hover,
.digital-theme-theme2 .dark\:hover\:bg-sky-500:hover {
background-color: var(--dn-cta-strong);
}
.digital-theme-theme2 .bg-sky-50 {
background-color: rgba(255, 237, 213, 0.85);
}
.dark .digital-theme-theme2 .dark\:bg-sky-900\/50 {
background-color: rgba(127, 29, 29, 0.28);
}
.digital-theme-theme2 .border-sky-200,
.digital-theme-theme2 .hover\:border-sky-300:hover {
border-color: rgba(255, 77, 79, 0.22);
}
.dark .digital-theme-theme2 .dark\:border-sky-800,
.dark .digital-theme-theme2 .dark\:hover\:border-sky-600:hover {
border-color: rgba(255, 77, 79, 0.28);
}
.digital-theme-theme2 .hover\:text-sky-600:hover,
.digital-theme-theme2 .group:hover .group-hover\:text-sky-600,
.dark .digital-theme-theme2 .dark\:group-hover\:text-sky-400 {
color: var(--dn-primary);
}
@media (max-width: 640px) {
.digital-theme-theme2 .dn-header {
top: 8px;
top: 0;
}
.digital-theme-theme2 .dn-header > .dn-container {
width: calc(100% - 1rem);
height: 56px;
width: 100%;
height: 4rem;
}
.digital-theme-theme2 .dn-hero {
min-height: auto;
padding-top: 7.5rem;
padding-top: 3rem;
}
.digital-theme-theme2 .dn-section {

28
app/manifest.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Nomadro 数字游民指南",
short_name: "Nomadro",
description: "数字游民的一站式生活操作系统。",
start_url: "/zh/digital",
scope: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ff4d4f",
icons: [
{
src: "/globe.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
{
src: "/globe.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "maskable",
},
],
};
}