s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
400
app/[locale]/digital/profile/ProfileContent.tsx
Normal file
400
app/[locale]/digital/profile/ProfileContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
app/[locale]/digital/profile/page.tsx
Normal file
21
app/[locale]/digital/profile/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
222
app/digital/components/DigitalThemeIcon.tsx
Normal file
222
app/digital/components/DigitalThemeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ? "日间模式" : "夜间模式"}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
461
app/globals.css
461
app/globals.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user