Compare commits
1
Commits
main
...
app-downloads-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aacf7f5a2f |
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 (
|
||||
|
||||
Reference in new issue
Block a user