feat(website): offer the beta channel on the download page
Beta testers had to find prerelease assets on GitHub by hand. The download page now carries a Stable/Beta switch, with stable staying the default and the canonical URL. The channel lives in the URL (?channel=beta) rather than client state so maintainers can link someone straight to it. That link outlives the beta it was shared for, so the switch only renders while the newest prerelease leads stable on its core version, and a stale ?channel=beta falls back to stable instead of serving a promoted version twice. "What's new" deep-links the minor group anchor (/changelog#release-0.3), not the exact entry: promotion rewrites the -beta.N entry in place, so that anchor dies while the group survives — and the group is the stable-to-beta diff a beta downloader came to read. A version missing from the bundled changelog links plain /changelog rather than a dead anchor, which covers the window between a tag going public and the site redeploying. Homebrew, the app stores, and the web app have no beta, so the Beta view drops those rows instead of showing inert placeholders.
This commit is contained in:
@@ -1,25 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import type { ReleaseChannels, ReleaseInfo } from "~/latest-release";
|
||||
import { getLatestRelease } from "~/release";
|
||||
import { getStarCount } from "~/stars";
|
||||
|
||||
interface ReleaseContext {
|
||||
version: string;
|
||||
linuxAppImageAsset: string;
|
||||
windowsX64Asset: string | null;
|
||||
windowsArm64Asset: string | null;
|
||||
}
|
||||
|
||||
interface StarsContext {
|
||||
stars: string;
|
||||
}
|
||||
|
||||
const ReleaseCtx = createContext<ReleaseContext>({
|
||||
version: "",
|
||||
linuxAppImageAsset: "",
|
||||
windowsX64Asset: null,
|
||||
windowsArm64Asset: null,
|
||||
const ReleaseCtx = createContext<ReleaseChannels>({
|
||||
stable: {
|
||||
version: "",
|
||||
linuxAppImageAsset: "",
|
||||
windowsX64Asset: null,
|
||||
windowsArm64Asset: null,
|
||||
},
|
||||
beta: null,
|
||||
});
|
||||
const StarsCtx = createContext<StarsContext>({ stars: "" });
|
||||
|
||||
@@ -27,8 +24,14 @@ const PLAUSIBLE_INIT_SCRIPT = {
|
||||
__html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`,
|
||||
};
|
||||
|
||||
export function useRelease(): ReleaseContext {
|
||||
return useContext(ReleaseCtx);
|
||||
/** The latest stable release. Everything on the site points here by default. */
|
||||
export function useRelease(): ReleaseInfo {
|
||||
return useContext(ReleaseCtx).stable;
|
||||
}
|
||||
|
||||
/** The current beta, or null when there is no beta ahead of stable. */
|
||||
export function useBetaRelease(): ReleaseInfo | null {
|
||||
return useContext(ReleaseCtx).beta;
|
||||
}
|
||||
|
||||
export function useStars(): StarsContext {
|
||||
@@ -38,7 +41,7 @@ export function useStars(): StarsContext {
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => {
|
||||
const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]);
|
||||
return { ...release, ...stars };
|
||||
return { release, ...stars };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
@@ -63,7 +66,7 @@ export const Route = createRootRoute({
|
||||
function RootComponent() {
|
||||
const data = Route.useLoaderData();
|
||||
return (
|
||||
<ReleaseCtx value={data}>
|
||||
<ReleaseCtx value={data.release}>
|
||||
<StarsCtx value={data}>
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import changelogMarkdown from "../../../../CHANGELOG.md?raw";
|
||||
import {
|
||||
changelogReleaseGroups,
|
||||
releaseAnchor,
|
||||
type ChangelogRelease,
|
||||
type ChangelogReleaseGroup,
|
||||
} from "~/changelog";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
interface ChangelogRelease {
|
||||
version: string;
|
||||
date: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
interface ChangelogReleaseGroup {
|
||||
version: string;
|
||||
releases: ChangelogRelease[];
|
||||
}
|
||||
|
||||
const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/;
|
||||
const patchMarkdownComponents: Components = { h3: "h4" };
|
||||
|
||||
export const Route = createFileRoute("/changelog")({
|
||||
@@ -38,66 +31,10 @@ function formatDate(date: string): string {
|
||||
}).format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
|
||||
function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const releases: ChangelogRelease[] = [];
|
||||
const versions = new Set<string>();
|
||||
let currentRelease: ChangelogRelease | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const heading = line.match(releaseHeadingPattern);
|
||||
if (heading) {
|
||||
const version = heading[1];
|
||||
if (versions.has(version)) {
|
||||
throw new Error(`Duplicate changelog version: ${version}`);
|
||||
}
|
||||
versions.add(version);
|
||||
|
||||
if (currentRelease) releases.push(currentRelease);
|
||||
currentRelease = {
|
||||
version,
|
||||
date: heading[2],
|
||||
markdown: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentRelease) currentRelease.markdown += `${line}\n`;
|
||||
}
|
||||
|
||||
if (currentRelease) releases.push(currentRelease);
|
||||
return releases;
|
||||
}
|
||||
|
||||
const changelogReleases = parseChangelog(changelogMarkdown);
|
||||
|
||||
function minorVersion(version: string): string {
|
||||
return version.split(".").slice(0, 2).join(".");
|
||||
}
|
||||
|
||||
function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseGroup[] {
|
||||
const groups: ChangelogReleaseGroup[] = [];
|
||||
|
||||
for (const release of releases) {
|
||||
const version = minorVersion(release.version);
|
||||
const currentGroup = groups.at(-1);
|
||||
if (currentGroup?.version === version) {
|
||||
currentGroup.releases.push(release);
|
||||
} else {
|
||||
groups.push({ version, releases: [release] });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
const changelogReleaseGroups = groupChangelogReleases(changelogReleases);
|
||||
|
||||
function HeadingAnchor({ version }: { version: string }) {
|
||||
const anchor = `release-${version}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`#${anchor}`}
|
||||
href={`#${releaseAnchor(version)}`}
|
||||
className="changelog-heading-anchor"
|
||||
aria-label={`Link to Paseo ${version}`}
|
||||
>
|
||||
@@ -107,11 +44,9 @@ function HeadingAnchor({ version }: { version: string }) {
|
||||
}
|
||||
|
||||
function PatchRelease({ release }: { release: ChangelogRelease }) {
|
||||
const anchor = `release-${release.version}`;
|
||||
|
||||
return (
|
||||
<section className="changelog-patch">
|
||||
<div id={anchor} className="changelog-patch-heading">
|
||||
<div id={releaseAnchor(release.version)} className="changelog-patch-heading">
|
||||
<HeadingAnchor version={release.version} />
|
||||
<h3 className="changelog-patch-title">{release.version}</h3>
|
||||
<time dateTime={release.date} className="changelog-release-date">
|
||||
@@ -126,11 +61,9 @@ function PatchRelease({ release }: { release: ChangelogRelease }) {
|
||||
}
|
||||
|
||||
function Release({ group }: { group: ChangelogReleaseGroup }) {
|
||||
const anchor = `release-${group.version}`;
|
||||
|
||||
return (
|
||||
<article className="changelog-release">
|
||||
<div id={anchor} className="changelog-release-heading">
|
||||
<div id={releaseAnchor(group.version)} className="changelog-release-heading">
|
||||
<HeadingAnchor version={group.version} />
|
||||
<h2 className="changelog-release-title">
|
||||
Paseo <span>{group.version}</span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { changelogLink } from "~/changelog";
|
||||
import { CodeBlock } from "~/components/code-block";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { pageMeta } from "~/meta";
|
||||
@@ -14,10 +16,19 @@ import {
|
||||
TerminalIcon,
|
||||
GlobeIcon,
|
||||
} from "~/downloads";
|
||||
import { useRelease } from "~/routes/__root";
|
||||
import { useBetaRelease, useRelease } from "~/routes/__root";
|
||||
import "~/styles.css";
|
||||
|
||||
interface DownloadSearch {
|
||||
channel?: "beta";
|
||||
}
|
||||
|
||||
const STABLE_SEARCH: DownloadSearch = {};
|
||||
const BETA_SEARCH: DownloadSearch = { channel: "beta" };
|
||||
|
||||
export const Route = createFileRoute("/download")({
|
||||
validateSearch: (search: Record<string, unknown>): DownloadSearch =>
|
||||
search.channel === "beta" ? { channel: "beta" } : {},
|
||||
head: () =>
|
||||
pageMeta(
|
||||
"Download Paseo for macOS, Windows, Linux, iOS, and Android",
|
||||
@@ -28,14 +39,38 @@ export const Route = createFileRoute("/download")({
|
||||
});
|
||||
|
||||
function Download() {
|
||||
const release = useRelease();
|
||||
const stable = useRelease();
|
||||
const beta = useBetaRelease();
|
||||
const { channel } = Route.useSearch();
|
||||
|
||||
// A ?channel=beta link outlives the beta it was shared for, so the release
|
||||
// decides the channel, not the URL.
|
||||
const activeBeta = channel === "beta" ? beta : null;
|
||||
const onBeta = activeBeta !== null;
|
||||
const release = activeBeta ?? stable;
|
||||
const { version } = release;
|
||||
const urls = downloadUrls(release);
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
|
||||
<p className="text-muted-foreground mb-10">v{version}</p>
|
||||
<div className="mb-10 flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
|
||||
<p className="text-muted-foreground">
|
||||
v{version}
|
||||
<span className="mx-2 text-muted-foreground/40">·</span>
|
||||
<Link
|
||||
{...changelogLink(version)}
|
||||
className="underline underline-offset-4 decoration-border hover:text-foreground hover:decoration-current transition-colors"
|
||||
>
|
||||
What's new
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
{beta && <ChannelSwitch onBeta={onBeta} />}
|
||||
</div>
|
||||
|
||||
{onBeta && <BetaNotice />}
|
||||
|
||||
{/* Desktop */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
|
||||
@@ -50,54 +85,36 @@ function Download() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* macOS */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppleIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">macOS</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PlatformRow icon={AppleIcon} label="macOS">
|
||||
<PillGroup>
|
||||
<DownloadPill href={urls.macAppleSilicon} label="Apple Silicon" />
|
||||
<DownloadPill href={urls.macIntel} label="Intel" />
|
||||
</div>
|
||||
</div>
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
|
||||
{/* Homebrew */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TerminalIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Homebrew</span>
|
||||
</div>
|
||||
<CodeBlock size="sm">brew install --cask paseo</CodeBlock>
|
||||
</div>
|
||||
{!onBeta && (
|
||||
<PlatformRow icon={TerminalIcon} label="Homebrew">
|
||||
<CodeBlock size="sm">brew install --cask paseo</CodeBlock>
|
||||
</PlatformRow>
|
||||
)}
|
||||
|
||||
{/* Windows */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<WindowsIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Windows</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PlatformRow icon={WindowsIcon} label="Windows">
|
||||
<PillGroup>
|
||||
<DownloadPill
|
||||
href={urls.windowsExeX64}
|
||||
label={urls.windowsExeArm64 ? "Intel / x64" : "Download"}
|
||||
/>
|
||||
{urls.windowsExeArm64 && <DownloadPill href={urls.windowsExeArm64} label="ARM64" />}
|
||||
</div>
|
||||
</div>
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
|
||||
{/* Linux */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<LinuxIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Linux</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PlatformRow icon={LinuxIcon} label="Linux">
|
||||
<PillGroup>
|
||||
<DownloadPill href={urls.linuxAppImage} label="AppImage" />
|
||||
<DownloadPill href={urls.linuxDeb} label="DEB" />
|
||||
<DownloadPill href={urls.linuxRpm} label="RPM" />
|
||||
</div>
|
||||
</div>
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -109,55 +126,45 @@ function Download() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{/* Android */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AndroidIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Android</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill href={playStoreUrl} label="Play Store" external />
|
||||
<PlatformRow icon={AndroidIcon} label="Android">
|
||||
<PillGroup>
|
||||
{!onBeta && <DownloadPill href={playStoreUrl} label="Play Store" external />}
|
||||
<DownloadPill href={urls.androidApk} label="APK" />
|
||||
</div>
|
||||
</div>
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
|
||||
{/* iOS */}
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppleIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">iOS</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill href={appStoreUrl} label="App Store" external />
|
||||
</div>
|
||||
</div>
|
||||
{!onBeta && (
|
||||
<PlatformRow icon={AppleIcon} label="iOS">
|
||||
<PillGroup>
|
||||
<DownloadPill href={appStoreUrl} label="App Store" external />
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Web */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Web</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connect to a server from any browser
|
||||
</p>
|
||||
{!onBeta && (
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Web</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connect to a server from any browser
|
||||
</p>
|
||||
</div>
|
||||
<GlobeIcon className="h-5 w-5 text-muted-foreground mt-1.5" />
|
||||
</div>
|
||||
<GlobeIcon className="h-5 w-5 text-muted-foreground mt-1.5" />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GlobeIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Web App</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadPill href={webAppUrl} label="Open" external />
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<PlatformRow icon={GlobeIcon} label="Web App">
|
||||
<PillGroup>
|
||||
<DownloadPill href={webAppUrl} label="Open" external />
|
||||
</PillGroup>
|
||||
</PlatformRow>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Server */}
|
||||
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8">
|
||||
@@ -172,21 +179,21 @@ function Download() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TerminalIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">npm</span>
|
||||
</div>
|
||||
<CodeBlock size="sm">npm install -g @getpaseo/cli && paseo</CodeBlock>
|
||||
</div>
|
||||
<PlatformRow icon={TerminalIcon} label="npm">
|
||||
<CodeBlock size="sm">
|
||||
{onBeta
|
||||
? "npm install -g @getpaseo/cli@beta && paseo"
|
||||
: "npm install -g @getpaseo/cli && paseo"}
|
||||
</CodeBlock>
|
||||
</PlatformRow>
|
||||
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TerminalIcon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">Nix</span>
|
||||
</div>
|
||||
<CodeBlock size="sm">nix run github:getpaseo/paseo</CodeBlock>
|
||||
</div>
|
||||
<PlatformRow icon={TerminalIcon} label="Nix">
|
||||
<CodeBlock size="sm">
|
||||
{onBeta
|
||||
? `nix run github:getpaseo/paseo/v${version}`
|
||||
: "nix run github:getpaseo/paseo"}
|
||||
</CodeBlock>
|
||||
</PlatformRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -206,6 +213,82 @@ function Download() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelSwitch({ onBeta }: { onBeta: boolean }) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Release channel"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-card/60 p-1"
|
||||
>
|
||||
<ChannelOption label="Stable" active={!onBeta} search={STABLE_SEARCH} />
|
||||
<ChannelOption label="Beta" active={onBeta} search={BETA_SEARCH} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelOption({
|
||||
label,
|
||||
active,
|
||||
search,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
search: DownloadSearch;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to="/download"
|
||||
search={search}
|
||||
replace
|
||||
resetScroll={false}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BetaNotice() {
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-primary/25 bg-primary/5 p-5 md:px-8 md:py-6">
|
||||
<p className="text-sm">Beta builds ship ahead of stable and can break.</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
Already running the desktop app? Set{" "}
|
||||
<span className="text-foreground">Settings → Release channel → Beta</span> and it updates
|
||||
itself from here on.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => ReactNode;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-foreground" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillGroup({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap items-center gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
function DownloadPill({
|
||||
href,
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user