s
This commit is contained in:
@@ -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">
|
||||
|
||||
81
app/[locale]/changelog/page.tsx
Normal file
81
app/[locale]/changelog/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
112
app/api/digital/jobs/route.ts
Normal file
112
app/api/digital/jobs/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
352
app/components/ContentSubmissionModal.tsx
Normal file
352
app/components/ContentSubmissionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
285
app/globals.css
285
app/globals.css
@@ -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
28
app/manifest.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user