feat(site): add plugin center listing and detail pages

This commit is contained in:
t8y2
2026-09-15 17:03:14 +08:00
parent 256917d8cf
commit ab277e0fa2
10 changed files with 960 additions and 3 deletions
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Check, Copy } from "lucide-react";
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import type { DocsLang } from "@/lib/i18n";
import { detectPlatformId, type DownloadPlatformId } from "@/lib/platformDetection";
import {
DBX_STORE_CONTRIBUTING_URL,
copyText,
fetchPluginCatalog,
formatPluginDate,
formatPluginSize,
formatRelativePluginTime,
latestPluginVersion,
preferredArtifactFor,
pluginDisplayDescription,
pluginDisplayName,
type MarketplacePlugin,
} from "@/lib/pluginCatalog";
const i18n = {
en: {
title: "Plugin Center",
viewPlugin: "View plugin",
permissionsCount: (count: number) => `${count} permission${count === 1 ? "" : "s"}`,
licenseUnknown: "Unlicensed",
refreshError: "Could not refresh the catalog; showing the last snapshot.",
submitTitle: "Built a plugin for DBX?",
submitDesc: "Package it as a .dbxp, submit a PR to dbx-store, and it will appear here and in the in-app plugin center after review.",
submitAction: "Submit to dbx-store",
submitDocs: "Plugin development docs",
emptyTitle: "No plugins yet",
emptyDesc: "The marketplace catalog is empty or unreachable right now. Try again in a moment.",
latest: "Latest",
updatedAgo: (time: string) => `updated ${time}`,
updatedOn: (time: string) => `updated ${time}`,
copyInstallUrl: "Copy install URL",
copied: "Copied",
},
cn: {
title: "插件中心",
viewPlugin: "查看插件",
permissionsCount: (count: number) => `${count} 项权限`,
licenseUnknown: "未声明许可证",
refreshError: "目录刷新失败,当前展示构建时的快照。",
submitTitle: "为 DBX 开发了插件?",
submitDesc: "打包为 .dbxp,向 dbx-store 提交 PR,审核通过后会同时出现在本页面和客户端插件中心。",
submitAction: "提交到 dbx-store",
submitDocs: "插件开发文档",
emptyTitle: "暂无插件",
emptyDesc: "商店目录暂时为空或无法访问,请稍后再试。",
latest: "最新",
updatedAgo: (time: string) => `${time}更新`,
updatedOn: (time: string) => `更新于 ${time}`,
copyInstallUrl: "复制安装 URL",
copied: "已复制",
},
};
function mergePlugins(snapshot: MarketplacePlugin[], live: MarketplacePlugin[]): MarketplacePlugin[] {
if (!snapshot.length) return live;
const byId = new Map(snapshot.map((plugin) => [plugin.id, plugin]));
for (const plugin of live) byId.set(plugin.id, plugin);
return [...byId.values()];
}
export function PluginsClient({ lang, initialPlugins }: { lang: DocsLang; initialPlugins: MarketplacePlugin[] }) {
const [plugins, setPlugins] = useState(initialPlugins);
const [refreshFailed, setRefreshFailed] = useState(false);
const [mounted, setMounted] = useState(false);
const [platformId, setPlatformId] = useState<DownloadPlatformId>("unknown");
const [copiedId, setCopiedId] = useState<string | null>(null);
const t = i18n[lang];
useEffect(() => {
setMounted(true);
detectPlatformId().then(setPlatformId);
}, []);
async function handleCopyInstall(plugin: MarketplacePlugin) {
const latest = latestPluginVersion(plugin);
const artifact = latest ? preferredArtifactFor(latest.artifacts, platformId) : null;
if (!artifact) return;
if (await copyText(artifact.url)) {
setCopiedId(plugin.id);
window.setTimeout(() => setCopiedId((current) => (current === plugin.id ? null : current)), 2000);
}
}
useEffect(() => {
let cancelled = false;
fetchPluginCatalog({ cache: "no-cache" })
.then((catalog) => {
if (cancelled) return;
if (catalog) setPlugins((current) => mergePlugins(current, catalog.plugins));
else setRefreshFailed(true);
})
.catch(() => {
if (!cancelled) setRefreshFailed(true);
});
return () => {
cancelled = true;
};
}, []);
return (
<main className="min-h-screen bg-[#08080a] text-landing-ink">
<LandingNav lang={lang} active="plugins" />
<section className="max-w-[1180px] mx-auto px-6 pt-32 pb-24">
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
{refreshFailed && <p className="mt-4 text-[13px] text-landing-muted">{t.refreshError}</p>}
{plugins.length === 0 ? (
<div className="mt-10 rounded-xl border border-landing-line bg-landing-panel p-8 text-center">
<p className="text-xl font-[720]">{t.emptyTitle}</p>
<p className="mt-2 text-sm text-landing-muted">{t.emptyDesc}</p>
</div>
) : (
<div className="mt-8 grid grid-cols-2 gap-5 max-[900px]:grid-cols-1">
{plugins.map((plugin) => {
const latest = latestPluginVersion(plugin);
const permissions = plugin.permissions ?? [];
const copied = copiedId === plugin.id;
return (
<div
key={plugin.id}
className="group relative flex flex-col rounded-xl border border-landing-line bg-landing-panel p-6 transition-colors hover:border-landing-blue"
>
{/* Stretched link: the whole card navigates; interactive children sit above it. */}
<Link
href={`/${lang}/plugins/${plugin.id}`}
prefetch={false}
className="absolute inset-0 z-[1] rounded-xl"
aria-label={`${t.viewPlugin}: ${pluginDisplayName(plugin, lang)}`}
/>
<div className="flex items-start gap-4">
<span className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white">
{plugin.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={plugin.icon} alt="" aria-hidden="true" width={40} height={40} className="size-10 object-contain" />
) : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-x-2.5">
<h2 className="truncate text-[19px] font-[760]">{pluginDisplayName(plugin, lang)}</h2>
{latest ? <span className="shrink-0 rounded-md border border-landing-line px-1.5 py-0.5 text-[11px] font-[650] text-landing-muted">v{latest.version}</span> : null}
</div>
<p className="mt-0.5 text-[13px] text-landing-muted">
{plugin.publisher}
{plugin.license ? ` · ${plugin.license}` : ""}
</p>
</div>
</div>
<p className="mt-3.5 line-clamp-2 text-sm leading-[1.7] text-landing-muted">{pluginDisplayDescription(plugin, lang)}</p>
<div className="mt-auto pt-4">
<div className="flex flex-wrap items-center gap-1.5">
{(plugin.tags ?? []).slice(0, 5).map((tag) => (
<span key={tag} className="rounded-md bg-landing-soft px-2 py-0.5 text-[11px] font-medium text-landing-muted">
{tag}
</span>
))}
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 text-[12px] text-landing-muted">
<span className="min-w-0 truncate">
{latest
? mounted
? t.updatedAgo(formatRelativePluginTime(latest.releasedAt, lang))
: t.updatedOn(formatPluginDate(latest.releasedAt, lang))
: ""}
{permissions.length > 0 ? ` · ${t.permissionsCount(permissions.length)}` : ""}
{latest ? ` · ${formatPluginSize(latest.artifacts[0]?.size ?? 0)}` : ""}
</span>
<span className="relative z-[2] inline-flex shrink-0 items-center gap-2.5">
<button
type="button"
onClick={() => handleCopyInstall(plugin)}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-landing-line px-2.5 text-[12px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink"
>
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
{copied ? t.copied : t.copyInstallUrl}
</button>
<Link
href={`/${lang}/plugins/${plugin.id}`}
prefetch={false}
className="landing-inline-link inline-flex items-center gap-[7px] text-sm font-[650]"
>
{t.viewPlugin}
<span aria-hidden="true">→</span>
</Link>
</span>
</div>
</div>
</div>
);
})}
</div>
)}
<div className="mt-10 rounded-xl border border-landing-line bg-landing-panel">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-5">
<div className="max-w-[640px]">
<h2 className="text-xl font-[720]">{t.submitTitle}</h2>
<p className="mt-2 text-sm leading-[1.7] text-landing-muted">{t.submitDesc}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<a
href={DBX_STORE_CONTRIBUTING_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-10 items-center rounded-lg bg-[#f0f1f4] px-4 text-[13px] font-[720] text-[#0a0b0e] transition hover:bg-white"
>
{t.submitAction}
</a>
<Link
href={`/${lang}/docs/plugin-development`}
prefetch={false}
className="inline-flex h-10 items-center rounded-lg border border-landing-line px-4 text-[13px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink"
>
{t.submitDocs}
</Link>
</div>
</div>
</div>
</section>
<LandingFooter lang={lang} />
</main>
);
}
@@ -0,0 +1,396 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ArrowUpRight, Check, Copy, Download, Package } from "lucide-react";
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import type { DocsLang } from "@/lib/i18n";
import { detectPlatformId, type DownloadPlatformId } from "@/lib/platformDetection";
import {
fetchPluginCatalog,
formatPluginDate,
formatPluginSize,
formatRelativePluginTime,
latestPluginVersion,
platformArtifactTarget,
pluginDisplayDescription,
pluginDisplayName,
pluginTargetLabel,
type MarketplacePlugin,
} from "@/lib/pluginCatalog";
const i18n = {
en: {
backToList: "Plugin Center",
publisher: "Publisher",
version: "Version",
license: "License",
licenseUnknown: "Unlicensed",
permissionsTitle: "Permissions",
permissionsCount: (count: number) => `${count} permission${count === 1 ? "" : "s"}`,
permissionsNote: "Permission codes are granted at install time and shown verbatim from the manifest.",
installTitle: "Install in DBX",
installSteps: "Open DBX and go to the Plugin Center, choose Install from URL, then paste the link for your platform. Packages are signature-checked before install.",
copyUrl: "Copy install URL",
copied: "Copied",
downloadShort: "Download",
detectedPlatform: "Detected platform",
download: "Download .dbxp",
sha256: "SHA-256",
size: "Size",
platform: "Platform",
versionsTitle: "Versions",
releaseNotes: "Release notes",
artifacts: "Artifacts",
sourceRepo: "Source",
homepage: "Homepage",
latest: "Latest",
aboutTitle: "About",
notFoundTitle: "Plugin not found",
notFoundDesc: "This plugin is not in the marketplace catalog. It may have been removed, or the link is incorrect.",
notFoundAction: "Back to Plugin Center",
loading: "Loading plugin…",
updatedAgo: (time: string) => `updated ${time}`,
updatedOn: (time: string) => `updated ${time}`,
},
cn: {
backToList: "插件中心",
publisher: "发布者",
version: "版本",
license: "许可证",
licenseUnknown: "未声明许可证",
permissionsTitle: "权限",
permissionsCount: (count: number) => `${count} 项权限`,
permissionsNote: "权限代码在安装时授予,此处按 manifest 原文展示。",
installTitle: "在 DBX 中安装",
installSteps: "打开 DBX 进入插件中心,选择“从 URL 安装”,粘贴对应平台的链接即可。安装前会校验制品签名。",
copyUrl: "复制安装 URL",
copied: "已复制",
downloadShort: "下载",
detectedPlatform: "检测到的平台",
download: "下载 .dbxp",
sha256: "SHA-256",
size: "大小",
platform: "平台",
versionsTitle: "版本历史",
releaseNotes: "更新说明",
artifacts: "安装包",
sourceRepo: "源码",
homepage: "主页",
latest: "最新",
aboutTitle: "简介",
notFoundTitle: "未找到该插件",
notFoundDesc: "该插件不在商店目录中,可能已下架或链接有误。",
notFoundAction: "返回插件中心",
loading: "正在加载插件…",
updatedAgo: (time: string) => `${time}更新`,
updatedOn: (time: string) => `更新于 ${time}`,
},
};
type CopyState = "idle" | "copied";
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export function PluginDetailClient({
lang,
id,
initialPlugin,
}: {
lang: DocsLang;
id: string | null;
initialPlugin: MarketplacePlugin | null;
}) {
// The static shell route (/plugins/detail) passes id=null and reads ?id= from the
// worker-rewritten URL; direct [id] pages always pass a concrete id.
const [resolvedId, setResolvedId] = useState<string | null>(id);
const [plugin, setPlugin] = useState<MarketplacePlugin | null>(initialPlugin);
const [status, setStatus] = useState<"loading" | "ready" | "missing">(initialPlugin ? "ready" : "loading");
const [platformId, setPlatformId] = useState<DownloadPlatformId>("unknown");
const [copyState, setCopyState] = useState<CopyState>("idle");
const [mounted, setMounted] = useState(false);
const t = i18n[lang];
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!resolvedId && typeof window !== "undefined") {
setResolvedId(new URLSearchParams(window.location.search).get("id"));
}
}, [resolvedId]);
useEffect(() => {
detectPlatformId().then((detected) => setPlatformId(detected));
}, []);
useEffect(() => {
if (!resolvedId) return;
let cancelled = false;
fetchPluginCatalog({ cache: "no-cache" })
.then((catalog) => {
if (cancelled) return;
const found = catalog?.plugins.find((candidate) => candidate.id === resolvedId) ?? null;
if (found) {
setPlugin(found);
setStatus("ready");
} else if (!initialPlugin) {
setStatus("missing");
}
})
.catch(() => {
if (!cancelled && !initialPlugin) setStatus("missing");
});
return () => {
cancelled = true;
};
}, [resolvedId, initialPlugin]);
const latest = plugin ? latestPluginVersion(plugin) : null;
const preferredTarget = platformArtifactTarget(platformId);
const preferredArtifact = useMemo(() => {
if (!latest) return null;
return latest.artifacts.find((artifact) => artifact.target === preferredTarget) ?? latest.artifacts[0] ?? null;
}, [latest, preferredTarget]);
async function handleCopy() {
if (!preferredArtifact) return;
if (await copyText(preferredArtifact.url)) {
setCopyState("copied");
window.setTimeout(() => setCopyState("idle"), 2000);
}
}
return (
<main className="min-h-screen bg-[#08080a] text-landing-ink">
<LandingNav lang={lang} active="plugins" />
<section className="max-w-[1180px] mx-auto px-6 pt-32 pb-24">
<Link href={`/${lang}/plugins`} prefetch={false} className="landing-inline-link inline-flex items-center gap-2 text-sm font-[650]">
<span aria-hidden="true">←</span>
{t.backToList}
</Link>
{status === "loading" && !plugin ? (
<h1 className="mt-16 text-2xl font-[720] text-landing-muted">{t.loading}</h1>
) : !plugin ? (
<div className="mt-10 rounded-xl border border-landing-line bg-landing-panel p-8 text-center">
<p className="text-xl font-[720]">{t.notFoundTitle}</p>
<p className="mt-2 text-sm text-landing-muted">{t.notFoundDesc}</p>
<Link href={`/${lang}/plugins`} prefetch={false} className="mt-5 inline-flex h-10 items-center rounded-lg bg-[#f0f1f4] px-4 text-[13px] font-[720] text-[#0a0b0e] transition hover:bg-white">
{t.notFoundAction}
</Link>
</div>
) : (
<div className="mt-6">
<div className="flex flex-wrap items-start gap-5">
<span className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white">
{plugin.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={plugin.icon} alt="" aria-hidden="true" width={52} height={52} className="size-13 object-contain" />
) : (
<Package size={28} className="text-[#0a0b0e]" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h1 className="text-4xl font-[820] tracking-tight">{pluginDisplayName(plugin, lang)}</h1>
{latest ? <span className="rounded-md border border-landing-line px-2 py-0.5 text-[12px] font-[650] text-landing-muted">v{latest.version}</span> : null}
</div>
<p className="mt-2 text-sm text-landing-muted">
<span className="text-landing-ink font-[650]">{plugin.publisher}</span>
{plugin.license ? ` · ${plugin.license}` : ""}
{latest
? ` · ${
mounted
? t.updatedAgo(formatRelativePluginTime(latest.releasedAt, lang))
: t.updatedOn(formatPluginDate(latest.releasedAt, lang))
}`
: ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{plugin.source ? (
<a href={plugin.source} target="_blank" rel="noopener noreferrer" className="inline-flex h-10 items-center gap-1.5 rounded-lg border border-landing-line px-4 text-[13px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink">
{t.sourceRepo}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
) : null}
{plugin.homepage ? (
<a href={plugin.homepage} target="_blank" rel="noopener noreferrer" className="inline-flex h-10 items-center gap-1.5 rounded-lg border border-landing-line px-4 text-[13px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink">
{t.homepage}
<ArrowUpRight size={14} aria-hidden="true" />
</a>
) : null}
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-5 min-[981px]:grid-cols-[minmax(0,1fr)_360px]">
{/* Right column: description above permissions, sticky while versions scroll.
DOM-first so mobile keeps it before the install card. */}
<div className="flex min-w-0 flex-col gap-5 self-start min-[981px]:sticky min-[981px]:top-24 min-[981px]:col-start-2 min-[981px]:row-start-1">
<div className="rounded-xl border border-landing-line bg-landing-panel px-6 py-5">
<h2 className="text-xl font-[720]">{t.aboutTitle}</h2>
<p className="mt-2 text-[14px] leading-[1.8] text-landing-muted">{pluginDisplayDescription(plugin, lang)}</p>
{(plugin.tags ?? []).length > 0 ? (
<div className="mt-3.5 flex flex-wrap gap-1.5">
{(plugin.tags ?? []).map((tag) => (
<span key={tag} className="rounded-md bg-landing-soft px-2 py-0.5 text-[11px] font-medium text-landing-muted">
{tag}
</span>
))}
</div>
) : null}
</div>
<div className="rounded-xl border border-landing-line bg-landing-panel px-6 py-5">
<h2 className="text-xl font-[720]">{t.permissionsTitle}</h2>
<p className="mt-1 text-[12px] text-landing-muted">
{plugin.permissions?.length ? t.permissionsCount(plugin.permissions.length) : "—"}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{(plugin.permissions ?? []).map((permission) => (
<span key={permission} className="rounded-md border border-landing-line bg-landing-soft px-2 py-1 font-mono text-[11px] text-landing-muted">
{permission}
</span>
))}
</div>
<p className="mt-4 text-[12px] leading-[1.7] text-landing-muted">{t.permissionsNote}</p>
</div>
</div>
<div className="min-w-0 min-[981px]:col-start-1 min-[981px]:row-start-1">
{/* Install */}
<div className="rounded-xl border border-landing-line bg-landing-panel">
<div className="border-b border-landing-line px-6 py-5">
<h2 className="text-xl font-[720]">{t.installTitle}</h2>
<p className="mt-2 max-w-[640px] text-sm leading-[1.7] text-landing-muted">{t.installSteps}</p>
{preferredArtifact ? (
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleCopy}
className="inline-flex h-10 items-center gap-2 rounded-lg bg-[#f0f1f4] px-4 text-[13px] font-[720] text-[#0a0b0e] transition hover:bg-white"
>
{copyState === "copied" ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}
{copyState === "copied" ? t.copied : t.copyUrl}
</button>
<a href={preferredArtifact.url} className="inline-flex h-10 items-center rounded-lg border border-landing-line px-4 text-[13px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink">
{t.download}
</a>
<span className="text-[12px] text-landing-muted">
{t.detectedPlatform}: {pluginTargetLabel(preferredArtifact.target, lang)} · {formatPluginSize(preferredArtifact.size)}
</span>
</div>
) : null}
</div>
<div className="px-6 py-5">
{latest && latest.artifacts.length > 0 ? (
<table className="w-full text-left text-[13px]">
<thead>
<tr className="text-[12px] uppercase tracking-wide text-landing-muted">
<th className="py-2 pr-4 font-[650]">{t.platform}</th>
<th className="py-2 pr-4 font-[650]">{t.size}</th>
<th className="py-2 pr-4 font-[650]">{t.sha256}</th>
<th className="py-2 font-[650] sr-only">{t.downloadShort}</th>
</tr>
</thead>
<tbody>
{latest.artifacts.map((artifact) => (
<tr key={artifact.url} className="border-t border-landing-line">
<td className="py-2.5 pr-4 whitespace-nowrap">
{artifact.target === preferredArtifact?.target ? (
<span className="font-[650] text-landing-ink">{pluginTargetLabel(artifact.target, lang)}</span>
) : (
<span className="text-landing-muted">{pluginTargetLabel(artifact.target, lang)}</span>
)}
</td>
<td className="py-2.5 pr-4 whitespace-nowrap text-landing-muted">{formatPluginSize(artifact.size)}</td>
<td className="py-2.5 pr-4 font-mono text-[11px] text-landing-muted">
<span title={artifact.sha256} className="cursor-pointer" onClick={() => copyText(artifact.sha256)}>
{artifact.sha256.slice(0, 16)}…
</span>
</td>
<td className="py-2.5 text-right">
<a
href={artifact.url}
aria-label={`${t.download}: ${pluginTargetLabel(artifact.target, lang)}`}
className="inline-flex h-7 items-center gap-1 rounded-md border border-landing-line px-2 text-[11px] font-[650] text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink"
>
<Download size={12} aria-hidden="true" />
{t.downloadShort}
</a>
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
</div>
{/* Versions */}
{plugin.versions.length > 0 ? (
<div className="mt-5 rounded-xl border border-landing-line bg-landing-panel px-6 py-5">
<h2 className="text-xl font-[720]">{t.versionsTitle}</h2>
<ul className="mt-5">
{plugin.versions.map((version, index) => {
const isLatest = version.version === plugin.latestVersion;
return (
<li
key={version.version}
className={`relative pb-7 pl-8 last:pb-0 before:absolute before:left-0 before:top-[7px] before:size-[9px] before:rounded-full before:content-[''] ${
isLatest ? "before:bg-landing-blue" : "before:bg-landing-muted/50"
} ${index < plugin.versions.length - 1 ? "after:absolute after:left-[4px] after:top-[20px] after:bottom-0 after:w-px after:bg-landing-line after:content-['']" : ""}`}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-mono text-[14px] font-[650]">v{version.version}</span>
<span className="text-[12px] text-landing-muted">{formatPluginDate(version.releasedAt, lang)}</span>
{isLatest ? (
<span className="rounded-md border border-landing-line px-1.5 py-0.5 text-[11px] font-[650] text-landing-green">{t.latest}</span>
) : null}
</div>
{version.releaseNotes ? (
<p className="mt-2 max-w-[760px] whitespace-pre-line text-[13px] leading-[1.7] text-landing-muted">{version.releaseNotes}</p>
) : null}
{index > 0 && version.artifacts.length > 0 ? (
<details className="mt-2">
<summary className="cursor-pointer text-[12px] font-[650] text-landing-muted hover:text-landing-ink">{t.artifacts}</summary>
<ul className="mt-2 space-y-1.5">
{version.artifacts.map((artifact) => (
<li key={artifact.url} className="flex flex-wrap items-center gap-x-3 text-[12px]">
<span className="text-landing-muted">{pluginTargetLabel(artifact.target, lang)}</span>
<span className="text-landing-muted">{formatPluginSize(artifact.size)}</span>
<a href={artifact.url} className="landing-inline-link font-[650]">
{t.download}
</a>
</li>
))}
</ul>
</details>
) : null}
</li>
);
})}
</ul>
</div>
) : null}
</div>
</div>
</div>
)}
</section>
<LandingFooter lang={lang} />
</main>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { DEFAULT_DESCRIPTION } from "@/lib/metadata";
import { fetchPluginCatalog, pluginDisplayDescription, pluginDisplayName } from "@/lib/pluginCatalog";
import { buildMetadata } from "@/lib/metadata";
import { resolveLang } from "@/lib/i18n";
import { PluginDetailClient } from "./PluginDetailClient";
// Reserved slug served by the worker for plugin pages missing from the build snapshot.
const SHELL_SLUG = "detail";
type PageParams = { params: Promise<{ lang: string; id: string }> };
async function findSnapshotPlugin(id: string) {
const catalog = await fetchPluginCatalog({ cache: "force-cache" });
return catalog?.plugins.find((plugin) => plugin.id === id) ?? null;
}
export async function generateMetadata({ params }: PageParams): Promise<Metadata> {
const { lang, id } = await params;
const l = resolveLang(lang);
const plugin = await findSnapshotPlugin(id);
return buildMetadata({
title: plugin ? pluginDisplayName(plugin, l) : "Plugin",
description: plugin ? pluginDisplayDescription(plugin, l) : DEFAULT_DESCRIPTION,
path: `/${l}/plugins/${id}`,
lang: l,
ogType: "website",
});
}
export async function generateStaticParams() {
const catalog = await fetchPluginCatalog({ cache: "force-cache" });
return (catalog?.plugins ?? [])
.map((plugin) => plugin.id)
.filter((id) => id !== SHELL_SLUG)
.map((id) => ({ id }));
}
export default async function PluginDetailPage({ params }: PageParams) {
const { lang, id } = await params;
const l = resolveLang(lang);
// Unknown ids render the client shell, which resolves data from the live catalog;
// exported pages always carry the build-time snapshot for instant paint and SEO.
const plugin = id === SHELL_SLUG ? null : await findSnapshotPlugin(id);
return <PluginDetailClient lang={l} id={id} initialPlugin={plugin} />;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { resolveLang } from "@/lib/i18n";
import { PluginDetailClient } from "../[id]/PluginDetailClient";
// Shell route for the worker fallback: pretty /plugins/<id> URLs missing from the
// build snapshot are served this page, which reads the id from ?id= and renders
// the detail view client-side. Keep noindex — real plugin pages are baked at build.
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const l = resolveLang(lang);
return {
title: "Plugin",
robots: { index: false, follow: false },
alternates: { canonical: `/${l}/plugins` },
};
}
export default async function PluginDetailShellPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const l = resolveLang(lang);
return <PluginDetailClient lang={l} id={null} initialPlugin={null} />;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { fetchPluginCatalog } from "@/lib/pluginCatalog";
import { buildMetadata } from "@/lib/metadata";
import { resolveLang } from "@/lib/i18n";
import { PluginsClient } from "./PluginsClient";
const pageMeta = {
en: {
title: "Plugin Center",
description: "Browse official DBX marketplace plugins: signed packages, versions, permissions, and one-click install URLs for the in-app plugin center.",
},
cn: {
title: "插件中心",
description: "浏览 DBX 官方插件商店收录的插件:签名校验的安装包、版本历史、权限说明,以及可直接粘贴到客户端插件中心的安装链接。",
},
};
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const l = resolveLang(lang);
const meta = pageMeta[l];
return buildMetadata({
title: meta.title,
description: meta.description,
path: `/${l}/plugins`,
lang: l,
ogType: "website",
});
}
export default async function PluginsPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const l = resolveLang(lang);
// Build-time snapshot for instant paint and SEO; the client refreshes it from R2 on mount.
const catalog = await fetchPluginCatalog({ cache: "force-cache" });
return <PluginsClient lang={l} initialPlugins={catalog?.plugins ?? []} />;
}
+5 -1
View File
@@ -12,6 +12,7 @@ const i18n = {
en: {
home: "Home",
docs: "Docs",
plugins: "Plugins",
changelog: "Changelog",
community: "Community",
sponsors: "Sponsors",
@@ -25,6 +26,7 @@ const i18n = {
cn: {
home: "首页",
docs: "文档",
plugins: "插件",
changelog: "更新日志",
community: "交流群",
sponsors: "赞助商",
@@ -37,13 +39,14 @@ const i18n = {
},
};
export function LandingNav({ lang, active }: { lang: DocsLang; active?: "home" | "databases" | "changelog" | "community" | "issue" | "sponsors" | "contributors" | "drivers" }) {
export function LandingNav({ lang, active }: { lang: DocsLang; active?: "home" | "databases" | "changelog" | "community" | "issue" | "sponsors" | "contributors" | "drivers" | "plugins" }) {
const ref = useRef<HTMLElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const t = i18n[lang];
const otherLang = LANG_CYCLE[(LANG_CYCLE.indexOf(lang) + 1) % LANG_CYCLE.length];
const langHrefMap: Record<string, string> = {
databases: `/${otherLang}/databases`,
plugins: `/${otherLang}/plugins`,
changelog: `/${otherLang}/changelog`,
community: `/${otherLang}/community`,
issue: `/${otherLang}/issue`,
@@ -55,6 +58,7 @@ export function LandingNav({ lang, active }: { lang: DocsLang; active?: "home" |
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: "plugins", href: `/${lang}/plugins`, label: t.plugins, 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 },
+176
View File
@@ -0,0 +1,176 @@
import type { DocsLang } from "@/lib/i18n";
import type { DownloadPlatformId } from "@/lib/platformDetection";
export type PluginArtifact = {
target: string;
url: string;
sha256: string;
signingKeyId: string;
size: number;
};
export type PluginVersion = {
version: string;
releasedAt: string;
releaseNotes?: string;
artifacts: PluginArtifact[];
};
export type PluginLocalization = {
name?: string;
description?: string;
};
export type MarketplacePlugin = {
id: string;
name: string;
description: string;
publisher: string;
verified?: boolean;
icon?: string;
tags?: string[];
permissions?: string[];
source?: string;
homepage?: string;
license?: string;
latestVersion: string;
versions: PluginVersion[];
localizations?: Record<string, PluginLocalization>;
};
export type PluginCatalog = {
catalogVersion?: number;
repository?: { id?: string; name?: string; homepage?: string };
plugins: MarketplacePlugin[];
};
// Same source the in-app plugin center consumes (R2 primary, GitHub raw fallback);
// R2 responds with a dbxio.com CORS header, so the browser refresh path works too.
export const PLUGIN_CATALOG_URLS = [
"https://dl.dbxio.com/catalog/index.json",
"https://raw.githubusercontent.com/t8y2/dbx-store/main/catalog/index.json",
] as const;
export const DBX_STORE_URL = "https://github.com/t8y2/dbx-store";
export const DBX_STORE_CONTRIBUTING_URL = "https://github.com/t8y2/dbx-store/blob/main/CONTRIBUTING.md";
function isPluginCatalog(value: unknown): value is PluginCatalog {
return (
typeof value === "object" &&
value !== null &&
Array.isArray((value as PluginCatalog).plugins) &&
(value as PluginCatalog).plugins.every((plugin) => typeof plugin?.id === "string")
);
}
export async function fetchPluginCatalog(init?: RequestInit): Promise<PluginCatalog | null> {
for (const url of PLUGIN_CATALOG_URLS) {
try {
const response = await fetch(url, { headers: { Accept: "application/json" }, ...init });
if (!response.ok) throw new Error(`Plugin catalog request failed with ${response.status}`);
const catalog = (await response.json()) as unknown;
if (isPluginCatalog(catalog)) return catalog;
} catch {
// Try the synchronized GitHub mirror next.
}
}
return null;
}
export function pluginDisplayName(plugin: MarketplacePlugin, lang: DocsLang): string {
if (lang === "cn") return plugin.localizations?.["zh-CN"]?.name || plugin.name;
return plugin.name;
}
export function pluginDisplayDescription(plugin: MarketplacePlugin, lang: DocsLang): string {
if (lang === "cn") return plugin.localizations?.["zh-CN"]?.description || plugin.description;
return plugin.description;
}
export function latestPluginVersion(plugin: MarketplacePlugin): PluginVersion | null {
return plugin.versions.find((version) => version.version === plugin.latestVersion) ?? plugin.versions[0] ?? null;
}
export function formatPluginSize(size: number): string {
if (!Number.isFinite(size) || size <= 0) return "—";
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${Math.max(1, Math.round(size / 1024))} KB`;
}
export function formatPluginDate(iso: string, lang: DocsLang): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(lang === "cn" ? "zh-CN" : "en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
// Relative timestamps go stale in prerendered HTML, so callers render the
// absolute date until mount and switch to this after hydration.
export function formatRelativePluginTime(iso: string, lang: DocsLang): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const elapsedMs = Date.now() - date.getTime();
if (elapsedMs < 60 * 1000) return lang === "cn" ? "刚刚" : "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["minute", 60 * 1000],
["hour", 60 * 60 * 1000],
["day", 24 * 60 * 60 * 1000],
["month", 30 * 24 * 60 * 60 * 1000],
["year", 365 * 24 * 60 * 60 * 1000],
];
let unit: Intl.RelativeTimeFormatUnit = "year";
let divisor = units[units.length - 1][1];
for (let i = units.length - 1; i >= 0; i -= 1) {
if (elapsedMs >= units[i][1]) {
unit = units[i][0];
divisor = units[i][1];
break;
}
}
const value = -Math.floor(elapsedMs / divisor);
return new Intl.RelativeTimeFormat(lang === "cn" ? "zh-CN" : "en-US", { numeric: "auto" }).format(value, unit);
}
export const PLUGIN_TARGET_LABELS: Record<string, { en: string; cn: string }> = {
"darwin-arm64": { en: "macOS (Apple Silicon)", cn: "macOS(Apple Silicon)" },
"darwin-x64": { en: "macOS (Intel)", cn: "macOS(Intel)" },
"linux-arm64": { en: "Linux (ARM64)", cn: "Linux(ARM64)" },
"linux-x64": { en: "Linux (x64)", cn: "Linux(x64)" },
"windows-arm64": { en: "Windows (ARM64)", cn: "Windows(ARM64)" },
"windows-x64": { en: "Windows (x64)", cn: "Windows(x64)" },
};
export function pluginTargetLabel(target: string, lang: DocsLang): string {
return PLUGIN_TARGET_LABELS[target]?.[lang] ?? target;
}
const PLATFORM_TARGET: Record<DownloadPlatformId, string> = {
"macos-arm": "darwin-arm64",
"macos-intel": "darwin-x64",
"macos-unknown": "darwin-arm64",
linux: "linux-x64",
"linux-arm": "linux-arm64",
windows: "windows-x64",
unknown: "darwin-arm64",
};
export function platformArtifactTarget(platformId: DownloadPlatformId): string {
return PLATFORM_TARGET[platformId] ?? "darwin-arm64";
}
export function preferredArtifactFor(artifacts: PluginArtifact[], platformId: DownloadPlatformId): PluginArtifact | null {
const target = platformArtifactTarget(platformId);
return artifacts.find((artifact) => artifact.target === target) ?? artifacts[0] ?? null;
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+17 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { issueRedirectPath, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
import { issueRedirectPath, pluginDetailShellRequest, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
test("signed OAuth payloads round-trip and reject tampering", async () => {
const signed = await signPayload({ login: "dbx-user" }, "test-secret");
@@ -27,3 +27,19 @@ test("static assets receive browser cache headers without caching HTML", () => {
assert.equal(staticAssetCacheControl("/cn"), null);
assert.equal(staticAssetCacheControl("/cn/changelog.txt"), null);
});
test("plugin detail fallback maps unknown ids onto the shell route", () => {
const shell = pluginDetailShellRequest(new URL("https://dbxio.com/cn/plugins/io.github.t8y2.s3"), new Request("https://dbxio.com/cn/plugins/io.github.t8y2.s3"));
assert.equal(shell?.url, "https://dbxio.com/cn/plugins/detail?id=io.github.t8y2.s3");
const encoded = pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins/a%20b"), new Request("https://dbxio.com/en/plugins/a%20b"));
assert.equal(encoded?.url, "https://dbxio.com/en/plugins/detail?id=a%2520b");
// The shell route itself and non-GET requests must pass through untouched.
assert.equal(pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins/detail"), new Request("https://dbxio.com/en/plugins/detail")), null);
assert.equal(
pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins/io.dbx.ssh"), new Request("https://dbxio.com/en/plugins/io.dbx.ssh", { method: "POST" })),
null,
);
assert.equal(pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins"), new Request("https://dbxio.com/en/plugins")), null);
});
+5 -1
View File
@@ -5,7 +5,11 @@ import { DEFAULT_LANGUAGE, LANGUAGES } from "./languages.mjs";
const OUT_DIR = resolve(import.meta.dirname, "../out");
const SITE_URL = "https://dbxio.com";
const EXCLUDE = new Set(["index.html", "404.html", "_not-found.html"]);
const EXCLUDE_PATHS = new Set(LANGUAGES.map((language) => `/${language}/issue`));
const EXCLUDE_PATHS = new Set([
...LANGUAGES.map((language) => `/${language}/issue`),
// noindex shell served by the worker for plugin ids missing from the build snapshot.
...LANGUAGES.map((language) => `/${language}/plugins/detail`),
]);
// hreflang codes differ from the route segment for locales whose segment is not
// already a language code.
const HREFLANG = { en: "en", cn: "zh" };
+16
View File
@@ -108,6 +108,17 @@ export function staticAssetCacheControl(pathname: string): string | null {
return null;
}
// Plugin detail pages are statically exported only for catalog ids known at build
// time (for SEO). dbx-store merges do not rebuild the site, so plugin pages the
// snapshot missed fall back to the /plugins/detail shell, which renders the plugin
// client-side from the live catalog. The pretty URL is preserved.
export function pluginDetailShellRequest(url: URL, request: Request): Request | null {
if (request.method !== "GET") return null;
const match = url.pathname.match(/^\/(en|cn)\/plugins\/([^/]+)\/?$/);
if (!match || match[2] === "detail") return null;
return new Request(`${url.origin}/${match[1]}/plugins/detail?id=${encodeURIComponent(match[2])}`, { method: "GET" });
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
@@ -635,6 +646,11 @@ export default {
if (url.pathname === "/api/auth/logout" && request.method === "POST") return logout();
if (url.pathname === "/api/contributor-avatar" && request.method === "GET") return contributorAvatar(request);
const response = await env.ASSETS.fetch(request);
if (response.status === 404) {
const shellRequest = pluginDetailShellRequest(url, request);
const shellResponse = shellRequest ? await env.ASSETS.fetch(shellRequest) : null;
if (shellResponse && shellResponse.status < 400) return shellResponse;
}
const cacheControl = staticAssetCacheControl(url.pathname);
if (!cacheControl || response.status < 200 || response.status >= 400) return response;