mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
feat(site): add plugin center listing and detail pages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 ?? []} />;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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,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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user