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

@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import { LocaleProvider } from "../../digital/context/LocaleContext";
import type { Locale } from "../../digital/context/LocaleContext";
import { getThemeConfig } from "@/config/digital";
import { getDigitalVisualThemeConfig, getThemeConfig } from "@/config/digital";
import { getThemeMessages } from "@/app/digital/lib/theme-data";
import DigitalProviders from "@/app/digital/DigitalProviders";
@@ -33,11 +34,18 @@ export default async function LocaleLayout({
}
const messages = await getThemeMessages(locale as Locale);
const visualTheme = getDigitalVisualThemeConfig();
return (
<LocaleProvider locale={locale as Locale} messages={messages}>
<DigitalProviders>
<div className="digital-scope">{children}</div>
<div
className={`digital-scope ${visualTheme.className}`}
data-digital-visual-theme={visualTheme.id}
style={visualTheme.cssVars as CSSProperties}
>
{children}
</div>
</DigitalProviders>
</LocaleProvider>
);

View File

@@ -0,0 +1,400 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useLocale, useTranslation } from "@/app/digital/i18n/navigation";
import AuthModal from "@/app/digital/components/AuthModal";
import DigitalThemeIcon, { type IconName } from "@/app/digital/components/DigitalThemeIcon";
import { getStoredToken, getStoredUser, pbLogout, pbSaveAuth } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
type ProfileUser = {
id: string;
email: string;
};
type AccountState = {
user: ProfileUser | null;
vip: boolean;
expiresAt: string | null;
};
type ActionItem = {
icon: IconName;
emoji: string;
title: string;
description: string;
href: string;
tag: string;
external?: boolean;
};
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
return (local || "?").slice(0, 1).toUpperCase();
}
function formatDate(value: string, locale: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale === "zh" ? "zh-CN" : "en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
function ActionCard({ item }: { item: ActionItem }) {
const content = (
<>
<div className="dn-icon-box flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300">
<DigitalThemeIcon name={item.icon} emoji={item.emoji} className="h-5 w-5 text-xl" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-3">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-slate-50">{item.title}</h3>
<span className="dn-status-pill shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{item.tag}
</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
</>
);
const className =
"dn-action-card group flex min-h-[150px] items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 text-left shadow-sm transition-all hover:border-sky-300 hover:shadow-md dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600";
if (item.external) {
return (
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
{content}
</a>
);
}
return (
<Link href={item.href} className={className}>
{content}
</Link>
);
}
export default function ProfileContent() {
const locale = useLocale();
const { t } = useTranslation("profile");
const { vipUrl } = useCrossSiteUrls();
const [account, setAccount] = useState<AccountState>({
user: null,
vip: false,
expiresAt: null,
});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const refreshAccount = useCallback(async (manual = false) => {
if (manual) setRefreshing(true);
else setLoading(true);
try {
const meRes = await fetch("/api/digital/auth/me", {
credentials: "include",
cache: "no-store",
});
const meData = await meRes.json().catch(() => null);
let user = meData?.user?.id && meData?.user?.email
? { id: meData.user.id as string, email: meData.user.email as string }
: null;
if (!user) {
const storedUser = getStoredUser();
const storedToken = getStoredToken();
if (storedUser && storedToken) {
user = { id: storedUser.id, email: storedUser.email };
await fetch("/api/digital/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: storedToken, record: storedUser }),
credentials: "include",
}).catch(() => null);
} else {
setAccount({ user: null, vip: false, expiresAt: null });
return;
}
}
if (meData?.token) {
pbSaveAuth(meData.token, user);
}
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(user.email)}`, {
credentials: "include",
cache: "no-store",
});
const vipData = await vipRes.json().catch(() => null);
setAccount({
user,
vip: vipData?.vip === true,
expiresAt: typeof vipData?.expires_at === "string" ? vipData.expires_at : null,
});
} catch {
const storedUser = getStoredUser();
setAccount({
user: storedUser ? { id: storedUser.id, email: storedUser.email } : null,
vip: false,
expiresAt: null,
});
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
refreshAccount();
}, [refreshAccount]);
const handleLogout = async () => {
await pbLogout();
setAccount({ user: null, vip: false, expiresAt: null });
window.dispatchEvent(new CustomEvent("auth:updated"));
};
const primaryActions = useMemo<ActionItem[]>(() => [
{
icon: "globe",
emoji: "🌐",
title: t("actions.community.title"),
description: t("actions.community.description"),
href: vipUrl,
tag: account.vip ? t("tags.active") : t("tags.apply"),
external: true,
},
{
icon: "course",
emoji: "🎓",
title: t("actions.course.title"),
description: t("actions.course.description"),
href: "/course",
tag: account.vip ? t("tags.unlocked") : t("tags.trial"),
},
{
icon: "book",
emoji: "📚",
title: t("actions.ebook.title"),
description: t("actions.ebook.description"),
href: "/ebook",
tag: t("tags.ready"),
},
{
icon: "phone",
emoji: "📱",
title: t("actions.app.title"),
description: t("actions.app.description"),
href: "/app",
tag: t("tags.download"),
},
], [account.vip, t, vipUrl]);
const workflowActions = useMemo<ActionItem[]>(() => [
{
icon: "compass",
emoji: "🧭",
title: t("workflow.roadmap.title"),
description: t("workflow.roadmap.description"),
href: "/#roadmap",
tag: t("tags.start"),
},
{
icon: "tool",
emoji: "🛠️",
title: t("workflow.tools.title"),
description: t("workflow.tools.description"),
href: "/#tools",
tag: t("tags.open"),
},
{
icon: "file",
emoji: "📄",
title: t("workflow.resources.title"),
description: t("workflow.resources.description"),
href: "/#resources",
tag: t("tags.curated"),
},
{
icon: "check",
emoji: "✅",
title: t("workflow.privacy.title"),
description: t("workflow.privacy.description"),
href: "/privacy",
tag: t("tags.policy"),
},
], [t]);
if (loading) {
return (
<section className="dn-profile-page dn-section min-h-[70vh] px-4 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-5xl">
<div className="dn-card rounded-2xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="h-4 w-32 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700" />
<div className="mt-5 h-10 w-2/3 animate-pulse rounded-lg bg-slate-200 dark:bg-slate-700" />
<div className="mt-4 h-4 w-full animate-pulse rounded-full bg-slate-100 dark:bg-slate-800" />
</div>
</div>
</section>
);
}
if (!account.user) {
return (
<section className="dn-profile-page dn-section min-h-[78vh] px-4 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-4xl">
<div className="dn-account-hero rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900 sm:p-10">
<div className="dn-icon-box mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-300">
<DigitalThemeIcon name="user" emoji="👤" className="h-7 w-7 text-3xl" />
</div>
<p className="dn-badge mx-auto mt-6 inline-flex rounded-full border border-sky-100 bg-sky-50 px-4 py-1.5 text-sm font-semibold text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300">
{t("signedOutBadge")}
</p>
<h1 className="dn-section-title mt-4 text-3xl font-bold text-slate-950 dark:text-white sm:text-4xl">
{t("signedOutTitle")}
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base leading-7 text-slate-500 dark:text-slate-400">
{t("signedOutDescription")}
</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={() => setAuthOpen(true)}
className="dn-button-primary inline-flex items-center justify-center rounded-full bg-sky-500 px-6 py-3 text-sm font-semibold text-white transition hover:bg-sky-600"
>
{t("loginCta")}
</button>
<Link
href="/"
className="dn-button-secondary inline-flex items-center justify-center rounded-full border border-slate-200 px-6 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{t("homeCta")}
</Link>
</div>
</div>
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => refreshAccount(true)}
/>
</section>
);
}
const expiryText = account.vip
? account.expiresAt
? formatDate(account.expiresAt, locale)
: t("lifetime")
: t("notVip");
return (
<section className="dn-profile-page dn-section px-4 pb-16 pt-32 sm:px-6 lg:px-8">
<div className="dn-container mx-auto max-w-6xl">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="dn-account-hero rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900 sm:p-8">
<p className="dn-badge inline-flex rounded-full border border-sky-100 bg-sky-50 px-4 py-1.5 text-sm font-semibold text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300">
{t("eyebrow")}
</p>
<h1 className="dn-section-title mt-5 text-3xl font-bold text-slate-950 dark:text-white sm:text-4xl">
{t("title")}
</h1>
<p className="mt-4 max-w-2xl text-base leading-7 text-slate-500 dark:text-slate-400">
{t("description")}
</p>
<div className="mt-7 flex flex-wrap gap-3">
<span className="dn-status-pill rounded-full bg-emerald-50 px-3 py-1.5 text-sm font-semibold text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300">
{t("loggedIn")}
</span>
<span className="dn-status-pill rounded-full bg-amber-50 px-3 py-1.5 text-sm font-semibold text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
{account.vip ? t("vipActive") : t("vipInactive")}
</span>
<span className="dn-status-pill rounded-full bg-slate-100 px-3 py-1.5 text-sm font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{t("expiry")}: {expiryText}
</span>
</div>
</div>
<aside
id="account"
className="dn-account-panel rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900"
>
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-sky-500 text-xl font-bold text-white">
{getAvatarLetter(account.user.email)}
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold text-slate-900 dark:text-white">{account.user.email}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("accountId")}</p>
</div>
</div>
<dl className="mt-6 space-y-4 text-sm">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("loginStatus")}</dt>
<dd className="font-semibold text-emerald-600 dark:text-emerald-300">{t("normal")}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("vipStatus")}</dt>
<dd className="font-semibold text-slate-900 dark:text-white">{account.vip ? t("enabled") : t("disabled")}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-500 dark:text-slate-400">{t("vipExpiry")}</dt>
<dd className="max-w-[170px] truncate font-semibold text-slate-900 dark:text-white">{expiryText}</dd>
</div>
</dl>
<div className="mt-6 grid gap-3">
<button
type="button"
onClick={() => refreshAccount(true)}
disabled={refreshing}
className="dn-button-secondary inline-flex items-center justify-center rounded-full border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-60 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
{refreshing ? t("refreshing") : t("refresh")}
</button>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-full border border-red-100 px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 dark:border-red-900/40 dark:text-red-300 dark:hover:bg-red-950/30"
>
{t("logout")}
</button>
</div>
</aside>
</div>
<section className="mt-10">
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<p className="text-sm font-semibold text-sky-600 dark:text-sky-300">{t("accessEyebrow")}</p>
<h2 className="dn-section-title mt-2 text-2xl font-bold text-slate-950 dark:text-white">{t("accessTitle")}</h2>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{primaryActions.map((item) => (
<ActionCard key={item.title} item={item} />
))}
</div>
</section>
<section id="resources" className="mt-10">
<div className="mb-4">
<p className="text-sm font-semibold text-sky-600 dark:text-sky-300">{t("workflowEyebrow")}</p>
<h2 className="dn-section-title mt-2 text-2xl font-bold text-slate-950 dark:text-white">{t("workflowTitle")}</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
{workflowActions.map((item) => (
<ActionCard key={item.title} item={item} />
))}
</div>
</section>
</div>
</section>
);
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import Header from "../../../digital/components/Header";
import Footer from "../../../digital/components/Footer";
import ProfileContent from "./ProfileContent";
export const metadata: Metadata = {
title: "个人资料 | Profile",
description: "数字游民指南账户中心,管理登录状态、VIP 权益和功能入口。",
};
export default function ProfilePage() {
return (
<>
<Header />
<main>
<ProfileContent />
</main>
<Footer />
</>
);
}

View File

@@ -29,10 +29,12 @@ export default memo(function SiteMenu({
const { t } = useTranslation("menu");
const pathname = usePathname();
const ref = useRef<HTMLDivElement>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(() => getStoredUserEmail());
useEffect(() => {
setUserEmail(getStoredUserEmail());
const onAuthUpdated = () => setUserEmail(getStoredUserEmail());
window.addEventListener("auth:updated", onAuthUpdated);
return () => window.removeEventListener("auth:updated", onAuthUpdated);
}, []);
useEffect(() => {
@@ -94,8 +96,8 @@ export default memo(function SiteMenu({
];
const profileItems: MenuItem[] = [
{ labelKey: "yourProfile", href: "/join", icon: "👤" },
{ labelKey: "settings", href: "/join", icon: "⚙️" },
{ labelKey: "yourProfile", href: "/digital/profile", icon: "👤" },
{ labelKey: "settings", href: "/settings/notifications", icon: "⚙️" },
{ labelKey: "favorites", href: "/", icon: "❤️" },
{ labelKey: "pricing", href: "/pricing", icon: "👑" },
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
@@ -196,11 +198,12 @@ export default memo(function SiteMenu({
</div>
{/* Footer */}
{userEmail && (
<div className="p-4 border-t border-gray-100 dark:border-gray-800">
<div className="p-4 border-t border-gray-100 dark:border-gray-800">
{userEmail ? (
<button
onClick={() => {
pbLogout();
setUserEmail(null);
onClose();
}}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
@@ -208,8 +211,19 @@ export default memo(function SiteMenu({
<span>🚪</span>
<span>{t("logout")}</span>
</button>
</div>
)}
) : (
<button
onClick={() => {
onLoginClick();
onClose();
}}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
>
<span>👤</span>
<span>{t("login")}</span>
</button>
)}
</div>
</div>
</>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/i18n/navigation";
import { Link, useTranslation } from "@/i18n/navigation";
import { pbLogout } from "@/app/lib/pocketbase";
function getAvatarLetter(email: string): string {
@@ -16,9 +16,9 @@ interface UserAvatarProps {
onLogout?: () => void;
}
/** 圆形头像 + VIP 标识 + 点击展开退出按钮 */
/** 圆形头像 + 账户菜单 */
export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) {
const { t } = useTranslation("common");
const { t } = useTranslation("menu");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -41,8 +41,9 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-gray-900 hover:bg-[#ff7a45]"
aria-label={t("logout")}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white transition hover:bg-[#ff7a45] dark:ring-gray-900"
aria-label={t("yourProfile")}
aria-expanded={open}
>
{getAvatarLetter(email)}
</button>
@@ -52,11 +53,42 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
</span>
)}
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="absolute right-0 top-full z-50 mt-2 w-[260px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">{email}</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{isVip ? "VIP" : t("yourProfile")}</p>
</div>
<div className="px-2 py-2">
<Link
href="/digital/profile"
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
>
<span className="w-5 text-center">👤</span>
<span>{t("yourProfile")}</span>
</Link>
<Link
href="/settings/notifications"
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
>
<span className="w-5 text-center">⚙️</span>
<span>{t("settings")}</span>
</Link>
<Link
href="/pricing"
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
>
<span className="w-5 text-center">👑</span>
<span>{t("pricing")}</span>
</Link>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
className="flex w-full items-center gap-2 border-t border-gray-100 px-5 py-3 text-left text-sm font-semibold text-red-600 transition hover:bg-red-50 dark:border-gray-700 dark:text-red-300 dark:hover:bg-red-950/30"
>
<span className="w-5 text-center">🚪</span>
{t("logout")}
</button>
</div>

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>

View File

@@ -852,3 +852,464 @@ html {
background: #334155;
}
}
/* ==================== Digital Visual Theme 2 ==================== */
.digital-scope.digital-theme-theme2 {
--background: var(--dn-bg);
--foreground: var(--dn-text);
--card: var(--dn-surface);
--card-foreground: var(--dn-text);
--muted: var(--dn-bg-soft);
--muted-foreground: var(--dn-text-muted);
--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;
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;
}
.digital-theme-theme2 .dn-container {
max-width: 1180px;
}
.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);
backdrop-filter: blur(18px);
pointer-events: auto;
}
.dark .digital-theme-theme2 .dn-header > .dn-container {
border-color: var(--dn-border);
background: rgba(17, 28, 44, 0.9);
}
.digital-theme-theme2 .dn-header-scrolled {
background: transparent;
box-shadow: none;
}
.digital-theme-theme2 .dn-brand {
color: var(--dn-text);
font-family: var(--dn-font-display), var(--dn-font-body), sans-serif;
letter-spacing: 0;
}
.digital-theme-theme2 .dn-brand svg,
.digital-theme-theme2 .dn-section-title svg,
.digital-theme-theme2 .dn-subsection-title svg {
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-nav {
gap: 0.25rem;
}
.digital-theme-theme2 .dn-nav-link,
.digital-theme-theme2 .dn-header-control,
.digital-theme-theme2 .dn-icon-button {
border-radius: var(--dn-radius-sm);
color: var(--dn-text-muted);
cursor: pointer;
}
.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);
}
.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);
}
.digital-theme-theme2 .dn-hero {
min-height: min(760px, 95vh);
padding-top: 8.75rem;
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));
}
.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));
}
.digital-theme-theme2 .dn-hero-glow {
display: none;
}
.digital-theme-theme2 .dn-hero-title,
.digital-theme-theme2 .dn-section-title {
color: var(--dn-text);
font-family: var(--dn-font-display), var(--dn-font-body), sans-serif;
font-weight: 800;
letter-spacing: 0;
}
.digital-theme-theme2 .dn-hero-title {
max-width: 760px;
line-height: 1.02;
}
.digital-theme-theme2 .dn-hero-copy,
.digital-theme-theme2 .dn-section-copy,
.digital-theme-theme2 .dn-card p,
.digital-theme-theme2 footer p {
color: var(--dn-text-muted);
}
.digital-theme-theme2 .gradient-text {
background: linear-gradient(120deg, var(--dn-primary), var(--dn-cta), var(--dn-accent));
-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;
}
.dark .digital-theme-theme2 .dn-badge {
border-color: rgba(139, 147, 255, 0.24);
background: rgba(17, 28, 44, 0.76);
}
.digital-theme-theme2 .dn-button-primary {
border: 1px solid transparent;
border-radius: var(--dn-radius);
background: var(--dn-cta);
color: #ffffff;
box-shadow: 0 14px 30px rgba(15, 159, 122, 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);
}
.digital-theme-theme2 .dn-button-secondary {
border-color: var(--dn-border);
border-radius: var(--dn-radius);
background: var(--dn-surface);
color: var(--dn-text);
box-shadow: none;
cursor: pointer;
}
.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);
}
.digital-theme-theme2 .dn-button-accent {
border-radius: var(--dn-radius);
background: var(--dn-accent);
color: #ffffff;
}
.digital-theme-theme2 .dn-section {
background: transparent;
padding-top: 5.5rem;
padding-bottom: 5.5rem;
}
.digital-theme-theme2 .dn-roadmap {
background: linear-gradient(180deg, rgba(238, 247, 243, 0.72), rgba(247, 245, 239, 0.78));
}
.dark .digital-theme-theme2 .dn-roadmap {
background: linear-gradient(180deg, rgba(22, 34, 53, 0.72), rgba(15, 23, 36, 0.76));
}
.digital-theme-theme2 .dn-card,
.digital-theme-theme2 .community-card,
.digital-theme-theme2 .resource-card {
border-color: var(--dn-border);
border-radius: var(--dn-radius);
background: var(--dn-surface);
box-shadow: var(--dn-shadow);
}
.digital-theme-theme2 .dn-account-hero,
.digital-theme-theme2 .dn-account-panel,
.digital-theme-theme2 .dn-action-card,
.digital-theme-theme2 .dn-profile-menu {
border-color: var(--dn-border);
border-radius: var(--dn-radius);
background: var(--dn-surface);
box-shadow: var(--dn-shadow);
}
.digital-theme-theme2 .dn-card:hover,
.digital-theme-theme2 .card-hover:hover,
.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);
box-shadow: var(--dn-shadow-hover);
transform: translateY(-2px);
}
.digital-theme-theme2 .dn-profile-page {
color: var(--dn-text);
}
.digital-theme-theme2 .dn-profile-menu {
overflow: hidden;
}
.digital-theme-theme2 .dn-profile-menu-item {
border-radius: var(--dn-radius-sm);
color: var(--dn-text);
cursor: pointer;
}
.digital-theme-theme2 .dn-profile-menu-item:hover {
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
}
.digital-theme-theme2 .dn-avatar-button {
background: var(--dn-primary);
}
.digital-theme-theme2 .dn-avatar-button:hover {
background: var(--dn-primary-strong);
}
.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);
}
.digital-theme-theme2 .dn-muted-card {
background: color-mix(in srgb, var(--dn-bg-soft) 84%, white);
box-shadow: none;
}
.dark .digital-theme-theme2 .dn-muted-card {
background: color-mix(in srgb, var(--dn-bg-soft) 86%, black);
}
.digital-theme-theme2 .rounded-2xl,
.digital-theme-theme2 .rounded-xl {
border-radius: var(--dn-radius);
}
.digital-theme-theme2 .dn-icon-box {
border: 1px solid rgba(79, 70, 229, 0.12);
border-radius: var(--dn-radius-sm);
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-card-accent {
background: linear-gradient(90deg, var(--dn-primary), var(--dn-cta), var(--dn-accent));
}
.digital-theme-theme2 .dn-counter {
border: 1px solid rgba(79, 70, 229, 0.12);
border-radius: 999px;
background: rgba(79, 70, 229, 0.08);
color: var(--dn-primary-strong);
}
.digital-theme-theme2 .dn-text-link,
.digital-theme-theme2 .text-sky-600,
.digital-theme-theme2 .dark\:text-sky-400 {
color: var(--dn-primary);
}
.digital-theme-theme2 .dn-tool-link,
.digital-theme-theme2 .dn-resource-link {
border: 1px solid transparent;
border-radius: var(--dn-radius-sm);
background: rgba(23, 32, 51, 0.035);
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);
}
.dark .digital-theme-theme2 .dn-tool-link,
.dark .digital-theme-theme2 .dn-resource-link {
background: rgba(255, 255, 255, 0.045);
}
.digital-theme-theme2 .dn-subsection-title {
color: var(--dn-text);
font-family: var(--dn-font-display), var(--dn-font-body), sans-serif;
}
.digital-theme-theme2 .dn-resource-tile {
cursor: not-allowed;
}
.digital-theme-theme2 .dn-resource-tile > div {
border-color: var(--dn-border);
border-radius: var(--dn-radius);
background: var(--dn-surface);
}
.digital-theme-theme2 .dn-pagination {
color: var(--dn-text-muted);
}
.digital-theme-theme2 .dn-icon-button {
border-color: var(--dn-border);
background: var(--dn-surface);
}
.digital-theme-theme2 .dn-pan {
overflow: hidden;
}
.digital-theme-theme2 .dn-tabs {
border-color: var(--dn-border);
background: rgba(23, 32, 51, 0.025);
}
.dark .digital-theme-theme2 .dn-tabs {
background: rgba(255, 255, 255, 0.03);
}
.digital-theme-theme2 .dn-tab {
border-radius: var(--dn-radius-sm);
color: var(--dn-text-muted);
cursor: pointer;
}
.digital-theme-theme2 .dn-tab-active {
background: rgba(79, 70, 229, 0.12);
color: var(--dn-primary-strong);
}
.digital-theme-theme2 .form-input,
.digital-theme-theme2 input,
.digital-theme-theme2 textarea,
.digital-theme-theme2 select {
border-color: var(--dn-border);
border-radius: var(--dn-radius-sm);
}
@media (min-width: 640px) {
.digital-theme-theme2 .form-input {
border-color: var(--dn-border);
background: var(--dn-surface);
color: var(--dn-text);
}
.digital-theme-theme2 .form-input:focus {
border-color: var(--dn-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
}
}
.digital-theme-theme2 .dn-footer {
border-color: var(--dn-border);
background: color-mix(in srgb, var(--dn-surface) 84%, transparent);
}
.digital-theme-theme2 a:focus-visible,
.digital-theme-theme2 button:focus-visible,
.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-offset: 2px;
}
@media (max-width: 640px) {
.digital-theme-theme2 .dn-header {
top: 8px;
}
.digital-theme-theme2 .dn-header > .dn-container {
width: calc(100% - 1rem);
height: 56px;
}
.digital-theme-theme2 .dn-hero {
min-height: auto;
padding-top: 7.5rem;
}
.digital-theme-theme2 .dn-section {
padding-top: 4rem;
padding-bottom: 4rem;
}
}
@media (prefers-reduced-motion: reduce) {
.digital-theme-theme2 *,
.digital-theme-theme2 *::before,
.digital-theme-theme2 *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}