This commit is contained in:
root
2026-06-07 15:57:47 +08:00
parent cd885f5fcd
commit 9de2465793
50 changed files with 4942 additions and 169 deletions

View File

@@ -2,9 +2,9 @@
import { useState } from "react";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { TOPIC_IDS } from "@/config/digital";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
import ResourceNavigation from "./ResourceNavigation";
const mainModuleKeys = ["destinations", "vipEntry", "appDownload"] as const;
@@ -20,6 +20,16 @@ const linkIcons: Record<string, string> = {
unmannedLive: "📺",
};
const linkIconNames: Record<string, IconName> = {
destinations: "map",
vipEntry: "spark",
appDownload: "phone",
indieDev: "tool",
aiAgent: "spark",
cloudPhone: "cloud",
unmannedLive: "video",
};
type CommunityProps = {
resourceData?: ResourceData;
};
@@ -41,25 +51,29 @@ export default function Community({ resourceData }: CommunityProps) {
const renderCardContent = (key: string) => (
<>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-xl dark:bg-slate-800">
{linkIcons[key]}
<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">
<DigitalThemeIcon
name={linkIconNames[key] ?? "compass"}
emoji={linkIcons[key]}
className="h-5 w-5"
/>
</span>
<div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-900 dark:text-slate-100">{t(`links.${key}.title`)}</h4>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t(`links.${key}.desc`)}</p>
<span className="mt-2 inline-block text-sm font-medium text-sky-600">{t(`links.${key}.label`)}</span>
<span className="dn-text-link mt-2 inline-block text-sm font-medium text-sky-600">{t(`links.${key}.label`)}</span>
</div>
</>
);
return (
<section id="community" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<section id="community" className="dn-section dn-community py-20 sm:py-28 dark:bg-slate-950">
<div className="dn-container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div id="resources" className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
<h2 className="dn-section-title text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
<span className="gradient-text">{t("resourcesTitle")}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
<p className="dn-section-copy mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("contributeSubtitle")}
<br />
{t("contributeSubtitle2")}
@@ -79,7 +93,7 @@ export default function Community({ resourceData }: CommunityProps) {
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
className="card-hover flex items-start gap-5 rounded-2xl border-2 border-slate-200 bg-white p-6 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600"
className="dn-card card-hover flex items-start gap-5 rounded-2xl border-2 border-slate-200 bg-white p-6 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600"
>
{renderCardContent(key)}
</Link>
@@ -98,10 +112,14 @@ export default function Community({ resourceData }: CommunityProps) {
key={key}
type="button"
onClick={showUpdating}
className="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"
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"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-200 text-lg dark:bg-slate-700">
{linkIcons[key]}
<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
name={linkIconNames[key] ?? "compass"}
emoji={linkIcons[key]}
className="h-5 w-5"
/>
</span>
<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>

View File

@@ -0,0 +1,222 @@
import { CURRENT_DIGITAL_VISUAL_THEME } from "@/config/digital/visual-theme.config";
import type { ReactNode } from "react";
export type IconName =
| "app"
| "book"
| "briefcase"
| "camera"
| "card"
| "check"
| "cloud"
| "compass"
| "course"
| "file"
| "globe"
| "home"
| "map"
| "music"
| "phone"
| "play"
| "server"
| "shopping"
| "spark"
| "tool"
| "user"
| "video"
| "wallet";
type DigitalThemeIconProps = {
name: IconName;
emoji: string;
className?: string;
decorative?: boolean;
};
const paths: Record<IconName, ReactNode> = {
app: (
<>
<rect x="7" y="3" width="10" height="18" rx="2" />
<path d="M10 18h4" />
</>
),
book: (
<>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z" />
<path d="M8 6h8" />
</>
),
camera: (
<>
<path d="M14.5 5 13 3H9L7.5 5H5a2 2 0 0 0-2 2v12h18V7a2 2 0 0 0-2-2h-4.5z" />
<circle cx="12" cy="13" r="4" />
</>
),
card: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 10h18" />
<path d="M7 15h3" />
</>
),
briefcase: (
<>
<path d="M10 6V5a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1" />
<rect x="3" y="6" width="18" height="15" rx="2" />
<path d="M3 12h18" />
</>
),
check: (
<>
<path d="M20 6 9 17l-5-5" />
</>
),
cloud: (
<>
<path d="M17.5 19H7a5 5 0 1 1 1.2-9.85A6.5 6.5 0 0 1 20 12.5 3.5 3.5 0 0 1 17.5 19z" />
</>
),
compass: (
<>
<circle cx="12" cy="12" r="9" />
<path d="m15.5 8.5-2 5-5 2 2-5 5-2z" />
</>
),
course: (
<>
<path d="m3 7 9-4 9 4-9 4-9-4z" />
<path d="M21 10v6" />
<path d="M7 10.5V16c0 1.7 2.7 3 5 3s5-1.3 5-3v-5.5" />
</>
),
file: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M8 13h8" />
<path d="M8 17h6" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a13 13 0 0 1 0 18" />
<path d="M12 3a13 13 0 0 0 0 18" />
</>
),
home: (
<>
<path d="m3 11 9-8 9 8" />
<path d="M5 10v10h14V10" />
<path d="M9 20v-6h6v6" />
</>
),
map: (
<>
<path d="m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3V6z" />
<path d="M9 3v15" />
<path d="M15 6v15" />
</>
),
music: (
<>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</>
),
phone: (
<>
<rect x="7" y="3" width="10" height="18" rx="2" />
<path d="M11 18h2" />
</>
),
play: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m10 9 5 3-5 3V9z" />
</>
),
server: (
<>
<rect x="4" y="4" width="16" height="7" rx="2" />
<rect x="4" y="13" width="16" height="7" rx="2" />
<path d="M8 8h.01" />
<path d="M8 17h.01" />
</>
),
shopping: (
<>
<path d="M6 7h15l-2 8H8L6 3H3" />
<circle cx="9" cy="20" r="1" />
<circle cx="18" cy="20" r="1" />
</>
),
spark: (
<>
<path d="M12 2v6" />
<path d="M12 16v6" />
<path d="M4.93 4.93 9.17 9.17" />
<path d="m14.83 14.83 4.24 4.24" />
<path d="M2 12h6" />
<path d="M16 12h6" />
<path d="m4.93 19.07 4.24-4.24" />
<path d="m14.83 9.17 4.24-4.24" />
</>
),
tool: (
<>
<path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3 18l3 3 6.5-6.5a4 4 0 0 0 5.2-5.2l-2.8 2.8-2.1-2.1 2.9-2.7z" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</>
),
video: (
<>
<rect x="3" y="6" width="14" height="12" rx="2" />
<path d="m17 10 4-2v8l-4-2" />
</>
),
wallet: (
<>
<path d="M4 7h16a1 1 0 0 1 1 1v12H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14" />
<path d="M16 13h2" />
</>
),
};
export default function DigitalThemeIcon({
name,
emoji,
className = "",
decorative = true,
}: DigitalThemeIconProps) {
if (CURRENT_DIGITAL_VISUAL_THEME === "theme1") {
return (
<span className={className} aria-hidden={decorative}>
{emoji}
</span>
);
}
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={decorative}
>
{paths[name]}
</svg>
);
}

