App downloads

This commit is contained in:
Brandon Thomas committed 2026-10-05 06:16:53 -04:00
1 parent b02f710c76
commit 036e378ac7
6 files changed
+465 -59

No files matched your search

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowDownToLineIcon } from "lucide-react";
import {
AppFamily,
AppGallery,
AppGetIt,
} from "@/components/apps/app-sections";
import { AppHeroDownloadButton } from "@/components/apps/app-downloads";
import {
AppIcon,
AppTab,
@@ -27,7 +27,9 @@ import {
craftAppOgImage,
craftAppPath,
craftAppIconUrl,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftShotUrl,
getCraftApp,
@@ -73,6 +75,7 @@ export default async function CraftAppPage({ params }: { params: Params }) {
if (!app) notFound();
const name = craftAppName(app);
const release = craftAppRelease(app);
const [hero] = app.shots;
const [before, accent, after] = app.headline;
const shareUrl = siteUrl(craftAppPath(app));
@@ -88,9 +91,9 @@ export default async function CraftAppPage({ params }: { params: Params }) {
thumbnailUrl: siteUrl(craftAppIconUrl(app)),
screenshot: app.shots.map((shot) => siteUrl(craftShotUrl(app, shot))),
sameAs: craftAppRepo(app),
...(app.release && {
softwareVersion: app.release.version,
downloadUrl: craftReleasePageUrl(app, app.release),
...(release && {
softwareVersion: release.version,
downloadUrl: craftReleasePageUrl(app, release),
}),
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
@@ -140,15 +143,17 @@ export default async function CraftAppPage({ params }: { params: Params }) {
>
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap justify-center gap-3">
{app.release && (
<Button href="#get-it" size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
Download {name}
</Button>
{release && (
<AppHeroDownloadButton
name={name}
recommended={craftReleaseDownloads(app, release).filter(
(download) => download.recommended,
)}
/>
)}
<DiscordButton
size="lg"
variant={app.release ? "secondary" : "primary"}
variant={release ? "secondary" : "primary"}
>
Join the Discord
</DiscordButton>
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui";
import {
craftAppName,
craftAppPath,
craftAppRelease,
craftShotUrl,
type CraftApp,
} from "@/lib/crafting-apps";
@@ -24,6 +25,7 @@ export default function AppCard({
compact?: boolean;
}) {
const [hero] = app.shots;
const release = craftAppRelease(app);
return (
<Link
href={craftAppPath(app)}
@@ -66,7 +68,9 @@ export default function AppCard({
{!compact && (
<div className="mt-6 flex flex-wrap gap-1.5">
<Badge label={app.status} className="text-(--app-ink)" />
{app.release && <Badge label="Installers ready" className="text-(--app-ink)" />}
{release && (
<Badge label="Installers ready" className="text-(--app-ink)" />
)}
{app.platforms.map((platform) => (
<Badge key={platform} label={platform} />
))}
@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowDownToLineIcon } from "lucide-react";
import { Button } from "@/components/ui";
import {
CRAFT_DESKTOP_PLATFORMS,
type CraftDesktopPlatform,
} 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.
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 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>
);
}
/** Hero CTA: a direct download once the OS is known, else a jump to #get-it. */
export function AppHeroDownloadButton({
recommended,
name,
}: {
recommended: CraftDownload[];
name: string;
}) {
const detected = useDetectedDesktop();
const mine = detected && pickDownload(recommended, detected);
return (
<Button href={mine ? mine.href : "#get-it"} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{mine ? `Download for ${mine.group}` : `Download ${name}`}
</Button>
);
}
function useDetectedDesktop(): DetectedDesktop | null {
const [detected, setDetected] = useState<DetectedDesktop | null>(null);
useEffect(() => {
setDetected(detectDesktop());
}, []);
return detected;
}
/** The platform's first recommended file, or one matching the CPU if any. */
function pickDownload(
recommended: CraftDownload[],
{ platform, arch }: DetectedDesktop,
): CraftDownload | undefined {
const candidates = recommended.filter((download) => download.group === platform);
return candidates.find((download) => arch && download.arch === arch) ?? candidates[0];
}
function detectDesktop(): DetectedDesktop | null {
const ua = navigator.userAgent;
if (/Android|iPhone|iPad|iPod|Mobile|CrOS/i.test(ua)) return null;
if (/Windows/i.test(ua)) return { platform: "Windows" };
if (/Macintosh|Mac OS X/i.test(ua)) {
// iPadOS Safari reports itself as a Mac; touch support gives it away.
return navigator.maxTouchPoints > 1 ? null : { platform: "macOS" };
}
if (/Linux/i.test(ua)) {
return {
platform: "Linux",
arch: /aarch64|arm64|armv8/i.test(ua) ? "aarch64" : "x86_64",
};
}
return null;
}
@@ -1,28 +1,41 @@
import Link from "next/link";
import { ArrowDownToLineIcon, ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import type { ReactNode } from "react";
import {
ArrowDownToLineIcon,
ArrowUpRightIcon,
ChevronDownIcon,
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,
craftAppIndex,
craftAppName,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
craftShotSourceUrl,
craftShotUrl,
formatList,
type CraftApp,
type CraftDownload,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
import { AppDownloadButtons } from "./app-downloads";
import { ColorAccent } from "./app-wordmark";
// Body sections of an /apps/<slug> page, in page order. Server components;
// the only client island is the CopyButton.
// the client islands are the CopyButton and the OS-aware download buttons.
const DOWNLOAD_GROUPS = [...CRAFT_DESKTOP_PLATFORMS, "Other"] as const;
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
@@ -93,12 +106,14 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
}
// Installers (or, until there are any, the Discord waitlist) beside
// build-from-source. Giving an app a `release` in crafting-apps.ts flips the
// left cell to download buttons.
// build-from-source. Versions and files come from crafting-app-releases.ts;
// a null release there flips the left cell to the waitlist.
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) : [];
return (
<SectionShell id="get-it">
@@ -110,32 +125,28 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
<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>
{app.release ? (
{release ? (
<>
<h3 className={CELL_HEADING_CLASSES}>
Download <ColorAccent>{name}</ColorAccent>.
</h3>
<p className="mt-4 max-w-md leading-relaxed text-muted">
Version {app.release.version}, free. Questions or feedback?
Version {release.version}, free. Questions or feedback?
The team is in the ArtCraft Discord.
</p>
<div className="mt-8 flex flex-wrap gap-3">
{craftReleaseDownloads(app, app.release).map((download) => (
<Button key={download.href} href={download.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{download.platform}
</Button>
))}
<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">
<ExternalLink href={craftReleasePageUrl(app, release)}>
Release notes on GitHub
</ExternalLink>
<ExternalLink href={craftReleasesUrl(app)}>
All releases
</ExternalLink>
</div>
<a
href={craftReleasePageUrl(app, app.release)}
target="_blank"
rel="noopener noreferrer"
className="hud-label mt-5 flex w-fit items-center gap-1.5 text-muted hover:text-ink"
>
All builds and checksums
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
</a>
</>
) : (
<>
@@ -209,6 +220,67 @@ 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
href={href}
target="_blank"
rel="noopener noreferrer"
className="hud-label flex w-fit items-center gap-1.5 text-muted hover:text-ink"
>
{children}
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
</a>
);
}
export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
const siblings = CRAFTING_APPS.filter((other) => other.slug !== app.slug);
return (
@@ -0,0 +1,185 @@
import type { CraftAppSlug } from "./crafting-apps";
// GitHub releases for the Crafting Apps, which drive every download button on
// the /apps pages.
//
// To bump every app: change CRAFTING_APPS_LATEST_VERSION.
// To pin one app to another version: give it its own release in
// CRAFT_APP_RELEASES, e.g. `craftRelease("0.1.1")`.
// To hide an app's downloads (Discord waitlist instead): set it to null.
// If a release renames, adds or drops files, edit CRAFT_RELEASE_ASSETS, or
// pass a custom list as craftRelease's second argument for a single app.
//
// Release tags are `v<version>` and live at github.com/storytold/<slug>.
export const CRAFTING_APPS_LATEST_VERSION = "0.2.0";
export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux";
/** Order of the per-platform download buttons. */
export const CRAFT_DESKTOP_PLATFORMS: CraftDesktopPlatform[] = ["macOS", "Windows", "Linux"];
export type CraftReleaseAsset = {
/** Asset filename; `{slug}` and `{version}` are filled in per app. */
file: string;
/** Group in the full file list. "Other" holds the CLI, web build and checksums. */
group: CraftDesktopPlatform | "Other";
/** Short name, e.g. "Installer (x64)". */
label: string;
/** One line on who should pick this file. */
description: string;
/** CPU architecture, used to choose between recommended Linux builds. */
arch?: "universal" | "x64" | "x86" | "x86_64" | "aarch64";
/**
* Offered as a platform's main download button. The first recommended
* asset in a group is the default; a later one wins only when its `arch`
* matches the visitor's detected CPU.
*/
recommended?: boolean;
};
export type CraftRelease = {
/** Version without the leading "v", e.g. "0.2.0". */
version: string;
/** Git tag. Defaults to `v<version>`. */
tag?: string;
assets: readonly CraftReleaseAsset[];
};
// The file set every Crafting App publishes, in display order within each group.
export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
{
file: "{slug}-{version}-macos-universal.dmg",
group: "macOS",
label: "Disk image (Universal)",
description: "Apple silicon and Intel Macs.",
arch: "universal",
recommended: true,
},
{
file: "{slug}-cli-{version}-macos-universal.zip",
group: "macOS",
label: "Command-line tool (Universal)",
description: "The headless CLI for scripts and agents.",
arch: "universal",
},
{
file: "{slug}-{version}-windows-x64.msi",
group: "Windows",
label: "Installer (64-bit)",
description: "Most Windows 10 and 11 PCs.",
arch: "x64",
recommended: true,
},
{
file: "{slug}-{version}-windows-x64-portable.zip",
group: "Windows",
label: "Portable (64-bit)",
description: "Unzip and run, no install.",
arch: "x64",
},
{
file: "{slug}-{version}-windows-x86.msi",
group: "Windows",
label: "Installer (32-bit)",
description: "Older 32-bit Windows.",
arch: "x86",
},
{
file: "{slug}-{version}-windows-x86-portable.zip",
group: "Windows",
label: "Portable (32-bit)",
description: "Unzip and run on 32-bit Windows.",
arch: "x86",
},
{
file: "{slug}-{version}-linux-x86_64.AppImage",
group: "Linux",
label: "AppImage (x86_64)",
description: "Any distro. Mark it executable and run.",
arch: "x86_64",
recommended: true,
},
{
file: "{slug}-{version}-linux-x86_64.deb",
group: "Linux",
label: "Debian package (x86_64)",
description: "Debian, Ubuntu and derivatives.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.rpm",
group: "Linux",
label: "RPM package (x86_64)",
description: "Fedora, RHEL and openSUSE.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-x86_64.tar.gz",
group: "Linux",
label: "Tarball (x86_64)",
description: "Plain binaries to unpack anywhere.",
arch: "x86_64",
},
{
file: "{slug}-{version}-linux-aarch64.AppImage",
group: "Linux",
label: "AppImage (ARM64)",
description: "Any distro on 64-bit ARM.",
arch: "aarch64",
recommended: true,
},
{
file: "{slug}-{version}-linux-aarch64.deb",
group: "Linux",
label: "Debian package (ARM64)",
description: "Debian, Ubuntu and derivatives on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.rpm",
group: "Linux",
label: "RPM package (ARM64)",
description: "Fedora, RHEL and openSUSE on ARM.",
arch: "aarch64",
},
{
file: "{slug}-{version}-linux-aarch64.tar.gz",
group: "Linux",
label: "Tarball (ARM64)",
description: "Plain binaries for 64-bit ARM.",
arch: "aarch64",
},
{
file: "{slug}-web-{version}.zip",
group: "Other",
label: "Web build",
description: "The WebAssembly app, to host yourself.",
},
{
file: "SHA256SUMS.txt",
group: "Other",
label: "SHA-256 checksums",
description: "Verify any download above.",
},
];
export function craftRelease(
version: string,
assets: readonly CraftReleaseAsset[] = CRAFT_RELEASE_ASSETS,
): CraftRelease {
return { version, assets };
}
const LATEST = craftRelease(CRAFTING_APPS_LATEST_VERSION);
// Every app must be listed, so a new slug fails the type check until it is.
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
photocraft: LATEST,
vectorcraft: LATEST,
filmcraft: LATEST,
lightcraft: LATEST,
printcraft: LATEST,
effectcraft: LATEST,
designcraft: LATEST,
};
@@ -1,4 +1,9 @@
import type { Tip } from "./campaign-data";
import {
CRAFT_APP_RELEASES,
type CraftRelease,
type CraftReleaseAsset,
} from "./crafting-app-releases";
import { mediaUrl } from "./links";
// The Crafting Apps family: open-source native apps from the ArtCraft team,
@@ -9,8 +14,8 @@ import { mediaUrl } from "./links";
// Facts (counts, timings, formats) come from each repo's README — keep them
// in sync when the READMEs change.
//
// Apps without a published GitHub release show a Discord waitlist. When one
// ships, give it `release` and its page offers the installers instead.
// Download versions and files live in crafting-app-releases.ts. Apps whose
// release there is null show a Discord waitlist instead of installers.
//
// Assets live in public/images/apps/<slug>/: icon.webp (the repo's 512 px
// hicolor render at 256 px), og.jpg (1200×630 share card) and the shots.
@@ -37,13 +42,6 @@ export type CraftShot = {
portrait?: boolean;
};
export type CraftRelease = {
/** Tag without the leading "v", e.g. "0.1.1-rc.4". */
version: string;
/** Asset names are `<slug>-<version>-<suffix>`. */
files: { platform: Exclude<CraftPlatform, "Web">; suffix: string }[];
};
export type CraftApp = {
slug: CraftAppSlug;
/** Prefix before "Craft" — the wordmark colors "Craft". */
@@ -62,7 +60,12 @@ export type CraftApp = {
features: Tip[];
/** First shot is the hero. */
shots: [CraftShot, ...CraftShot[]];
release?: CraftRelease;
};
/** One release file, resolved to a URL for a specific app. */
export type CraftDownload = Omit<CraftReleaseAsset, "file"> & {
fileName: string;
href: string;
};
export const CRAFTING_APPS_GITHUB_ORG = "https://github.com/storytold";
@@ -79,14 +82,6 @@ export const CRAFTING_APPS: CraftApp[] = [
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
release: {
version: "0.1.1-rc.4",
files: [
{ platform: "macOS", suffix: "macos-universal.dmg" },
{ platform: "Windows", suffix: "windows-x64.msi" },
{ platform: "Linux", suffix: "linux-x86_64.AppImage" },
],
},
features: [
{
title: "Familiar by design",
@@ -630,19 +625,34 @@ export function craftAppIconSourceUrl(app: CraftApp): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/assets/app-icon/${app.slug}-1024.png`;
}
export function craftReleasePageUrl(app: CraftApp, release: CraftRelease): string {
return `${craftAppRepo(app)}/releases/tag/v${release.version}`;
export function craftAppRelease(app: CraftApp): CraftRelease | null {
return CRAFT_APP_RELEASES[app.slug];
}
export function craftReleaseTag(release: CraftRelease): string {
return release.tag ?? `v${release.version}`;
}
export function craftReleasePageUrl(app: CraftApp, release: CraftRelease): string {
return `${craftAppRepo(app)}/releases/tag/${craftReleaseTag(release)}`;
}
export function craftReleasesUrl(app: CraftApp): string {
return `${craftAppRepo(app)}/releases`;
}
/** Every file in the release, in catalog order. */
export function craftReleaseDownloads(
app: CraftApp,
release: CraftRelease,
): { platform: string; href: string }[] {
const base = `${craftAppRepo(app)}/releases/download/v${release.version}`;
return release.files.map(({ platform, suffix }) => ({
platform,
href: `${base}/${app.slug}-${release.version}-${suffix}`,
}));
): CraftDownload[] {
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
return release.assets.map(({ file, ...asset }) => {
const fileName = file
.replaceAll("{slug}", app.slug)
.replaceAll("{version}", release.version);
return { ...asset, fileName, href: `${base}/${fileName}` };
});
}
export function craftAppOgImage(app: CraftApp): string {