Compare commits

..
Author SHA1 Message Date
bflatastic 2f1724b5e4 Add Craft Apps marketing pages to the website
Adds an /apps hub and an /apps/<slug> page for each of the five Craft Apps
(PhotoCraft, DrawCraft, FilmCraft, LightCraft, PrintCraft), all driven by
one data file (src/lib/crafting-apps.ts).

- Hub: lineup cards, shared principles, Discord CTA
- App pages: hero screenshot, highlights, gallery, build-from-source with
  copy button, Discord waitlist for installers, sibling apps
- Per-app color identities (AA in both themes), text wordmarks
- Share bar (copy link, X, Reddit, Bluesky, LinkedIn), per-app OG images
  and SoftwareApplication JSON-LD
- Nav: "Craft Apps" link after a divider, Discord button on desktop
- Footer column, sitemap entries, ruler sections, press kit screenshots
- Shared DiscordButton and CopyButton components; SITE_URL in lib/links
2026-10-01 22:55:10 +07:00
43 changed files with 100 additions and 909 deletions

No files matched your search

@@ -25,13 +25,6 @@ const nextConfig: NextConfig = {
permanent: false,
}),
),
// DrawCraft was renamed VectorCraft; the Crafting Apps READMEs still
// link the old page.
{
source: "/apps/drawcraft",
destination: "/apps/vectorcraft",
permanent: true,
},
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
// the canonical route is /media/<token>. Mirrored in netlify.toml so
// the edge answers it before the Next runtime.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

