Compare commits

...
1 Commits
Author SHA1 Message Date
Brandon Thomas aacf7f5a2f app downloads 2 2026-10-05 06:38:17 -04:00
3 changed files with 165 additions and 133 deletions

No files matched your search

@@ -5,7 +5,10 @@ import {
AppGallery,
AppGetIt,
} from "@/components/apps/app-sections";
import { AppHeroDownloadButton } from "@/components/apps/app-downloads";
import {
AppHeroDownloadButton,
AppHeroOtherDownloadsLink,
} from "@/components/apps/app-downloads";
import {
AppIcon,
AppTab,
@@ -168,6 +171,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
View on GitHub
</Button>
</div>
{release && <AppHeroOtherDownloadsLink />}
<p className="hud-label text-faint">
{app.platforms.join(" · ")} · Free and open source
</p>
@@ -1,7 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowDownToLineIcon } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import {
AppleIcon,
ArrowDownIcon,
ArrowDownToLineIcon,
SquareTerminalIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { WindowsIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import {
CRAFT_DESKTOP_PLATFORMS,
@@ -9,74 +16,116 @@ import {
} from "@/lib/crafting-app-releases";
import type { CraftDownload } from "@/lib/crafting-apps";
// OS-aware download buttons for /apps/<slug>. Detection runs after mount
// (no user agent on the server), so the first paint offers every platform
// equally and the "for your system" button arrives with hydration. Phones,
// tablets and Chromebooks keep the neutral layout.
// OS-aware downloads for /apps/<slug>, modeled on the ArtCraft /download
// page. Detection runs after mount (no user agent on the server), so the
// first paint treats every platform equally and "Your system" arrives with
// hydration. Phones, tablets and Chromebooks keep the neutral layout.
type DetectedDesktop = {
platform: CraftDesktopPlatform;
arch?: CraftDownload["arch"];
};
/** Main buttons in the Get-it section: your platform first, then the rest. */
export function AppDownloadButtons({
recommended,
version,
}: {
/** The release's `recommended` files. */
recommended: CraftDownload[];
version: string;
}) {
const PLATFORM_ICONS: Record<CraftDesktopPlatform, ReactNode> = {
macOS: <AppleIcon aria-hidden className="h-5 w-5" />,
Windows: <WindowsIcon className="h-5 w-5" />,
Linux: <SquareTerminalIcon aria-hidden className="h-5 w-5" />,
};
/**
* One card per desktop platform: its recommended build as the main button,
* then every other file for that platform. The visitor's platform is tagged
* "Your system" and gets the solid button.
*/
export function AppPlatformDownloads({ downloads }: { downloads: CraftDownload[] }) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
const defaults = CRAFT_DESKTOP_PLATFORMS.flatMap((platform) => {
const download = pickDownload(recommended, { platform });
return download ? [download] : [];
});
if (!mine) {
return (
<div className="mt-8 flex flex-wrap gap-3">
{defaults.map((download) => (
<Button key={download.href} href={download.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{download.group}
</Button>
))}
</div>
);
}
const others = defaults.filter((download) => download.group !== mine.group);
return (
<div className="mt-8 flex flex-col gap-3">
<Button href={mine.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download for {mine.group}
</Button>
<p className="hud-label text-faint">
{mine.label} · v{version}
</p>
{others.length > 0 && (
<div className="mt-3 flex flex-wrap gap-3">
{others.map((download) => (
<Button
key={download.href}
href={download.href}
variant="secondary"
size="md"
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{download.group}
</Button>
))}
</div>
)}
<div className="grid gap-px bg-line md:grid-cols-3">
{CRAFT_DESKTOP_PLATFORMS.map((platform, i) => {
const files = downloads.filter((download) => download.group === platform);
const isDetected = detected?.platform === platform;
const main = pickDownload(files, isDetected ? detected : { platform });
if (!main) return null;
const others = files.filter((download) => download !== main);
return (
<article key={platform} data-reveal className="flex flex-col bg-bg">
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
<p className="hud-label text-muted">
{isDetected ? (
<span className="text-(--app-ink,var(--accent-ink))">Your system</span>
) : (
"Platform"
)}
</p>
<p className="hud-label text-faint">{String(i + 1).padStart(2, "0")}</p>
</div>
<div className="flex flex-1 flex-col p-6 md:p-8">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center border border-line text-ink">
{PLATFORM_ICONS[platform]}
</span>
<h3 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
{platform}
</h3>
</div>
<p className="mt-5 text-sm text-ink">{main.label}</p>
<p className="mt-1 text-sm text-muted">{main.description}</p>
<Button
href={main.href}
variant={isDetected ? "primary" : "secondary"}
className="mt-6 w-full"
>
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download for {platform}
</Button>
{others.length > 0 && (
<>
<p className="hud-label mt-8 text-faint">Other {platform} downloads</p>
<ul className="mt-2 border-t border-line">
{others.map((download) => (
<li key={download.href} className="border-b border-line">
<DownloadRow download={download} />
</li>
))}
</ul>
</>
)}
</div>
</article>
);
})}
</div>
);
}
/** A compact file link: label, description and filename. */
export function DownloadRow({ download, className }: { download: CraftDownload; className?: string }) {
return (
<a
href={download.href}
title={download.fileName}
className={twMerge(
"group/file flex items-center justify-between gap-4 py-3 hover:bg-bg-sunken",
className,
)}
>
<span className="min-w-0">
<span className="block text-sm text-ink">{download.label}</span>
<span className="block text-sm text-muted">{download.description}</span>
<span className="mt-1 block truncate font-mono text-[11px] text-faint">
{download.fileName}
</span>
</span>
<ArrowDownToLineIcon
aria-hidden
className="h-4 w-4 shrink-0 text-muted group-hover/file:text-ink"
/>
</a>
);
}
/** Hero CTA: a direct download once the OS is known, else a jump to #get-it. */
export function AppHeroDownloadButton({
recommended,
@@ -95,6 +144,24 @@ export function AppHeroDownloadButton({
);
}
/**
* Shown under the hero once the direct download is live, so visitors on
* another machine, CPU or package format can still find their build.
*/
export function AppHeroOtherDownloadsLink() {
const detected = useDetectedDesktop();
if (!detected) return null;
return (
<a
href="#get-it"
className="hud-label flex items-center gap-1.5 text-muted hover:text-ink"
>
Other platforms and downloads
<ArrowDownIcon aria-hidden className="h-3.5 w-3.5" />
</a>
);
}
function useDetectedDesktop(): DetectedDesktop | null {
const [detected, setDetected] = useState<DetectedDesktop | null>(null);
useEffect(() => {
@@ -105,10 +172,12 @@ function useDetectedDesktop(): DetectedDesktop | null {
/** The platform's first recommended file, or one matching the CPU if any. */
function pickDownload(
recommended: CraftDownload[],
downloads: CraftDownload[],
{ platform, arch }: DetectedDesktop,
): CraftDownload | undefined {
const candidates = recommended.filter((download) => download.group === platform);
const candidates = downloads.filter(
(download) => download.group === platform && download.recommended,
);
return candidates.find((download) => arch && download.arch === arch) ?? candidates[0];
}
@@ -1,17 +1,11 @@
import Link from "next/link";
import type { ReactNode } from "react";
import {
ArrowDownToLineIcon,
ArrowUpRightIcon,
ChevronDownIcon,
TerminalIcon,
} from "lucide-react";
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Badge, Button, CopyButton } from "@/components/ui";
import { CRAFT_DESKTOP_PLATFORMS } from "@/lib/crafting-app-releases";
import {
CRAFTING_APPS,
craftAppBuildCommand,
@@ -26,16 +20,13 @@ import {
craftShotUrl,
formatList,
type CraftApp,
type CraftDownload,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppDownloadButtons } from "./app-downloads";
import { AppPlatformDownloads, DownloadRow } from "./app-downloads";
import { ColorAccent } from "./app-wordmark";
// Body sections of an /apps/<slug> page, in page order. Server components;
// the client islands are the CopyButton and the OS-aware download buttons.
const DOWNLOAD_GROUPS = [...CRAFT_DESKTOP_PLATFORMS, "Other"] as const;
// the client islands are the CopyButton and the OS-aware platform cards.
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
@@ -105,15 +96,17 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
);
}
// Installers (or, until there are any, the Discord waitlist) beside
// build-from-source. Versions and files come from crafting-app-releases.ts;
// a null release there flips the left cell to the waitlist.
// Platform cards (your system first-class, every other build one click
// away) above release details and build-from-source. Versions and files come
// from crafting-app-releases.ts; a null release there drops the cards and
// shows the Discord waitlist instead.
export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
const name = craftAppName(app);
const command = craftAppBuildCommand(app);
const desktop = app.platforms.filter((platform) => platform !== "Web");
const release = craftAppRelease(app);
const downloads = release ? craftReleaseDownloads(app, release) : [];
const extras = downloads.filter((download) => download.group === "Other");
return (
<SectionShell id="get-it">
@@ -122,24 +115,36 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
label={`Get ${name}`}
annotation="Free · Open source"
/>
{release && (
<div className="border-b border-line">
<AppPlatformDownloads downloads={downloads} />
</div>
)}
<div className="grid gap-px bg-line md:grid-cols-2">
<div data-reveal className="flex flex-col bg-bg p-6 md:p-10">
<p className="hud-label text-faint">Installers</p>
{release ? (
<>
<p className="hud-label text-faint">Release</p>
<h3 className={CELL_HEADING_CLASSES}>
Download <ColorAccent>{name}</ColorAccent>.
Version <ColorAccent>{release.version}</ColorAccent>.
</h3>
<p className="mt-4 max-w-md leading-relaxed text-muted">
Version {release.version}, free. Questions or feedback?
The team is in the ArtCraft Discord.
Free for every platform above. Questions or feedback? The
team is in the ArtCraft Discord.
</p>
<AppDownloadButtons
recommended={downloads.filter((download) => download.recommended)}
version={release.version}
/>
<AllDownloads downloads={downloads} />
<div className="mt-5 flex flex-wrap gap-x-6 gap-y-2">
{extras.length > 0 && (
<>
<p className="hud-label mt-8 text-faint">Other downloads</p>
<ul className="mt-2 border-t border-line">
{extras.map((download) => (
<li key={download.href} className="border-b border-line">
<DownloadRow download={download} />
</li>
))}
</ul>
</>
)}
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2">
<ExternalLink href={craftReleasePageUrl(app, release)}>
Release notes on GitHub
</ExternalLink>
@@ -150,6 +155,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
</>
) : (
<>
<p className="hud-label text-faint">Installers</p>
<h3 className={CELL_HEADING_CLASSES}>
Installers are <ColorAccent>on the way</ColorAccent>.
</h3>
@@ -219,54 +225,6 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
);
}
// The other apps, compact, linking back to the hub.
// Every file in the release, grouped by platform, behind a disclosure.
function AllDownloads({ downloads }: { downloads: CraftDownload[] }) {
return (
<details className="group/all mt-6 border border-line">
<summary className="hud-label flex cursor-pointer list-none items-center justify-between gap-2 px-4 py-3 text-muted hover:text-ink [&::-webkit-details-marker]:hidden">
All downloads ({downloads.length} files)
<ChevronDownIcon
aria-hidden
className="h-3.5 w-3.5 transition-transform group-open/all:rotate-180"
/>
</summary>
{DOWNLOAD_GROUPS.map((group) => {
const files = downloads.filter((download) => download.group === group);
if (files.length === 0) return null;
return (
<div key={group} className="border-t border-line">
<p className="hud-label bg-bg-sunken px-4 py-2 text-faint">{group}</p>
<ul>
{files.map((download) => (
<li key={download.href} className="border-t border-line">
<a
href={download.href}
className="group/file flex items-center justify-between gap-4 px-4 py-3 hover:bg-bg-sunken"
>
<span className="min-w-0">
<span className="block text-sm text-ink">{download.label}</span>
<span className="block text-sm text-muted">{download.description}</span>
<span className="mt-1 block truncate font-mono text-[11px] text-faint">
{download.fileName}
</span>
</span>
<ArrowDownToLineIcon
aria-hidden
className="h-4 w-4 shrink-0 text-muted group-hover/file:text-ink"
/>
</a>
</li>
))}
</ul>
</div>
);
})}
</details>
);
}
function ExternalLink({ href, children }: { href: string; children: ReactNode }) {
return (
<a
@@ -281,6 +239,7 @@ function ExternalLink({ href, children }: { href: string; children: ReactNode })
);
}
// The other apps, compact, linking back to the hub.
export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
const siblings = CRAFTING_APPS.filter((other) => other.slug !== app.slug);
return (