feat(docs): optimize mobile layout and localized metadata

This commit is contained in:
t8y2 2026-07-31 19:45:19 +08:00
parent c43e8455c4
commit ce3bb09796
No known key found for this signature in database
28 changed files with 909 additions and 396 deletions

View File

@ -0,0 +1,38 @@
import "../global.css";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL } from "@/lib/metadata";
export const metadata: Metadata = {
title: SITE_NAME,
description: DEFAULT_DESCRIPTION,
metadataBase: new URL(SITE_URL),
icons: {
icon: "/favicon.png",
shortcut: "/favicon.png",
apple: "/logo.png",
},
openGraph: {
siteName: SITE_NAME,
images: [{ url: DEFAULT_OG_IMAGE }],
},
twitter: {
card: "summary_large_image",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#0b1120",
colorScheme: "dark light",
};
export default function RedirectRootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN">
<body className="flex min-h-screen flex-col">{children}</body>
</html>
);
}

View File

@ -1,4 +1,5 @@
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { ChangelogRuntime } from "@/components/landing/ChangelogRuntime";
import { fetchChangelog } from "@/lib/changelog";
import { buildMetadata } from "@/lib/metadata";
@ -35,17 +36,19 @@ export default async function ChangelogPage({ params }: { params: Promise<{ lang
const initialData = await fetchChangelog(l);
return (
<div className="min-h-screen bg-[#0b1120] text-landing-ink">
<main className="min-h-screen bg-[#0b1120] text-landing-ink">
<LandingNav lang={l} active="changelog" />
<div className="max-w-[860px] mx-auto px-6 pt-32 pb-4">
<div className="max-w-[860px] mx-auto px-6 pt-32 pb-4 max-[760px]:px-[18px] max-[760px]:pt-28">
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
</div>
<div className="max-w-[860px] mx-auto px-6 pb-24">
<div className="max-w-[860px] mx-auto px-6 pb-24 max-[760px]:px-[18px]">
<ChangelogRuntime lang={l} initialReleases={initialData.releases} />
</div>
</div>
<LandingFooter lang={l} />
</main>
);
}

View File

@ -1,4 +1,5 @@
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { buildMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
@ -79,15 +80,15 @@ export default async function CommunityPage({ params }: { params: Promise<{ lang
const t = i18n[l];
return (
<div className="min-h-screen bg-[#0b1120] text-landing-ink">
<main className="min-h-screen bg-[#0b1120] text-landing-ink">
<LandingNav lang={l} active="community" />
<div className="max-w-[860px] mx-auto px-6 pt-32 pb-4">
<div className="max-w-[860px] mx-auto px-6 pt-32 pb-4 max-[760px]:px-[18px] max-[760px]:pt-28">
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
</div>
<div className="max-w-[860px] mx-auto px-6 pb-24">
<div className="max-w-[860px] mx-auto px-6 pb-24 max-[760px]:px-[18px]">
<div className="grid gap-4 mt-8">
{channels.map((ch) => {
const meta = t[ch.id as keyof typeof t] as { name: string; desc: string };
@ -108,6 +109,8 @@ export default async function CommunityPage({ params }: { params: Promise<{ lang
})}
</div>
</div>
</div>
<LandingFooter lang={l} />
</main>
);
}

View File

@ -4,7 +4,7 @@ import { ContributorsExperience } from "@/components/contributors/ContributorsEx
import { LandingFooter } from "@/components/landing/LandingFooter";
import { LandingNav } from "@/components/landing/LandingNav";
import type { ContributorActivityData } from "@/lib/contributorActivity";
import { SITE_URL } from "@/lib/metadata";
import { buildMetadata } from "@/lib/metadata";
const pageMetadata = {
en: {
@ -22,22 +22,12 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
const locale = lang === "cn" ? "cn" : "en";
const metadata = pageMetadata[locale];
return {
...metadata,
alternates: {
canonical: `${SITE_URL}/${locale}/contributors`,
languages: {
en: `${SITE_URL}/en/contributors`,
zh: `${SITE_URL}/cn/contributors`,
"x-default": `${SITE_URL}/en/contributors`,
},
},
openGraph: {
title: metadata.title,
description: metadata.description,
url: `${SITE_URL}/${locale}/contributors`,
},
};
return buildMetadata({
title: metadata.title,
description: metadata.description,
path: `/${locale}/contributors`,
lang: locale,
});
}
export default async function ContributorsPage({ params }: { params: Promise<{ lang: string }> }) {

View File

@ -2,8 +2,10 @@ import type { CSSProperties } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { Spotlight } from "@/components/aceternity/Spotlight";
import { RevealSection } from "@/components/landing/RevealSection";
import { ExpandableDatabaseGrid } from "@/components/landing/ExpandableDatabaseGrid";
import { buildMetadata } from "@/lib/metadata";
const databaseSupport = [
@ -130,48 +132,48 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
const t = i18n[l];
return (
<div className="min-h-screen bg-[#0b1120] text-landing-ink">
<main className="min-h-screen bg-[#0b1120] text-landing-ink">
<LandingNav lang={l} active="databases" />
{/* Hero */}
<section className="relative pt-28 pb-6">
<section className="relative overflow-hidden pt-28 pb-6">
<Spotlight />
<div className="relative z-[1] max-w-[1180px] mx-auto px-7">
<div className="relative z-[1] max-w-[1180px] mx-auto px-7 max-[760px]:px-[18px]">
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
<p className="mt-3 text-landing-muted text-lg max-w-[640px]">{t.desc}</p>
</div>
</section>
{/* Database Grid */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-10">
<div className="grid grid-cols-9 gap-3 max-[1240px]:grid-cols-7 max-[960px]:grid-cols-5 max-[640px]:grid-cols-3 max-[440px]:grid-cols-2 max-[760px]:gap-2.5">
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-10 max-[760px]:px-[18px]">
<ExpandableDatabaseGrid lang={l}>
{databaseSupport.map((db) => {
const isCta = "href" in db && db.href;
const CardTag = isCta ? "a" : "div";
return (
<CardTag
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:py-4 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
key={db.name}
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
style={{ "--db-tone": db.tone } as CSSProperties}
data-stagger
>
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px]">
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
{isCta ? (
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
) : (
<img src={db.icon} alt="" width={38} height={38} className="block w-[38px] h-[38px] object-contain" />
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
)}
</div>
<strong className={`text-sm font-[650] leading-[1.2] text-center ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
</CardTag>
);
})}
</div>
</ExpandableDatabaseGrid>
</RevealSection>
{/* Vendor CTA */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-16">
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-16 max-[760px]:px-[18px]">
<div className="landing-glass-card rounded-[10px] p-8 text-center max-w-[640px] mx-auto">
<h2 className="text-[21px] font-[720]">{t.ctaTitle}</h2>
<p className="mt-2 text-landing-muted text-sm leading-[1.65]">{t.ctaDesc}</p>
@ -186,12 +188,14 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
</RevealSection>
{/* Footer link to docs */}
<div className="max-w-[1180px] mx-auto px-7 pb-20 text-center">
<div className="max-w-[1180px] mx-auto px-7 pb-20 text-center max-[760px]:px-[18px]">
<Link href={`/${l}/docs/databases`} className="landing-inline-link inline-flex items-center gap-[7px] text-sm font-[650]">
{t.footerLink}
<svg width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</Link>
</div>
</div>
<LandingFooter lang={l} />
</main>
);
}

View File

@ -88,14 +88,16 @@ export default async function Page({ params }: { params: Promise<{ lang: string;
};
return (
<DocsPage toc={toc}>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
<DocsTitle>{title}</DocsTitle>
<DocsBody>
<MDX components={mdxComponents} />
</DocsBody>
</DocsPage>
<main className="contents">
<DocsPage toc={toc}>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
<DocsTitle>{title}</DocsTitle>
<DocsBody>
<MDX components={mdxComponents} />
</DocsBody>
</DocsPage>
</main>
);
}

View File

@ -12,7 +12,7 @@ export default async function Layout({ params, children }: { params: Promise<{ l
nav={{
title: (
<div className="flex items-center gap-2">
<img src="/logo.png" alt="DBX" width={24} height={24} />
<img src="/logo.png" alt="" aria-hidden="true" width={24} height={24} />
<span className="font-semibold">DBX</span>
</div>
),

View File

@ -208,6 +208,7 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
<section className="pt-[100px] pb-8 max-[760px]:pt-[80px] max-[760px]:pb-6">
<div className="max-w-[1180px] mx-auto px-7 max-[760px]:px-[18px]">
<div className="grid justify-items-center max-w-[900px] mx-auto text-center">
<h1 className="text-[clamp(30px,4vw,46px)] font-[820] leading-[1.08] tracking-tight text-landing-ink">{t.title}</h1>
<p className="min-w-0 mx-auto text-[15px] font-[460] leading-[1.7] text-landing-muted max-w-[760px] max-[760px]:text-[13px] max-[760px]:whitespace-normal max-[760px]:max-w-[300px]">{t.subtitle}</p>
</div>
</div>

View File

@ -1,9 +1,11 @@
import "../global.css";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { RootProvider } from "fumadocs-ui/provider/next";
import { StaticSearchDialog } from "@/components/StaticSearchDialog";
import { i18nUI } from "@/lib/i18n";
import { SITE_URL, SITE_NAME, DEFAULT_DESCRIPTION } from "@/lib/metadata";
import { buildMetadata, DEFAULT_DESCRIPTION, getHtmlLang, SITE_NAME, SITE_URL } from "@/lib/metadata";
import { buildSiteStructuredData } from "@/lib/structuredData";
const LOCALE_MAP: Record<string, { locale: string; title: string; description: string }> = {
en: {
@ -23,41 +25,68 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
const l = lang === "cn" ? "cn" : "en";
const meta = LOCALE_MAP[l];
const pageMetadata = buildMetadata({
title: meta.title,
description: meta.description,
path: `/${l}`,
lang: l,
});
return {
...pageMetadata,
title: {
default: meta.title,
template: `%s | ${SITE_NAME}`,
},
description: meta.description,
openGraph: {
locale: meta.locale,
siteName: SITE_NAME,
url: `${SITE_URL}/${l}`,
},
alternates: {
canonical: `${SITE_URL}/${l}`,
languages: {
en: `${SITE_URL}/en`,
zh: `${SITE_URL}/cn`,
"x-default": `${SITE_URL}/en`,
},
metadataBase: new URL(SITE_URL),
icons: {
icon: "/favicon.png",
shortcut: "/favicon.png",
apple: "/logo.png",
},
robots: { index: true, follow: true },
openGraph: { ...pageMetadata.openGraph, locale: meta.locale },
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#0b1120",
colorScheme: "dark light",
};
export default async function LangLayout({ params, children }: { params: Promise<{ lang: string }>; children: ReactNode }) {
const { lang } = await params;
const locale = lang === "cn" ? "cn" : "en";
const siteStructuredData = buildSiteStructuredData();
return (
<RootProvider
i18n={i18nUI.provider(lang)}
search={{
SearchDialog: StaticSearchDialog,
}}
theme={{ defaultTheme: "system", enableSystem: true }}
>
{children}
</RootProvider>
<html lang={getHtmlLang(locale)} suppressHydrationWarning>
<head>
<script
defer
src="https://analytics.unihub.top/script.js"
data-website-id="69afbe68-e06e-4fa8-84cd-e47d6d44baf0"
data-domains="dbxio.com,www.dbxio.com"
/>
{siteStructuredData.map((structuredData) => (
<script key={structuredData["@id"]} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
))}
</head>
<body className="flex min-h-screen flex-col">
<RootProvider
i18n={i18nUI.provider(locale)}
search={{
SearchDialog: StaticSearchDialog,
}}
theme={{ defaultTheme: "system", enableSystem: true }}
>
{children}
</RootProvider>
</body>
</html>
);
}

View File

@ -10,12 +10,15 @@ import { InstallTabs } from "@/components/landing/InstallTabs";
import { LandingLatestUpdates } from "@/components/landing/LandingLatestUpdates";
import { RevealSection } from "@/components/landing/RevealSection";
import { ContributorsWallContent } from "@/components/landing/ContributorsWall";
import { ExpandableDatabaseGrid } from "@/components/landing/ExpandableDatabaseGrid";
import contributorSnapshot from "@/data/contributors.json";
import type { ContributorActivityData } from "@/lib/contributorActivity";
import { contributorsFromActivity } from "@/lib/contributors";
import { getAppVersion } from "@/lib/appVersion";
import { fetchChangelog } from "@/lib/changelog";
import { fetchLatestReleaseInfo } from "@/lib/latestRelease";
import { buildMetadata, getHtmlLang } from "@/lib/metadata";
import { buildSoftwareApplicationStructuredData } from "@/lib/structuredData";
import { ArrowRight, Bot, Database, FileCode, GitCompare, Network, Search, Shield, Table, Terminal, Zap } from "lucide-react";
function formatStars(count: number) {
@ -427,8 +430,6 @@ const i18nText = {
},
};
import { buildMetadata } from "@/lib/metadata";
const landingMeta = {
en: {
title: "DBX - 20 MB to manage 70+ databases!",
@ -468,6 +469,7 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
const contributors = contributorsFromActivity(contributorData.contributors);
const initialDownloadVersion = initialLatestRelease?.version ?? appVersion;
const testimonialItems = testimonials[l];
const softwareStructuredData = buildSoftwareApplicationStructuredData(l, initialDownloadVersion);
const sponsorItems = [
{
name: "RainYun",
@ -496,18 +498,19 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
];
return (
<main className="landing">
<main className="landing" lang={getHtmlLang(l)}>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareStructuredData) }} />
{/* Nav */}
<LandingNav lang={l} active="home" />
{/* Hero */}
<section className="landing-hero">
<section className="landing-hero" aria-labelledby="landing-title">
<Spotlight />
<div className="relative z-[1] max-w-[1180px] mx-auto px-7 max-[1040px]:max-w-[920px] max-[760px]:px-[18px]">
<div className="landing-hero-copy relative z-[6] grid justify-items-center max-w-[900px] mx-auto text-center max-[1040px]:max-w-[760px]">
<h1 className="min-w-0 m-0 text-[clamp(36px,4.2vw,56px)] font-[820] leading-[1.06] text-landing-ink whitespace-nowrap max-[760px]:text-[clamp(26px,7vw,38px)]">{t.heroTitle}</h1>
<p className="landing-hero-subtitle min-w-0 mt-5 mx-auto text-[17px] font-[460] leading-[1.8] whitespace-nowrap max-[760px]:text-[15px] max-[760px]:leading-[1.68] max-[760px]:whitespace-normal max-[760px]:max-w-[320px]">{t.heroSubtitle}</p>
<div className="w-full max-w-[520px] mt-10">
<h1 id="landing-title" className="min-w-0 m-0 text-[clamp(36px,4.2vw,56px)] font-[820] leading-[1.06] text-landing-ink whitespace-nowrap max-[760px]:max-w-[12ch] max-[760px]:whitespace-normal max-[760px]:text-balance max-[760px]:text-[clamp(29px,8.7vw,38px)] max-[760px]:leading-[1.08]">{t.heroTitle}</h1>
<p className="landing-hero-subtitle min-w-0 mt-5 mx-auto text-[17px] font-[460] leading-[1.8] whitespace-nowrap max-[900px]:max-w-[680px] max-[900px]:whitespace-normal max-[760px]:max-w-[320px] max-[760px]:text-[15px] max-[760px]:leading-[1.68]">{t.heroSubtitle}</p>
<div className="w-full max-w-[520px] mt-10 max-[760px]:mt-7">
<InstallTabs lang={l} version={initialDownloadVersion} />
</div>
</div>
@ -516,9 +519,9 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
</section>
{/* Metrics */}
<RevealSection className="grid grid-cols-4 gap-3 max-w-[1180px] mx-auto px-7 pt-6 pb-11 [animation:landing-rise_0.72s_ease-out_0.1s_both] max-[760px]:grid-cols-1 max-[760px]:px-[18px] max-[760px]:pb-8">
<RevealSection className="grid grid-cols-4 gap-3 max-w-[1180px] mx-auto px-7 pt-6 pb-11 [animation:landing-rise_0.72s_ease-out_0.1s_both] max-[760px]:grid-cols-2 max-[760px]:gap-2.5 max-[760px]:px-[18px] max-[760px]:pb-7" aria-label={l === "cn" ? "DBX 核心指标" : "DBX key metrics"}>
{metricItems.map((item) => (
<div key={item.label} data-stagger className="landing-glass-card min-h-[118px] rounded-[10px] p-[22px] max-[760px]:min-h-[96px] max-[760px]:p-[18px]">
<div key={item.label} data-stagger className="landing-glass-card min-h-[118px] rounded-[10px] p-[22px] max-[760px]:min-h-[88px] max-[760px]:p-4">
<strong className="block text-landing-ink text-2xl font-[720]">{item.value}</strong>
<span className="block mt-1 text-landing-muted text-[13px]">{item.label}</span>
</div>
@ -526,7 +529,7 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
</RevealSection>
{/* Doc start */}
<RevealSection className="landing-glass-card-green flex items-center justify-between gap-[22px] max-w-[calc(1180px-56px)] mx-auto px-7 py-7 rounded-[10px] max-[760px]:block max-[760px]:px-[18px]">
<RevealSection className="landing-glass-card-green flex items-center justify-between gap-[22px] max-w-[calc(1180px-56px)] mx-auto px-7 py-7 rounded-[10px] max-[760px]:block max-[760px]:mx-[18px] max-[760px]:px-[18px] max-[760px]:py-5">
<div>
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.docsStart}</h2>
<p className="mt-2 text-landing-muted text-sm leading-[1.65]">{t.docsStartDesc}</p>
@ -543,12 +546,12 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.workflowsTitle}</h2>
<p className="mt-2 max-w-[650px] text-landing-muted text-sm leading-[1.65] justify-self-end text-right max-[760px]:max-w-none max-[760px]:text-left">{t.workflowsDesc}</p>
</div>
<div className="landing-workflow-grid grid grid-cols-4 rounded-[10px] overflow-hidden max-[1040px]:grid-cols-2 max-[760px]:grid-cols-1">
<div className="landing-workflow-grid grid grid-cols-4 rounded-[10px] overflow-hidden max-[1040px]:grid-cols-2 max-[760px]:grid-cols-2 max-[360px]:grid-cols-1">
{workflowItems.map((item, i) => (
<Link
key={item.title}
href={item.href}
className={`landing-workflow-card min-h-[250px] p-6 border-r border-r-landing-line max-[760px]:min-h-0 max-[760px]:border-r-0 max-[760px]:border-b max-[760px]:border-b-landing-line max-[760px]:last:border-b-0 ${i === workflowItems.length - 1 ? "border-r-0" : ""}`}
className={`landing-workflow-card min-h-[250px] p-6 border-r border-r-landing-line max-[760px]:min-h-0 max-[760px]:p-[18px] ${i === workflowItems.length - 1 ? "border-r-0" : ""}`}
target="_blank"
data-stagger
>
@ -576,30 +579,30 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
</Link>
</div>
</div>
<div className="grid grid-cols-9 gap-3 max-[1240px]:grid-cols-7 max-[960px]:grid-cols-5 max-[640px]:grid-cols-3 max-[440px]:grid-cols-2 max-[760px]:gap-2.5">
<ExpandableDatabaseGrid lang={l}>
{databaseSupport.map((db) => {
const isCta = "href" in db && db.href;
const CardTag = isCta ? "a" : "div";
return (
<CardTag
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:py-4 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
className={`landing-db-card grid place-items-center aspect-square rounded-[10px] px-2.5 py-[18px] max-[760px]:px-1.5 max-[760px]:py-2.5 ${isCta ? "border-2 border-dashed border-[color-mix(in_srgb,var(--color-landing-blue)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--color-landing-blue)_70%,transparent)] transition-colors cursor-pointer" : ""}`}
key={db.name}
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
style={{ "--db-tone": db.tone } as CSSProperties}
data-stagger
>
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px]">
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px] max-[760px]:size-8 max-[760px]:mb-2">
{isCta ? (
<span className="grid place-items-center w-10 h-10 rounded-full border-2 border-dashed text-landing-blue border-landing-blue text-2xl leading-none">+</span>
) : (
<img src={db.icon} alt="" width={38} height={38} className="block w-[38px] h-[38px] object-contain" />
<img src={db.icon} alt="" width={38} height={38} loading="lazy" decoding="async" className="block w-[38px] h-[38px] object-contain max-[760px]:size-7" />
)}
</div>
<strong className={`text-sm font-[650] leading-[1.2] text-center ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
<strong className={`text-sm font-[650] leading-[1.2] text-center max-[760px]:text-[11px] ${isCta ? "text-landing-blue" : "text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]"}`}>{db.name}</strong>
</CardTag>
);
})}
</div>
</ExpandableDatabaseGrid>
</RevealSection>
{/* Testimonials */}
@ -619,9 +622,9 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
<div className="grid grid-cols-[minmax(220px,0.42fr)_minmax(0,0.58fr)] gap-9 items-end mb-[22px] max-[760px]:block">
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.capabilitiesTitle}</h2>
</div>
<div className="grid grid-cols-3 gap-2.5 max-[1040px]:grid-cols-2 max-[760px]:grid-cols-1 max-[760px]:mt-[18px]">
<div className="grid grid-cols-3 gap-2.5 max-[1040px]:grid-cols-2 max-[760px]:grid-cols-2 max-[760px]:mt-[18px] max-[360px]:grid-cols-1">
{capabilityItems.map((item) => (
<div key={item.label} className="landing-capability flex items-center gap-2.5 min-h-[72px] rounded-lg px-[15px] py-3.5" data-stagger>
<div key={item.label} className="landing-capability flex items-center gap-2.5 min-h-[72px] rounded-lg px-[15px] py-3.5 max-[760px]:min-h-[62px] max-[760px]:px-3" data-stagger>
<item.icon size={18} className="shrink-0 text-landing-blue" />
<span className="text-landing-ink text-[13px] font-[560] leading-[1.45]">{item.label}</span>
</div>
@ -637,16 +640,16 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
{/* Sponsor */}
<RevealSection className="max-w-[1180px] mx-auto px-7 mt-10 max-[760px]:px-[18px]">
<p className="m-0 text-xs font-[720] uppercase tracking-[0.18em] text-landing-blue">{t.sponsorLabel}</p>
<div className="mt-3 grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
<div className="landing-sponsor-grid mt-3 grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
{sponsorItems.map((sponsor) => (
<div key={sponsor.name} className="flex min-h-[154px] items-center gap-5 rounded-[10px] border border-landing-line bg-landing-panel px-5 py-4 max-[560px]:block">
<Link href={sponsor.href} target="_blank" className="flex h-20 w-28 shrink-0 items-center justify-center rounded-lg bg-white px-4 py-3 shadow-[0_10px_30px_rgba(15,23,42,0.08)]">
<img src={sponsor.logo} alt={sponsor.name} className={sponsor.logoClass} />
<div key={sponsor.name} className="landing-sponsor-card flex min-h-[154px] items-center gap-5 rounded-[10px] border border-landing-line bg-landing-panel px-5 py-4 max-[560px]:block">
<Link href={sponsor.href} target="_blank" rel="noopener noreferrer" className="flex h-20 w-28 shrink-0 items-center justify-center rounded-lg bg-white px-4 py-3 shadow-[0_10px_30px_rgba(15,23,42,0.08)]">
<img src={sponsor.logo} alt={sponsor.name} width={112} height={56} loading="lazy" decoding="async" className={sponsor.logoClass} />
</Link>
<div className="min-w-0 flex-1 max-[560px]:mt-4">
<h2 className="text-lg font-[720] text-landing-ink">{sponsor.name}</h2>
<p className="mt-1.5 text-sm leading-[1.65] text-landing-muted">{sponsor.description}</p>
<Link href={sponsor.href} target="_blank" className="landing-inline-link mt-3 inline-flex items-center gap-[7px] text-sm font-[650]">
<Link href={sponsor.href} target="_blank" rel="noopener noreferrer" className="landing-inline-link mt-3 inline-flex items-center gap-[7px] text-sm font-[650]">
{sponsor.action}
<span aria-hidden="true"></span>
</Link>

View File

@ -67,6 +67,8 @@
html {
background: var(--color-fd-background);
color-scheme: light;
overflow-x: clip;
scroll-padding-top: 84px;
}
.dark {
@ -76,9 +78,15 @@ html {
body {
min-height: 100vh;
background: var(--color-fd-background);
overflow-x: clip;
text-rendering: optimizeLegibility;
}
a,
button {
-webkit-tap-highlight-color: transparent;
}
.dark body {
background: var(--color-fd-background);
}
@ -446,6 +454,15 @@ a {
opacity: 1;
}
.landing-nav.is-menu-open {
border-bottom-color: var(--color-landing-line);
background: rgba(8, 12, 20, 0.94);
}
.landing-nav.is-menu-open::before {
opacity: 1;
}
.landing-nav-link {
transition:
background 0.16s ease,
@ -459,6 +476,79 @@ a {
transform: translateY(-1px);
}
.landing-mobile-menu {
position: fixed;
inset: 60px 0 0;
pointer-events: none;
visibility: hidden;
}
.landing-mobile-menu[data-open="true"] {
pointer-events: auto;
visibility: visible;
}
.landing-mobile-menu-backdrop {
position: absolute;
inset: 0;
border: 0;
z-index: 0;
background: rgba(2, 6, 12, 0.82);
opacity: 0;
backdrop-filter: blur(8px);
transition: opacity 0.2s ease;
}
.landing-mobile-menu-panel {
position: relative;
display: grid;
gap: 4px;
max-height: calc(100dvh - 76px);
margin: 0 12px;
padding: 10px;
overflow-y: auto;
border: 1px solid var(--color-landing-line);
border-radius: 0 0 16px 16px;
z-index: 1;
background: #0b1120;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.44);
opacity: 0;
transform: translateY(-12px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.landing-mobile-menu[data-open="true"] .landing-mobile-menu-backdrop,
.landing-mobile-menu[data-open="true"] .landing-mobile-menu-panel {
opacity: 1;
}
.landing-mobile-menu[data-open="true"] .landing-mobile-menu-panel {
transform: translateY(0);
}
.landing-mobile-menu-link {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 20px;
border-radius: 9px;
padding: 0 14px;
color: var(--color-landing-muted);
font-size: 14px;
font-weight: 620;
}
.landing-mobile-menu-link:hover,
.landing-mobile-menu-link:focus-visible,
.landing-mobile-menu-link[aria-current="page"] {
background: var(--color-landing-soft);
color: var(--color-landing-ink);
outline: none;
}
/* Hero */
.landing-hero {
@ -591,17 +681,22 @@ a {
0 0 0 1px rgba(255, 255, 255, 0.78) inset;
}
.landing-install-trigger > svg:first-child {
fill: currentColor;
stroke-width: 0;
.landing-install-primary,
.landing-install-toggle {
color: inherit;
}
.landing-install-trigger > svg:last-child {
color: #5f6876;
.landing-install-primary:focus-visible,
.landing-install-toggle:focus-visible {
outline: 2px solid #2563eb;
outline-offset: -3px;
}
.landing-install-toggle svg {
transition: transform 0.16s ease;
}
.landing-install[data-open="true"] .landing-install-trigger > svg:last-child {
.landing-install[data-open="true"] .landing-install-toggle svg {
transform: rotate(180deg);
}
@ -615,12 +710,10 @@ a {
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
visibility 0.16s ease;
transform 0.16s ease;
visibility: hidden;
}
.landing-install:hover .landing-install-menu,
.landing-install[data-open="true"] .landing-install-menu {
opacity: 1;
pointer-events: auto;
@ -707,20 +800,38 @@ a {
backdrop-filter: blur(16px);
}
.landing-product-dots button {
background: rgba(226, 232, 240, 0.38);
.landing-product-dot {
position: relative;
background: transparent;
color: transparent;
}
.landing-product-dot::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 9px;
height: 9px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.38);
transform: translate(-50%, -50%);
transition:
width 0.2s ease,
background 0.2s ease,
opacity 0.2s ease;
}
.landing-product-dots button[aria-current="true"] {
width: 28px;
.landing-product-dot[aria-current="true"]::before {
width: 22px;
background: color-mix(in srgb, var(--color-landing-sky) 84%, white);
}
.landing-product-dot:focus-visible {
outline: 2px solid var(--color-landing-sky);
outline-offset: -2px;
}
.landing-product-dots span {
position: absolute;
width: 1px;
@ -751,6 +862,7 @@ a {
/* Inline link */
.landing-inline-link {
min-height: 28px;
color: var(--color-landing-blue);
transition:
color 0.16s ease,
@ -986,6 +1098,14 @@ a {
transform: translateY(-1px);
}
.changelog-release-summary {
display: none;
}
.changelog-release > .changelog-release-body {
display: block !important;
}
/* Responsive */
@media (max-width: 1040px) {
@ -1003,8 +1123,13 @@ a {
}
@media (max-width: 760px) {
.landing-nav-inner {
padding-right: max(14px, env(safe-area-inset-right));
padding-left: max(14px, env(safe-area-inset-left));
}
.landing-hero {
padding-top: 104px;
padding-top: 96px;
min-height: 0;
}
@ -1020,20 +1145,23 @@ a {
.aceternity-spotlight {
inset: -18% auto auto 50%;
width: 96vw;
width: min(96vw, 680px);
}
.landing-product-dots {
right: 12px;
bottom: 11px;
.landing-product:hover {
transform: none;
}
.landing-workflow-card {
border-right: 0;
border-right: 1px solid var(--color-landing-line);
border-bottom: 1px solid var(--color-landing-line);
}
.landing-workflow-card:last-child {
.landing-workflow-card:nth-child(2n) {
border-right: 0;
}
.landing-workflow-card:nth-last-child(-n + 2) {
border-bottom: 0;
}
@ -1044,16 +1172,122 @@ a {
.landing-testimonial-wall::before,
.landing-testimonial-wall::after {
width: 46px;
width: 22px;
}
.landing-testimonial-marquee {
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
}
.landing-testimonial-marquee::-webkit-scrollbar,
.landing-sponsor-grid::-webkit-scrollbar {
display: none;
}
.landing-testimonial-track {
animation: none;
}
.landing-testimonial-card {
scroll-snap-align: center;
}
.landing-contributor-grid {
gap: 8px;
display: grid;
grid-template-columns: repeat(auto-fit, 32px);
gap: 5px;
justify-content: center;
}
.landing-contributor-avatar {
width: 46px;
height: 46px;
width: 32px;
height: 32px;
border-width: 1px;
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.16);
}
.landing-contributor-avatar:nth-child(n + 73),
.landing-database-grid[data-expanded="false"] > :nth-child(n + 25) {
display: none;
}
.landing-contributor-tooltip {
display: none;
}
.landing-sponsor-grid {
display: flex;
gap: 12px;
margin-right: -18px;
margin-left: -18px;
padding-right: 18px;
padding-left: 18px;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-padding-inline: 18px;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
}
.landing-sponsor-card {
flex: 0 0 min(84vw, 330px);
scroll-snap-align: center;
}
.landing-final-link {
min-height: 44px;
}
.landing-database-toggle:focus-visible {
outline: 2px solid var(--color-landing-sky);
outline-offset: 2px;
}
.changelog-release-summary {
display: flex;
}
.changelog-release:not([open]) > .changelog-release-body {
display: none !important;
}
.changelog-release[open] .changelog-release-chevron {
transform: rotate(180deg);
}
.changelog-release-chevron {
transition: transform 0.18s ease;
}
}
@media (max-width: 360px) {
.landing-workflow-card,
.landing-workflow-card:nth-child(2n) {
border-right: 0;
border-bottom: 1px solid var(--color-landing-line);
}
.landing-workflow-card:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--color-landing-line);
}
.landing-workflow-card:last-child {
border-bottom: 0;
}
}
@media (hover: none), (pointer: coarse) {
.landing-nav-link:hover,
.landing-inline-link:hover,
.landing-workflow-card:hover,
.landing-db-card:hover,
.landing-capability:hover,
.landing-final-link:hover,
.landing-contributor-avatar:hover {
transform: none;
}
}

View File

@ -1,112 +0,0 @@
import "./global.css";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { SITE_URL, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE } from "@/lib/metadata";
export const metadata: Metadata = {
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`,
},
description: DEFAULT_DESCRIPTION,
metadataBase: new URL(SITE_URL),
icons: {
icon: "/favicon.png",
shortcut: "/favicon.png",
apple: "/logo.png",
},
openGraph: {
siteName: SITE_NAME,
images: [{ url: DEFAULT_OG_IMAGE }],
},
twitter: {
card: "summary_large_image",
},
};
const WEBSITE_SCHEMA = {
"@context": "https://schema.org",
"@type": "WebSite",
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${SITE_URL}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
} as const;
const SOFTWARE_APP_SCHEMA = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
applicationCategory: "Database Management",
operatingSystem: ["Windows", "macOS", "Linux", "Docker"],
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
author: {
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
},
releaseNotes: `${SITE_URL}/en/changelog`,
screenshot: `${SITE_URL}/screenshot-dark.png`,
featureList: [
"70+ database engines (MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, and many more)",
"AI-powered SQL generation and explanation",
"MCP Server integration for AI coding agents",
"Schema browser and diff tools",
"Data grid with inline editing",
"SSH tunnel support",
"Docker self-hosting",
],
} as const;
const ORG_SCHEMA = {
"@context": "https://schema.org",
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
sameAs: [
"https://github.com/t8y2/dbx",
"https://www.npmjs.com/package/@dbx-app/mcp-server",
],
} as const;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html suppressHydrationWarning>
<head>
<script
defer
src="https://analytics.unihub.top/script.js"
data-website-id="69afbe68-e06e-4fa8-84cd-e47d6d44baf0"
data-domains="dbxio.com,www.dbxio.com"
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(WEBSITE_SCHEMA) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(SOFTWARE_APP_SCHEMA) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ORG_SCHEMA) }}
/>
</head>
<body className="flex min-h-screen flex-col">{children}</body>
</html>
);
}

View File

@ -46,12 +46,23 @@ export function HeroProductStage() {
>
<div className="relative aspect-[16/10] overflow-hidden">
{productSlides.map((slide, index) => (
<img alt={slide.alt} aria-hidden={index !== activeSlide} className="landing-product-slide absolute inset-0 z-[1] w-full h-full object-cover object-left-top" data-active={index === activeSlide} key={slide.src} src={slide.src} />
<img
alt={slide.alt}
aria-hidden={index !== activeSlide}
className="landing-product-slide absolute inset-0 z-[1] w-full h-full object-cover object-left-top"
data-active={index === activeSlide}
decoding="async"
fetchPriority={index === 0 ? "high" : "auto"}
key={slide.src}
loading={index === 0 ? "eager" : "lazy"}
sizes="(max-width: 760px) calc(100vw - 36px), (max-width: 1040px) calc(100vw - 56px), 1180px"
src={slide.src}
/>
))}
</div>
<div className="landing-product-dots absolute right-[18px] bottom-4 z-[5] flex gap-[7px] items-center rounded-full p-1.5 max-[760px]:right-3 max-[760px]:bottom-[11px]" aria-label="DBX product screenshots">
<div className="landing-product-dots absolute right-[18px] bottom-4 z-[5] flex items-center rounded-full p-1 max-[760px]:right-2 max-[760px]:bottom-2" aria-label="DBX product screenshots">
{productSlides.map((slide, index) => (
<button aria-current={index === activeSlide} aria-label={`Show ${slide.label} screenshot`} key={slide.src} onClick={() => setActiveSlide(index)} title={slide.label} type="button" className="block w-[9px] h-[9px] border-0 rounded-full p-0 cursor-pointer">
<button aria-current={index === activeSlide} aria-label={`Show ${slide.label} screenshot`} key={slide.src} onClick={() => setActiveSlide(index)} title={slide.label} type="button" className="landing-product-dot block size-8 border-0 rounded-full p-0 cursor-pointer">
<span>{slide.label}</span>
</button>
))}

View File

@ -17,12 +17,12 @@ export function InfiniteMovingCards({ items, direction = "left", speed = "normal
const repeatedItems = [...items, ...items];
return (
<div className="landing-testimonial-marquee flex overflow-hidden select-none" data-direction={direction} data-speed={speed} aria-label="Testimonials">
<div className="landing-testimonial-marquee flex overflow-hidden select-none" data-direction={direction} data-speed={speed} aria-label="Testimonials" tabIndex={0}>
<div className="landing-testimonial-track flex w-max min-w-full gap-3.5 px-[7px]">
{repeatedItems.map((item, index) => (
<figure className="landing-testimonial-card flex-[0_0_340px] min-h-[188px] rounded-[9px] m-0 p-[22px] max-[760px]:flex-[0_0_286px] max-[760px]:min-h-[210px] max-[760px]:p-[18px]" key={`${item.name}-${index}`}>
<figure aria-hidden={index >= items.length} className="landing-testimonial-card flex-[0_0_340px] min-h-[188px] rounded-[9px] m-0 p-[22px] max-[760px]:flex-[0_0_286px] max-[760px]:min-h-[210px] max-[760px]:p-[18px]" key={`${item.name}-${index}`}>
<div className="flex gap-3 items-center">
<img className="w-[42px] h-[42px] shrink-0 border border-[rgba(178,200,230,0.18)] rounded-full bg-[rgba(255,255,255,0.04)] object-cover" src={item.avatar} alt="" width={42} height={42} />
<img className="w-[42px] h-[42px] shrink-0 border border-[rgba(178,200,230,0.18)] rounded-full bg-[rgba(255,255,255,0.04)] object-cover" src={item.avatar} alt="" width={42} height={42} loading="lazy" decoding="async" />
<figcaption>
<strong className="block text-landing-ink text-[15px] font-[740] leading-[1.25]">{item.name}</strong>
<span className="block mt-[3px] text-landing-muted text-xs leading-[1.35]">{item.role}</span>

View File

@ -192,6 +192,7 @@
.directoryGrid strong { display: block; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.directoryGrid small { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
.directoryGrid svg { color: #718396; }
.directoryToggle { display: none; width: 100%; min-height: 46px; margin-top: 12px; border: 1px solid rgba(155, 176, 205, 0.2); border-radius: 10px; background: rgba(17, 21, 29, 0.88); color: var(--ink); font-size: 13px; font-weight: 740; cursor: pointer; }
.empty { padding: 60px; border: 1px dashed rgba(155, 176, 205, 0.2); border-radius: 12px; color: var(--muted); text-align: center; }
.modalBackdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(31, 27, 66, 0.74); backdrop-filter: blur(16px); }
@ -237,6 +238,8 @@
.directorySection { padding-right: 18px; padding-left: 18px; }
.directoryHeader { align-items: stretch; flex-direction: column; }
.directoryGrid { grid-template-columns: 1fr; }
.directoryGrid[data-expanded="false"] > :nth-child(n + 25) { display: none; }
.directoryToggle { display: block; }
.certificatePreview { min-height: 390px; padding: 30px; }
.certificatePreview > p { max-width: 68%; }
.previewSeal { right: 34px; width: 74px; height: 74px; font-size: 22px; }

View File

@ -21,6 +21,8 @@ const copy = {
stars: "GitHub stars",
directory: "Contributor directory",
search: "Search GitHub login",
showAll: "Show all contributors",
showLess: "Show fewer contributors",
noResults: "No matching contributor.",
signedInAs: "Verified as",
claim: "Open my certificate",
@ -47,6 +49,8 @@ const copy = {
stars: "个 GitHub Star",
directory: "完整贡献者名单",
search: "搜索 GitHub 用户名",
showAll: "展开全部贡献者",
showLess: "收起贡献者名单",
noResults: "没有匹配的贡献者。",
signedInAs: "已验证身份",
claim: "打开我的证书",
@ -185,6 +189,7 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
const [auth, setAuth] = useState<AuthState>({ status: "loading" });
const [query, setQuery] = useState("");
const [certificateOpen, setCertificateOpen] = useState(false);
const [directoryExpanded, setDirectoryExpanded] = useState(false);
const mergedPullRequests = useMemo(() => data.contributors.reduce((total, contributor) => total + contributor.mergedPullRequests, 0), [data.contributors]);
const commits = useMemo(() => data.contributors.reduce((total, contributor) => total + contributor.commits, 0), [data.contributors]);
@ -248,7 +253,7 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
<div className={styles.heroCertificate}>
<div className={styles.certificatePreview}>
<div className={styles.previewTop}><span><img src="/logo.png" alt="DBX" width={28} height={28} />DBX</span><ShieldCheck size={18} /></div>
<div className={styles.previewTop}><span><img src="/logo.png" alt="" aria-hidden="true" width={28} height={28} />DBX</span><ShieldCheck size={18} /></div>
<small>{text.certificate}</small>
<strong>@{verifiedContributor?.login ?? "YOUR_NAME"}</strong>
<p>{text.certificateBody}</p>
@ -271,7 +276,7 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
<section className={styles.directorySection}>
<div className={styles.directoryHeader}><div><h2>{text.directory}</h2></div><label><Search size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={text.search} /></label></div>
{filtered.length ? (
<div className={styles.directoryGrid}>
<div className={styles.directoryGrid} data-expanded={directoryExpanded || query.trim().length > 0}>
{filtered.map((contributor) => (
<a key={contributor.login} href={contributor.profileUrl} target="_blank" rel="noopener noreferrer">
<img src={contributor.avatarUrl} alt="" width={46} height={46} loading="lazy" />
@ -281,13 +286,18 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
))}
</div>
) : <div className={styles.empty}>{text.noResults}</div>}
{filtered.length > 24 && query.trim().length === 0 ? (
<button type="button" className={styles.directoryToggle} aria-expanded={directoryExpanded} onClick={() => setDirectoryExpanded((current) => !current)}>
{directoryExpanded ? text.showLess : `${text.showAll} (${filtered.length})`}
</button>
) : null}
</section>
{certificateOpen && verifiedContributor ? (
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCertificateOpen(false); }}>
<section className={styles.modal} role="dialog" aria-modal="true" aria-label={text.certificate}>
<div className={styles.fullCertificate}>
<div className={styles.previewTop}><span><img src="/logo.png" alt="DBX" width={30} height={30} />DBX · OPEN SOURCE DATABASE TOOL</span><ShieldCheck size={20} /></div>
<div className={styles.previewTop}><span><img src="/logo.png" alt="" aria-hidden="true" width={30} height={30} />DBX · OPEN SOURCE DATABASE TOOL</span><ShieldCheck size={20} /></div>
<span className={styles.verifiedText}>{text.verified}</span>
<small>{text.certificate}</small>
<em>{text.awardedTo}</em>

View File

@ -22,56 +22,62 @@ function formatDate(dateStr: string, lang: string) {
return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
function ReleaseCard({ release, lang }: { release: ChangelogRelease; lang: string }) {
function ReleaseCard({ release, lang, featured }: { release: ChangelogRelease; lang: string; featured: boolean }) {
const t = lang === "cn" ? { publishedOn: "发布于", download: "下载", seeGitHub: "查看 GitHub Release 获取详情" } : { publishedOn: "Published on", download: "Download", seeGitHub: "See GitHub Release for details" };
return (
<div className="py-12 border-t border-[rgba(155,176,205,0.18)]">
{/* Header */}
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full border border-[rgba(155,176,205,0.25)] text-sm font-semibold text-[#e2e8f0]">
<Tag size={13} className="text-[#6ea8ff]" />
{release.tag.replace("v", "")}
</span>
<span className="text-[15px] text-[#64748b]">
{t.publishedOn} {formatDate(release.date, lang)}
</span>
<details className="changelog-release border-t border-[rgba(155,176,205,0.18)]" open={featured || undefined}>
<summary className="changelog-release-summary min-h-[72px] cursor-pointer list-none items-center justify-between gap-4 py-4 text-[#e2e8f0]">
<span className="min-w-0">
<strong className="block truncate text-[17px] font-[720]">Release {release.tag}</strong>
<span className="mt-1 block text-xs text-[#64748b]">{formatDate(release.date, lang)}</span>
</span>
<ChevronDown className="changelog-release-chevron shrink-0 text-[#6ea8ff]" size={18} />
</summary>
<div className="changelog-release-body py-12 max-[760px]:py-8">
<div className="flex items-center justify-between gap-4 mb-8 max-[760px]:items-start max-[760px]:flex-wrap max-[760px]:mb-6">
<div className="flex items-center gap-4 max-[760px]:flex-wrap max-[760px]:gap-2.5">
<span className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full border border-[rgba(155,176,205,0.25)] text-sm font-semibold text-[#e2e8f0]">
<Tag size={13} className="text-[#6ea8ff]" />
{release.tag.replace("v", "")}
</span>
<span className="text-[15px] text-[#64748b] max-[760px]:text-[13px]">
{t.publishedOn} {formatDate(release.date, lang)}
</span>
</div>
<a href={`https://github.com/t8y2/dbx/releases/tag/${release.tag}`} target="_blank" rel="noopener noreferrer" className="flex min-h-11 items-center gap-1.5 px-4 rounded-full border border-[rgba(155,176,205,0.25)] text-sm text-[#e2e8f0] hover:border-[rgba(155,176,205,0.4)] transition-colors">
{t.download}
<ChevronDown size={14} />
</a>
</div>
<a href={`https://github.com/t8y2/dbx/releases/tag/${release.tag}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 px-4 py-2 rounded-full border border-[rgba(155,176,205,0.25)] text-sm text-[#e2e8f0] hover:border-[rgba(155,176,205,0.4)] transition-colors">
{t.download}
<ChevronDown size={14} />
</a>
<h2 className="text-[28px] font-[720] text-[#f7fbff] mb-10 max-[760px]:text-2xl max-[760px]:mb-7">Release {release.tag}</h2>
{release.sections.map((section, sectionIndex) => (
<div key={sectionIndex} className={sectionIndex > 0 ? "mt-10 max-[760px]:mt-8" : ""}>
<h3 className="text-xl font-bold text-[#f7fbff] mb-5 max-[760px]:text-lg max-[760px]:mb-4">{sectionLabels[section.type]?.[lang] || section.title}</h3>
<ul className="space-y-3">
{section.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex gap-3 text-[15px] leading-relaxed text-[#b8c5d6] max-[760px]:text-sm">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-[#475569] shrink-0" />
<span>
{item.desc ? (
<>
{item.title}{item.desc}
</>
) : (
item.title
)}
</span>
</li>
))}
</ul>
</div>
))}
{release.sections.length === 0 && <p className="text-[15px] text-[#64748b] italic">{t.seeGitHub}</p>}
</div>
{/* Title */}
<h2 className="text-[28px] font-[720] text-[#f7fbff] mb-10">Release {release.tag}</h2>
{/* Sections */}
{release.sections.map((section, si) => (
<div key={si} className={si > 0 ? "mt-10" : ""}>
<h3 className="text-xl font-bold text-[#f7fbff] mb-5">{sectionLabels[section.type]?.[lang] || section.title}</h3>
<ul className="space-y-3">
{section.items.map((item, ii) => (
<li key={ii} className="flex gap-3 text-[15px] leading-relaxed text-[#b8c5d6]">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-[#475569] shrink-0" />
<span>
{item.desc ? (
<>
{item.title}{item.desc}
</>
) : (
item.title
)}
</span>
</li>
))}
</ul>
</div>
))}
{release.sections.length === 0 && <p className="text-[15px] text-[#64748b] italic">{t.seeGitHub}</p>}
</div>
</details>
);
}
@ -101,8 +107,8 @@ export function ChangelogList({ releases, lang }: { releases: ChangelogRelease[]
return (
<>
{visible.map((release) => (
<ReleaseCard key={release.tag} release={release} lang={lang} />
{visible.map((release, index) => (
<ReleaseCard key={release.tag} release={release} lang={lang} featured={index === 0} />
))}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-12 text-[#64748b] text-sm">

View File

@ -0,0 +1,37 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
export function ExpandableDatabaseGrid({ children, lang }: { children: ReactNode; lang: "en" | "cn" }) {
const [expanded, setExpanded] = useState(false);
const label = expanded
? lang === "cn"
? "收起数据库列表"
: "Show fewer databases"
: lang === "cn"
? "展开全部数据库"
: "Show all databases";
return (
<>
<div
id="landing-database-grid"
className="landing-database-grid grid grid-cols-9 gap-3 max-[1240px]:grid-cols-7 max-[960px]:grid-cols-5 max-[640px]:grid-cols-4 max-[360px]:grid-cols-3 max-[760px]:gap-2"
data-expanded={expanded}
>
{children}
</div>
<button
type="button"
className="landing-database-toggle mt-3 hidden min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-landing-line bg-landing-panel px-4 text-sm font-[650] text-landing-ink max-[760px]:flex"
aria-controls="landing-database-grid"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<span>{label}</span>
<span aria-hidden="true">{expanded ? "" : "+"}</span>
</button>
</>
);
}

View File

@ -51,6 +51,17 @@ export function InstallTabs({ lang, version }: InstallTabsProps) {
setPlatformId(detectPlatformId());
}, []);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [open]);
const primary = useMemo(() => options.find((o) => o.id === platformId) ?? options[0], [options, platformId]);
const menuOptions = useMemo(() => options.filter((o) => o.id !== platformId), [options, platformId]);
@ -63,32 +74,35 @@ export function InstallTabs({ lang, version }: InstallTabsProps) {
setOpen(false);
}
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<a
aria-controls="landing-install-menu"
aria-expanded={open}
aria-haspopup="listbox"
className="landing-install-trigger grid grid-cols-[auto_minmax(0,1fr)_auto] gap-4 items-center w-[min(340px,calc(100vw-48px))] min-h-[68px] border-0 rounded-full mx-auto px-6 cursor-pointer"
href={primary.href}
onFocus={() => setOpen(true)}
>
<PlatformIcon id={primary.id} size={30} variant="dark" />
<span className="grid gap-0.5 min-w-0">
<strong className="overflow-hidden text-[15px] font-[780] leading-[1.2] truncate">{downloadLabel[lang]}</strong>
<small className="overflow-hidden text-xs font-[520] leading-tight truncate text-[color-mix(in_srgb,#0f172a_48%,#94a3b8)]">{primary.label}</small>
</span>
<ChevronDown size={18} />
</a>
<div className="landing-install-trigger grid grid-cols-[minmax(0,1fr)_52px] items-stretch w-[min(340px,calc(100vw-36px))] min-h-[68px] border-0 rounded-full mx-auto overflow-hidden">
<a className="landing-install-primary grid grid-cols-[auto_minmax(0,1fr)] gap-4 items-center min-w-0 px-6 max-[360px]:gap-3 max-[360px]:px-5" href={primary.href}>
<PlatformIcon id={primary.id} size={30} variant="dark" />
<span className="grid gap-0.5 min-w-0 text-left">
<strong className="overflow-hidden text-[15px] font-[780] leading-[1.2] truncate">{downloadLabel[lang]}</strong>
<small className="overflow-hidden text-xs font-[520] leading-tight truncate text-[color-mix(in_srgb,#0f172a_48%,#94a3b8)]">{primary.label}</small>
</span>
</a>
<button
type="button"
aria-controls="landing-install-menu"
aria-expanded={open}
aria-haspopup="menu"
aria-label={lang === "cn" ? "显示其他下载选项" : "Show other download options"}
className="landing-install-toggle grid place-items-center border-0 border-l border-l-[rgba(15,23,42,0.12)] bg-transparent text-[#5f6876] cursor-pointer"
onClick={() => setOpen((current) => !current)}
>
<ChevronDown size={18} />
</button>
</div>
<div
className="landing-install-menu absolute z-30 top-[calc(100%+12px)] left-1/2 -translate-x-1/2 grid w-[min(300px,calc(100vw-48px))] border border-[rgba(155,176,205,0.17)] rounded-xl py-1.5"
id="landing-install-menu"
role="listbox"
role="menu"
aria-label={lang === "cn" ? "下载选项" : "Download options"}
>
{menuOptions.map((item) => (
<a aria-selected="false" className="landing-install-option grid grid-cols-[24px_minmax(0,1fr)_18px] gap-3 items-center min-w-0 border-0 px-[18px] py-3 bg-transparent text-left cursor-pointer" href={item.href} key={item.id} role="option">
<a className="landing-install-option grid grid-cols-[24px_minmax(0,1fr)_18px] gap-3 items-center min-h-11 min-w-0 border-0 px-[18px] py-3 bg-transparent text-left cursor-pointer" href={item.href} key={item.id} role="menuitem">
<PlatformIcon id={item.id} size={20} variant="light" />
<strong className="overflow-hidden text-sm font-[640] leading-[1.2] truncate">{item.label}</strong>
<Download size={15} aria-hidden="true" />

View File

@ -27,8 +27,8 @@ export function LandingFooter({ lang }: { lang: "en" | "cn" }) {
<div className="max-w-[1180px] mx-auto px-7 py-7 max-[760px]:px-[18px]">
<div className="flex items-center justify-between gap-4 max-[760px]:flex-col max-[760px]:gap-3 max-[760px]:text-center">
{/* Logo */}
<Link href={`/${lang}`} className="flex items-center gap-2.5 text-[var(--color-landing-ink)] text-lg font-[820] shrink-0">
<img src="/logo.png" alt="DBX" width={22} height={22} />
<Link href={`/${lang}`} className="flex min-h-11 items-center gap-2.5 text-[var(--color-landing-ink)] text-lg font-[820] shrink-0">
<img src="/logo.png" alt="" aria-hidden="true" width={22} height={22} />
<span>DBX</span>
</Link>
@ -37,13 +37,13 @@ export function LandingFooter({ lang }: { lang: "en" | "cn" }) {
{/* Repo icons */}
<div className="flex items-center gap-3 shrink-0">
<a href="https://github.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" className="text-[var(--color-landing-muted)] hover:text-[var(--color-landing-ink)] transition-colors" aria-label="GitHub">
<a href="https://github.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" className="inline-flex size-11 items-center justify-center text-[var(--color-landing-muted)] hover:text-[var(--color-landing-ink)] transition-colors" aria-label="GitHub">
<GithubIcon />
</a>
<a href="https://cnb.cool/dbxio.com/dbx" target="_blank" rel="noopener noreferrer" className="opacity-40 hover:opacity-100 transition-opacity" aria-label="CNB">
<a href="https://cnb.cool/dbxio.com/dbx" target="_blank" rel="noopener noreferrer" className="inline-flex size-11 items-center justify-center opacity-40 hover:opacity-100 transition-opacity" aria-label="CNB">
<img src="/icons/cnb.svg" alt="CNB" width={18} height={18} />
</a>
<a href="https://atomgit.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" className="opacity-40 hover:opacity-100 transition-opacity" aria-label="AtomGit">
<a href="https://atomgit.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" className="inline-flex size-11 items-center justify-center opacity-40 hover:opacity-100 transition-opacity" aria-label="AtomGit">
<img src="/icons/atomgit.png" alt="AtomGit" width={18} height={18} />
</a>
</div>

View File

@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { Github } from "lucide-react";
import { useEffect, useRef } from "react";
import { Github, Menu, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
const i18n = {
en: {
@ -14,12 +14,26 @@ const i18n = {
contributors: "Contributors",
drivers: "Offline Drivers",
langLabel: "Switch to Chinese",
menu: "Open navigation",
closeMenu: "Close navigation",
},
cn: {
home: "首页",
docs: "文档",
changelog: "更新日志",
community: "交流群",
sponsors: "赞助商",
contributors: "贡献者",
drivers: "离线驱动",
langLabel: "切换到英文",
menu: "打开导航",
closeMenu: "关闭导航",
},
cn: { home: "首页", docs: "文档", changelog: "更新日志", community: "交流群", sponsors: "赞助商", contributors: "贡献者", drivers: "离线驱动", langLabel: "切换到英文" },
};
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "sponsors" | "contributors" | "drivers" }) {
const ref = useRef<HTMLElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const t = i18n[lang];
const otherLang = lang === "cn" ? "en" : "cn";
const langHrefMap: Record<string, string> = {
@ -31,6 +45,15 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
drivers: `/${otherLang}/drivers`,
};
const langHref = langHrefMap[active ?? ""] ?? `/${otherLang}`;
const navItems = [
{ id: "home", href: `/${lang}`, label: t.home, tabletHidden: false },
{ id: "docs", href: `/${lang}/docs/what-is-dbx`, label: t.docs, tabletHidden: false },
{ id: "changelog", href: `/${lang}/changelog`, label: t.changelog, tabletHidden: false },
{ id: "community", href: `/${lang}/community`, label: t.community, tabletHidden: false },
{ id: "sponsors", href: `/${lang}/sponsors`, label: t.sponsors, tabletHidden: true },
{ id: "contributors", href: `/${lang}/contributors`, label: t.contributors, tabletHidden: true },
{ id: "drivers", href: `/${lang}/drivers`, label: t.drivers, tabletHidden: false },
] as const;
useEffect(() => {
const node = ref.current;
@ -45,41 +68,84 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!menuOpen) return;
const previousOverflow = document.body.style.overflow;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
const closeOnDesktop = () => {
if (window.innerWidth > 760) setMenuOpen(false);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", closeOnDesktop);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", closeOnDesktop);
};
}, [menuOpen]);
return (
<nav ref={ref} className="landing-nav">
<div className="flex items-center justify-between max-w-[1180px] h-16 mx-auto px-7 max-[760px]:min-h-[58px] max-[760px]:h-auto max-[760px]:px-[18px] max-[760px]:py-2.5">
<Link href={`/${lang}`} className="flex items-center gap-2.5 text-landing-ink text-2xl font-[820]">
<img src="/logo.png" alt="DBX" width={28} height={28} />
<nav ref={ref} className={`landing-nav${menuOpen ? " is-menu-open" : ""}`} aria-label={lang === "cn" ? "主导航" : "Primary navigation"}>
<div className="landing-nav-inner flex items-center justify-between max-w-[1180px] h-16 mx-auto px-7 max-[760px]:min-h-[60px] max-[760px]:h-auto max-[760px]:px-[18px] max-[760px]:py-2">
<Link href={`/${lang}`} className="landing-nav-brand flex min-h-11 items-center gap-2.5 text-landing-ink text-2xl font-[820]" onClick={() => setMenuOpen(false)}>
<img src="/logo.png" alt="" aria-hidden="true" width={28} height={28} />
<span>DBX</span>
</Link>
<div className="flex items-center gap-1">
<Link href={`/${lang}`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "home" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.home}
</Link>
<Link href={`/${lang}/docs/what-is-dbx`} className="landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden text-landing-muted">
{t.docs}
</Link>
<Link href={`/${lang}/changelog`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "changelog" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.changelog}
</Link>
<Link href={`/${lang}/community`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "community" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.community}
</Link>
<Link href={`/${lang}/sponsors`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[900px]:hidden ${active === "sponsors" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.sponsors}
</Link>
<Link href={`/${lang}/contributors`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[900px]:hidden ${active === "contributors" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.contributors}
</Link>
<Link href={`/${lang}/drivers`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "drivers" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.drivers}
</Link>
<Link href="https://github.com/t8y2/dbx" target="_blank" aria-label="GitHub" title="GitHub" className="landing-nav-link inline-flex size-9 items-center justify-center rounded-[7px] text-landing-muted max-[760px]:hidden">
{navItems.map((item) => (
<Link
key={item.id}
href={item.href}
aria-current={active === item.id ? "page" : undefined}
className={`landing-nav-link inline-flex min-h-11 items-center rounded-[7px] px-[11px] text-[13px] font-medium max-[760px]:hidden ${item.tabletHidden ? "max-[900px]:hidden" : ""} ${active === item.id ? "text-landing-ink" : "text-landing-muted"}`}
>
{item.label}
</Link>
))}
<Link href="https://github.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" aria-label="GitHub" title="GitHub" className="landing-nav-link inline-flex size-11 items-center justify-center rounded-[7px] text-landing-muted max-[760px]:hidden">
<Github size={18} strokeWidth={2} />
</Link>
<Link href={langHref} aria-label={t.langLabel} title={t.langLabel} className="landing-nav-link ml-1.5 inline-flex h-9 items-center justify-center rounded-[7px] border border-landing-line px-2.5 text-[12px] font-[650] tracking-tight text-landing-muted">
<Link href={langHref} aria-label={t.langLabel} title={t.langLabel} className="landing-nav-link ml-1.5 inline-flex min-h-11 items-center justify-center rounded-[7px] border border-landing-line px-3 text-[12px] font-[650] tracking-tight text-landing-muted" onClick={() => setMenuOpen(false)}>
/A
</Link>
<button
type="button"
className="landing-nav-link ml-1 inline-flex size-11 items-center justify-center rounded-[7px] border border-landing-line text-landing-ink min-[761px]:hidden"
aria-controls="landing-mobile-menu"
aria-expanded={menuOpen}
aria-label={menuOpen ? t.closeMenu : t.menu}
onClick={() => setMenuOpen((current) => !current)}
>
{menuOpen ? <X size={21} /> : <Menu size={21} />}
</button>
</div>
</div>
<div id="landing-mobile-menu" className="landing-mobile-menu min-[761px]:hidden" data-open={menuOpen} aria-hidden={!menuOpen}>
<button type="button" className="landing-mobile-menu-backdrop" aria-label={t.closeMenu} tabIndex={menuOpen ? 0 : -1} onClick={() => setMenuOpen(false)} />
<div className="landing-mobile-menu-panel">
{navItems.map((item) => (
<Link
key={item.id}
href={item.href}
aria-current={active === item.id ? "page" : undefined}
className="landing-mobile-menu-link"
onClick={() => setMenuOpen(false)}
tabIndex={menuOpen ? 0 : -1}
>
<span>{item.label}</span>
<span aria-hidden="true"></span>
</Link>
))}
<Link href="https://github.com/t8y2/dbx" target="_blank" rel="noopener noreferrer" className="landing-mobile-menu-link" onClick={() => setMenuOpen(false)} tabIndex={menuOpen ? 0 : -1}>
<span className="inline-flex items-center gap-2"><Github size={17} /> GitHub</span>
<span aria-hidden="true"></span>
</Link>
</div>
</div>
</nav>

View File

@ -1,15 +1,15 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
type RevealSectionProps = {
type RevealSectionProps = Omit<ComponentPropsWithoutRef<"section">, "children" | "className"> & {
children: ReactNode;
className?: string;
delay?: number;
};
export function RevealSection({ children, className = "", delay = 0 }: RevealSectionProps) {
export function RevealSection({ children, className = "", delay = 0, ...sectionProps }: RevealSectionProps) {
const ref = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
@ -32,7 +32,7 @@ export function RevealSection({ children, className = "", delay = 0 }: RevealSec
}, []);
return (
<section ref={ref} className={`${className} landing-reveal${visible ? " is-visible" : ""}`} style={{ "--reveal-delay": `${delay}ms` } as CSSProperties}>
<section {...sectionProps} ref={ref} className={`${className} landing-reveal${visible ? " is-visible" : ""}`} style={{ ...sectionProps.style, "--reveal-delay": `${delay}ms` } as CSSProperties}>
{children}
</section>
);

View File

@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildSiteStructuredData, buildSoftwareApplicationStructuredData } from "./structuredData";
test("site structured data does not advertise a nonexistent search route", () => {
const [website, organization] = buildSiteStructuredData();
assert.equal(website["@type"], "WebSite");
assert.equal("potentialAction" in website, false);
assert.equal(organization["@id"], "https://dbxio.com/#organization");
});
test("software structured data stays localized and versioned", () => {
const english = buildSoftwareApplicationStructuredData("en", "0.5.71");
const chinese = buildSoftwareApplicationStructuredData("cn", "0.5.71");
assert.equal(english.applicationCategory, "DeveloperApplication");
assert.equal(english.softwareVersion, "0.5.71");
assert.equal(english.inLanguage, "en");
assert.match(english.description, /70\+ databases/);
assert.equal(chinese.inLanguage, "zh-CN");
assert.match(chinese.description, /70\+ 种数据库/);
assert.equal(chinese.license, "https://github.com/t8y2/dbx/blob/main/LICENSE");
});

View File

@ -0,0 +1,96 @@
import { DEFAULT_DESCRIPTION, SITE_NAME, SITE_URL } from "./metadata";
const localizedDescription = {
en: DEFAULT_DESCRIPTION,
cn: "70+ 种数据库,仅 20 MB。支持桌面端、Docker 自托管、AI 助手与 MCP Server。",
} as const;
const localizedFeatureList = {
en: [
"Manage 70+ SQL, NoSQL, vector, time-series, embedded databases, and message queues",
"Desktop apps for Windows, macOS, and Linux",
"Docker self-hosting for browser access",
"AI-assisted SQL generation, explanation, optimization, and repair",
"MCP Server integration for AI coding agents",
"Schema browsing, schema diff, data editing, import, and export",
],
cn: [
"统一管理 70+ 种 SQL、NoSQL、向量、时序、嵌入式数据库与消息队列",
"提供 Windows、macOS 与 Linux 桌面端",
"支持 Docker 自托管与浏览器访问",
"支持 AI 生成、解释、优化与修复 SQL",
"通过 MCP Server 连接 AI 编程智能体",
"提供结构浏览、结构对比、数据编辑、导入与导出",
],
} as const;
export function buildSiteStructuredData() {
return [
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${SITE_URL}/#website`,
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
publisher: { "@id": `${SITE_URL}/#organization` },
inLanguage: ["en", "zh-CN"],
},
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: SITE_NAME,
url: SITE_URL,
description: DEFAULT_DESCRIPTION,
logo: `${SITE_URL}/logo.png`,
sameAs: [
"https://github.com/t8y2/dbx",
"https://www.npmjs.com/package/@dbx-app/mcp-server",
"https://cnb.cool/dbxio.com/dbx",
"https://atomgit.com/t8y2/dbx",
],
},
] as const;
}
export function buildSoftwareApplicationStructuredData(lang: "en" | "cn", version: string) {
const language = lang === "cn" ? "zh-CN" : "en";
return {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": `${SITE_URL}/#software`,
name: SITE_NAME,
url: `${SITE_URL}/${lang}`,
description: localizedDescription[lang],
applicationCategory: "DeveloperApplication",
applicationSubCategory: "Database management",
operatingSystem: "Windows, macOS, Linux, Docker",
softwareVersion: version,
isAccessibleForFree: true,
inLanguage: language,
codeRepository: "https://github.com/t8y2/dbx",
downloadUrl: "https://github.com/t8y2/dbx/releases/latest",
releaseNotes: `${SITE_URL}/${lang}/changelog`,
license: "https://github.com/t8y2/dbx/blob/main/LICENSE",
screenshot: [
`${SITE_URL}/screenshot-dark.png`,
`${SITE_URL}/screenshot-er.png`,
`${SITE_URL}/screenshot-grid.png`,
],
featureList: [...localizedFeatureList[lang]],
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
author: { "@id": `${SITE_URL}/#organization` },
publisher: { "@id": `${SITE_URL}/#organization` },
sameAs: [
"https://github.com/t8y2/dbx",
"https://www.npmjs.com/package/@dbx-app/mcp-server",
],
} as const;
}

View File

@ -1,51 +1,50 @@
# DBX - Database Workspace
# https://dbxio.com
# DBX
## What is DBX?
DBX is an open-source database workspace that supports 25+ database engines in a 15 MB package, available as both desktop and Docker deployment. It includes AI-powered SQL generation, MCP server integration for coding agents, schema tools, and self-hosted browser access.
> DBX is an open-source database workspace that manages 70+ databases in a roughly 20 MB desktop application. It also supports Docker self-hosting, an AI SQL assistant, a CLI, and an MCP Server for AI coding agents.
## Core Features
- **SQL Editor**: CodeMirror 6-based editor with syntax highlighting, auto-completion, formatting, and query history.
- **AI SQL Assistant**: Generate, explain, optimize, and fix SQL using Claude or OpenAI models.
- **MCP Integration**: Expose database connections to AI coding agents (Claude Code, Cursor, Windsurf) via Model Context Protocol.
- **Data Grid**: Virtual scrolling, inline editing, filtering, sorting, and export to CSV/JSON/Markdown.
- **Schema Browser**: Browse databases, schemas, tables, columns, indexes, foreign keys, and triggers.
- **Schema Diff**: Compare schemas across environments and generate migration SQL.
- **Redis Browser**: Pattern-based key search with support for all Redis data types.
- **MongoDB Browser**: Document CRUD with paginated browsing.
- **SSH Tunnels**: Connect to databases behind firewalls with key/password authentication.
Canonical website: https://dbxio.com
Source repository: https://github.com/t8y2/dbx
License: Apache-2.0
## Supported Databases
MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, MariaDB, TiDB, and more.
## Product facts
## MCP Server
```bash
npm install -g @dbx-app/mcp-server
```
Configure `.mcp.json` to let AI agents query databases through DBX connections:
```json
{
"mcpServers": {
"dbx": {
"command": "npx",
"args": ["-y", "@dbx-app/mcp-server"]
}
}
}
```
For Windows portable builds, set DBX_DATA_DIR to the portable data directory that contains dbx.db, for example D:\DBX_x64-portable\data.
- Platforms: Windows, macOS (Apple Silicon and Intel), Linux, and Docker self-hosting.
- Database coverage: 70+ SQL, NoSQL, vector, time-series, embedded databases, compatible engines, and message queues.
- Representative databases: MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, MariaDB, Doris, Dameng, GaussDB, Kafka, and RabbitMQ.
- Main workflows: connection management, SQL editing, data browsing and editing, schema browsing, schema diff, import/export, SSH tunnels, and query history.
- AI workflows: generate, explain, optimize, and repair SQL with user-configured model providers.
- Agent integration: the DBX MCP Server exposes approved database connections to compatible AI coding agents.
- Distribution: desktop releases, Docker deployment, npm packages for CLI and MCP Server, and offline JDBC driver downloads.
## Key Links
- Documentation: https://dbxio.com/en/docs/what-is-dbx
## Install and deploy
- Desktop releases: https://github.com/t8y2/dbx/releases/latest
- Getting started: https://dbxio.com/en/docs/getting-started
- Docker deployment: https://dbxio.com/en/docs/1panel
- CLI: https://dbxio.com/en/docs/cli
- MCP Server: https://dbxio.com/en/docs/mcp
- Offline drivers: https://dbxio.com/en/drivers
## Documentation
- Product overview: https://dbxio.com/en/docs/what-is-dbx
- Supported databases: https://dbxio.com/en/databases
- Query editor: https://dbxio.com/en/docs/query-editor
- Data grid: https://dbxio.com/en/docs/data-grid
- Schema browser: https://dbxio.com/en/docs/schema-browser
- Schema diff: https://dbxio.com/en/docs/schema-diff
- AI assistant: https://dbxio.com/en/docs/ai-assistant
- Web API: https://dbxio.com/en/docs/web-api
- Changelog: https://dbxio.com/en/changelog
- Community: https://dbxio.com/en/community
- GitHub: https://github.com/t8y2/dbx
- Drivers: https://dbxio.com/en/drivers
## Platform Support
- Desktop: Windows, macOS (Intel + Apple Silicon), Linux
- Docker: Self-hosted browser-based access
- MCP: npm package for AI agent integration
## Chinese resources
## Pricing
DBX is free and open-source (AGPL-3.0).
- 中文首页: https://dbxio.com/cn
- 中文文档: https://dbxio.com/cn/docs/what-is-dbx
- 中文数据库列表: https://dbxio.com/cn/databases
- 中文更新日志: https://dbxio.com/cn/changelog
## Attribution guidance
Use DBX as the product name and link to https://dbxio.com or https://github.com/t8y2/dbx. Treat release versions, supported database counts, contributor counts, and download links as dynamic; verify them from the canonical website or repository before making time-sensitive claims.

View File

@ -3,8 +3,6 @@ import { resolve, relative } from "path";
const OUT_DIR = resolve(import.meta.dirname, "../out");
const SITE_URL = "https://dbxio.com";
const TODAY = new Date().toISOString().split("T")[0];
const EXCLUDE = new Set(["index.html", "404.html", "_not-found.html"]);
function* walkDir(dir) {
@ -51,9 +49,8 @@ const urls = [];
const seen = new Set();
for (const [, langs] of pagesByPath) {
const primary = langs.en || langs.cn;
if (!primary || seen.has(primary)) continue;
seen.add(primary);
const localizedPages = [langs.en, langs.cn].filter(Boolean);
if (localizedPages.length === 0) continue;
const altLinks = [];
if (langs.en) {
@ -67,7 +64,11 @@ for (const [, langs] of pagesByPath) {
altLinks.push({ lang: "x-default", href: `${SITE_URL}${langs.en}` });
}
urls.push({ loc: `${SITE_URL}${primary}`, lastmod: TODAY, altLinks });
for (const localizedPage of localizedPages) {
if (seen.has(localizedPage)) continue;
seen.add(localizedPage);
urls.push({ loc: `${SITE_URL}${localizedPage}`, altLinks });
}
}
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
@ -78,7 +79,6 @@ ${urls
(entry) =>
` <url>
<loc>${entry.loc}</loc>
<lastmod>${entry.lastmod}</lastmod>
${entry.altLinks.map((alt) => ` <xhtml:link rel="alternate" hreflang="${alt.lang}" href="${alt.href}" />`).join("\n")}
</url>`,
)

View File

@ -1,5 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, join, relative } from "node:path";
import { extname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const outputDirectory = fileURLToPath(new URL("../out/", import.meta.url));
@ -36,3 +36,54 @@ if (violations.length > 0) {
}
console.log(`Static export verified: ${files.length} files contain no GitHub API endpoints.`);
const requiredContent = [
{ file: "en.html", includes: ['<html lang="en"', '"@type":"SoftwareApplication"'] },
{ file: "cn.html", includes: ['<html lang="zh-CN"', '"@type":"SoftwareApplication"'] },
{ file: "llms.txt", includes: ["70+ database", "20 MB", "Apache-2.0"] },
];
for (const requirement of requiredContent) {
const content = await readFile(join(outputDirectory, requirement.file), "utf8");
for (const expected of requirement.includes) {
if (!content.includes(expected)) {
throw new Error(`${requirement.file} is missing required static content: ${expected}`);
}
}
if (content.includes('"potentialAction"')) {
throw new Error(`${requirement.file} advertises a search action that the static site does not provide.`);
}
}
console.log("Static export metadata verified for language, software schema, and llms.txt accuracy.");
const localizedHtmlFiles = files.filter(
(file) =>
extname(file) === ".html" &&
["en", "cn"].some(
(lang) => file === join(outputDirectory, `${lang}.html`) || file.startsWith(`${join(outputDirectory, lang)}${sep}`),
),
);
for (const file of localizedHtmlFiles) {
const content = await readFile(file, "utf8");
const relativePath = relative(outputDirectory, file);
for (const landmark of ["<main", "<h1"]) {
if (!content.includes(landmark)) {
throw new Error(`${relativePath} is missing required semantic landmark: ${landmark}>`);
}
}
}
console.log(`Static export semantics verified for ${localizedHtmlFiles.length} localized pages.`);
const sitemap = await readFile(join(outputDirectory, "sitemap.xml"), "utf8");
for (const localizedHome of ["https://dbxio.com/en", "https://dbxio.com/cn"]) {
if (!sitemap.includes(`<loc>${localizedHome}</loc>`)) {
throw new Error(`sitemap.xml is missing localized URL: ${localizedHome}`);
}
}
console.log("Static export sitemap verified with independent English and Chinese URLs.");