View File

@@ -1,23 +1,26 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
import DigitalThemeIcon from "./DigitalThemeIcon";
const featureKeys = ["location", "skill", "balance"] as const;
const featureIcons = {
location: { name: "globe", emoji: "🌏" },
skill: { name: "spark", emoji: "⚡" },
balance: { name: "compass", emoji: "🔄" },
} as const;
export default function Features() {
const { t } = useTranslation("features");
const features = featureKeys.map((key) => ({
icon: key === "location" ? "🌏" : key === "skill" ? "⚡" : "🔄",
key,
}));
const features = featureKeys.map((key) => ({ ...featureIcons[key], key }));
return (
<section id="features" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<section id="features" className="dn-section dn-features py-20 sm:py-28 dark:bg-slate-950">
<div className="dn-container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
<h2 className="dn-section-title text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>{t("titleSuffix")}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
<p className="dn-section-copy mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
<br className="hidden sm:block" />
{t("subtitle2")}
@@ -28,10 +31,10 @@ export default function Features() {
{features.map((f) => (
<div
key={f.key}
className="card-hover rounded-2xl border border-slate-100 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900"
className="dn-card card-hover rounded-2xl border border-slate-100 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl dark:bg-sky-900/50">
{f.icon}
<div className="dn-icon-box mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl dark:bg-sky-900/50">
<DigitalThemeIcon name={f.name} emoji={f.emoji} className="h-7 w-7" />
</div>
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
{t(`items.${f.key}.title`)}

View File

@@ -2,6 +2,7 @@
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import DigitalThemeIcon from "./DigitalThemeIcon";
export default function Footer() {
const { t } = useTranslation("footer");
@@ -43,12 +44,12 @@ export default function Footer() {
},
];
return (
<footer className="border-t border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
<footer className="dn-footer border-t border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="dn-container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-5">
<div className="lg:col-span-1">
<Link href="/" className="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<span className="text-2xl">🌍</span>
<Link href="/" className="dn-brand flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="globe" emoji="🌍" className="h-7 w-7 text-2xl" />
<span>{t("siteName")}</span>
</Link>
<p className="mt-3 text-sm leading-relaxed text-slate-500 dark:text-slate-400">

View File

@@ -5,6 +5,7 @@ import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/app/d
import { getStoredUserEmail } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import AuthModal from "./AuthModal";
import DigitalThemeIcon from "./DigitalThemeIcon";
import ThemeToggle from "./ThemeToggle";
import UserAvatar from "./UserAvatar";
@@ -15,16 +16,18 @@ export default function Header() {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [scrolled, setScrolled] = useState(() =>
typeof window !== "undefined" ? window.scrollY > 8 : false
);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [vip, setVip] = useState(false);
const { vipUrl } = useCrossSiteUrls();
const navLinks = [
{ labelKey: "roadmap" as const, href: "#roadmap" },
{ labelKey: "tools" as const, href: "#tools" },
{ labelKey: "roadmap" as const, href: "/#roadmap" },
{ labelKey: "tools" as const, href: "/#tools" },
{ labelKey: "community" as const, href: vipUrl, external: true },
{ labelKey: "resources" as const, href: "#resources" },
{ labelKey: "resources" as const, href: "/#resources" },
];
const fetchAuthAndVip = useCallback(async () => {
@@ -52,43 +55,62 @@ export default function Header() {
fetchAuthAndVip();
}, [fetchAuthAndVip]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const onVipUpdated = () => fetchAuthAndVip();
const onAuthSuccess = () => fetchAuthAndVip();
const onAuthUpdated = () => fetchAuthAndVip();
window.addEventListener("vip:updated", onVipUpdated);
window.addEventListener("auth:success", onAuthSuccess);
window.addEventListener("auth:updated", onAuthUpdated);
return () => {
window.removeEventListener("vip:updated", onVipUpdated);
window.removeEventListener("auth:success", onAuthSuccess);
window.removeEventListener("auth:updated", onAuthUpdated);
};
}, [fetchAuthAndVip]);
return (
<header
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
className={`dn-header fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
scrolled
? "bg-white/80 backdrop-blur-lg shadow-sm dark:bg-slate-900/80 dark:shadow-slate-950/50"
? "dn-header-scrolled bg-white/80 backdrop-blur-lg shadow-sm dark:bg-slate-900/80 dark:shadow-slate-950/50"
: "bg-transparent"
}`}
>
<div className="mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center justify-between gap-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] lg:pl-[max(2rem,env(safe-area-inset-left))] lg:pr-[max(2rem,env(safe-area-inset-right))]">
<Link href="/" className="flex min-w-0 shrink items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<span className="text-2xl">🌍</span>
<div className="dn-container mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center justify-between gap-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] lg:pl-[max(2rem,env(safe-area-inset-left))] lg:pr-[max(2rem,env(safe-area-inset-right))]">
<Link href="/" className="dn-brand flex min-w-0 shrink items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="globe" emoji="🌍" className="h-7 w-7 text-2xl" />
<span className="hidden sm:inline">{t("siteName")}</span>
<span className="sm:hidden">{t("siteNameShort")}</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
<nav className="dn-nav hidden items-center gap-1 md:flex">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={"external" in link && link.external ? "_blank" : undefined}
rel={"external" in link && link.external ? "noopener noreferrer" : undefined}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
</nav>
@@ -98,7 +120,7 @@ export default function Header() {
) : (
<button
onClick={() => setAuthOpen(true)}
className="hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
className="dn-button-secondary hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
>
{t("loginRegister")}
</button>
@@ -106,7 +128,7 @@ export default function Header() {
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
@@ -115,7 +137,7 @@ export default function Header() {
<ThemeToggle />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
aria-label={t("toggleMenu")}
>
{mobileOpen ? (
@@ -128,29 +150,40 @@ export default function Header() {
</svg>
)}
</button>
<a
href="#roadmap"
className="hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500 sm:inline-flex"
<Link
href="/#roadmap"
className="dn-button-primary hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500 sm:inline-flex"
>
{t("explore")}
</a>
</Link>
</div>
</div>
{mobileOpen && (
<div className="overflow-x-hidden border-t border-slate-100 bg-white/95 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-900/95 md:hidden">
<div className="dn-mobile-menu overflow-x-hidden border-t border-slate-100 bg-white/95 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-900/95 md:hidden">
<nav className="mx-auto w-full max-w-7xl space-y-1 px-4 py-3 pr-[max(1rem,env(safe-area-inset-right))]">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={"external" in link && link.external ? "_blank" : undefined}
rel={"external" in link && link.external ? "noopener noreferrer" : undefined}
onClick={() => setMobileOpen(false)}
className="block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
<button
type="button"
@@ -163,9 +196,16 @@ export default function Header() {
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<div className="dn-mobile-account mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className="min-w-0 flex-1"
>
<span className="block truncate text-sm font-semibold text-slate-700 dark:text-slate-200">{userEmail}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{t("profile")}</span>
</Link>
</div>
) : (
<button
@@ -173,18 +213,18 @@ export default function Header() {
setMobileOpen(false);
setAuthOpen(true);
}}
className="mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
className="dn-button-secondary mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
>
{t("loginRegister")}
</button>
)}
<a
href="#roadmap"
<Link
href="/#roadmap"
onClick={() => setMobileOpen(false)}
className="mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
className="dn-button-primary mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
>
{t("explore")}
</a>
</Link>
</nav>
</div>
)}

View File

@@ -1,28 +1,29 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
import DigitalThemeIcon from "./DigitalThemeIcon";
export default function Hero() {
const { t } = useTranslation("hero");
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-32 sm:pb-20">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-sky-50/80 via-white to-white dark:from-slate-900 dark:via-slate-900 dark:to-slate-950" />
<div className="pointer-events-none absolute top-0 left-1/2 h-[600px] w-[800px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/60 via-cyan-50/40 to-amber-50/30 blur-3xl dark:from-sky-900/20 dark:via-slate-800/30 dark:to-amber-900/10" />
<section className="dn-hero relative overflow-hidden pt-24 pb-16 sm:pt-32 sm:pb-20">
<div className="dn-hero-bg pointer-events-none absolute inset-0 bg-gradient-to-b from-sky-50/80 via-white to-white dark:from-slate-900 dark:via-slate-900 dark:to-slate-950" />
<div className="dn-hero-glow pointer-events-none absolute top-0 left-1/2 h-[600px] w-[800px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/60 via-cyan-50/40 to-amber-50/30 blur-3xl dark:from-sky-900/20 dark:via-slate-800/30 dark:to-amber-900/10" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="dn-container relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
<span>🎒</span>
<div className="dn-badge mb-6 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
<DigitalThemeIcon name="briefcase" emoji="🎒" className="h-4 w-4" />
<span>{t("badge")}</span>
</div>
<h1 className="mx-auto max-w-4xl text-4xl font-extrabold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl lg:text-7xl">
<h1 className="dn-hero-title mx-auto max-w-4xl text-4xl font-extrabold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl lg:text-7xl">
<span className="gradient-text">{t("title")}</span>
<br className="sm:hidden" />
{t("titleSuffix")}
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-slate-600 dark:text-slate-400 sm:text-xl">
<p className="dn-hero-copy mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-slate-600 dark:text-slate-400 sm:text-xl">
{t("subtitle")}
<br className="hidden sm:block" />
<span className="text-slate-400 dark:text-slate-500 sm:text-slate-600">
@@ -33,14 +34,14 @@ export default function Hero() {
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
<a
href="#roadmap"
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-sky-500 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30 sm:w-auto"
className="dn-button-primary inline-flex w-full items-center justify-center gap-2 rounded-full bg-sky-500 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30 sm:w-auto"
>
{t("ctaStart")}
<span></span>
</a>
<a
href="#community"
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-8 py-3.5 text-base font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700 sm:w-auto"
className="dn-button-secondary inline-flex w-full items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-8 py-3.5 text-base font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700 sm:w-auto"
>
{t("ctaResources")}
</a>

View File

@@ -3,18 +3,26 @@
import { useState, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
const PER_PAGE_MOBILE = 4;
const PER_PAGE_PC = 8;
const BREAKPOINT = 768;
const panCategoryIconNames: Record<string, IconName> = {
docs: "file",
templates: "check",
firmware: "spark",
storage: "cloud",
};
function useIsPC() {
return useSyncExternalStore(
(callback) => {
if (typeof window === "undefined") return () => {};
const mq = window.matchMedia(`(min-width: ${BREAKPOINT}px)`);
const mq = window.matchMedia(`(min-width: ${BREAKPOINT}px)`);
const fn = () => callback();
mq.addEventListener("change", fn);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
},
() =>
@@ -68,11 +76,11 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
}, [data.products, safeProductsPage, productsPerPage]);
return (
<div className="mt-12 space-y-16">
<div className="dn-resources mt-12 space-y-16">
{/* 图书 - 封面+书名,数量少则大封面,多则分页 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span>📚</span>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="book" emoji="📚" className="h-5 w-5" />
{t("resourceSections.books")}
{data.books.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
@@ -88,7 +96,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
key={`book-${booksPage}-${i}`}
type="button"
onClick={showUpdating}
className="group flex cursor-not-allowed flex-col text-left"
className="dn-resource-tile group flex cursor-not-allowed flex-col text-left"
>
<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"
@@ -107,12 +115,12 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
))}
</div>
{booksTotalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<div className="dn-pagination mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setBooksPage((p) => Math.max(0, p - 1))}
disabled={safeBooksPage === 0}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -126,7 +134,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
type="button"
onClick={() => setBooksPage((p) => Math.min(booksTotalPages - 1, p + 1))}
disabled={safeBooksPage === booksTotalPages - 1}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -139,8 +147,8 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
{/* 商业数码 - 商品图+跳转电商 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span>🛒</span>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="shopping" emoji="🛒" className="h-5 w-5" />
{t("resourceSections.products")}
{data.products.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
@@ -156,7 +164,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
key={`product-${productsPage}-${i}`}
type="button"
onClick={showUpdating}
className="group flex cursor-not-allowed flex-col text-left"
className="dn-resource-tile group flex cursor-not-allowed flex-col text-left"
>
<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"
@@ -175,12 +183,12 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
))}
</div>
{productsTotalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<div className="dn-pagination mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setProductsPage((p) => Math.max(0, p - 1))}
disabled={safeProductsPage === 0}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -194,7 +202,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
type="button"
onClick={() => setProductsPage((p) => Math.min(productsTotalPages - 1, p + 1))}
disabled={safeProductsPage === productsTotalPages - 1}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
className="dn-icon-button flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -207,24 +215,28 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
{/* 网盘 - 分类 Tab */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span></span>
<h3 className="dn-subsection-title mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="cloud" emoji="☁️" className="h-5 w-5" />
{t("resourceSections.pan")}
</h3>
<div className="rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<div className="flex flex-wrap gap-1 border-b border-slate-200 p-2 dark:border-slate-700">
<div className="dn-card dn-pan rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<div className="dn-tabs flex flex-wrap gap-1 border-b border-slate-200 p-2 dark:border-slate-700">
{data.pan.categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setActivePanCategory(cat.id)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
className={`dn-tab rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activePanCategory === cat.id
? "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
? "dn-tab-active bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span className="mr-1.5">{cat.icon}</span>
<DigitalThemeIcon
name={panCategoryIconNames[cat.id] ?? "file"}
emoji={cat.icon}
className="mr-1.5 inline h-4 w-4"
/>
{cat.title}
</button>
))}
@@ -243,10 +255,14 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
href={item.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex items-center gap-3 rounded-xl border border-slate-100 p-3 transition-colors hover:border-sky-200 hover:bg-sky-50/50 dark:border-slate-700 dark:hover:border-sky-800 dark:hover:bg-sky-900/20"
className="dn-resource-link flex items-center gap-3 rounded-xl border border-slate-100 p-3 transition-colors hover:border-sky-200 hover:bg-sky-50/50 dark:border-slate-700 dark:hover:border-sky-800 dark:hover:bg-sky-900/20"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-lg dark:bg-slate-800">
{activeCategory.icon}
<div className="dn-icon-box flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-lg dark:bg-slate-800">
<DigitalThemeIcon
name={panCategoryIconNames[activeCategory.id] ?? "file"}
emoji={activeCategory.icon}
className="h-5 w-5"
/>
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-900 dark:text-slate-100">{item.name}</div>

View File

@@ -1,27 +1,28 @@
"use client";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import DigitalThemeIcon from "./DigitalThemeIcon";
const days = [
{ day: 1, icon: "👋", color: "bg-sky-500" },
{ day: 2, icon: "💡", color: "bg-cyan-500" },
{ day: 3, icon: "💰", color: "bg-teal-500" },
{ day: 4, icon: "🛠️", color: "bg-emerald-500" },
{ day: 5, icon: "📋", color: "bg-amber-500" },
{ day: 6, icon: "🗺️", color: "bg-orange-500" },
{ day: 7, icon: "🚀", color: "bg-rose-500" },
];
{ day: 1, icon: "👋", iconName: "user", color: "bg-sky-500" },
{ day: 2, icon: "💡", iconName: "spark", color: "bg-cyan-500" },
{ day: 3, icon: "💰", iconName: "wallet", color: "bg-teal-500" },
{ day: 4, icon: "🛠️", iconName: "tool", color: "bg-emerald-500" },
{ day: 5, icon: "📋", iconName: "check", color: "bg-amber-500" },
{ day: 6, icon: "🗺️", iconName: "map", color: "bg-orange-500" },
{ day: 7, icon: "🚀", iconName: "compass", color: "bg-rose-500" },
] as const;
export default function Roadmap() {
const { t } = useTranslation("roadmap");
return (
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<section id="roadmap" className="dn-section dn-roadmap bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
<div className="dn-container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
<h2 className="dn-section-title text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
<p className="dn-section-copy mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
</p>
</div>
@@ -36,7 +37,7 @@ export default function Roadmap() {
<Link
key={d.day}
href={`/day/${d.day}`}
className="card-hover group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
className="dn-card dn-day-card card-hover group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
>
<div className="flex items-center gap-3">
<div
@@ -44,7 +45,7 @@ export default function Roadmap() {
>
DAY {d.day}
</div>
<span className="text-xl">{d.icon}</span>
<DigitalThemeIcon name={d.iconName} emoji={d.icon} className="h-5 w-5 text-xl text-slate-500 dark:text-slate-300" />
<h3 className="text-base font-bold text-slate-900 group-hover:text-sky-600 dark:text-slate-100 dark:group-hover:text-sky-400">
{t(`days.${d.day}.title`)}
</h3>
@@ -68,10 +69,10 @@ export default function Roadmap() {
<div className="grid gap-6 sm:grid-cols-2">
<Link
href="/ebook"
className="group flex flex-col overflow-hidden rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-sky-50 to-cyan-50/50 p-8 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:from-sky-900/20 dark:to-cyan-900/10 dark:hover:border-sky-600"
className="dn-card dn-product-card group flex flex-col overflow-hidden rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-sky-50 to-cyan-50/50 p-8 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:from-sky-900/20 dark:to-cyan-900/10 dark:hover:border-sky-600"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-500 text-3xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
📖
<div className="dn-icon-box mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-500 text-3xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
<DigitalThemeIcon name="book" emoji="📖" className="h-7 w-7" />
</div>
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{t("ebook.title")}
@@ -86,10 +87,10 @@ export default function Roadmap() {
<Link
href="/course"
className="group flex w-full flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 text-left shadow-md transition-all hover:border-amber-300 hover:shadow-lg dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10 dark:hover:border-amber-700"
className="dn-card dn-product-card group flex w-full flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 text-left shadow-md transition-all hover:border-amber-300 hover:shadow-lg dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10 dark:hover:border-amber-700"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
🎓
<div className="dn-icon-box mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
<DigitalThemeIcon name="course" emoji="🎓" className="h-7 w-7" />
</div>
<span className="mb-2 inline-block text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
{t("course.tag")}
@@ -100,7 +101,7 @@ export default function Roadmap() {
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
{t("course.desc")}
</p>
<span className="mt-6 inline-flex items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white transition group-hover:bg-amber-600 dark:bg-amber-600 dark:group-hover:bg-amber-500">
<span className="dn-button-accent mt-6 inline-flex items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white transition group-hover:bg-amber-600 dark:bg-amber-600 dark:group-hover:bg-amber-500">
{t("course.cta")}
</span>
</Link>

View File

@@ -9,7 +9,7 @@ export default function ThemeToggle() {
<button
type="button"
onClick={toggleTheme}
className="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
className="dn-header-control rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
aria-label={theme === "dark" ? "切换到日间模式" : "切换到夜间模式"}
title={theme === "dark" ? "日间模式" : "夜间模式"}
>

View File

@@ -2,25 +2,45 @@
import { useTranslation } from "@/app/digital/i18n/navigation";
import type { ToolsCategory } from "@/app/digital/lib/theme-data";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
type ToolsProps = {
data: { categories: ToolsCategory[] };
};
const categoryIconNames: Record<string, IconName> = {
"nomad-community": "user",
toolbox: "tool",
"us-phone": "phone",
finance: "card",
vps: "server",
"residential-ip": "globe",
"residential-vps": "home",
hardware: "tool",
software: "spark",
"digital-gear": "camera",
tiktok: "music",
youtube: "play",
web: "globe",
"live-stream": "video",
payment: "wallet",
forum: "file",
};
export default function Tools({ data }: ToolsProps) {
const categories = data.categories;
const { t } = useTranslation("tools");
return (
<section id="tools" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<section id="tools" className="dn-section dn-tools py-20 sm:py-28 dark:bg-slate-950">
<div className="dn-container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
<div className="dn-badge mb-4 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
{t("badge")}
</div>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
<h2 className="dn-section-title text-3xl font-bold tracking-tight sm:text-4xl">
<span className="gradient-text">{t("title")}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
<p className="dn-section-copy mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
</p>
</div>
@@ -29,16 +49,22 @@ export default function Tools({ data }: ToolsProps) {
{categories.map((cat) => (
<div
key={cat.title}
className="card-hover group overflow-visible rounded-2xl border border-slate-100 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
className="dn-card dn-tool-card card-hover group overflow-visible rounded-2xl border border-slate-100 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className={`h-1.5 bg-gradient-to-r ${cat.color}`} />
<div className={`dn-card-accent h-1.5 bg-gradient-to-r ${cat.color}`} />
<div className="p-5">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{cat.icon}</span>
<span className="dn-icon-box dn-tool-icon flex h-9 w-9 items-center justify-center rounded-lg text-2xl">
<DigitalThemeIcon
name={categoryIconNames[cat.id] ?? "tool"}
emoji={cat.icon}
className="h-5 w-5"
/>
</span>
<h3 className="font-bold text-slate-900 dark:text-slate-100">{cat.title}</h3>
</div>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
<span className={`dn-counter rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
{cat.tools.length}
</span>
</div>
@@ -49,7 +75,7 @@ export default function Tools({ data }: ToolsProps) {
href={tool.href}
target="_blank"
rel="noopener noreferrer"
className="group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:hover:text-sky-400"
className="dn-tool-link group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:hover:text-sky-400"
>
<span className="font-medium text-slate-700 dark:text-slate-300">{tool.name}</span>
{tool.desc && (

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { pbLogout } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
@@ -17,12 +19,29 @@ interface UserAvatarProps {
onRefreshVip?: () => void;
}
/** 圆形头像 + VIP 标识 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
type MenuLink = {
href: string;
label: string;
icon: IconName;
emoji: string;
external?: boolean;
};
/** 圆形头像 + 账户菜单,供 digital / meetup / vip 三站复用 */
export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
const { t } = useTranslation("common");
const { vipUrl } = useCrossSiteUrls();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const menuLinks: MenuLink[] = [
{ href: "/profile", label: t("profile"), icon: "user", emoji: "👤" },
{ href: "/course", label: t("course"), icon: "course", emoji: "🎓" },
{ href: "/ebook", label: t("ebook"), icon: "book", emoji: "📚" },
{ href: "/app", label: t("app"), icon: "phone", emoji: "📱" },
{ href: vipUrl, label: t("community"), icon: "globe", emoji: "🌐", external: true },
];
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
@@ -34,6 +53,9 @@ export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
const handleLogout = async () => {
await pbLogout();
setOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:updated"));
}
onLogout?.();
};
@@ -42,30 +64,67 @@ export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
aria-label={t("logout")}
className="dn-avatar-button relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white transition hover:bg-sky-600 dark:ring-slate-900"
aria-label={t("accountMenu")}
aria-expanded={open}
>
{getAvatarLetter(email)}
{vip && (
<span
className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 text-[10px] text-amber-900"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-400 px-1 text-[9px] font-bold text-amber-900"
title="VIP"
>
👑
VIP
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{vip && (
<div className="border-b border-slate-100 px-4 py-2 text-xs font-medium text-amber-600 dark:border-slate-700 dark:text-amber-400">
👑 VIP
<div className="dn-profile-menu absolute right-0 top-full z-50 mt-2 w-[280px] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-bold text-white">
{getAvatarLetter(email)}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">{email}</p>
<p className="mt-0.5 text-xs font-medium text-slate-500 dark:text-slate-400">
{vip ? t("vipMember") : t("standardMember")}
</p>
</div>
</div>
)}
</div>
<div className="px-2 py-2">
{menuLinks.map((item) =>
item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="dn-profile-menu-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
<DigitalThemeIcon name={item.icon} emoji={item.emoji} className="h-4 w-4 shrink-0 text-base" />
<span>{item.label}</span>
</a>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className="dn-profile-menu-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
<DigitalThemeIcon name={item.icon} emoji={item.emoji} className="h-4 w-4 shrink-0 text-base" />
<span>{item.label}</span>
</Link>
)
)}
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
className="dn-profile-menu-item flex w-full items-center gap-3 border-t border-slate-100 px-5 py-3 text-left text-sm font-semibold text-red-600 transition hover:bg-red-50 dark:border-slate-700 dark:text-red-300 dark:hover:bg-red-950/30"
>
<DigitalThemeIcon name="home" emoji="↩" className="h-4 w-4 shrink-0 text-base" />
{t("logout")}
</button>
</div>