Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1724b5e4 |
No files matched your search
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,183 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AppFamily,
|
||||
AppGallery,
|
||||
AppGetIt,
|
||||
} from "@/components/apps/app-sections";
|
||||
import {
|
||||
AppTab,
|
||||
AppWordmark,
|
||||
ColorAccent,
|
||||
appThemeClass,
|
||||
} from "@/components/apps/app-wordmark";
|
||||
import ShareBar from "@/components/apps/share-bar";
|
||||
import { ClosingCta, CampaignSection, TipGrid } from "@/components/campaign/sections";
|
||||
import CampaignHero from "@/components/campaign/campaign-hero";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
CRAFTING_APPS,
|
||||
craftAppIndex,
|
||||
craftAppName,
|
||||
craftAppOgImage,
|
||||
craftAppPath,
|
||||
craftAppRepo,
|
||||
craftShotUrl,
|
||||
getCraftApp,
|
||||
} from "@/lib/crafting-apps";
|
||||
import { SITE_URL, siteUrl } from "@/lib/links";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return CRAFTING_APPS.map((app) => ({ slug: app.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const app = getCraftApp(slug);
|
||||
if (!app) return {};
|
||||
const name = craftAppName(app);
|
||||
const title = `${name}: ${app.headline.join("")}`;
|
||||
return {
|
||||
title,
|
||||
description: app.lede,
|
||||
alternates: { canonical: craftAppPath(app) },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: craftAppPath(app),
|
||||
siteName: "ArtCraft",
|
||||
title,
|
||||
description: app.pitch,
|
||||
images: [{ url: craftAppOgImage(app), width: 1200, height: 630, alt: app.shots[0].alt }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CraftAppPage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const app = getCraftApp(slug);
|
||||
if (!app) notFound();
|
||||
|
||||
const name = craftAppName(app);
|
||||
const [hero] = app.shots;
|
||||
const [before, accent, after] = app.headline;
|
||||
const shareUrl = siteUrl(craftAppPath(app));
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name,
|
||||
applicationCategory: app.schemaCategory,
|
||||
operatingSystem: app.platforms.join(", "),
|
||||
description: app.lede,
|
||||
url: shareUrl,
|
||||
image: siteUrl(craftAppOgImage(app)),
|
||||
screenshot: app.shots.map((shot) => siteUrl(craftShotUrl(app, shot))),
|
||||
codeRepository: craftAppRepo(app),
|
||||
isAccessibleForFree: true,
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
publisher: { "@type": "Organization", name: "ArtCraft", url: SITE_URL },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={appThemeClass(app)}>
|
||||
<RevealManager />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<CampaignHero
|
||||
id={app.slug}
|
||||
label={name}
|
||||
annotation={`${app.category} · ${app.status}`}
|
||||
kicker={
|
||||
<span className="flex items-center gap-3">
|
||||
<AppTab>{craftAppIndex(app)}</AppTab>
|
||||
<AppWordmark app={app} className="text-2xl" />
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
{before}
|
||||
<ColorAccent>{accent}</ColorAccent>
|
||||
{after}
|
||||
</>
|
||||
}
|
||||
lede={app.lede}
|
||||
media={
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={craftShotUrl(app, hero)}
|
||||
alt={hero.alt}
|
||||
fetchPriority="high"
|
||||
width={1600}
|
||||
height={1000}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
}
|
||||
mediaCaption={hero.caption}
|
||||
mediaTag="Screenshot"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
<Button
|
||||
href={craftAppRepo(app)}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<p className="hud-label text-faint">
|
||||
{app.platforms.join(" · ")} · Free and open source
|
||||
</p>
|
||||
<ShareBar url={shareUrl} text={`${name}: ${app.pitch}`} />
|
||||
</div>
|
||||
</CampaignHero>
|
||||
|
||||
<CampaignSection
|
||||
id="highlights"
|
||||
index="02"
|
||||
label="Highlights"
|
||||
annotation={app.category}
|
||||
title={
|
||||
<>
|
||||
What's <ColorAccent>inside</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TipGrid items={app.features} columns={3} />
|
||||
</CampaignSection>
|
||||
|
||||
<AppGallery app={app} index="03" />
|
||||
<AppGetIt app={app} index="04" />
|
||||
<AppFamily app={app} index="05" />
|
||||
|
||||
<ClosingCta
|
||||
eyebrow="Open source · Free"
|
||||
title={
|
||||
<>
|
||||
Help shape <AppWordmark app={app} />.
|
||||
</>
|
||||
}
|
||||
lede={`Report bugs, request features and get early builds of ${name} in the ArtCraft Discord.`}
|
||||
>
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
</ClosingCta>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
BotIcon,
|
||||
CodeXmlIcon,
|
||||
CpuIcon,
|
||||
GlobeIcon,
|
||||
HardDriveIcon,
|
||||
KeyboardIcon,
|
||||
} from "lucide-react";
|
||||
import AppCard from "@/components/apps/app-card";
|
||||
import ShareBar from "@/components/apps/share-bar";
|
||||
import {
|
||||
CampaignSection,
|
||||
ClosingCta,
|
||||
TipGrid,
|
||||
} from "@/components/campaign/sections";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
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_GITHUB_ORG,
|
||||
CRAFTING_APPS_OG_IMAGE,
|
||||
CRAFTING_APPS_PRINCIPLES,
|
||||
craftAppIndex,
|
||||
} from "@/lib/crafting-apps";
|
||||
import { siteUrl } from "@/lib/links";
|
||||
|
||||
const TITLE = "Crafting Apps: open-source creative tools";
|
||||
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,
|
||||
description: DESCRIPTION,
|
||||
alternates: { canonical: "/apps" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/apps",
|
||||
siteName: "ArtCraft",
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
images: [{ url: CRAFTING_APPS_OG_IMAGE, width: 1200, height: 630 }],
|
||||
},
|
||||
};
|
||||
|
||||
// Order matches CRAFTING_APPS_PRINCIPLES.
|
||||
const PRINCIPLE_ICONS = [
|
||||
<CodeXmlIcon key="open" aria-hidden className="h-5 w-5" />,
|
||||
<CpuIcon key="native" aria-hidden className="h-5 w-5" />,
|
||||
<KeyboardIcon key="familiar" aria-hidden className="h-5 w-5" />,
|
||||
<HardDriveIcon key="local" aria-hidden className="h-5 w-5" />,
|
||||
<BotIcon key="agents" aria-hidden className="h-5 w-5" />,
|
||||
<GlobeIcon key="web" aria-hidden className="h-5 w-5" />,
|
||||
];
|
||||
|
||||
export default function CraftingAppsPage() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
id="apps"
|
||||
index="01"
|
||||
label="Crafting Apps"
|
||||
annotation="Five apps · Open source · Pure Rust"
|
||||
title={
|
||||
<>
|
||||
Five apps. One <ColorAccent>craft</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
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>
|
||||
<Button
|
||||
href={CRAFTING_APPS_GITHUB_ORG}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
Browse on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<ShareBar
|
||||
url={siteUrl("/apps")}
|
||||
text="Crafting Apps: five open-source creative apps from ArtCraft"
|
||||
className="mt-6"
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<SectionShell id="lineup">
|
||||
<SectionEyebrow
|
||||
index="02"
|
||||
label="The lineup"
|
||||
annotation="Pick a craft to explore"
|
||||
/>
|
||||
<div
|
||||
data-reveal-group
|
||||
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)} />
|
||||
))}
|
||||
<div data-reveal className="flex flex-col bg-bg p-6 md:p-8">
|
||||
<p className="hud-label text-faint">Next up</p>
|
||||
<h3 className="mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong">
|
||||
More crafts <ColorAccent>on the way</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-3 leading-relaxed text-muted">
|
||||
Tell us what to rebuild next, test early builds and follow
|
||||
development with the team in the ArtCraft Discord.
|
||||
</p>
|
||||
<div className="mt-auto pt-8">
|
||||
<DiscordButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
<CampaignSection
|
||||
id="principles"
|
||||
index="03"
|
||||
label="Principles"
|
||||
annotation="Shared by every craft"
|
||||
title={
|
||||
<>
|
||||
Built the <ColorAccent>hard</ColorAccent> way.
|
||||
</>
|
||||
}
|
||||
lede="Every Crafting App is written from scratch in Rust and held to the same rules."
|
||||
>
|
||||
<TipGrid
|
||||
items={CRAFTING_APPS_PRINCIPLES}
|
||||
columns={3}
|
||||
icons={PRINCIPLE_ICONS}
|
||||
/>
|
||||
</CampaignSection>
|
||||
|
||||
<ClosingCta
|
||||
eyebrow="Open source · Free"
|
||||
title={
|
||||
<>
|
||||
Build it <ColorAccent>with us</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
lede="Early builds, roadmaps and the people making the Crafting Apps all live in the ArtCraft Discord."
|
||||
>
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
</ClosingCta>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +313,27 @@ html[data-ruler="on"]::-webkit-scrollbar {
|
||||
:root:not([data-theme="light"]) .plan-enterprise { --plan-ink: #7ea3ee; }
|
||||
}
|
||||
|
||||
/* 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-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; }
|
||||
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
|
||||
: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; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .app-photocraft { --app-ink: #7fb0ff; }
|
||||
: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; }
|
||||
}
|
||||
|
||||
/* Plan cell: a color wash pooling under the index tab and fading into the
|
||||
page surface; highlighted plans get a 2px frame in their color. */
|
||||
.plan-card {
|
||||
|
||||
@@ -12,10 +12,9 @@ import LandingContext from "@/components/landing-context";
|
||||
import MotionProvider from "@/components/motion-provider";
|
||||
import ScrollRuler from "@/components/ruler/scroll-ruler";
|
||||
import TunerPanel from "@/components/dev/tuner-panel";
|
||||
import { SITE_URL } from "@/lib/links";
|
||||
import "./globals.css";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
|
||||
// Display face: variable Archivo with its width axis loaded — headings run
|
||||
// slightly expanded (font-stretch) for the industrial-grotesque look, and
|
||||
// the hero wordmark renders the same family at its poster extreme
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import PressKitGallery from "@/components/press-kit/press-kit-gallery";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SOCIAL_LINKS } from "@/lib/links";
|
||||
import { PRESS_CONTACT_PHONE } from "@/lib/press-kit-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -55,16 +53,9 @@ export default function PressKitPage() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
className="mt-8"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
<DiscordButton size="lg" className="mt-8">
|
||||
Contact us on Discord
|
||||
</Button>
|
||||
</DiscordButton>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { CRAFTING_APPS, craftAppPath } from "@/lib/crafting-apps";
|
||||
import { getFaqItems, getNewsPosts, getTutorialItems } from "@/lib/content";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
import { SITE_URL } from "@/lib/links";
|
||||
|
||||
type Entry = MetadataRoute.Sitemap[number];
|
||||
|
||||
@@ -19,6 +19,12 @@ const STATIC_ROUTES: { path: string; priority: number; changeFrequency: Entry["c
|
||||
{ path: "/seedance2-5", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/minimax-h3", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/creators/jboogxcreative", priority: 0.6, changeFrequency: "monthly" },
|
||||
{ path: "/apps", priority: 0.8, changeFrequency: "weekly" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
path: craftAppPath(app),
|
||||
priority: 0.8,
|
||||
changeFrequency: "weekly" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Badge } from "@/components/ui";
|
||||
import {
|
||||
craftAppName,
|
||||
craftAppPath,
|
||||
craftShotUrl,
|
||||
type CraftApp,
|
||||
} from "@/lib/crafting-apps";
|
||||
import { AppTab, AppWordmark, appThemeClass } from "./app-wordmark";
|
||||
|
||||
// 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,
|
||||
compact = false,
|
||||
}: {
|
||||
app: CraftApp;
|
||||
index: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [hero] = app.shots;
|
||||
return (
|
||||
<Link
|
||||
href={craftAppPath(app)}
|
||||
data-reveal
|
||||
className={twMerge(
|
||||
"group relative flex h-full flex-col bg-bg",
|
||||
appThemeClass(app),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 z-10 h-0.5 origin-left scale-x-0 bg-(--app) transition-transform duration-300 group-hover:scale-x-100"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-3">
|
||||
<AppTab>{index}</AppTab>
|
||||
<p className="hud-label truncate text-faint">{app.category}</p>
|
||||
</div>
|
||||
<div className="relative aspect-[16/10] overflow-hidden border-b border-line bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={craftShotUrl(app, hero)}
|
||||
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 ? "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)" />
|
||||
{app.platforms.map((platform) => (
|
||||
<Badge key={platform} label={platform} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="hud-label mt-auto flex items-center gap-1.5 pt-6 text-muted group-hover:text-ink">
|
||||
Explore {craftAppName(app)}
|
||||
<ArrowUpRightIcon
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowDownToLineIcon, 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 {
|
||||
CRAFTING_APPS,
|
||||
craftAppBuildCommand,
|
||||
craftAppIndex,
|
||||
craftAppName,
|
||||
craftAppRepo,
|
||||
craftShotSourceUrl,
|
||||
craftShotUrl,
|
||||
formatList,
|
||||
type CraftApp,
|
||||
} from "@/lib/crafting-apps";
|
||||
import AppCard from "./app-card";
|
||||
import { ColorAccent } from "./app-wordmark";
|
||||
|
||||
// Body sections of an /apps/<slug> page, in page order. Server components;
|
||||
// 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; the rest lead with a full-width shot, then pair.
|
||||
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
|
||||
const [, ...shots] = app.shots;
|
||||
return (
|
||||
<SectionShell id="gallery">
|
||||
<SectionEyebrow
|
||||
index={index}
|
||||
label="Screenshots"
|
||||
annotation={`Captured in ${craftAppName(app)}`}
|
||||
/>
|
||||
<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", i === 0 && "md:col-span-2")}
|
||||
>
|
||||
<a
|
||||
href={craftShotSourceUrl(app, shot)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block aspect-[16/10] overflow-hidden bg-bg-sunken"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={craftShotUrl(app, shot)}
|
||||
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">
|
||||
Full size
|
||||
<ArrowUpRightIcon aria-hidden className="h-3 w-3" />
|
||||
</span>
|
||||
</a>
|
||||
<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")}
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
return (
|
||||
<SectionShell id="get-it">
|
||||
<SectionEyebrow
|
||||
index={index}
|
||||
label={`Get ${name}`}
|
||||
annotation="Free · Open source"
|
||||
/>
|
||||
<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.downloads ? (
|
||||
<>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Download <ColorAccent>{name}</ColorAccent>.
|
||||
</h3>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Installers are <ColorAccent>on the way</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-4 max-w-md leading-relaxed text-muted">
|
||||
Native installers for {formatList(desktop)} are coming. Join
|
||||
the ArtCraft Discord to get them first, try early builds and
|
||||
talk with the team building {name}.
|
||||
</p>
|
||||
<DiscordButton size="lg" className="mt-8">
|
||||
Get notified on Discord
|
||||
</DiscordButton>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 pt-10">
|
||||
{app.platforms.map((platform) => (
|
||||
<Badge key={platform} label={platform} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal className="flex min-w-0 flex-col bg-bg p-6 md:p-10">
|
||||
<p className="hud-label text-faint">Build from source</p>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Run it <ColorAccent>today</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-4 max-w-md leading-relaxed text-muted">
|
||||
Needs Rust {app.rustVersion} or newer. New to Rust? Install it with{" "}
|
||||
<a
|
||||
href="https://rustup.rs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink underline decoration-line-strong underline-offset-4 hover:decoration-current"
|
||||
>
|
||||
rustup
|
||||
</a>
|
||||
, then run:
|
||||
</p>
|
||||
<figure className="mt-6 border border-line bg-bg-sunken">
|
||||
<div className="flex items-center justify-between border-b border-line pl-4">
|
||||
<figcaption className="hud-label flex items-center gap-2 text-faint">
|
||||
<TerminalIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
Terminal
|
||||
</figcaption>
|
||||
<CopyButton
|
||||
value={command}
|
||||
variant="ghost"
|
||||
className="h-9 border-l border-line px-3"
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
</figure>
|
||||
<Button
|
||||
href={craftAppRepo(app)}
|
||||
variant="secondary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-8"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
View source on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SectionShell id="family">
|
||||
<SectionEyebrow index={index} label="The family" annotation="Crafting Apps" />
|
||||
<div className="flex flex-col gap-4 px-6 py-12 md:flex-row md:items-end md:justify-between md:px-10 md:py-16">
|
||||
<h2
|
||||
data-reveal
|
||||
className="max-w-2xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl"
|
||||
>
|
||||
More from the <ColorAccent>family</ColorAccent>.
|
||||
</h2>
|
||||
<Link
|
||||
href="/apps"
|
||||
className="hud-label flex items-center gap-1.5 text-muted hover:text-ink"
|
||||
>
|
||||
All Crafting Apps
|
||||
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
data-reveal-group
|
||||
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
{siblings.map((sibling) => (
|
||||
<AppCard
|
||||
key={sibling.slug}
|
||||
app={sibling}
|
||||
index={craftAppIndex(sibling)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
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 {
|
||||
return `app-${app.slug}`;
|
||||
}
|
||||
|
||||
// Text wordmark: the display face throughout, "Craft" picked out in the
|
||||
// app's ink color. Needs an appThemeClass ancestor (or pass `themed`).
|
||||
export function AppWordmark({
|
||||
app,
|
||||
themed = false,
|
||||
className,
|
||||
}: {
|
||||
app: CraftApp;
|
||||
themed?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"font-display font-medium tracking-[-0.03em] text-ink-strong",
|
||||
themed && appThemeClass(app),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{app.prefix}
|
||||
<ColorAccent>Craft</ColorAccent>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The Crafting Apps pages' contrast word: same face as the headline, set in
|
||||
// the app's ink color (the site accent outside an app scope) instead of the
|
||||
// serif italic used elsewhere on the site.
|
||||
export function ColorAccent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[var(--app-ink,var(--accent-ink))]">{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, CopyButton } from "@/components/ui";
|
||||
|
||||
// Share strip: copy-link plus intent links for the networks our audience
|
||||
// posts to. Hairline cells (gap-px over the line color) so it wraps cleanly
|
||||
// on narrow screens. Intent URLs need no SDKs or tracking scripts.
|
||||
const TARGETS: { name: string; href: (url: string, text: string) => string }[] = [
|
||||
{
|
||||
name: "X",
|
||||
href: (url, text) =>
|
||||
`https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
|
||||
},
|
||||
{
|
||||
name: "Reddit",
|
||||
href: (url, text) =>
|
||||
`https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`,
|
||||
},
|
||||
{
|
||||
name: "Bluesky",
|
||||
href: (url, text) =>
|
||||
`https://bsky.app/intent/compose?text=${encodeURIComponent(`${text} ${url}`)}`,
|
||||
},
|
||||
{
|
||||
name: "LinkedIn",
|
||||
href: (url) =>
|
||||
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||||
},
|
||||
];
|
||||
|
||||
// Copy and network cells share one ghost-button treatment.
|
||||
const CELL_CLASSES = "h-9 bg-bg px-3";
|
||||
|
||||
export default function ShareBar({
|
||||
url,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
/** Absolute URL to share. */
|
||||
url: string;
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-fit flex-wrap gap-px border border-line bg-line",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="hud-label flex h-9 items-center bg-bg px-3 text-faint">
|
||||
Share
|
||||
</span>
|
||||
<CopyButton
|
||||
value={url}
|
||||
label="Copy link"
|
||||
copiedLabel="Link copied"
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
/>
|
||||
{TARGETS.map((target) => (
|
||||
<Button
|
||||
key={target.name}
|
||||
href={target.href(url, text)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Share on ${target.name}`}
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
>
|
||||
{target.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,25 +7,36 @@ export default function CampaignHero({
|
||||
id,
|
||||
label,
|
||||
annotation,
|
||||
kicker,
|
||||
title,
|
||||
lede,
|
||||
children,
|
||||
media,
|
||||
mediaCaption,
|
||||
mediaTag = "Reel",
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
annotation?: string;
|
||||
/** Small line above the headline (e.g. a product wordmark). */
|
||||
kicker?: ReactNode;
|
||||
title: ReactNode;
|
||||
lede: ReactNode;
|
||||
children?: ReactNode;
|
||||
media?: ReactNode;
|
||||
mediaCaption?: string;
|
||||
/** Right-hand label on the media strip. */
|
||||
mediaTag?: string;
|
||||
}) {
|
||||
return (
|
||||
<SectionShell id={id}>
|
||||
<SectionEyebrow index="01" label={label} annotation={annotation} />
|
||||
<div data-reveal-group className="flex flex-col items-center px-6 py-14 text-center md:px-10 md:py-20">
|
||||
{kicker && (
|
||||
<div data-reveal className="mb-6">
|
||||
{kicker}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
data-reveal
|
||||
className="max-w-4xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl lg:text-7xl"
|
||||
@@ -45,7 +56,7 @@ export default function CampaignHero({
|
||||
<figure className="border-t border-line">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2 md:px-10">
|
||||
<figcaption className="hud-label text-faint">{mediaCaption}</figcaption>
|
||||
<p className="hud-label text-faint">Reel</p>
|
||||
<p className="hud-label text-faint">{mediaTag}</p>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
{media}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDownIcon, InfoIcon } from "lucide-react";
|
||||
import { GitHubIcon, DiscordIcon } from "@/components/icons";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent } from "@/components/page/page-header";
|
||||
import { Button } from "@/components/ui";
|
||||
@@ -161,10 +162,7 @@ export function CommunityCta({ id = "community" }: { id?: string }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button href={SOCIAL_LINKS.DISCORD} target="_blank" rel="noopener noreferrer">
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
Join Discord
|
||||
</Button>
|
||||
<DiscordButton />
|
||||
<Button
|
||||
href={SOCIAL_LINKS.GITHUB}
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import { Button, type ButtonProps } from "@/components/ui";
|
||||
import { SOCIAL_LINKS } from "@/lib/links";
|
||||
|
||||
// The site-wide "come talk to us" CTA: always the ArtCraft Discord, always
|
||||
// a new tab.
|
||||
export default function DiscordButton({
|
||||
children = "Join Discord",
|
||||
...rest
|
||||
}: Omit<ButtonProps, "href" | "target" | "rel" | "external">) {
|
||||
return (
|
||||
<Button
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...rest}
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { CRAFTING_APPS, craftAppName, craftAppPath } from "@/lib/crafting-apps";
|
||||
import { SOCIAL_LINKS, SUPPORT_EMAIL, WEBAPP_URL } from "@/lib/links";
|
||||
|
||||
const LINK_COLUMNS: {
|
||||
@@ -13,6 +14,16 @@ const LINK_COLUMNS: {
|
||||
{ name: "Launch App", href: WEBAPP_URL, external: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Craft Apps",
|
||||
links: [
|
||||
{ name: "All Craft Apps", href: "/apps" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
name: craftAppName(app),
|
||||
href: craftAppPath(app),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Resources",
|
||||
links: [
|
||||
@@ -39,8 +50,8 @@ export default function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-line">
|
||||
<div className="mx-auto max-w-[1280px] border-x border-line">
|
||||
<div className="grid gap-px bg-line md:grid-cols-[2fr_1fr_1fr_1fr]">
|
||||
<div className="bg-bg p-6 md:p-10">
|
||||
<div className="grid gap-px bg-line md:grid-cols-4 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr]">
|
||||
<div className="bg-bg p-6 md:col-span-4 md:p-10 lg:col-span-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt="ArtCraft"
|
||||
|
||||
@@ -7,10 +7,13 @@ import * as NavigationMenu from "@radix-ui/react-navigation-menu";
|
||||
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SOCIAL_LINKS, WEBAPP_URL, webappUrl } from "@/lib/links";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import ThemeToggle from "./theme-toggle";
|
||||
|
||||
type NavLeaf = { name: string; href: string };
|
||||
// `dividerBefore` sets a hairline before the entry, separating ArtCraft
|
||||
// product links from the rest of the family.
|
||||
type NavLeaf = { name: string; href: string; dividerBefore?: boolean };
|
||||
type NavGroup = { name: string; href?: string; children: NavLeaf[] };
|
||||
type NavEntry = NavLeaf | NavGroup;
|
||||
|
||||
@@ -29,6 +32,7 @@ const NAV_ITEMS: NavEntry[] = [
|
||||
},
|
||||
{ name: "Download", href: "/download" },
|
||||
{ name: "Support", href: "/support" },
|
||||
{ name: "Craft Apps", href: "/apps", dividerBefore: true },
|
||||
];
|
||||
|
||||
// Marathon-style: the inversion highlight sits on the label only, not the cell.
|
||||
@@ -104,7 +108,10 @@ export default function SiteNav() {
|
||||
return (
|
||||
<NavigationMenu.Item
|
||||
key={entry.name}
|
||||
className="flex items-stretch"
|
||||
className={twMerge(
|
||||
"flex items-stretch",
|
||||
entry.dividerBefore && "ml-1.5 border-l border-line pl-1.5",
|
||||
)}
|
||||
>
|
||||
<NavigationMenu.Link asChild>
|
||||
{isExternalHref(entry.href) ? (
|
||||
@@ -194,6 +201,16 @@ export default function SiteNav() {
|
||||
>
|
||||
<span className={NAV_LABEL_CLASSES}>Pricing</span>
|
||||
</Link>
|
||||
<a
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Join the ArtCraft Discord"
|
||||
title="Join the ArtCraft Discord"
|
||||
className="flex w-12 items-center justify-center border-l border-line text-muted hover:bg-invert-bg hover:text-invert-fg"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
</a>
|
||||
<ThemeToggle className="w-12 border-l border-line" />
|
||||
<Button
|
||||
href={WEBAPP_URL}
|
||||
@@ -238,6 +255,7 @@ export default function SiteNav() {
|
||||
const leafClassName = twMerge(
|
||||
DROPDOWN_ITEM_CLASSES,
|
||||
"py-3",
|
||||
entry.dividerBefore && "border-t border-line",
|
||||
isCurrent ? "text-ink" : "text-muted active:text-ink",
|
||||
);
|
||||
const leafLabel = (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button, type ButtonProps } from "./button";
|
||||
|
||||
const FEEDBACK_MS = 1500;
|
||||
|
||||
type CopyState = "idle" | "copied" | "failed";
|
||||
|
||||
export interface CopyButtonProps
|
||||
extends Omit<ButtonProps, "onClick" | "href" | "children"> {
|
||||
value: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
// Button that writes `value` to the clipboard and confirms inline. The
|
||||
// label snaps back after a beat; repeat clicks restart the timer.
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
copiedLabel = "Copied",
|
||||
...rest
|
||||
}: CopyButtonProps) {
|
||||
const [state, setState] = useState<CopyState>("idle");
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
async function copy() {
|
||||
let next: CopyState = "copied";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
next = "failed";
|
||||
}
|
||||
setState(next);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setState("idle"), FEEDBACK_MS);
|
||||
}
|
||||
|
||||
const Icon = state === "copied" ? CheckIcon : CopyIcon;
|
||||
const text =
|
||||
state === "copied" ? copiedLabel : state === "failed" ? "Copy blocked" : label;
|
||||
|
||||
return (
|
||||
<Button onClick={copy} {...rest}>
|
||||
<Icon aria-hidden className="h-3.5 w-3.5" />
|
||||
<span aria-live="polite">{text}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyButton;
|
||||
@@ -5,6 +5,7 @@ export { Badge, type BadgeProps } from "./badge";
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
export { Checkbox, type CheckboxProps } from "./checkbox";
|
||||
export { CloseButton, type CloseButtonProps } from "./close-button";
|
||||
export { CopyButton, type CopyButtonProps } from "./copy-button";
|
||||
export { Input, type InputProps } from "./input";
|
||||
export { Label, type LabelProps } from "./label";
|
||||
export { Modal, type ModalProps } from "./modal";
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { Tip } from "./campaign-data";
|
||||
import { mediaUrl } from "./links";
|
||||
|
||||
// The Crafting Apps family: open-source native apps from the ArtCraft team,
|
||||
// each in its own storytold/<slug> repo. One entry drives the /apps hub card,
|
||||
// the /apps/<slug> page, the nav, footer, sitemap, ruler and press kit.
|
||||
//
|
||||
// Copy rule: describe the category, never name third-party products.
|
||||
// Facts (counts, timings, formats) come from each repo's README — keep them
|
||||
// in sync when the READMEs change.
|
||||
//
|
||||
// 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"
|
||||
| "drawcraft"
|
||||
| "filmcraft"
|
||||
| "lightcraft"
|
||||
| "printcraft";
|
||||
|
||||
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
|
||||
|
||||
export type CraftShot = {
|
||||
/** File under public/images/apps/<slug>/. */
|
||||
file: string;
|
||||
/** Full-resolution original in the repo's docs/images/ (press kit). */
|
||||
source: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export type CraftApp = {
|
||||
slug: CraftAppSlug;
|
||||
/** Prefix before "Craft" — the wordmark sets "Craft" in the serif. */
|
||||
prefix: string;
|
||||
category: string;
|
||||
status: string;
|
||||
/** 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;
|
||||
lede: string;
|
||||
/** schema.org applicationCategory. */
|
||||
schemaCategory: "DesignApplication" | "MultimediaApplication" | "BusinessApplication";
|
||||
platforms: CraftPlatform[];
|
||||
rustVersion: string;
|
||||
features: Tip[];
|
||||
/** First shot is the hero. */
|
||||
shots: [CraftShot, ...CraftShot[]];
|
||||
downloads?: { label: string; href: string }[];
|
||||
};
|
||||
|
||||
export const CRAFTING_APPS_GITHUB_ORG = "https://github.com/storytold";
|
||||
|
||||
export const CRAFTING_APPS: CraftApp[] = [
|
||||
{
|
||||
slug: "photocraft",
|
||||
prefix: "Photo",
|
||||
category: "Image editor",
|
||||
status: "Early alpha",
|
||||
headline: ["The image editor you already ", "know", " how to use."],
|
||||
pitch: "The open-source image editor you already know how to use.",
|
||||
lede: "Layers, masks, adjustment layers, layer styles, type, vectors and brushes in a native app written entirely in Rust. Open source, offline, and yours.",
|
||||
schemaCategory: "DesignApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Familiar by design",
|
||||
body: "The menus, shortcuts, panels and tools your hands already know. Productive on day one, no retraining.",
|
||||
},
|
||||
{
|
||||
title: "Native GPU canvas",
|
||||
body: "Renders on Metal, Vulkan, DirectX 12 and WebGPU. No Electron and no web view, just a fast native compositor.",
|
||||
},
|
||||
{
|
||||
title: "Real layered files",
|
||||
body: "Opens and saves layered PSD and PSB documents. 134 of 135 real-world test files round-trip byte for byte.",
|
||||
},
|
||||
{
|
||||
title: "Non-destructive editing",
|
||||
body: "16 adjustment layers, 70+ live-preview filters, smart objects with smart filters, and a full set of layer styles.",
|
||||
},
|
||||
{
|
||||
title: "On-device selection",
|
||||
body: "Select subjects and objects and refine masks with tools that run on your machine, not someone else's server.",
|
||||
},
|
||||
{
|
||||
title: "Serious color",
|
||||
body: "8, 16 and 32-bit documents in RGB, CMYK, Lab and Grayscale, with ICC color management and soft proofing.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "photocraft-demo.jpg",
|
||||
caption: "Curves and type layers — The Great Wave",
|
||||
alt: "PhotoCraft editing The Great Wave off Kanagawa with a Curves adjustment and type layers in the Layers panel",
|
||||
},
|
||||
{
|
||||
file: "adjustments.webp",
|
||||
source: "photocraft-adjustments.jpg",
|
||||
caption: "Levels with a live histogram",
|
||||
alt: "PhotoCraft applying a Levels adjustment layer to Impression, Sunrise with the histogram panel open",
|
||||
},
|
||||
{
|
||||
file: "layer-styles.webp",
|
||||
source: "photocraft-layer-styles.jpg",
|
||||
caption: "Layer styles on live type",
|
||||
alt: "PhotoCraft's Layer Style dialog adding an outer glow and stroke to the word Earthrise",
|
||||
},
|
||||
{
|
||||
file: "masks.webp",
|
||||
source: "photocraft-masks.jpg",
|
||||
caption: "Selections and masked adjustments",
|
||||
alt: "PhotoCraft with an elliptical selection and a Hue/Saturation adjustment on Girl with a Pearl Earring",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "drawcraft",
|
||||
prefix: "Draw",
|
||||
category: "Vector illustration",
|
||||
status: "In development",
|
||||
headline: ["Vector illustration, ", "reimagined", " in pure Rust."],
|
||||
pitch: "Fast, open-source vector illustration, reimagined in pure Rust.",
|
||||
lede: "The pen, panels and shortcuts working illustrators expect, in a native app that keeps up with you. Open source, and in your browser too.",
|
||||
schemaCategory: "DesignApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "The workflow you know",
|
||||
body: "Pen, direct selection, shape booleans, snapping guides and an appearance panel, laid out the way you expect.",
|
||||
},
|
||||
{
|
||||
title: "Genuinely fast",
|
||||
body: "20,000 shapes render in about 27 ms at retina resolution, and the interface runs at 120 fps.",
|
||||
},
|
||||
{
|
||||
title: "Booleans that never fail",
|
||||
body: "Exact curve booleans mean no more “cannot perform operation”. Unlimited undo means no more fear.",
|
||||
},
|
||||
{
|
||||
title: "Live, editable effects",
|
||||
body: "Blends, envelope distort, gradient mesh, live radial, grid and mirror repeats, glows and drop shadows — all still editable.",
|
||||
},
|
||||
{
|
||||
title: "Image tracing",
|
||||
body: "Turn raster images into clean vector paths with 12 tracing presets.",
|
||||
},
|
||||
{
|
||||
title: "Open formats",
|
||||
body: "Native JSON documents, SVG and PDF import and export, PNG, JPEG and WebP output, and multi-size export for screens.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "shot-1-neon.png",
|
||||
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: "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: "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: "DrawCraft direct-selecting the anchors and handles of a crescent moon path",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "filmcraft",
|
||||
prefix: "Film",
|
||||
category: "Video editor",
|
||||
status: "In development",
|
||||
headline: ["Professional video editing, ", "rebuilt", " from scratch."],
|
||||
pitch: "Professional, open-source video editing, rebuilt from scratch in Rust.",
|
||||
lede: "Source and program monitors, a real timeline and every trim mode, in a native editor written in pure Rust — down to its own codecs.",
|
||||
schemaCategory: "MultimediaApplication",
|
||||
platforms: ["macOS", "Windows", "Linux"],
|
||||
rustVersion: "1.95",
|
||||
features: [
|
||||
{
|
||||
title: "A timeline you already know",
|
||||
body: "Source and program monitors, three-point editing, every trim mode, and J/K/L dynamic trimming.",
|
||||
},
|
||||
{
|
||||
title: "Frame-exact time",
|
||||
body: "An integer timebase keeps 23.976 and 29.97 drop-frame exact. No drift, no rounding, no mystery frames.",
|
||||
},
|
||||
{
|
||||
title: "Its own codecs",
|
||||
body: "No FFmpeg inside. Native H.264, HEVC, ProRes, VP9, AAC and Opus, with bit-exact decoders.",
|
||||
},
|
||||
{
|
||||
title: "Color grading",
|
||||
body: "Curves, color wheels, HSL secondaries, LUTs, HDR and log workflows, plus scopes to check your work.",
|
||||
},
|
||||
{
|
||||
title: "Effects and keyframes",
|
||||
body: "About 55 video effects and 30 transitions, keyframes with value and velocity graphs, and a linear-light GPU compositor.",
|
||||
},
|
||||
{
|
||||
title: "Broadcast-ready audio",
|
||||
body: "EBU R128 loudness meters, a mixer with automation, and native DSP effects.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "color.webp",
|
||||
source: "filmcraft-color.png",
|
||||
caption: "Scopes and timeline — Charade (1963)",
|
||||
alt: "FilmCraft with video scopes, the program monitor showing Charade (1963), and a multi-track timeline",
|
||||
},
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "filmcraft-hero.png",
|
||||
caption: "Effect controls — Night of the Living Dead (1968)",
|
||||
alt: "FilmCraft's effect controls and timeline editing Night of the Living Dead (1968)",
|
||||
},
|
||||
{
|
||||
file: "keyframes.webp",
|
||||
source: "filmcraft-keyframes.png",
|
||||
caption: "Keyframes with value and velocity graphs",
|
||||
alt: "FilmCraft keyframing scale with value and velocity graphs on a title shot",
|
||||
},
|
||||
{
|
||||
file: "export.webp",
|
||||
source: "filmcraft-export.png",
|
||||
caption: "Export with destination presets",
|
||||
alt: "FilmCraft export screen with H.264 settings and destination presets",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "lightcraft",
|
||||
prefix: "Light",
|
||||
category: "Photo library & raw developer",
|
||||
status: "In development",
|
||||
headline: ["Your photos. Your pixels. Your ", "machine", "."],
|
||||
pitch: "An open-source photo library and raw developer that runs on your machine.",
|
||||
lede: "A fast photo library and non-destructive raw developer, written from scratch in pure Rust. No account, no cloud, no telemetry, no subscription.",
|
||||
schemaCategory: "MultimediaApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Non-destructive by design",
|
||||
body: "A scene-referred, wide-gamut, 32-bit float pipeline. Your originals are never touched.",
|
||||
},
|
||||
{
|
||||
title: "Every control you reach for",
|
||||
body: "Light, color, effects, tone curve, an 8-band color mixer, 3-way color grading wheels and 18 built-in presets.",
|
||||
},
|
||||
{
|
||||
title: "Precise masking",
|
||||
body: "Brush, linear and radial gradients, luminance and color ranges, plus sky, subject and background masks.",
|
||||
},
|
||||
{
|
||||
title: "Native raw decoding",
|
||||
body: "Pure-Rust decoders for DNG, CR2, ARW, NEF, RAF (including X-Trans), RW2, PEF and ORF, with more on the way.",
|
||||
},
|
||||
{
|
||||
title: "A library that keeps up",
|
||||
body: "Albums, ratings, flags, field search and virtualized grids built for big catalogs.",
|
||||
},
|
||||
{
|
||||
title: "Instant feedback",
|
||||
body: "The GPU develop pipeline updates a 24 MP raw in about 4 ms per slider move.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "hero-tetons.jpg",
|
||||
caption: "Develop — The Tetons and the Snake River",
|
||||
alt: "LightCraft developing Ansel Adams' The Tetons and the Snake River with light and effects sliders",
|
||||
},
|
||||
{
|
||||
file: "grading.webp",
|
||||
source: "grading-migrant-mother.jpg",
|
||||
caption: "Color grading wheels — Migrant Mother",
|
||||
alt: "LightCraft color grading wheels applied to Dorothea Lange's Migrant Mother",
|
||||
},
|
||||
{
|
||||
file: "masking.webp",
|
||||
source: "masking.jpg",
|
||||
caption: "Sky and radial gradient masks",
|
||||
alt: "LightCraft masking panel with a radial gradient mask warming a sunset sky",
|
||||
},
|
||||
{
|
||||
file: "library.webp",
|
||||
source: "grid-demo.jpg",
|
||||
caption: "Library with albums and ratings",
|
||||
alt: "LightCraft library grid with albums, ratings and the develop panel",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "printcraft",
|
||||
prefix: "Print",
|
||||
category: "PDF workbench",
|
||||
status: "Early alpha",
|
||||
headline: ["The ", "open-source", " PDF workbench."],
|
||||
pitch: "The open-source PDF workbench: read, organize, combine, split and secure.",
|
||||
lede: "Read, organize, combine, split and secure PDFs in a fast, native app written in Rust from the ground up. No account, no telemetry, no cloud.",
|
||||
schemaCategory: "BusinessApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Faithful rendering",
|
||||
body: "World scripts, vertical Japanese, color emoji and transparency. Zero crashes across a 983-file test corpus.",
|
||||
},
|
||||
{
|
||||
title: "Search as you type",
|
||||
body: "Instant find, and text selection that follows the document's real reading order.",
|
||||
},
|
||||
{
|
||||
title: "Organize like cards",
|
||||
body: "Rotate, delete, insert and reorder pages, with deep undo behind every change.",
|
||||
},
|
||||
{
|
||||
title: "Combine, extract, split",
|
||||
body: "Assemble and break apart documents while keeping links, form fields, layers and bookmarks intact.",
|
||||
},
|
||||
{
|
||||
title: "Fearless saves",
|
||||
body: "Incremental, atomic saves with autosave and crash recovery.",
|
||||
},
|
||||
{
|
||||
title: "Built-in security",
|
||||
body: "Opens everything from RC4 to AES-256 encryption and honors author permissions.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "printcraft-viewer.png",
|
||||
caption: "Viewer with threaded comments",
|
||||
alt: "PrintCraft showing a showcase PDF cover with the tools sidebar and threaded comments",
|
||||
},
|
||||
{
|
||||
file: "organize.webp",
|
||||
source: "printcraft-organize.png",
|
||||
caption: "Organize pages like cards",
|
||||
alt: "PrintCraft page organizer with three pages selected for rotate, delete and extract",
|
||||
},
|
||||
{
|
||||
file: "palette.webp",
|
||||
source: "printcraft-palette.png",
|
||||
caption: "A command palette for every tool",
|
||||
alt: "PrintCraft command palette filtering page tools by the word page",
|
||||
},
|
||||
{
|
||||
file: "twoup.webp",
|
||||
source: "printcraft-twoup.png",
|
||||
caption: "Two-up Read mode, dark theme",
|
||||
alt: "PrintCraft two-up Read mode in the dark theme showing typeset specimen pages",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const LIST_FORMAT = new Intl.ListFormat("en", { type: "conjunction" });
|
||||
|
||||
/** "a, b and c" — Oxford comma per the en locale. */
|
||||
export function formatList(items: string[]): string {
|
||||
return LIST_FORMAT.format(items);
|
||||
}
|
||||
|
||||
const BROWSER_APP_NAMES = CRAFTING_APPS.filter((app) =>
|
||||
app.platforms.includes("Web"),
|
||||
).map(craftAppName);
|
||||
|
||||
// Family-wide rules, shown on the /apps hub.
|
||||
export const CRAFTING_APPS_PRINCIPLES: Tip[] = [
|
||||
{
|
||||
title: "Open source",
|
||||
body: "Every line is on GitHub under permissive licenses. Read it, fork it, build on it.",
|
||||
},
|
||||
{
|
||||
title: "Native, not wrapped",
|
||||
body: "Pure Rust compiled to real desktop apps for macOS, Windows and Linux. No Electron, no web views.",
|
||||
},
|
||||
{
|
||||
title: "Familiar from day one",
|
||||
body: "Layouts, tools and shortcuts that working professionals already know, so there's nothing to relearn.",
|
||||
},
|
||||
{
|
||||
title: "Your files, your machine",
|
||||
body: "Everything runs locally on your own files. No cloud round-trips between you and your work.",
|
||||
},
|
||||
{
|
||||
title: "Agent-ready",
|
||||
body: "Drive every app from a CLI, a JSON control channel or an MCP server, built for automation and AI agents.",
|
||||
},
|
||||
{
|
||||
title: "Also in your browser",
|
||||
body: `${formatList(BROWSER_APP_NAMES)} also compile to WebAssembly and run in a browser tab.`,
|
||||
},
|
||||
];
|
||||
|
||||
export const CRAFTING_APPS_OG_IMAGE = mediaUrl("/images/apps/og.jpg");
|
||||
|
||||
export function getCraftApp(slug: string): CraftApp | undefined {
|
||||
return CRAFTING_APPS.find((app) => app.slug === slug);
|
||||
}
|
||||
|
||||
export function craftAppName(app: CraftApp): string {
|
||||
return `${app.prefix}Craft`;
|
||||
}
|
||||
|
||||
/** Two-digit lineup position, e.g. "03". */
|
||||
export function craftAppIndex(app: CraftApp): string {
|
||||
return String(CRAFTING_APPS.indexOf(app) + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function craftAppPath(app: CraftApp): string {
|
||||
return `/apps/${app.slug}`;
|
||||
}
|
||||
|
||||
export function craftAppRepo(app: CraftApp): string {
|
||||
return `${CRAFTING_APPS_GITHUB_ORG}/${app.slug}`;
|
||||
}
|
||||
|
||||
/** Site-relative path; use craftShotUrl for an <img> src. */
|
||||
export function craftShotPath(app: CraftApp, shot: CraftShot): string {
|
||||
return `/images/apps/${app.slug}/${shot.file}`;
|
||||
}
|
||||
|
||||
export function craftShotUrl(app: CraftApp, shot: CraftShot): string {
|
||||
return mediaUrl(craftShotPath(app, shot));
|
||||
}
|
||||
|
||||
export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
|
||||
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
|
||||
}
|
||||
|
||||
export function craftAppOgImage(app: CraftApp): string {
|
||||
return mediaUrl(`/images/apps/${app.slug}/og.jpg`);
|
||||
}
|
||||
|
||||
export function craftAppBuildCommand(app: CraftApp): string {
|
||||
return [
|
||||
`git clone ${craftAppRepo(app)}.git`,
|
||||
`cd ${app.slug}`,
|
||||
`cargo run --release -p ${app.slug}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CRAFTING_APPS, craftAppName } from "./crafting-apps";
|
||||
import { cdnMediaUrl, mediaUrl } from "./links";
|
||||
|
||||
// Product feature roster, ported from the shipping landing page. Copy is the
|
||||
@@ -120,9 +121,20 @@ export const RULER_SECTIONS: RulerSection[] = [
|
||||
{ id: "seedance-2", label: "SEEDANCE 2.0" },
|
||||
{ id: "seedance-2-5", label: "SEEDANCE 2.5" },
|
||||
{ id: "minimax-h3", label: "MINIMAX H3" },
|
||||
// Crafting Apps: the /apps hub header and each /apps/<slug> hero.
|
||||
{ id: "apps", label: "CRAFTING APPS" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
id: app.slug,
|
||||
label: craftAppName(app).toUpperCase(),
|
||||
})),
|
||||
{ id: "creator", label: "SPOTLIGHT" },
|
||||
{ id: "overview", label: "OVERVIEW" },
|
||||
{ id: "highlights", label: "HIGHLIGHTS" },
|
||||
{ id: "lineup", label: "LINEUP" },
|
||||
{ id: "principles", label: "PRINCIPLES" },
|
||||
{ id: "gallery", label: "SCREENSHOTS" },
|
||||
{ id: "get-it", label: "GET IT" },
|
||||
{ id: "family", label: "THE FAMILY" },
|
||||
{ id: "examples", label: "EXAMPLES" },
|
||||
{ id: "tips", label: "PROMPT TIPS" },
|
||||
{ id: "campaign-faq", label: "FAQ" },
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// Cross-app link configuration, ported from the Vite site's config/links.ts.
|
||||
// The marketing site links out to the webapp for all product features.
|
||||
|
||||
// Canonical origin for metadata, sitemaps and share links.
|
||||
export const SITE_URL = "https://getartcraft.com";
|
||||
|
||||
/** Absolute URL for a site path; already-absolute URLs (media CDN) pass through. */
|
||||
export function siteUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export const WEBAPP_URL =
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.getartcraft.com/";
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
// - downloadUrl: the downloadable file (e.g. Cloudflare R2 URL)
|
||||
// - containThumbnail: letterbox the thumbnail with padding (good for logos)
|
||||
|
||||
import {
|
||||
CRAFTING_APPS,
|
||||
craftAppName,
|
||||
craftShotPath,
|
||||
craftShotSourceUrl,
|
||||
} from "./crafting-apps";
|
||||
|
||||
export type PressKitAssetType = "video" | "image" | "embed" | "link";
|
||||
|
||||
export type PressKitAsset = {
|
||||
@@ -104,6 +111,16 @@ export const PRESS_KIT_CATEGORIES: PressKitCategory[] = [
|
||||
{
|
||||
name: "Screenshots & media",
|
||||
description: "High-resolution screenshots and promotional images",
|
||||
assets: [],
|
||||
// Crafting Apps: every shot, downloading the full-resolution original.
|
||||
assets: CRAFTING_APPS.flatMap((app) =>
|
||||
app.shots.map((shot) => ({
|
||||
type: "image" as const,
|
||||
title: `${craftAppName(app)}: ${shot.caption}`,
|
||||
description: shot.alt,
|
||||
thumbnail: craftShotPath(app, shot),
|
||||
downloadUrl: craftShotSourceUrl(app, shot),
|
||||
downloadLabel: "Full resolution",
|
||||
})),
|
||||
),
|
||||
},
|
||||
];
|
||||