@@ -6,11 +6,6 @@ import {
AppGetIt,
} from "@/components/apps/app-sections";
import {
AppHeroDownloadButton,
AppHeroOtherDownloadsLink,
} from "@/components/apps/app-downloads";
import {
AppIcon,
AppTab,
AppWordmark,
ColorAccent,
@@ -29,11 +24,7 @@ import {
craftAppName,
craftAppOgImage,
craftAppPath,
craftAppIconUrl,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftShotUrl,
getCraftApp,
} from "@/lib/crafting-apps";
@@ -78,7 +69,6 @@ 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));
@@ -91,13 +81,8 @@ export default async function CraftAppPage({ params }: { params: Params }) {
description: app.lede,
url: shareUrl,
image: siteUrl(craftAppOgImage(app)),
thumbnailUrl: siteUrl(craftAppIconUrl(app)),
screenshot: app.shots.map((shot) => siteUrl(craftShotUrl(app, shot))),
sameAs: craftAppRepo(app),
...(release && {
softwareVersion: release.version,
downloadUrl: craftReleasePageUrl(app, release),
}),
codeRepository: craftAppRepo(app),
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@type": "Organization", name: "ArtCraft", url: SITE_URL },
@@ -116,12 +101,9 @@ export default async function CraftAppPage({ params }: { params: Params }) {
label={name}
annotation={`${app.category} · ${app.status}`}
kicker={
<span className="flex flex-col items-center gap-5">
<AppIcon app={app} priority className="h-24 w-24 md:h-28 md:w-28" />
<span className="flex items-center gap-3">
<AppTab>{craftAppIndex(app)}</AppTab>
<AppWordmark app={app} className="text-2xl" />
</span>
<span className="flex items-center gap-3">
<AppTab>{craftAppIndex(app)}</AppTab>
<AppWordmark app={app} className="text-2xl" />
</span>
}
title={
@@ -138,6 +120,8 @@ export default async function CraftAppPage({ params }: { params: Params }) {
src={craftShotUrl(app, hero)}
alt={hero.alt}
fetchPriority="high"
width={1600}
height={1000}
className="absolute inset-0 h-full w-full object-cover"
/>
}
@@ -146,20 +130,7 @@ 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">
{release && (
<AppHeroDownloadButton
name={name}
recommended={craftReleaseDownloads(app, release).filter(
(download) => download.recommended,
)}
/>
)}
<DiscordButton
size="lg"
variant={release ? "secondary" : "primary"}
>
Join the Discord
</DiscordButton>
<DiscordButton size="lg">Join the Discord</DiscordButton>
<Button
href={craftAppRepo(app)}
variant="secondary"
@@ -171,7 +142,6 @@ 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,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
BotIcon,
CodeXmlIcon,
@@ -18,26 +17,22 @@ import {
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { AppIcon, ColorAccent, appThemeClass } from "@/components/apps/app-wordmark";
import { ColorAccent } from "@/components/apps/app-wordmark";
import { PageHeader } from "@/components/page/page-header";
import RevealManager from "@/components/reveal-manager";
import { Button } from "@/components/ui";
import {
CRAFTING_APPS,
CRAFTING_APPS_COUNT_WORD,
CRAFTING_APPS_GITHUB_ORG,
CRAFTING_APPS_OG_IMAGE,
CRAFTING_APPS_PRINCIPLES,
craftAppIndex,
craftAppName,
craftAppPath,
} from "@/lib/crafting-apps";
import { siteUrl } from "@/lib/links";
const TITLE = "Crafting Apps: open-source creative tools";
const COVERAGE =
"Image editing, vector illustration, video, photography, PDFs, motion graphics and page layout";
const DESCRIPTION = `${COVERAGE}: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} native, open-source apps from the ArtCraft team, built in Rust and free to use.`;
const DESCRIPTION =
"Image editing, vector illustration, video, photography and PDFs: five native, open-source apps from the ArtCraft team, built in Rust and free to use.";
export const metadata: Metadata = {
title: TITLE,
@@ -72,13 +67,13 @@ export default function CraftingAppsPage() {
id="apps"
index="01"
label="Crafting Apps"
annotation={`${CRAFTING_APPS_COUNT_WORD} apps · Open source · Pure Rust`}
annotation="Five apps · Open source · Pure Rust"
title={
<>
{CRAFTING_APPS_COUNT_WORD} apps. One <ColorAccent>craft</ColorAccent>.
Five apps. One <ColorAccent>craft</ColorAccent>.
</>
}
lede={`${COVERAGE}. Native, open-source apps from the ArtCraft team, built in Rust and free to use.`}
lede="Image editing, vector illustration, video, photography and PDFs. Native, open-source apps from the ArtCraft team, built in Rust and free to use."
>
<div data-reveal className="mt-8 flex flex-wrap gap-3">
<DiscordButton size="lg">Join the Discord</DiscordButton>
@@ -95,31 +90,9 @@ export default function CraftingAppsPage() {
</div>
<ShareBar
url={siteUrl("/apps")}
text={`Crafting Apps: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} open-source creative apps from ArtCraft`}
text="Crafting Apps: five open-source creative apps from ArtCraft"
className="mt-6"
/>
<nav
aria-label="Crafting Apps"
data-reveal
className="mt-12 flex flex-wrap gap-x-3 gap-y-6 sm:gap-x-5"
>
{CRAFTING_APPS.map((app) => (
<Link
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
>
<AppIcon
app={app}
priority
className="h-14 w-14 transition-transform duration-300 ease-out group-hover:-translate-y-1 sm:h-20 sm:w-20"
/>
<span className="text-xs font-medium text-muted group-hover:text-(--app-ink)">
{craftAppName(app)}
</span>
</Link>
))}
</nav>
</PageHeader>
<SectionShell id="lineup">
@@ -130,7 +103,7 @@ export default function CraftingAppsPage() {
/>
<div
data-reveal-group
className="grid gap-px bg-line sm:grid-cols-2 xl:grid-cols-4"
className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3"
>
{CRAFTING_APPS.map((app) => (
<AppCard key={app.slug} app={app} index={craftAppIndex(app)} />
@@ -313,32 +313,25 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root:not([data-theme="light"]) .plan-enterprise { --plan-ink: #7ea3ee; }
}
/* Crafting Apps color identities (each app's icon tile color), same
contract as the plan colors:
/* Crafting Apps color identities, same contract as the plan colors:
`--app` is the saturated surface color (black text on it passes AA),
`--app-ink` the contrast-safe text shade per theme. */
.app-photocraft { --app: #2f7bf5; --app-ink: #1a5bc4; }
.app-vectorcraft { --app: #e8573f; --app-ink: #b83a24; }
.app-drawcraft { --app: #e8573f; --app-ink: #b83a24; }
.app-filmcraft { --app: #8b5cf6; --app-ink: #6a3fd6; }
.app-lightcraft { --app: #f2a516; --app-ink: #8a5800; }
.app-printcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
:root[data-theme="dark"] .app-vectorcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-drawcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-filmcraft { --app-ink: #b69cff; }
:root[data-theme="dark"] .app-lightcraft { --app-ink: #ffc14d; }
:root[data-theme="dark"] .app-printcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .app-photocraft { --app-ink: #7fb0ff; }
:root:not([data-theme="light"]) .app-vectorcraft { --app-ink: #ff8a70; }
:root:not([data-theme="light"]) .app-drawcraft { --app-ink: #ff8a70; }
:root:not([data-theme="light"]) .app-filmcraft { --app-ink: #b69cff; }
:root:not([data-theme="light"]) .app-lightcraft { --app-ink: #ffc14d; }
:root:not([data-theme="light"]) .app-printcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
}
/* Plan cell: a color wash pooling under the index tab and fading into the
@@ -5,16 +5,14 @@ import { Badge } from "@/components/ui";
import {
craftAppName,
craftAppPath,
craftAppRelease,
craftShotUrl,
type CraftApp,
} from "@/lib/crafting-apps";
import { AppIcon, AppTab, AppWordmark, appThemeClass } from "./app-wordmark";
import { AppTab, AppWordmark, appThemeClass } from "./app-wordmark";
// Lineup cell: color tab + category, the hero screenshot, the app icon
// straddling the screenshot's bottom edge, wordmark, pitch, and (unless
// compact) status/platform chips. The whole cell is the link; a hairline in
// the app's color draws across the top on hover.
// Lineup cell: color tab + category, the hero screenshot, wordmark, pitch,
// and (unless compact) platform/status chips. The whole cell is the link; a
// hairline in the app's color draws across the top on hover.
export default function AppCard({
app,
index,
@@ -25,7 +23,6 @@ export default function AppCard({
compact?: boolean;
}) {
const [hero] = app.shots;
const release = craftAppRelease(app);
return (
<Link
href={craftAppPath(app)}
@@ -50,27 +47,22 @@ export default function AppCard({
alt={hero.alt}
loading="lazy"
decoding="async"
width={1600}
height={1000}
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
</div>
<div className={twMerge("flex flex-1 flex-col", compact ? "px-6 pb-6" : "px-6 pb-6 md:px-8 md:pb-8")}>
<AppIcon
app={app}
className={twMerge(
"relative transition-transform duration-300 ease-out group-hover:-translate-y-1",
compact ? "-mt-7 h-14 w-14" : "-mt-9 h-18 w-18",
)}
/>
<h3 className="mt-4 leading-none">
<AppWordmark app={app} className={compact ? "text-2xl" : "text-3xl"} />
<div className={twMerge("flex flex-1 flex-col", compact ? "p-6" : "p-6 md:p-8")}>
<h3 className="leading-none">
<AppWordmark
app={app}
className={compact ? "text-2xl" : "text-3xl sm:text-4xl"}
/>
</h3>
<p className="mt-3 leading-relaxed text-muted">{app.pitch}</p>
{!compact && (
<div className="mt-6 flex flex-wrap gap-1.5">
<Badge label={app.status} className="text-(--app-ink)" />
{release && (
<Badge label="Installers ready" className="text-(--app-ink)" />
)}
{app.platforms.map((platform) => (
<Badge key={platform} label={platform} />
))}
@@ -1,199 +0,0 @@
"use client";
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,
type CraftDesktopPlatform,
} from "@/lib/crafting-app-releases";
import type { CraftDownload } from "@/lib/crafting-apps";
// 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"];
};
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();
return (
<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,
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>
);
}
/**
* 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(() => {
setDetected(detectDesktop());
}, []);
return detected;
}
/** The platform's first recommended file, or one matching the CPU if any. */
function pickDownload(
downloads: CraftDownload[],
{ platform, arch }: DetectedDesktop,
): CraftDownload | undefined {
const candidates = downloads.filter(
(download) => download.group === platform && download.recommended,
);
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,6 +1,5 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { ArrowDownToLineIcon, ArrowUpRightIcon, TerminalIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
@@ -11,31 +10,24 @@ import {
craftAppBuildCommand,
craftAppIndex,
craftAppName,
craftAppRelease,
craftAppRepo,
craftReleaseDownloads,
craftReleasePageUrl,
craftReleasesUrl,
craftShotSourceUrl,
craftShotUrl,
formatList,
type CraftApp,
} from "@/lib/crafting-apps";
import AppCard from "./app-card";
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 platform cards.
// the only client island is the CopyButton.
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// The hero carries shot 01. Window captures lead with a full-width shot,
// then pair; portrait pages (layout spreads) sit four across.
// The hero carries shot 01; the rest lead with a full-width shot, then pair.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
const [, ...shots] = app.shots;
const portrait = shots.every((shot) => shot.portrait);
return (
<SectionShell id="gallery">
<SectionEyebrow
@@ -43,27 +35,18 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
label="Screenshots"
annotation={`Captured in ${craftAppName(app)}`}
/>
<div
data-reveal-group
className={twMerge(
"grid gap-px bg-line",
portrait ? "grid-cols-2 lg:grid-cols-4" : "md:grid-cols-2",
)}
>
<div data-reveal-group className="grid gap-px bg-line md:grid-cols-2">
{shots.map((shot, i) => (
<figure
key={shot.file}
data-reveal
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
className={twMerge("bg-bg", i === 0 && "md:col-span-2")}
>
<a
href={craftShotSourceUrl(app, shot)}
target="_blank"
rel="noopener noreferrer"
className={twMerge(
"group relative block overflow-hidden bg-bg-sunken",
shot.portrait ? "aspect-[7/9]" : "aspect-[16/10]",
)}
className="group relative block aspect-[16/10] overflow-hidden bg-bg-sunken"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -71,6 +54,8 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
alt={shot.alt}
loading="lazy"
decoding="async"
width={1600}
height={1000}
className="h-full w-full object-cover"
/>
<span className="hud-label absolute right-3 bottom-3 flex items-center gap-1 bg-bg/80 px-2 py-1 text-ink opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
@@ -78,12 +63,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
<ArrowUpRightIcon aria-hidden className="h-3 w-3" />
</span>
</a>
<figcaption
className={twMerge(
"flex items-center justify-between gap-4 border-t border-line py-3",
portrait ? "px-4 md:px-6" : "px-6 md:px-10",
)}
>
<figcaption className="flex items-center justify-between gap-4 border-t border-line px-6 py-3 md:px-10">
<span className="hud-label text-muted">{shot.caption}</span>
<span className="hud-label text-faint">
{String(i + 2).padStart(2, "0")}
@@ -96,17 +76,13 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
);
}
// 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.
// Installers (or, until there are any, the Discord waitlist) beside
// build-from-source. Filling `downloads` in crafting-apps.ts flips the left
// cell to download buttons.
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">
@@ -115,47 +91,25 @@ 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">
{release ? (
<p className="hud-label text-faint">Installers</p>
{app.downloads ? (
<>
<p className="hud-label text-faint">Release</p>
<h3 className={CELL_HEADING_CLASSES}>
Version <ColorAccent>{release.version}</ColorAccent>.
Download <ColorAccent>{name}</ColorAccent>.
</h3>
<p className="mt-4 max-w-md leading-relaxed text-muted">
Free for every platform above. Questions or feedback? The
team is in the ArtCraft Discord.
</p>
{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>
<ExternalLink href={craftReleasesUrl(app)}>
All releases
</ExternalLink>
<div className="mt-8 flex flex-wrap gap-3">
{app.downloads.map((download) => (
<Button key={download.href} href={download.href} size="lg">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />
{download.label}
</Button>
))}
</div>
</>
) : (
<>
<p className="hud-label text-faint">Installers</p>
<h3 className={CELL_HEADING_CLASSES}>
Installers are <ColorAccent>on the way</ColorAccent>.
</h3>
@@ -225,20 +179,6 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
);
}
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>
);
}
// 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);
@@ -262,7 +202,7 @@ export function AppFamily({ app, index }: { app: CraftApp; index: string }) {
</div>
<div
data-reveal-group
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-3"
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-4"
>
{siblings.map((sibling) => (
<AppCard
@@ -1,37 +1,6 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { craftAppIconUrl, type CraftApp } from "@/lib/crafting-apps";
// The app's engraved-animal icon: a rounded tile with transparent corners,
// so the drop shadow follows the tile shape. Decorative — it always sits
// beside the app's name.
export function AppIcon({
app,
priority = false,
className,
}: {
app: CraftApp;
/** Above the fold: load eagerly at high priority. */
priority?: boolean;
className?: string;
}) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={craftAppIconUrl(app)}
alt=""
width={256}
height={256}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : undefined}
decoding="async"
className={twMerge(
"h-14 w-14 shrink-0 drop-shadow-[0_6px_14px_rgba(0,0,0,0.28)]",
className,
)}
/>
);
}
import type { CraftApp } from "@/lib/crafting-apps";
// Scopes the app's color identity (`--app`, `--app-ink` in globals.css).
export function appThemeClass(app: CraftApp): string {
@@ -68,11 +37,11 @@ export function AppWordmark({
// serif italic used elsewhere on the site.
export function ColorAccent({ children }: { children: ReactNode }) {
return (
<span className="text-(--app-ink,var(--accent-ink))">{children}</span>
<span className="text-[var(--app-ink,var(--accent-ink))]">{children}</span>
);
}
// Saturated index tab in the app's color (black text passes AA on every app).
// Saturated index tab in the app's color (black text passes AA on all five).
export function AppTab({ children }: { children: ReactNode }) {
return (
<span className="hud-label bg-(--app) px-2 py-1 font-bold text-black">
@@ -36,7 +36,7 @@ export default function Hero() {
data-reveal
className="relative mt-8 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl"
>
Capable tools{" "}
Controllable AI{" "}
<span className="font-serif italic font-normal text-muted">
for artists.
</span>
@@ -46,8 +46,9 @@ export default function Hero() {
data-reveal
className="relative mt-4 max-w-md text-lg leading-relaxed text-muted"
>
ArtCraft builds open source tools for artists. We pride ourselves
on freedom of control and expression.
Artists need and deserve unparalleled control and precision.
ArtCraft&rsquo;s got you covered — compose in real 3D, then
render with AI.
</p>
<div
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -42,44 +41,5 @@ export default function MotionProvider({
};
}, []);
return (
<>
{children}
<RouteScrollReset />
</>
);
}
// Lenis keeps its own scroll target, so the router's scroll-to-top on a
// client navigation gets eased straight back to the previous page's offset.
// Snap Lenis to the top on each new route, except on the first render (a
// reload keeps its restored offset), on back/forward (the browser restores
// the old offset) and for #hash targets (anchors scroll themselves).
// Reduced-motion visitors have no Lenis; the router's native reset already
// works for them.
function RouteScrollReset() {
const pathname = usePathname();
const lastPathname = useRef(pathname);
// Path a back/forward traversal landed on. A hash-only traversal never
// changes the pathname, so it can't suppress the next real navigation.
const traversalPath = useRef<string | null>(null);
useEffect(() => {
const onPopState = () => {
traversalPath.current = window.location.pathname;
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
if (pathname === lastPathname.current) return;
lastPathname.current = pathname;
const traversed = traversalPath.current === pathname;
traversalPath.current = null;
if (traversed || window.location.hash) return;
lenisRef.current?.scrollTo(0, { immediate: true, force: true });
}, [pathname]);
return null;
return <>{children}</>;
}
@@ -1,185 +0,0 @@
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,9 +1,4 @@
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,
@@ -14,20 +9,15 @@ import { mediaUrl } from "./links";
// Facts (counts, timings, formats) come from each repo's README — keep them
// in sync when the READMEs change.
//
// 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.
// No installers are published yet. When an app ships a release, give it
// `downloads` and the page swaps its "coming soon" cell for the buttons.
export type CraftAppSlug =
| "photocraft"
| "vectorcraft"
| "drawcraft"
| "filmcraft"
| "lightcraft"
| "printcraft"
| "effectcraft"
| "designcraft";
| "printcraft";
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
@@ -38,17 +28,15 @@ export type CraftShot = {
source: string;
caption: string;
alt: string;
/** Portrait page (7:9) rather than a 16:10 window capture. */
portrait?: boolean;
};
export type CraftApp = {
slug: CraftAppSlug;
/** Prefix before "Craft" — the wordmark colors "Craft". */
/** Prefix before "Craft" — the wordmark sets "Craft" in the serif. */
prefix: string;
category: string;
status: string;
/** Headline split around its one color-accented contrast word. */
/** Headline split around its one serif-italic contrast word. */
headline: [before: string, accent: string, after: string];
/** One line for cards and share text. */
pitch: string;
@@ -60,12 +48,7 @@ export type CraftApp = {
features: Tip[];
/** First shot is the hero. */
shots: [CraftShot, ...CraftShot[]];
};
/** One release file, resolved to a URL for a specific app. */
export type CraftDownload = Omit<CraftReleaseAsset, "file"> & {
fileName: string;
href: string;
downloads?: { label: string; href: string }[];
};
export const CRAFTING_APPS_GITHUB_ORG = "https://github.com/storytold";
@@ -136,8 +119,8 @@ export const CRAFTING_APPS: CraftApp[] = [
],
},
{
slug: "vectorcraft",
prefix: "Vector",
slug: "drawcraft",
prefix: "Draw",
category: "Vector illustration",
status: "In development",
headline: ["Vector illustration, ", "reimagined", " in pure Rust."],
@@ -176,26 +159,26 @@ export const CRAFTING_APPS: CraftApp[] = [
{
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in VectorCraft",
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
caption: "Neon Drive — made in DrawCraft",
alt: "DrawCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
},
{
file: "ribbons.webp",
source: "shot-2-ribbons.png",
caption: "Live blends between editable spines",
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
alt: "DrawCraft showing Live Blends: 70 smooth-color steps between two ribbon paths",
},
{
file: "sheet.webp",
source: "shot-3-sheet.png",
caption: "Booleans, mesh, repeat and envelope",
alt: "VectorCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
alt: "DrawCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Direct selection on bezier anchors",
alt: "VectorCraft direct-selecting the anchors and handles of a crescent moon path",
alt: "DrawCraft direct-selecting the anchors and handles of a crescent moon path",
},
],
},
@@ -391,152 +374,8 @@ export const CRAFTING_APPS: CraftApp[] = [
},
],
},
{
slug: "effectcraft",
prefix: "Effect",
category: "Motion graphics & VFX",
status: "In development",
headline: ["Motion graphics and visual ", "effects", ", in pure Rust."],
pitch: "Open-source motion graphics and visual effects, built in pure Rust.",
lede: "Compositions, layers, keyframes, 259 effects, expressions, 3D cameras and lights, and a render queue in a native compositor written in pure Rust. Young, moving fast, and already usable.",
schemaCategory: "MultimediaApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.95",
features: [
{
title: "Animate the way you know",
body: "Familiar panels and keyframes: linear, bezier, hold and eased keys, roving keys, and a graph editor with value and speed curves.",
},
{
title: "Layers of every kind",
body: "Solids, shapes, text, footage, nested compositions, nulls, adjustment layers, cameras and lights, with parenting, track mattes and 38 blend modes.",
},
{
title: "259 effects",
body: "Blur, color correction, distortion, generators, keying, particles and simulation, stylize and time effects, each with sensible defaults.",
},
{
title: "Real 3D",
body: "3D layers with cameras, depth of field and lights that cast soft ray-traced shadows, plus orbit, pan and dolly tools.",
},
{
title: "Expressions",
body: "JavaScript expressions with wiggle, loops and layer references, edited inline and linked straight to any property.",
},
{
title: "Export anywhere",
body: "H.264 and ProRes with alpha, PNG, TIFF and 32-bit EXR sequences, GIF, and Lottie import and export for the web. No FFmpeg inside.",
},
],
shots: [
{
file: "hero.webp",
source: "effectcraft-hero.png",
caption: "An animated title on the timeline",
alt: "EffectCraft's composition panel showing an animated EFFECTCRAFT title, with the project panel, a timeline of text, shape and solid layers, and the properties panel",
},
{
file: "graph-editor.webp",
source: "effectcraft-graph-editor.png",
caption: "Graph editor with eased keyframes",
alt: "EffectCraft's graph editor showing an eased value curve for a text animator",
},
{
file: "effects.webp",
source: "effectcraft-effects.png",
caption: "Particle simulation effects",
alt: "EffectCraft's effect controls for a particle simulation bursting from the center of the composition",
},
{
file: "3d.webp",
source: "effectcraft-3d.png",
caption: "3D cameras, lights and soft shadows",
alt: "EffectCraft's 3D showcase: intersecting cards lit by a spot light with soft shadows on a gridded floor, seen from a custom camera view",
},
],
},
{
slug: "designcraft",
prefix: "Design",
category: "Page layout & publishing",
status: "In development",
headline: ["Page layout and ", "publishing", ", rebuilt in pure Rust."],
pitch: "Open-source page layout and publishing, rebuilt in pure Rust.",
lede: "Spreads and parent pages, threaded stories, styles, swatches and text wrap in a native layout app with a professional paragraph composer. No subscription, no licence server, no telemetry.",
schemaCategory: "DesignApplication",
platforms: ["macOS", "Windows", "Linux", "Web"],
rustVersion: "1.90",
features: [
{
title: "The layout tools you know",
body: "Spreads and parent pages, frames and threaded stories, paragraph and character styles, swatches and text wrap, laid out the way you expect.",
},
{
title: "Beautiful type",
body: "A Knuth–Plass paragraph composer, dictionary hyphenation, optical margin alignment and baseline grids. Line breaks match on screen and in PDF.",
},
{
title: "Print-ready PDF",
body: "Real selectable text with embedded fonts, CMYK and spot colors, bleed, crop marks and archival PDF/A output.",
},
{
title: "Fast",
body: "Multithreaded SIMD rendering, copy-on-write documents with instant undo, and cached composition.",
},
{
title: "Open formats",
body: "A documented native format, PNG export, and layout interchange import and export for moving work between tools.",
},
{
title: "Agent-native",
body: "Every menu item, tool, panel and dialog can be driven over a JSON control channel and an MCP server.",
},
],
shots: [
{
file: "hero.webp",
source: "ui-spread.png",
caption: "Threaded columns and text wrap — Quarterly",
alt: "DesignCraft showing a magazine spread: a threaded three-column story with its thread line, a wrapped pull quote, and the Properties panel's text frame options",
},
{
file: "cover.webp",
source: "page-cover.png",
caption: "Cover",
alt: "Magazine cover for The Spring Issue: a dusk landscape of layered purple hills under a pale sun, with the serif headline The Quiet Art of Layout",
portrait: true,
},
{
file: "feature.webp",
source: "page-2.png",
caption: "Styles",
alt: "Feature opener titled Notes on the Grid with a kicker rule, italic deck, landscape picture, caption and two columns of justified body text",
portrait: true,
},
{
file: "columns.webp",
source: "page-3.png",
caption: "Threading and wrap",
alt: "Three columns of justified, hyphenated body text wrapping around a shaded pull quote",
portrait: true,
},
{
file: "swatches.webp",
source: "page-4.png",
caption: "Swatches",
alt: "Coming Next page titled Color, Ink and Paper on a plum background with four labeled color swatches",
portrait: true,
},
],
},
];
const COUNT_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
/** "Seven" — the family size as a capitalized word, for headlines. */
export const CRAFTING_APPS_COUNT_WORD =
COUNT_WORDS[CRAFTING_APPS.length] ?? String(CRAFTING_APPS.length);
const LIST_FORMAT = new Intl.ListFormat("en", { type: "conjunction" });
/** "a, b and c" — Oxford comma per the en locale. */
@@ -612,49 +451,6 @@ export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
}
export function craftAppIconPath(app: CraftApp): string {
return `/images/apps/${app.slug}/icon.webp`;
}
export function craftAppIconUrl(app: CraftApp): string {
return mediaUrl(craftAppIconPath(app));
}
/** The repo's 1024 px icon render (press kit download). */
export function craftAppIconSourceUrl(app: CraftApp): string {
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/assets/app-icon/${app.slug}-1024.png`;
}
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,
): 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 {
return mediaUrl(`/images/apps/${app.slug}/og.jpg`);
}
@@ -9,8 +9,6 @@
import {
CRAFTING_APPS,
craftAppIconPath,
craftAppIconSourceUrl,
craftAppName,
craftShotPath,
craftShotSourceUrl,
@@ -110,18 +108,6 @@ export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
},
],
},
{
name: "Crafting Apps icons",
description: "App icons for every Crafting App, 1024 px PNG",
assets: CRAFTING_APPS.map((app) => ({
type: "image" as const,
title: `${craftAppName(app)} icon`,
thumbnail: craftAppIconPath(app),
downloadUrl: craftAppIconSourceUrl(app),
downloadLabel: "1024 px PNG",
containThumbnail: true,
})),
},
{
name: "Screenshots & media",
description: "High-resolution screenshots and promotional images",
+23 -21
View File
@@ -1,40 +1,42 @@
#!/usr/bin/env bash
# This works on Linux and MacOS to launch the website (Next.js) dev server
# This works on Linux and MacOS to launch the frontend dev server
root_dir=$(pwd)
frontend_path="${root_dir}/frontend"
website_path="${frontend_path}/apps/artcraft-website-next"
port=3000
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${frontend_path}"
echo "Running Artcraft Website (Next.js) in Dev Mode..."
echo "Running Artcraft Website in Dev Mode..."
echo ""
# Kill any process running on the dev port, which will block startup
if lsof -i tcp:${port} &>/dev/null; then
lsof -i tcp:${port} -t | xargs kill -9
echo "Killed process running on port ${port}"
# Kill any process running on port 5741, which will block startup
if lsof -i tcp:4200 &>/dev/null; then
lsof -i tcp:4200 -t | xargs kill -9
echo "Killed process running on port 4200"
else
echo "No process running on port ${port}"
echo "No process running on port 4200"
fi
# The Next.js app is excluded from the frontend npm workspace and has its own
# package-lock.json, so it installs and runs from its own directory.
pushd "${website_path}" || exit
pushd "${frontend_path}" || exit
frontend_npm_install
# Backend devs: launch with USE_LOCAL_API=1 to point the website at a local
# storyteller-web on http://localhost:12345. Otherwise it hits production.
if [[ "${USE_LOCAL_API:-0}" == "1" ]]; then
export NEXT_PUBLIC_API_HOST="http://localhost:12345"
echo "USE_LOCAL_API=1 — API calls will target ${NEXT_PUBLIC_API_HOST}"
else
echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
fi
export VITE_ENVIRONMENT_TYPE="production"
npm run dev -- --port ${port}
# # Backend devs: launch with USE_LOCAL_API=1 (or `export USE_LOCAL_API=1` once
# # in your shell profile) to point the frontend at a local storyteller-web on
# # http://localhost:12345. Unset/0 — the frontend-dev default — hits
# # production. This replaces the comment/uncomment dance around
# # StorytellerApiHostStore.setDevelopment() in src/main.tsx.
# export VITE_USE_LOCAL_API="${USE_LOCAL_API:-0}"
# if [[ "${VITE_USE_LOCAL_API}" == "1" ]]; then
# echo "USE_LOCAL_API=1 — API calls will target http://localhost:12345"
# else
# echo "API calls will target production (set USE_LOCAL_API=1 for a local backend)"
# fi
export VITE_USE_LOCAL_API="true"
nx dev artcraft-website
popd || exit