feat(docs): optimize mobile layout and localized metadata
This commit is contained in:
28 files changed
+909
-396
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -1,4 +1,5 @@
|
|||||||
import { LandingNav } from "@/components/landing/LandingNav";
|
import { LandingNav } from "@/components/landing/LandingNav";
|
||||||
|
import { LandingFooter } from "@/components/landing/LandingFooter";
|
||||||
import { ChangelogRuntime } from "@/components/landing/ChangelogRuntime";
|
import { ChangelogRuntime } from "@/components/landing/ChangelogRuntime";
|
||||||
import { fetchChangelog } from "@/lib/changelog";
|
import { fetchChangelog } from "@/lib/changelog";
|
||||||
import { buildMetadata } from "@/lib/metadata";
|
import { buildMetadata } from "@/lib/metadata";
|
||||||
@@ -35,17 +36,19 @@ export default async function ChangelogPage({ params }: { params: Promise<{ lang
|
|||||||
const initialData = await fetchChangelog(l);
|
const initialData = await fetchChangelog(l);
|
||||||
|
|
||||||
return (
|
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" />
|
<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>
|
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
|
||||||
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
|
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
|
||||||
</div>
|
</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} />
|
<ChangelogRuntime lang={l} initialReleases={initialData.releases} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<LandingFooter lang={l} />
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LandingNav } from "@/components/landing/LandingNav";
|
import { LandingNav } from "@/components/landing/LandingNav";
|
||||||
|
import { LandingFooter } from "@/components/landing/LandingFooter";
|
||||||
import { buildMetadata } from "@/lib/metadata";
|
import { buildMetadata } from "@/lib/metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
@@ -79,15 +80,15 @@ export default async function CommunityPage({ params }: { params: Promise<{ lang
|
|||||||
const t = i18n[l];
|
const t = i18n[l];
|
||||||
|
|
||||||
return (
|
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" />
|
<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>
|
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
|
||||||
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
|
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 mt-8">
|
||||||
{channels.map((ch) => {
|
{channels.map((ch) => {
|
||||||
const meta = t[ch.id as keyof typeof t] as { name: string; desc: string };
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
<LandingFooter lang={l} />
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { ContributorsExperience } from "@/components/contributors/ContributorsEx
|
|||||||
import { LandingFooter } from "@/components/landing/LandingFooter";
|
import { LandingFooter } from "@/components/landing/LandingFooter";
|
||||||
import { LandingNav } from "@/components/landing/LandingNav";
|
import { LandingNav } from "@/components/landing/LandingNav";
|
||||||
import type { ContributorActivityData } from "@/lib/contributorActivity";
|
import type { ContributorActivityData } from "@/lib/contributorActivity";
|
||||||
import { SITE_URL } from "@/lib/metadata";
|
import { buildMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
const pageMetadata = {
|
const pageMetadata = {
|
||||||
en: {
|
en: {
|
||||||
@@ -22,22 +22,12 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
|||||||
const locale = lang === "cn" ? "cn" : "en";
|
const locale = lang === "cn" ? "cn" : "en";
|
||||||
const metadata = pageMetadata[locale];
|
const metadata = pageMetadata[locale];
|
||||||
|
|
||||||
return {
|
return buildMetadata({
|
||||||
...metadata,
|
title: metadata.title,
|
||||||
alternates: {
|
description: metadata.description,
|
||||||
canonical: `${SITE_URL}/${locale}/contributors`,
|
path: `/${locale}/contributors`,
|
||||||
languages: {
|
lang: locale,
|
||||||
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`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContributorsPage({ params }: { params: Promise<{ lang: string }> }) {
|
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 type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LandingNav } from "@/components/landing/LandingNav";
|
import { LandingNav } from "@/components/landing/LandingNav";
|
||||||
|
import { LandingFooter } from "@/components/landing/LandingFooter";
|
||||||
import { Spotlight } from "@/components/aceternity/Spotlight";
|
import { Spotlight } from "@/components/aceternity/Spotlight";
|
||||||
import { RevealSection } from "@/components/landing/RevealSection";
|
import { RevealSection } from "@/components/landing/RevealSection";
|
||||||
|
import { ExpandableDatabaseGrid } from "@/components/landing/ExpandableDatabaseGrid";
|
||||||
import { buildMetadata } from "@/lib/metadata";
|
import { buildMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
const databaseSupport = [
|
const databaseSupport = [
|
||||||
@@ -130,48 +132,48 @@ export default async function DatabasesPage({ params }: { params: Promise<{ lang
|
|||||||
const t = i18n[l];
|
const t = i18n[l];
|
||||||
|
|
||||||
return (
|
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" />
|
<LandingNav lang={l} active="databases" />
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="relative pt-28 pb-6">
|
<section className="relative overflow-hidden pt-28 pb-6">
|
||||||
<Spotlight />
|
<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>
|
<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>
|
<p className="mt-3 text-landing-muted text-lg max-w-[640px]">{t.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Database Grid */}
|
{/* Database Grid */}
|
||||||
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-10">
|
<RevealSection className="max-w-[1180px] mx-auto px-7 pb-10 max-[760px]:px-[18px]">
|
||||||
<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) => {
|
{databaseSupport.map((db) => {
|
||||||
const isCta = "href" in db && db.href;
|
const isCta = "href" in db && db.href;
|
||||||
const CardTag = isCta ? "a" : "div";
|
const CardTag = isCta ? "a" : "div";
|
||||||
return (
|
return (
|
||||||
<CardTag
|
<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}
|
key={db.name}
|
||||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||||
data-stagger
|
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 ? (
|
{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>
|
<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>
|
</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>
|
</CardTag>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ExpandableDatabaseGrid>
|
||||||
</RevealSection>
|
</RevealSection>
|
||||||
|
|
||||||
{/* Vendor CTA */}
|
{/* 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">
|
<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>
|
<h2 className="text-[21px] font-[720]">{t.ctaTitle}</h2>
|
||||||
<p className="mt-2 text-landing-muted text-sm leading-[1.65]">{t.ctaDesc}</p>
|
<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>
|
</RevealSection>
|
||||||
|
|
||||||
{/* Footer link to docs */}
|
{/* 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]">
|
<Link href={`/${l}/docs/databases`} className="landing-inline-link inline-flex items-center gap-[7px] text-sm font-[650]">
|
||||||
{t.footerLink}
|
{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>
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<LandingFooter lang={l} />
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,14 +88,16 @@ export default async function Page({ params }: { params: Promise<{ lang: string;
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocsPage toc={toc}>
|
<main className="contents">
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
|
<DocsPage toc={toc}>
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
|
||||||
<DocsTitle>{title}</DocsTitle>
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }} />
|
||||||
<DocsBody>
|
<DocsTitle>{title}</DocsTitle>
|
||||||
<MDX components={mdxComponents} />
|
<DocsBody>
|
||||||
</DocsBody>
|
<MDX components={mdxComponents} />
|
||||||
</DocsPage>
|
</DocsBody>
|
||||||
|
</DocsPage>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default async function Layout({ params, children }: { params: Promise<{ l
|
|||||||
nav={{
|
nav={{
|
||||||
title: (
|
title: (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="font-semibold">DBX</span>
|
||||||
</div>
|
</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">
|
<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="max-w-[1180px] mx-auto px-7 max-[760px]:px-[18px]">
|
||||||
<div className="grid justify-items-center max-w-[900px] mx-auto text-center">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+53
-24
@@ -1,9 +1,11 @@
|
|||||||
|
import "../global.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||||
import { StaticSearchDialog } from "@/components/StaticSearchDialog";
|
import { StaticSearchDialog } from "@/components/StaticSearchDialog";
|
||||||
import { i18nUI } from "@/lib/i18n";
|
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 }> = {
|
const LOCALE_MAP: Record<string, { locale: string; title: string; description: string }> = {
|
||||||
en: {
|
en: {
|
||||||
@@ -23,41 +25,68 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
|||||||
const l = lang === "cn" ? "cn" : "en";
|
const l = lang === "cn" ? "cn" : "en";
|
||||||
const meta = LOCALE_MAP[l];
|
const meta = LOCALE_MAP[l];
|
||||||
|
|
||||||
|
const pageMetadata = buildMetadata({
|
||||||
|
title: meta.title,
|
||||||
|
description: meta.description,
|
||||||
|
path: `/${l}`,
|
||||||
|
lang: l,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...pageMetadata,
|
||||||
title: {
|
title: {
|
||||||
default: meta.title,
|
default: meta.title,
|
||||||
template: `%s | ${SITE_NAME}`,
|
template: `%s | ${SITE_NAME}`,
|
||||||
},
|
},
|
||||||
description: meta.description,
|
metadataBase: new URL(SITE_URL),
|
||||||
openGraph: {
|
icons: {
|
||||||
locale: meta.locale,
|
icon: "/favicon.png",
|
||||||
siteName: SITE_NAME,
|
shortcut: "/favicon.png",
|
||||||
url: `${SITE_URL}/${l}`,
|
apple: "/logo.png",
|
||||||
},
|
|
||||||
alternates: {
|
|
||||||
canonical: `${SITE_URL}/${l}`,
|
|
||||||
languages: {
|
|
||||||
en: `${SITE_URL}/en`,
|
|
||||||
zh: `${SITE_URL}/cn`,
|
|
||||||
"x-default": `${SITE_URL}/en`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
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 }) {
|
export default async function LangLayout({ params, children }: { params: Promise<{ lang: string }>; children: ReactNode }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const locale = lang === "cn" ? "cn" : "en";
|
||||||
|
const siteStructuredData = buildSiteStructuredData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootProvider
|
<html lang={getHtmlLang(locale)} suppressHydrationWarning>
|
||||||
i18n={i18nUI.provider(lang)}
|
<head>
|
||||||
search={{
|
<script
|
||||||
SearchDialog: StaticSearchDialog,
|
defer
|
||||||
}}
|
src="https://analytics.unihub.top/script.js"
|
||||||
theme={{ defaultTheme: "system", enableSystem: true }}
|
data-website-id="69afbe68-e06e-4fa8-84cd-e47d6d44baf0"
|
||||||
>
|
data-domains="dbxio.com,www.dbxio.com"
|
||||||
{children}
|
/>
|
||||||
</RootProvider>
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-25
@@ -10,12 +10,15 @@ import { InstallTabs } from "@/components/landing/InstallTabs";
|
|||||||
import { LandingLatestUpdates } from "@/components/landing/LandingLatestUpdates";
|
import { LandingLatestUpdates } from "@/components/landing/LandingLatestUpdates";
|
||||||
import { RevealSection } from "@/components/landing/RevealSection";
|
import { RevealSection } from "@/components/landing/RevealSection";
|
||||||
import { ContributorsWallContent } from "@/components/landing/ContributorsWall";
|
import { ContributorsWallContent } from "@/components/landing/ContributorsWall";
|
||||||
|
import { ExpandableDatabaseGrid } from "@/components/landing/ExpandableDatabaseGrid";
|
||||||
import contributorSnapshot from "@/data/contributors.json";
|
import contributorSnapshot from "@/data/contributors.json";
|
||||||
import type { ContributorActivityData } from "@/lib/contributorActivity";
|
import type { ContributorActivityData } from "@/lib/contributorActivity";
|
||||||
import { contributorsFromActivity } from "@/lib/contributors";
|
import { contributorsFromActivity } from "@/lib/contributors";
|
||||||
import { getAppVersion } from "@/lib/appVersion";
|
import { getAppVersion } from "@/lib/appVersion";
|
||||||
import { fetchChangelog } from "@/lib/changelog";
|
import { fetchChangelog } from "@/lib/changelog";
|
||||||
import { fetchLatestReleaseInfo } from "@/lib/latestRelease";
|
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";
|
import { ArrowRight, Bot, Database, FileCode, GitCompare, Network, Search, Shield, Table, Terminal, Zap } from "lucide-react";
|
||||||
|
|
||||||
function formatStars(count: number) {
|
function formatStars(count: number) {
|
||||||
@@ -427,8 +430,6 @@ const i18nText = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
import { buildMetadata } from "@/lib/metadata";
|
|
||||||
|
|
||||||
const landingMeta = {
|
const landingMeta = {
|
||||||
en: {
|
en: {
|
||||||
title: "DBX - 20 MB to manage 70+ databases!",
|
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 contributors = contributorsFromActivity(contributorData.contributors);
|
||||||
const initialDownloadVersion = initialLatestRelease?.version ?? appVersion;
|
const initialDownloadVersion = initialLatestRelease?.version ?? appVersion;
|
||||||
const testimonialItems = testimonials[l];
|
const testimonialItems = testimonials[l];
|
||||||
|
const softwareStructuredData = buildSoftwareApplicationStructuredData(l, initialDownloadVersion);
|
||||||
const sponsorItems = [
|
const sponsorItems = [
|
||||||
{
|
{
|
||||||
name: "RainYun",
|
name: "RainYun",
|
||||||
@@ -496,18 +498,19 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="landing">
|
<main className="landing" lang={getHtmlLang(l)}>
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareStructuredData) }} />
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<LandingNav lang={l} active="home" />
|
<LandingNav lang={l} active="home" />
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="landing-hero">
|
<section className="landing-hero" aria-labelledby="landing-title">
|
||||||
<Spotlight />
|
<Spotlight />
|
||||||
<div className="relative z-[1] max-w-[1180px] mx-auto px-7 max-[1040px]:max-w-[920px] max-[760px]:px-[18px]">
|
<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]">
|
<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>
|
<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-[760px]:text-[15px] max-[760px]:leading-[1.68] max-[760px]:whitespace-normal max-[760px]:max-w-[320px]">{t.heroSubtitle}</p>
|
<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">
|
<div className="w-full max-w-[520px] mt-10 max-[760px]:mt-7">
|
||||||
<InstallTabs lang={l} version={initialDownloadVersion} />
|
<InstallTabs lang={l} version={initialDownloadVersion} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,9 +519,9 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* 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) => (
|
{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>
|
<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>
|
<span className="block mt-1 text-landing-muted text-[13px]">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,7 +529,7 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
|||||||
</RevealSection>
|
</RevealSection>
|
||||||
|
|
||||||
{/* Doc start */}
|
{/* 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>
|
<div>
|
||||||
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.docsStart}</h2>
|
<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>
|
<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>
|
<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>
|
<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>
|
||||||
<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) => (
|
{workflowItems.map((item, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.title}
|
key={item.title}
|
||||||
href={item.href}
|
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"
|
target="_blank"
|
||||||
data-stagger
|
data-stagger
|
||||||
>
|
>
|
||||||
@@ -576,30 +579,30 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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) => {
|
{databaseSupport.map((db) => {
|
||||||
const isCta = "href" in db && db.href;
|
const isCta = "href" in db && db.href;
|
||||||
const CardTag = isCta ? "a" : "div";
|
const CardTag = isCta ? "a" : "div";
|
||||||
return (
|
return (
|
||||||
<CardTag
|
<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}
|
key={db.name}
|
||||||
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
{...(isCta ? { href: db.href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
style={{ "--db-tone": db.tone } as CSSProperties}
|
style={{ "--db-tone": db.tone } as CSSProperties}
|
||||||
data-stagger
|
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 ? (
|
{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>
|
<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>
|
</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>
|
</CardTag>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ExpandableDatabaseGrid>
|
||||||
</RevealSection>
|
</RevealSection>
|
||||||
|
|
||||||
{/* Testimonials */}
|
{/* 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">
|
<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>
|
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.capabilitiesTitle}</h2>
|
||||||
</div>
|
</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) => (
|
{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" />
|
<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>
|
<span className="text-landing-ink text-[13px] font-[560] leading-[1.45]">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,16 +640,16 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
|
|||||||
{/* Sponsor */}
|
{/* Sponsor */}
|
||||||
<RevealSection className="max-w-[1180px] mx-auto px-7 mt-10 max-[760px]:px-[18px]">
|
<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>
|
<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) => (
|
{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">
|
<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" 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)]">
|
<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} className={sponsor.logoClass} />
|
<img src={sponsor.logo} alt={sponsor.name} width={112} height={56} loading="lazy" decoding="async" className={sponsor.logoClass} />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0 flex-1 max-[560px]:mt-4">
|
<div className="min-w-0 flex-1 max-[560px]:mt-4">
|
||||||
<h2 className="text-lg font-[720] text-landing-ink">{sponsor.name}</h2>
|
<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>
|
<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}
|
{sponsor.action}
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+258
-24
@@ -67,6 +67,8 @@
|
|||||||
html {
|
html {
|
||||||
background: var(--color-fd-background);
|
background: var(--color-fd-background);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
overflow-x: clip;
|
||||||
|
scroll-padding-top: 84px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -76,9 +78,15 @@ html {
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--color-fd-background);
|
background: var(--color-fd-background);
|
||||||
|
overflow-x: clip;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.dark body {
|
.dark body {
|
||||||
background: var(--color-fd-background);
|
background: var(--color-fd-background);
|
||||||
}
|
}
|
||||||
@@ -446,6 +454,15 @@ a {
|
|||||||
opacity: 1;
|
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 {
|
.landing-nav-link {
|
||||||
transition:
|
transition:
|
||||||
background 0.16s ease,
|
background 0.16s ease,
|
||||||
@@ -459,6 +476,79 @@ a {
|
|||||||
transform: translateY(-1px);
|
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 */
|
/* Hero */
|
||||||
|
|
||||||
.landing-hero {
|
.landing-hero {
|
||||||
@@ -591,17 +681,22 @@ a {
|
|||||||
0 0 0 1px rgba(255, 255, 255, 0.78) inset;
|
0 0 0 1px rgba(255, 255, 255, 0.78) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-install-trigger > svg:first-child {
|
.landing-install-primary,
|
||||||
fill: currentColor;
|
.landing-install-toggle {
|
||||||
stroke-width: 0;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-install-trigger > svg:last-child {
|
.landing-install-primary:focus-visible,
|
||||||
color: #5f6876;
|
.landing-install-toggle:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-install-toggle svg {
|
||||||
transition: transform 0.16s ease;
|
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);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,12 +710,10 @@ a {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition:
|
transition:
|
||||||
opacity 0.16s ease,
|
opacity 0.16s ease,
|
||||||
transform 0.16s ease,
|
transform 0.16s ease;
|
||||||
visibility 0.16s ease;
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-install:hover .landing-install-menu,
|
|
||||||
.landing-install[data-open="true"] .landing-install-menu {
|
.landing-install[data-open="true"] .landing-install-menu {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -707,20 +800,38 @@ a {
|
|||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-product-dots button {
|
.landing-product-dot {
|
||||||
background: rgba(226, 232, 240, 0.38);
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
color: 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:
|
transition:
|
||||||
width 0.2s ease,
|
width 0.2s ease,
|
||||||
background 0.2s ease,
|
background 0.2s ease,
|
||||||
opacity 0.2s ease;
|
opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-product-dots button[aria-current="true"] {
|
.landing-product-dot[aria-current="true"]::before {
|
||||||
width: 28px;
|
width: 22px;
|
||||||
background: color-mix(in srgb, var(--color-landing-sky) 84%, white);
|
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 {
|
.landing-product-dots span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -751,6 +862,7 @@ a {
|
|||||||
/* Inline link */
|
/* Inline link */
|
||||||
|
|
||||||
.landing-inline-link {
|
.landing-inline-link {
|
||||||
|
min-height: 28px;
|
||||||
color: var(--color-landing-blue);
|
color: var(--color-landing-blue);
|
||||||
transition:
|
transition:
|
||||||
color 0.16s ease,
|
color 0.16s ease,
|
||||||
@@ -986,6 +1098,14 @@ a {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.changelog-release-summary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-release > .changelog-release-body {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@media (max-width: 1040px) {
|
||||||
@@ -1003,8 +1123,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@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 {
|
.landing-hero {
|
||||||
padding-top: 104px;
|
padding-top: 96px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,20 +1145,23 @@ a {
|
|||||||
|
|
||||||
.aceternity-spotlight {
|
.aceternity-spotlight {
|
||||||
inset: -18% auto auto 50%;
|
inset: -18% auto auto 50%;
|
||||||
width: 96vw;
|
width: min(96vw, 680px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-product-dots {
|
.landing-product:hover {
|
||||||
right: 12px;
|
transform: none;
|
||||||
bottom: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-workflow-card {
|
.landing-workflow-card {
|
||||||
border-right: 0;
|
border-right: 1px solid var(--color-landing-line);
|
||||||
border-bottom: 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;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,16 +1172,122 @@ a {
|
|||||||
|
|
||||||
.landing-testimonial-wall::before,
|
.landing-testimonial-wall::before,
|
||||||
.landing-testimonial-wall::after {
|
.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 {
|
.landing-contributor-grid {
|
||||||
gap: 8px;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, 32px);
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-contributor-avatar {
|
.landing-contributor-avatar {
|
||||||
width: 46px;
|
width: 32px;
|
||||||
height: 46px;
|
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">
|
<div className="relative aspect-[16/10] overflow-hidden">
|
||||||
{productSlides.map((slide, index) => (
|
{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>
|
||||||
<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) => (
|
{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>
|
<span>{slide.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export function InfiniteMovingCards({ items, direction = "left", speed = "normal
|
|||||||
const repeatedItems = [...items, ...items];
|
const repeatedItems = [...items, ...items];
|
||||||
|
|
||||||
return (
|
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]">
|
<div className="landing-testimonial-track flex w-max min-w-full gap-3.5 px-[7px]">
|
||||||
{repeatedItems.map((item, index) => (
|
{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">
|
<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>
|
<figcaption>
|
||||||
<strong className="block text-landing-ink text-[15px] font-[740] leading-[1.25]">{item.name}</strong>
|
<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>
|
<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 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 small { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||||
.directoryGrid svg { color: #718396; }
|
.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; }
|
.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); }
|
.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; }
|
.directorySection { padding-right: 18px; padding-left: 18px; }
|
||||||
.directoryHeader { align-items: stretch; flex-direction: column; }
|
.directoryHeader { align-items: stretch; flex-direction: column; }
|
||||||
.directoryGrid { grid-template-columns: 1fr; }
|
.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 { min-height: 390px; padding: 30px; }
|
||||||
.certificatePreview > p { max-width: 68%; }
|
.certificatePreview > p { max-width: 68%; }
|
||||||
.previewSeal { right: 34px; width: 74px; height: 74px; font-size: 22px; }
|
.previewSeal { right: 34px; width: 74px; height: 74px; font-size: 22px; }
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const copy = {
|
|||||||
stars: "GitHub stars",
|
stars: "GitHub stars",
|
||||||
directory: "Contributor directory",
|
directory: "Contributor directory",
|
||||||
search: "Search GitHub login",
|
search: "Search GitHub login",
|
||||||
|
showAll: "Show all contributors",
|
||||||
|
showLess: "Show fewer contributors",
|
||||||
noResults: "No matching contributor.",
|
noResults: "No matching contributor.",
|
||||||
signedInAs: "Verified as",
|
signedInAs: "Verified as",
|
||||||
claim: "Open my certificate",
|
claim: "Open my certificate",
|
||||||
@@ -47,6 +49,8 @@ const copy = {
|
|||||||
stars: "个 GitHub Star",
|
stars: "个 GitHub Star",
|
||||||
directory: "完整贡献者名单",
|
directory: "完整贡献者名单",
|
||||||
search: "搜索 GitHub 用户名",
|
search: "搜索 GitHub 用户名",
|
||||||
|
showAll: "展开全部贡献者",
|
||||||
|
showLess: "收起贡献者名单",
|
||||||
noResults: "没有匹配的贡献者。",
|
noResults: "没有匹配的贡献者。",
|
||||||
signedInAs: "已验证身份",
|
signedInAs: "已验证身份",
|
||||||
claim: "打开我的证书",
|
claim: "打开我的证书",
|
||||||
@@ -185,6 +189,7 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
|
|||||||
const [auth, setAuth] = useState<AuthState>({ status: "loading" });
|
const [auth, setAuth] = useState<AuthState>({ status: "loading" });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [certificateOpen, setCertificateOpen] = useState(false);
|
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 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]);
|
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.heroCertificate}>
|
||||||
<div className={styles.certificatePreview}>
|
<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>
|
<small>{text.certificate}</small>
|
||||||
<strong>@{verifiedContributor?.login ?? "YOUR_NAME"}</strong>
|
<strong>@{verifiedContributor?.login ?? "YOUR_NAME"}</strong>
|
||||||
<p>{text.certificateBody}</p>
|
<p>{text.certificateBody}</p>
|
||||||
@@ -271,7 +276,7 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
|
|||||||
<section className={styles.directorySection}>
|
<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>
|
<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 ? (
|
{filtered.length ? (
|
||||||
<div className={styles.directoryGrid}>
|
<div className={styles.directoryGrid} data-expanded={directoryExpanded || query.trim().length > 0}>
|
||||||
{filtered.map((contributor) => (
|
{filtered.map((contributor) => (
|
||||||
<a key={contributor.login} href={contributor.profileUrl} target="_blank" rel="noopener noreferrer">
|
<a key={contributor.login} href={contributor.profileUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<img src={contributor.avatarUrl} alt="" width={46} height={46} loading="lazy" />
|
<img src={contributor.avatarUrl} alt="" width={46} height={46} loading="lazy" />
|
||||||
@@ -281,13 +286,18 @@ export function ContributorsExperience({ data, lang }: { data: ContributorActivi
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <div className={styles.empty}>{text.noResults}</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>
|
</section>
|
||||||
|
|
||||||
{certificateOpen && verifiedContributor ? (
|
{certificateOpen && verifiedContributor ? (
|
||||||
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCertificateOpen(false); }}>
|
<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}>
|
<section className={styles.modal} role="dialog" aria-modal="true" aria-label={text.certificate}>
|
||||||
<div className={styles.fullCertificate}>
|
<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>
|
<span className={styles.verifiedText}>{text.verified}</span>
|
||||||
<small>{text.certificate}</small>
|
<small>{text.certificate}</small>
|
||||||
<em>{text.awardedTo}</em>
|
<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" });
|
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" };
|
const t = lang === "cn" ? { publishedOn: "发布于", download: "下载", seeGitHub: "查看 GitHub Release 获取详情" } : { publishedOn: "Published on", download: "Download", seeGitHub: "See GitHub Release for details" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-12 border-t border-[rgba(155,176,205,0.18)]">
|
<details className="changelog-release border-t border-[rgba(155,176,205,0.18)]" open={featured || undefined}>
|
||||||
{/* Header */}
|
<summary className="changelog-release-summary min-h-[72px] cursor-pointer list-none items-center justify-between gap-4 py-4 text-[#e2e8f0]">
|
||||||
<div className="flex items-center justify-between gap-4 mb-8">
|
<span className="min-w-0">
|
||||||
<div className="flex items-center gap-4">
|
<strong className="block truncate text-[17px] font-[720]">Release {release.tag}</strong>
|
||||||
<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]">
|
<span className="mt-1 block text-xs text-[#64748b]">{formatDate(release.date, lang)}</span>
|
||||||
<Tag size={13} className="text-[#6ea8ff]" />
|
</span>
|
||||||
{release.tag.replace("v", "")}
|
<ChevronDown className="changelog-release-chevron shrink-0 text-[#6ea8ff]" size={18} />
|
||||||
</span>
|
</summary>
|
||||||
<span className="text-[15px] text-[#64748b]">
|
<div className="changelog-release-body py-12 max-[760px]:py-8">
|
||||||
{t.publishedOn} {formatDate(release.date, lang)}
|
<div className="flex items-center justify-between gap-4 mb-8 max-[760px]:items-start max-[760px]:flex-wrap max-[760px]:mb-6">
|
||||||
</span>
|
<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>
|
</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}
|
<h2 className="text-[28px] font-[720] text-[#f7fbff] mb-10 max-[760px]:text-2xl max-[760px]:mb-7">Release {release.tag}</h2>
|
||||||
<ChevronDown size={14} />
|
|
||||||
</a>
|
{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>
|
</div>
|
||||||
|
</details>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +107,8 @@ export function ChangelogList({ releases, lang }: { releases: ChangelogRelease[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{visible.map((release) => (
|
{visible.map((release, index) => (
|
||||||
<ReleaseCard key={release.tag} release={release} lang={lang} />
|
<ReleaseCard key={release.tag} release={release} lang={lang} featured={index === 0} />
|
||||||
))}
|
))}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={sentinelRef} className="flex justify-center py-12 text-[#64748b] text-sm">
|
<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());
|
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 primary = useMemo(() => options.find((o) => o.id === platformId) ?? options[0], [options, platformId]);
|
||||||
const menuOptions = useMemo(() => options.filter((o) => o.id !== platformId), [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);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setOpen(true)}
|
|
||||||
onMouseLeave={() => setOpen(false)}
|
|
||||||
>
|
>
|
||||||
<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">
|
||||||
aria-controls="landing-install-menu"
|
<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}>
|
||||||
aria-expanded={open}
|
<PlatformIcon id={primary.id} size={30} variant="dark" />
|
||||||
aria-haspopup="listbox"
|
<span className="grid gap-0.5 min-w-0 text-left">
|
||||||
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"
|
<strong className="overflow-hidden text-[15px] font-[780] leading-[1.2] truncate">{downloadLabel[lang]}</strong>
|
||||||
href={primary.href}
|
<small className="overflow-hidden text-xs font-[520] leading-tight truncate text-[color-mix(in_srgb,#0f172a_48%,#94a3b8)]">{primary.label}</small>
|
||||||
onFocus={() => setOpen(true)}
|
</span>
|
||||||
>
|
</a>
|
||||||
<PlatformIcon id={primary.id} size={30} variant="dark" />
|
<button
|
||||||
<span className="grid gap-0.5 min-w-0">
|
type="button"
|
||||||
<strong className="overflow-hidden text-[15px] font-[780] leading-[1.2] truncate">{downloadLabel[lang]}</strong>
|
aria-controls="landing-install-menu"
|
||||||
<small className="overflow-hidden text-xs font-[520] leading-tight truncate text-[color-mix(in_srgb,#0f172a_48%,#94a3b8)]">{primary.label}</small>
|
aria-expanded={open}
|
||||||
</span>
|
aria-haspopup="menu"
|
||||||
<ChevronDown size={18} />
|
aria-label={lang === "cn" ? "显示其他下载选项" : "Show other download options"}
|
||||||
</a>
|
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
|
<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"
|
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"
|
id="landing-install-menu"
|
||||||
role="listbox"
|
role="menu"
|
||||||
aria-label={lang === "cn" ? "下载选项" : "Download options"}
|
aria-label={lang === "cn" ? "下载选项" : "Download options"}
|
||||||
>
|
>
|
||||||
{menuOptions.map((item) => (
|
{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" />
|
<PlatformIcon id={item.id} size={20} variant="light" />
|
||||||
<strong className="overflow-hidden text-sm font-[640] leading-[1.2] truncate">{item.label}</strong>
|
<strong className="overflow-hidden text-sm font-[640] leading-[1.2] truncate">{item.label}</strong>
|
||||||
<Download size={15} aria-hidden="true" />
|
<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="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">
|
<div className="flex items-center justify-between gap-4 max-[760px]:flex-col max-[760px]:gap-3 max-[760px]:text-center">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href={`/${lang}`} className="flex items-center gap-2.5 text-[var(--color-landing-ink)] text-lg font-[820] shrink-0">
|
<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="DBX" width={22} height={22} />
|
<img src="/logo.png" alt="" aria-hidden="true" width={22} height={22} />
|
||||||
<span>DBX</span>
|
<span>DBX</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -37,13 +37,13 @@ export function LandingFooter({ lang }: { lang: "en" | "cn" }) {
|
|||||||
|
|
||||||
{/* Repo icons */}
|
{/* Repo icons */}
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<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 />
|
<GithubIcon />
|
||||||
</a>
|
</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} />
|
<img src="/icons/cnb.svg" alt="CNB" width={18} height={18} />
|
||||||
</a>
|
</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} />
|
<img src="/icons/atomgit.png" alt="AtomGit" width={18} height={18} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Github } from "lucide-react";
|
import { Github, Menu, X } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const i18n = {
|
const i18n = {
|
||||||
en: {
|
en: {
|
||||||
@@ -14,12 +14,26 @@ const i18n = {
|
|||||||
contributors: "Contributors",
|
contributors: "Contributors",
|
||||||
drivers: "Offline Drivers",
|
drivers: "Offline Drivers",
|
||||||
langLabel: "Switch to Chinese",
|
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" }) {
|
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "sponsors" | "contributors" | "drivers" }) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const t = i18n[lang];
|
const t = i18n[lang];
|
||||||
const otherLang = lang === "cn" ? "en" : "cn";
|
const otherLang = lang === "cn" ? "en" : "cn";
|
||||||
const langHrefMap: Record<string, string> = {
|
const langHrefMap: Record<string, string> = {
|
||||||
@@ -31,6 +45,15 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
|
|||||||
drivers: `/${otherLang}/drivers`,
|
drivers: `/${otherLang}/drivers`,
|
||||||
};
|
};
|
||||||
const langHref = langHrefMap[active ?? ""] ?? `/${otherLang}`;
|
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(() => {
|
useEffect(() => {
|
||||||
const node = ref.current;
|
const node = ref.current;
|
||||||
@@ -45,41 +68,84 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
|
|||||||
return () => window.removeEventListener("scroll", onScroll);
|
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 (
|
return (
|
||||||
<nav ref={ref} className="landing-nav">
|
<nav ref={ref} className={`landing-nav${menuOpen ? " is-menu-open" : ""}`} aria-label={lang === "cn" ? "主导航" : "Primary navigation"}>
|
||||||
<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">
|
<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="flex items-center gap-2.5 text-landing-ink text-2xl font-[820]">
|
<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="DBX" width={28} height={28} />
|
<img src="/logo.png" alt="" aria-hidden="true" width={28} height={28} />
|
||||||
<span>DBX</span>
|
<span>DBX</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1">
|
<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"}`}>
|
{navItems.map((item) => (
|
||||||
{t.home}
|
<Link
|
||||||
</Link>
|
key={item.id}
|
||||||
<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">
|
href={item.href}
|
||||||
{t.docs}
|
aria-current={active === item.id ? "page" : undefined}
|
||||||
</Link>
|
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"}`}
|
||||||
<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}
|
{item.label}
|
||||||
</Link>
|
</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 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">
|
||||||
</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">
|
|
||||||
<Github size={18} strokeWidth={2} />
|
<Github size={18} strokeWidth={2} />
|
||||||
</Link>
|
</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
|
文/A
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
type RevealSectionProps = {
|
type RevealSectionProps = Omit<ComponentPropsWithoutRef<"section">, "children" | "className"> & {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
delay?: number;
|
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 ref = useRef<HTMLElement | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export function RevealSection({ children, className = "", delay = 0 }: RevealSec
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</section>
|
</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;
|
||||||
|
}
|
||||||
+41
-42
@@ -1,51 +1,50 @@
|
|||||||
# DBX - Database Workspace
|
# DBX
|
||||||
# https://dbxio.com
|
|
||||||
|
|
||||||
## What is DBX?
|
> 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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Core Features
|
Canonical website: https://dbxio.com
|
||||||
- **SQL Editor**: CodeMirror 6-based editor with syntax highlighting, auto-completion, formatting, and query history.
|
Source repository: https://github.com/t8y2/dbx
|
||||||
- **AI SQL Assistant**: Generate, explain, optimize, and fix SQL using Claude or OpenAI models.
|
License: Apache-2.0
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Supported Databases
|
## Product facts
|
||||||
MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, MariaDB, TiDB, and more.
|
|
||||||
|
|
||||||
## MCP Server
|
- Platforms: Windows, macOS (Apple Silicon and Intel), Linux, and Docker self-hosting.
|
||||||
```bash
|
- Database coverage: 70+ SQL, NoSQL, vector, time-series, embedded databases, compatible engines, and message queues.
|
||||||
npm install -g @dbx-app/mcp-server
|
- 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.
|
||||||
Configure `.mcp.json` to let AI agents query databases through DBX connections:
|
- AI workflows: generate, explain, optimize, and repair SQL with user-configured model providers.
|
||||||
```json
|
- 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.
|
||||||
"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.
|
|
||||||
|
|
||||||
## Key Links
|
## Install and deploy
|
||||||
- Documentation: https://dbxio.com/en/docs/what-is-dbx
|
|
||||||
|
- 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
|
- Changelog: https://dbxio.com/en/changelog
|
||||||
- Community: https://dbxio.com/en/community
|
- Community: https://dbxio.com/en/community
|
||||||
- GitHub: https://github.com/t8y2/dbx
|
|
||||||
- Drivers: https://dbxio.com/en/drivers
|
|
||||||
|
|
||||||
## Platform Support
|
## Chinese resources
|
||||||
- Desktop: Windows, macOS (Intel + Apple Silicon), Linux
|
|
||||||
- Docker: Self-hosted browser-based access
|
|
||||||
- MCP: npm package for AI agent integration
|
|
||||||
|
|
||||||
## Pricing
|
- 中文首页: https://dbxio.com/cn
|
||||||
DBX is free and open-source (AGPL-3.0).
|
- 中文文档: 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 OUT_DIR = resolve(import.meta.dirname, "../out");
|
||||||
const SITE_URL = "https://dbxio.com";
|
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"]);
|
const EXCLUDE = new Set(["index.html", "404.html", "_not-found.html"]);
|
||||||
|
|
||||||
function* walkDir(dir) {
|
function* walkDir(dir) {
|
||||||
@@ -51,9 +49,8 @@ const urls = [];
|
|||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
for (const [, langs] of pagesByPath) {
|
for (const [, langs] of pagesByPath) {
|
||||||
const primary = langs.en || langs.cn;
|
const localizedPages = [langs.en, langs.cn].filter(Boolean);
|
||||||
if (!primary || seen.has(primary)) continue;
|
if (localizedPages.length === 0) continue;
|
||||||
seen.add(primary);
|
|
||||||
|
|
||||||
const altLinks = [];
|
const altLinks = [];
|
||||||
if (langs.en) {
|
if (langs.en) {
|
||||||
@@ -67,7 +64,11 @@ for (const [, langs] of pagesByPath) {
|
|||||||
altLinks.push({ lang: "x-default", href: `${SITE_URL}${langs.en}` });
|
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"?>
|
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -78,7 +79,6 @@ ${urls
|
|||||||
(entry) =>
|
(entry) =>
|
||||||
` <url>
|
` <url>
|
||||||
<loc>${entry.loc}</loc>
|
<loc>${entry.loc}</loc>
|
||||||
<lastmod>${entry.lastmod}</lastmod>
|
|
||||||
${entry.altLinks.map((alt) => ` <xhtml:link rel="alternate" hreflang="${alt.lang}" href="${alt.href}" />`).join("\n")}
|
${entry.altLinks.map((alt) => ` <xhtml:link rel="alternate" hreflang="${alt.lang}" href="${alt.href}" />`).join("\n")}
|
||||||
</url>`,
|
</url>`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readdir, readFile } from "node:fs/promises";
|
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";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const outputDirectory = fileURLToPath(new URL("../out/", import.meta.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.`);
|
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.");
|
||||||
Reference in new issue
Block a user