feat: redesign docs landing hero with Tailwind migration

- Migrate landing CSS from raw classes to Tailwind v4 utilities (~1180 → ~680 lines)
- Add @theme block with landing design tokens
- Shrink download button, add OS auto-detection
- Nav bar transparent at top, glass effect on scroll
- Adjust hero spacing, title size, background color
- Remove white background from logo and favicon
- Force subtitle to single line on desktop
This commit is contained in:
t8y2 2026-05-16 16:03:04 +08:00
parent 32aa7733f8
commit 5cbb659d54
8 changed files with 600 additions and 1093 deletions

View File

@ -3,6 +3,7 @@ import type { CSSProperties } from 'react';
import { HeroProductStage } from '@/components/aceternity/HeroProductStage';
import { InfiniteMovingCards } from '@/components/aceternity/InfiniteMovingCards';
import { Spotlight } from '@/components/aceternity/Spotlight';
import { LandingNav } from '@/components/landing/LandingNav';
import { InstallTabs } from '@/components/landing/InstallTabs';
import { RevealSection } from '@/components/landing/RevealSection';
import {
@ -349,11 +350,11 @@ const i18nText = {
navChangelog: 'Changelog',
navCommunity: 'Community',
lang: '中文',
eyebrow: 'Open-source database workspace',
heroTitle: 'A focused database client for daily work.',
heroTitle: 'A focused database client for daily work',
heroSubtitle:
'DBX brings connections, SQL editing, data grids, schema tools, AI assistance, and self-hosted access into one lightweight product.',
download: 'Download DBX',
downloadName: 'Download DBX',
readDocs: 'Read the docs',
docsStart: 'Start here',
docsStartDesc: 'Install DBX, create your first connection, and learn the main workflow.',
@ -375,10 +376,10 @@ const i18nText = {
navChangelog: '更新日志',
navCommunity: '社区',
lang: 'English',
eyebrow: '开源数据库工作台',
heroTitle: '专注日常工作的数据库客户端。',
heroTitle: '专注日常工作的数据库客户端',
heroSubtitle: 'DBX 将连接管理、SQL 编辑、数据表格、结构工具、AI 助手和自托管访问放进一个轻量产品里。',
download: '下载 DBX',
downloadName: '下载 DBX',
readDocs: '查看文档',
docsStart: '从这里开始',
docsStartDesc: '安装 DBX、创建第一个连接并了解主要工作流。',
@ -414,81 +415,90 @@ export default async function LandingPage({
return (
<main className="landing">
<nav className="landing-nav">
<div className="landing-nav-inner">
<Link href={`/${l}`} className="landing-logo">
<img src="/logo.png" alt="DBX" width={28} height={28} />
{/* Nav */}
<LandingNav>
<div className="flex items-center justify-between max-w-[1180px] h-16 mx-auto px-7 max-[760px]:min-h-[58px] max-[760px]:h-auto max-[760px]:px-[18px] max-[760px]:py-2.5">
<Link href={`/${l}`} className="flex items-center gap-2.5 text-landing-ink text-lg font-extrabold">
<img src="/logo.png" alt="DBX" width={28} height={28} className="rounded-[7px]" />
<span>DBX</span>
</Link>
<div className="landing-nav-links">
<Link href={`/${l}/docs/what-is-dbx`} target="_blank">
<div className="flex items-center gap-1">
<Link href={`/${l}/docs/what-is-dbx`} target="_blank" className="landing-nav-link rounded-[7px] px-[11px] py-2 text-landing-muted text-[13px] font-medium max-[760px]:hidden">
{t.navDocs}
</Link>
<Link href={`/${l}/docs/changelog`} target="_blank">
<Link href={`/${l}/docs/changelog`} target="_blank" className="landing-nav-link rounded-[7px] px-[11px] py-2 text-landing-muted text-[13px] font-medium max-[760px]:hidden">
{t.navChangelog}
</Link>
<Link href="https://github.com/t8y2/dbx" target="_blank">
<Link href="https://github.com/t8y2/dbx" target="_blank" className="landing-nav-link rounded-[7px] px-[11px] py-2 text-landing-muted text-[13px] font-medium max-[760px]:hidden">
GitHub
</Link>
<Link href={l === 'cn' ? '/en' : '/cn'} className="landing-lang-switch">
<Link href={l === 'cn' ? '/en' : '/cn'} className="landing-nav-link rounded-[7px] px-[11px] py-2 text-landing-muted text-[13px] font-medium ml-1.5 border border-landing-line">
{t.lang}
</Link>
</div>
</div>
</nav>
</LandingNav>
{/* Hero */}
<section className="landing-hero">
<Spotlight />
<div className="landing-hero-copy">
<div className="landing-eyebrow">{t.eyebrow}</div>
<h1>{t.heroTitle}</h1>
<p>{t.heroSubtitle}</p>
<div className="landing-hero-cta">
<Link href="https://github.com/t8y2/dbx/releases/latest" className="landing-btn-primary" target="_blank">
{t.download}
<ArrowRight size={16} />
</Link>
<Link href={`/${l}/docs/getting-started`} className="landing-btn-secondary" target="_blank">
{t.readDocs}
</Link>
<div className="relative z-[1] max-w-[1180px] mx-auto px-7 max-[1040px]:max-w-[920px] max-[760px]:px-[18px]">
<div className="landing-hero-copy relative z-[6] grid justify-items-center max-w-[900px] mx-auto text-center max-[1040px]:max-w-[760px]">
<h1 className="m-0 text-[clamp(36px,4.2vw,56px)] font-[820] leading-[1.06] text-landing-ink whitespace-nowrap max-[760px]:text-[clamp(26px,7vw,38px)]">
{t.heroTitle}
</h1>
<p className="landing-hero-subtitle mt-5 mx-auto text-[17px] font-[460] leading-[1.8] whitespace-nowrap max-[760px]:text-[15px] max-[760px]:leading-[1.68] max-[760px]:whitespace-normal max-[760px]:max-w-[320px]">
{t.heroSubtitle}
</p>
<div className="w-full max-w-[520px] mt-10">
<InstallTabs lang={l} />
</div>
</div>
<InstallTabs lang={l} />
<HeroProductStage />
</div>
<HeroProductStage />
</section>
<RevealSection className="landing-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">
{metricItems.map((item) => (
<div key={item.label}>
<strong>{item.value}</strong>
<span>{item.label}</span>
<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]">
<strong className="block text-landing-ink text-2xl font-[720]">{item.value}</strong>
<span className="block mt-1 text-landing-muted text-[13px]">{item.label}</span>
</div>
))}
</RevealSection>
<RevealSection className="landing-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]">
<div>
<h2>{t.docsStart}</h2>
<p>{t.docsStartDesc}</p>
<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>
</div>
<Link href={`/${l}/docs/getting-started`} className="landing-inline-link" target="_blank">
<Link href={`/${l}/docs/getting-started`} className="landing-inline-link flex shrink-0 items-center gap-[7px] text-sm font-[650] max-[760px]:mt-4" target="_blank">
{t.readDocs}
<ArrowRight size={15} />
</Link>
</RevealSection>
<RevealSection className="landing-section">
<div className="landing-section-heading">
<h2>{t.workflowsTitle}</h2>
<p>{t.workflowsDesc}</p>
{/* Workflows */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pt-[70px] pb-1 max-[760px]:px-[18px]">
<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.workflowsTitle}</h2>
<p className="mt-2 max-w-[650px] text-landing-muted text-sm leading-[1.65] justify-self-end text-right max-[760px]:max-w-none max-[760px]:text-left">{t.workflowsDesc}</p>
</div>
<div className="landing-workflow-grid">
{workflowItems.map((item) => (
<Link key={item.title} href={item.href} className="landing-workflow-card" target="_blank">
<item.icon size={20} />
<h3>{item.title}</h3>
<p>{item.desc}</p>
<span>
<div className="landing-workflow-grid grid grid-cols-4 rounded-[10px] overflow-hidden max-[1040px]:grid-cols-2 max-[760px]:grid-cols-1">
{workflowItems.map((item, i) => (
<Link
key={item.title}
href={item.href}
className={`landing-workflow-card min-h-[250px] p-6 border-r border-r-landing-line max-[760px]:min-h-0 max-[760px]:border-r-0 max-[760px]:border-b max-[760px]:border-b-landing-line max-[760px]:last:border-b-0 ${i === workflowItems.length - 1 ? 'border-r-0' : ''}`}
target="_blank"
data-stagger
>
<item.icon size={20} className="text-landing-blue" />
<h3 className="mt-[18px] text-base font-bold">{item.title}</h3>
<p className="mt-2.5 text-landing-muted text-[13px] leading-[1.62]">{item.desc}</p>
<span className="inline-flex items-center gap-1.5 mt-[18px] text-landing-ink text-[13px] font-[650]">
{t.readDocs}
<ArrowRight size={14} />
</span>
@ -497,78 +507,83 @@ export default async function LandingPage({
</div>
</RevealSection>
<RevealSection className="landing-section landing-support">
<div className="landing-section-heading">
<h2>{t.supportTitle}</h2>
<p>{t.supportDesc}</p>
{/* Database support */}
<RevealSection className="relative max-w-[1180px] mx-auto px-7 pt-[70px] pb-1 max-[760px]:px-[18px]">
<div className="grid grid-cols-[minmax(260px,0.28fr)_minmax(0,0.72fr)] gap-9 items-end mb-[30px] max-[760px]:block">
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.supportTitle}</h2>
<p className="mt-2 max-w-[760px] text-landing-muted text-sm leading-[1.65] justify-self-end text-right max-[760px]:max-w-none max-[760px]:text-left">{t.supportDesc}</p>
</div>
<div className="landing-db-grid">
<div className="grid grid-cols-8 gap-3 max-[760px]:grid-cols-2 max-[760px]:gap-2.5 max-[760px]:mt-[18px]">
{databaseSupport.map((db) => (
<div className="landing-db-card" key={db.name} style={{ '--db-tone': db.tone } as CSSProperties}>
<div className="landing-db-icon">
{db.icon ? <img src={db.icon} alt="" width={42} height={42} /> : <span>{db.initials}</span>}
<div className="landing-db-card grid place-items-center min-h-[126px] rounded-[10px] px-2.5 py-[18px] max-[760px]:min-h-[122px] max-[760px]:py-4" key={db.name} style={{ '--db-tone': db.tone } as CSSProperties} data-stagger>
<div className="landing-db-icon grid place-items-center w-12 h-12 mb-[15px]">
{db.icon ? <img src={db.icon} alt="" width={38} height={38} className="block w-[38px] h-[38px] object-contain" /> : <span className="grid place-items-center min-w-[46px] h-8 rounded-lg px-2 text-white text-xs font-[780]">{db.initials}</span>}
</div>
<strong>{db.name}</strong>
<strong className="text-sm font-[650] leading-[1.2] text-center text-[color-mix(in_srgb,var(--color-landing-ink)_92%,var(--color-landing-muted))]">{db.name}</strong>
</div>
))}
</div>
</RevealSection>
<RevealSection className="landing-section landing-testimonials">
<div className="landing-section-heading">
<h2>{t.testimonialsTitle}</h2>
<p>{t.testimonialsDesc}</p>
{/* Testimonials */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pt-[70px] pb-1 overflow-hidden max-[760px]:px-[18px]">
<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.testimonialsTitle}</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.testimonialsDesc}</p>
</div>
<div className="landing-testimonial-wall">
<div className="landing-testimonial-wall relative grid gap-3.5 -mx-7 py-1 max-[760px]:-mx-[18px] max-[760px]:mt-[18px]">
<InfiniteMovingCards items={testimonialItems.slice(0, 6)} speed="slow" />
<InfiniteMovingCards items={testimonialItems.slice(6)} direction="right" speed="slow" />
</div>
</RevealSection>
<RevealSection className="landing-section">
<div className="landing-section-heading">
<h2>{t.capabilitiesTitle}</h2>
{/* Capabilities */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pt-[70px] pb-1 max-[760px]:px-[18px]">
<div className="grid grid-cols-[minmax(220px,0.42fr)_minmax(0,0.58fr)] gap-9 items-end mb-[22px] max-[760px]:block">
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.capabilitiesTitle}</h2>
</div>
<div className="landing-capability-grid">
<div className="grid grid-cols-3 gap-2.5 max-[1040px]:grid-cols-2 max-[760px]:grid-cols-1 max-[760px]:mt-[18px]">
{capabilityItems.map((item) => (
<div key={item.label} className="landing-capability">
<item.icon size={18} />
<span>{item.label}</span>
<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>
<item.icon size={18} className="shrink-0 text-landing-blue" />
<span className="text-landing-ink text-[13px] font-[560] leading-[1.45]">{item.label}</span>
</div>
))}
</div>
</RevealSection>
<RevealSection className="landing-updates">
<div className="landing-update-version">{latest.version}</div>
<div className="landing-update-copy">
<h2>{latest.title}</h2>
<p>{latest.desc}</p>
{/* Updates */}
<RevealSection className="grid grid-cols-[minmax(86px,0.16fr)_minmax(210px,0.32fr)_minmax(0,0.38fr)_max-content] gap-[22px] items-center max-w-[1180px] mx-auto px-7 border-t border-b border-landing-line mt-[62px] py-6 max-[1040px]:grid-cols-[minmax(0,1fr)_max-content] max-[760px]:block max-[760px]:px-[18px]">
<div className="landing-update-version w-max rounded-[7px] px-2.5 py-[7px] text-[13px] font-[720]">{latest.version}</div>
<div className="max-[1040px]:col-span-full max-[760px]:mt-4">
<h2 className="m-0 text-[21px] font-[720] text-landing-ink">{latest.title}</h2>
<p className="mt-1.5 text-landing-muted text-[13px] leading-[1.55]">{latest.desc}</p>
</div>
<ul className="landing-update-list">
<ul className="grid gap-2 m-0 p-0 list-none max-[1040px]:col-span-full max-[760px]:mt-4">
{latest.items.map((item) => (
<li key={item}>
<CheckCircle2 size={14} />
<li key={item} className="flex gap-2 items-center text-[13px] font-[560] leading-[1.45] text-[color-mix(in_srgb,var(--color-landing-ink)_88%,var(--color-landing-muted))]" data-stagger>
<CheckCircle2 size={14} className="shrink-0 text-landing-green" />
<span>{item}</span>
</li>
))}
</ul>
<Link href={`/${l}/docs/changelog`} className="landing-inline-link" target="_blank">
<Link href={`/${l}/docs/changelog`} className="landing-inline-link flex shrink-0 items-center gap-[7px] text-sm font-[650] max-[760px]:mt-4" target="_blank">
{latest.link}
<ArrowRight size={15} />
</Link>
</RevealSection>
<RevealSection className="landing-final">
{/* Final CTA */}
<RevealSection className="flex items-center justify-between gap-6 max-w-[1180px] mx-auto px-7 border border-landing-line rounded-[10px] bg-landing-panel mt-[72px] mb-14 py-[30px] max-[760px]:block max-[760px]:px-[18px]">
<div>
<h2>{t.footerTitle}</h2>
<p>{t.footerDesc}</p>
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{t.footerTitle}</h2>
<p className="mt-2 text-landing-muted text-sm leading-[1.65]">{t.footerDesc}</p>
</div>
<div className="landing-final-actions">
<Link href="https://github.com/t8y2/dbx/releases/latest" target="_blank">
<div className="flex items-center gap-2.5 flex-wrap justify-end max-[760px]:mt-[18px]">
<Link href="https://github.com/t8y2/dbx/releases/latest" target="_blank" className="landing-final-link inline-flex items-center justify-center min-h-[42px] rounded-[7px] px-[15px] text-sm font-[650]">
{t.release}
</Link>
<Link href={`/${l}/docs/getting-started#docker`} target="_blank">
<Link href={`/${l}/docs/getting-started#docker`} target="_blank" className="landing-final-link inline-flex items-center justify-center min-h-[42px] rounded-[7px] px-[15px] text-sm font-[650]">
{t.docker}
</Link>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,76 @@
'use client';
import { motion } from 'motion/react';
import { useEffect, useState } from 'react';
const productSlides = [
{
src: '/screenshot-dark.png',
alt: 'DBX data grid and AI assistant screenshot',
label: 'Data grid',
},
{
src: '/screenshot-connections.jpg',
alt: 'DBX connection management screenshot',
label: 'Connections',
},
{
src: '/screenshot-grid.png',
alt: 'DBX table data grid screenshot',
label: 'Tables',
},
{
src: '/screenshot-ai.jpg',
alt: 'DBX AI assistant screenshot',
label: 'AI',
},
];
export function HeroProductStage() {
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % productSlides.length);
}, 4200);
return () => window.clearInterval(timer);
}, []);
return (
<motion.div
className="landing-product"
initial={{ opacity: 0, y: 28, rotateX: 4, rotateY: -6 }}
animate={{ opacity: 1, y: 0, rotateX: 0, rotateY: 0 }}
transition={{ duration: 0.9, delay: 0.12, ease: [0.16, 1, 0.3, 1] }}
className="landing-product relative w-full mt-16 mb-12 overflow-hidden rounded-2xl max-[1040px]:max-w-[900px] max-[1040px]:mt-14 max-[760px]:mt-10 max-[760px]:mb-7 max-[760px]:rounded-xl"
initial={{ opacity: 0, y: 36, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.9, delay: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
<div className="landing-window-bar">
<span />
<span />
<span />
<div className="relative aspect-[16/10] overflow-hidden">
{productSlides.map((slide, index) => (
<img
alt={slide.alt}
aria-hidden={index !== activeSlide}
className="landing-product-slide absolute inset-0 z-[1] w-full h-full object-cover object-left-top"
data-active={index === activeSlide}
key={slide.src}
src={slide.src}
/>
))}
</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">
{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"
>
<span>{slide.label}</span>
</button>
))}
</div>
<img src="/screenshot-dark.png" alt="DBX product screenshot" />
</motion.div>
);
}

View File

@ -18,22 +18,22 @@ export function InfiniteMovingCards({ items, direction = 'left', speed = 'normal
return (
<div
className="landing-testimonial-marquee"
className="landing-testimonial-marquee flex overflow-hidden select-none"
data-direction={direction}
data-speed={speed}
aria-label="Testimonials"
>
<div className="landing-testimonial-track">
<div className="landing-testimonial-track flex w-max min-w-full gap-3.5 px-[7px]">
{repeatedItems.map((item, index) => (
<figure className="landing-testimonial-card" key={`${item.name}-${index}`}>
<div className="landing-testimonial-author">
<img className="landing-testimonial-avatar" src={item.avatar} alt="" width={42} height={42} />
<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}`}>
<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} />
<figcaption>
<strong>{item.name}</strong>
<span>{item.role}</span>
<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>
</figcaption>
</div>
<blockquote>{item.quote}</blockquote>
<blockquote className="mt-[18px] text-sm font-[560] leading-[1.58] text-[color-mix(in_srgb,var(--color-landing-ink)_88%,var(--color-landing-muted))]">{item.quote}</blockquote>
</figure>
))}
</div>

View File

@ -1,127 +1,127 @@
'use client';
import { Check, Copy, ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { ChevronDown, Download, Server } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
type InstallTabsProps = {
lang: 'en' | 'cn';
};
const releaseUrl = 'https://github.com/t8y2/dbx/releases/latest';
type InstallOption = {
id: string;
label: string;
href: string;
};
const installOptions = {
const allOptions: Record<string, InstallOption[]> = {
en: [
{
id: 'macos',
label: 'macOS',
note: 'Homebrew',
command: 'brew install --cask t8y2/tap/dbx',
linkText: 'GitHub Releases',
},
{
id: 'windows',
label: 'Windows',
note: 'Scoop',
command: 'scoop bucket add dbx https://github.com/t8y2/scoop-bucket; scoop install dbx',
linkText: 'GitHub Releases',
},
{
id: 'linux',
label: 'Linux',
note: '.deb / AppImage',
command: 'chmod +x DBX*.AppImage',
linkText: 'Download packages',
},
{
id: 'docker',
label: 'Docker',
note: 'Self-hosted',
command: 'docker run -d --name dbx -p 4224:4224 -v dbx-data:/app/data t8y2/dbx',
linkText: 'Docker docs',
href: '/en/docs/getting-started#docker',
},
{ id: 'macos-arm', label: 'For macOS (Apple Silicon)', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_aarch64.dmg' },
{ id: 'macos-intel', label: 'For macOS (Intel)', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_x64.dmg' },
{ id: 'windows', label: 'For Windows', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_x64-setup.exe' },
{ id: 'linux', label: 'For Linux x64', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_amd64.AppImage' },
{ id: 'linux-arm', label: 'For Linux ARM64', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_aarch64.AppImage' },
],
cn: [
{
id: 'macos',
label: 'macOS',
note: 'Homebrew',
command: 'brew install --cask t8y2/tap/dbx',
linkText: 'GitHub Releases',
},
{
id: 'windows',
label: 'Windows',
note: 'Scoop',
command: 'scoop bucket add dbx https://github.com/t8y2/scoop-bucket; scoop install dbx',
linkText: 'GitHub Releases',
},
{
id: 'linux',
label: 'Linux',
note: '.deb / AppImage',
command: 'chmod +x DBX*.AppImage',
linkText: '下载安装包',
},
{
id: 'docker',
label: 'Docker',
note: '自托管',
command: 'docker run -d --name dbx -p 4224:4224 -v dbx-data:/app/data t8y2/dbx',
linkText: 'Docker 文档',
href: '/cn/docs/getting-started#docker',
},
{ id: 'macos-arm', label: '适用于 macOS (Apple Silicon)', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_aarch64.dmg' },
{ id: 'macos-intel', label: '适用于 macOS (Intel)', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_x64.dmg' },
{ id: 'windows', label: '适用于 Windows', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_x64-setup.exe' },
{ id: 'linux', label: '适用于 Linux x64', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_amd64.AppImage' },
{ id: 'linux-arm', label: '适用于 Linux ARM64', href: 'https://dl.dbxio.com/releases/latest/DBX_0.5.9_aarch64.AppImage' },
],
};
export function InstallTabs({ lang }: InstallTabsProps) {
const options = installOptions[lang];
const [activeId, setActiveId] = useState(options[0].id);
const [copied, setCopied] = useState(false);
const active = options.find((item) => item.id === activeId) ?? options[0];
const downloadLabel = { en: 'Download DBX', cn: '下载 DBX' };
async function copyCommand() {
await navigator.clipboard.writeText(active.command);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
const platformIconPaths = {
dark: {
'linux-arm': '/icons/platform/linux.svg',
linux: '/icons/platform/linux.svg',
'macos-arm': '/icons/platform/macos.png',
'macos-intel': '/icons/platform/macos.png',
windows: '/icons/platform/windows.png',
},
light: {
'linux-arm': '/icons/platform/linux.svg',
linux: '/icons/platform/linux.svg',
'macos-arm': '/icons/platform/macos-white.png',
'macos-intel': '/icons/platform/macos-white.png',
windows: '/icons/platform/windows.png',
},
};
function detectPlatformId(): string {
if (typeof navigator === 'undefined') return 'macos-arm';
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('win')) return 'windows';
if (ua.includes('linux')) return ua.includes('aarch64') || ua.includes('arm') ? 'linux-arm' : 'linux';
return 'macos-arm';
}
function PlatformIcon({ id, size, variant }: { id: string; size: number; variant: 'dark' | 'light' }) {
const src = platformIconPaths[variant][id as keyof (typeof platformIconPaths)['dark']];
if (!src) return <Server size={size} />;
return <img alt="" aria-hidden="true" height={size} src={src} width={size} />;
}
export function InstallTabs({ lang }: InstallTabsProps) {
const options = allOptions[lang];
const [open, setOpen] = useState(false);
const [platformId, setPlatformId] = useState('macos-arm');
useEffect(() => {
setPlatformId(detectPlatformId());
}, []);
const primary = useMemo(() => options.find((o) => o.id === platformId) ?? options[0], [options, platformId]);
const menuOptions = useMemo(() => options.filter((o) => o.id !== platformId), [options, platformId]);
return (
<div className="landing-install">
<div className="landing-install-tabs" role="tablist" aria-label={lang === 'cn' ? '安装平台' : 'Install platform'}>
{options.map((item) => (
<button
aria-selected={item.id === active.id}
className="landing-install-tab"
<div
className="landing-install relative z-20 block w-fit max-w-full mx-auto"
data-open={open}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
}
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<a
aria-controls="landing-install-menu"
aria-expanded={open}
aria-haspopup="listbox"
className="landing-install-trigger grid grid-cols-[auto_minmax(0,1fr)_auto] gap-4 items-center w-[min(400px,calc(100vw-48px))] min-h-[68px] border-0 rounded-full mx-auto px-7 cursor-pointer"
href={primary.href}
onFocus={() => setOpen(true)}
>
<PlatformIcon id={primary.id} size={30} variant="dark" />
<span className="grid gap-0.5 min-w-0">
<strong className="overflow-hidden text-[15px] font-[780] leading-[1.2] truncate">{downloadLabel[lang]}</strong>
<small className="overflow-hidden text-xs font-[520] leading-tight truncate text-[color-mix(in_srgb,#0f172a_48%,#94a3b8)]">{primary.label}</small>
</span>
<ChevronDown size={18} />
</a>
<div
className="landing-install-menu absolute z-30 top-[calc(100%+12px)] left-1/2 -translate-x-1/2 grid w-[min(360px,calc(100vw-48px))] border border-[rgba(155,176,205,0.17)] rounded-xl py-1.5"
id="landing-install-menu"
role="listbox"
aria-label={lang === 'cn' ? '下载选项' : 'Download options'}
>
{menuOptions.map((item) => (
<a
aria-selected="false"
className="landing-install-option grid grid-cols-[24px_minmax(0,1fr)_18px] gap-3 items-center min-w-0 border-0 px-[18px] py-3 bg-transparent text-left cursor-pointer"
href={item.href}
key={item.id}
onClick={() => {
setActiveId(item.id);
setCopied(false);
}}
role="tab"
type="button"
role="option"
>
<span>{item.label}</span>
<small>{item.note}</small>
</button>
<PlatformIcon id={item.id} size={20} variant="light" />
<strong className="overflow-hidden text-sm font-[640] leading-[1.2] truncate">{item.label}</strong>
<Download size={15} aria-hidden="true" />
</a>
))}
</div>
<div className="landing-install-command">
<code>{active.command}</code>
<button
aria-label={lang === 'cn' ? '复制安装命令' : 'Copy install command'}
className="landing-install-copy"
onClick={copyCommand}
title={lang === 'cn' ? '复制安装命令' : 'Copy install command'}
type="button"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<a className="landing-install-release" href={active.href ?? releaseUrl} target="_blank">
{active.linkText}
<ExternalLink size={14} />
</a>
</div>
);
}

View File

@ -0,0 +1,26 @@
'use client';
import { useEffect, useRef } from 'react';
export function LandingNav({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
function onScroll() {
node!.classList.toggle('is-scrolled', window.scrollY > 60);
}
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav ref={ref} className="landing-nav">
{children}
</nav>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 86 KiB