feat(docs): optimize mobile layout and localized metadata
This commit is contained in:
parent
c43e8455c4
commit
ce3bb09796
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }> }) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue