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
368 changed files with 29159 additions and 2416 deletions

No files matched your search

+9 -4
View File
@@ -34,6 +34,7 @@ artcraft-services/
├── frontend/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
@@ -41,9 +42,9 @@ artcraft-services/
## Updating Desktop Download Links
Desktop release links are configured independently in **two places**. When
Desktop release links are configured independently in **three places**. When
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
`MAC_VERSION` in both unless the request explicitly targets one platform or
`MAC_VERSION` in all three unless the request explicitly targets one platform or
app:
1. **Current public website (Next.js):**
@@ -51,7 +52,11 @@ app:
This controls the installer links and visible version at
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
(newest first), then update both version selectors.
2. **Browser webapp:**
2. **Legacy website (Vite):**
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
Updating only this file does **not** update the current public website.
3. **Browser webapp:**
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
Update both version constants; these links are used by the welcome and
checkout-success pages at `app.getartcraft.com`.
@@ -60,7 +65,7 @@ Keep previous releases for rollback. The Next.js download page reads its visible
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
not hardcode a separate version in the page component.
Check that both configs produce the requested release tag and installer
Check that all three configs produce the requested release tag and installer
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
+8 -5
View File
@@ -104,7 +104,7 @@ and signals for state, Three.js for 3D scenes, and shared UI and generation tool
| Path | Purpose |
|----------------------------------|-----------------------------------------------|
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
| `frontend/apps/artcraft-website-next` | Product website at `getartcraft.com` (Next.js) |
| `frontend/apps/artcraft-website` | Product website at `getartcraft.com` |
| `frontend/libs/api` | HTTP clients, API host selection, and models |
| `frontend/libs/omni-gen` | Shared generation logic |
| `frontend/libs/components` | Reusable UI, editors, and generation controls |
@@ -190,11 +190,14 @@ The browser app runs at `http://localhost:4201`. `VITE_USE_LOCAL_API=true` selec
the hosted API. API host selection lives in
[`StorytellerApiHostStore`](./frontend/libs/api/src/lib/config/StorytellerApiHostStore.ts).
From `frontend`, build the web app with `npx nx build artcraft-webapp`. The
product website is a standalone Next.js app (not part of the Nx workspace); run it
from the repository root with `./script/website/unix_frontend_website_dev.sh`.
From `frontend`, build the web app or run the product website with:
The website runs at `http://localhost:3000`. Repository-root launchers are in
```bash
npx nx build artcraft-webapp
npx nx dev artcraft-website
```
The website runs at `http://localhost:4200`. Repository-root launchers are in
[`script/website`](./script/website). For desktop development, use the
[ArtCraft desktop repository](https://github.com/storytold/artcraft).
+1
View File
@@ -255,6 +255,7 @@ From the services repository:
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
cd frontend
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
```
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
+2 -2
View File
@@ -35,6 +35,6 @@ VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
The web app runs at `http://localhost:4201`, using the local backend. To develop
against the hosted API, omit `VITE_USE_LOCAL_API=true`.
For the product website, run `./script/website/unix_frontend_website_dev.sh` from the
repository root; it is a standalone Next.js app on `http://localhost:3000`. Repository-root launchers live under [`script/website`](../script/website).
For the product website, run `npx nx dev artcraft-website` from `frontend`; it uses
`http://localhost:4200`. Repository-root launchers live under [`script/website`](../script/website).
See the [frontend README](../frontend/README.md) for builds and troubleshooting.
@@ -1,11 +1,10 @@
use std::str::FromStr;
use std::time::Duration;
use log::info;
use errors::AnyhowResult;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
@@ -21,37 +20,20 @@ pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
// NB: Every cached prepared statement stays open on the server until its connection evicts it
// or closes, and the server caps the total across ALL clients (`max_prepared_stmt_count`,
// 16382 on the managed DB). sqlx's default of 100 per connection let `replicas *
// MYSQL_MAX_CONNECTIONS * 100` blow past that cap, failing new prepares with MySQL error 1461.
// Keep `replicas * MYSQL_MAX_CONNECTIONS * MYSQL_STATEMENT_CACHE_CAPACITY` well under it.
let statement_cache_capacity = easyenv::get_env_num::<usize>("MYSQL_STATEMENT_CACHE_CAPACITY", 25)?;
// NB: Recycling connections releases any server-side statements orphaned by cancelled queries
// (eg. a client disconnect between prepare and close). sqlx's default lifetime is 30 minutes.
let max_lifetime_seconds = easyenv::get_env_num::<u64>("MYSQL_MAX_LIFETIME_SECONDS", 600)?;
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
// log the connection string (it contains credentials).
info!(
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}, \
statement_cache_capacity={}, max_lifetime_seconds={}",
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
max_connections, min_connections, acquire_timeout_seconds,
statement_cache_capacity, max_lifetime_seconds,
);
let connect_options = MySqlConnectOptions::from_str(&db_connection_string)?
.statement_cache_capacity(statement_cache_capacity);
let pool_options = MySqlPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds))
.max_lifetime(Some(Duration::from_secs(max_lifetime_seconds)));
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
let pool = pool_options
.connect_with(connect_options)
.connect(&db_connection_string)
.await?;
Ok(pool)
+3 -1
View File
@@ -31,7 +31,7 @@ From the **repository root**, the launcher scripts (these preflight your
environment, install dependencies, and free the dev port first):
```bash
./script/website/unix_frontend_website_dev.sh # artcraft-website-next (marketing site), port 3000
./script/website/unix_frontend_website_dev.sh # artcraft-website (marketing site), port 4200
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
```
@@ -46,12 +46,14 @@ by its full or relative path:
Or directly from this directory:
```bash
nx dev artcraft-website
npx nx dev @frontend/artcraft-webapp
```
## Building
```bash
npx nx build artcraft-website
npx nx build artcraft-webapp
```
+18 -1
View File
@@ -4,6 +4,8 @@ import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { execSync } from 'child_process';
import path from 'path';
// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched with
// fetch(), but the CDNs only send CORS headers for the production origin —
@@ -69,6 +71,21 @@ function cdnProxyPlugin() {
};
}
// Custom plugin to generate news.json on dev server start
function generateNewsPlugin() {
return {
name: 'generate-news',
buildStart() {
try {
const scriptPath = path.resolve(__dirname, '../../scripts/generate-news-json.mjs');
execSync(`node "${scriptPath}"`, { stdio: 'inherit' });
} catch (e) {
console.warn('Failed to generate news.json:', e);
}
},
};
}
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/artcraft-webapp',
@@ -93,7 +110,7 @@ export default defineConfig(() => ({
port: 4301,
host: 'localhost',
},
plugins: [cdnProxyPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
plugins: [cdnProxyPlugin(), generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -4,10 +4,6 @@
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
# legacy-route redirects declared in next.config.ts. Other media is in public/.
#
# Google Analytics: set NEXT_PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX) in the
# Netlify UI, scoped to the Production context so deploy previews don't
# report. It is inlined at build time, so redeploy after changing it.
[build]
command = "npm run build"
@@ -25,25 +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,
},
// PrintCraft was renamed PdfCraft. Old page links and share images
// (cached by social sites) follow it.
{
source: "/apps/printcraft",
destination: "/apps/pdfcraft",
permanent: true,
},
{
source: "/images/apps/printcraft/:file",
destination: "/images/apps/pdfcraft/:file",
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: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 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: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 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.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 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,27 +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 { trackAttrs } from "@/lib/analytics";
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,
@@ -73,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>
@@ -96,32 +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)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<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">
@@ -132,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-pdfcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
.app-printcraft { --app: #12a58a; --app-ink: #0a7563; }
: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-pdfcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
: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-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-pdfcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
:root:not([data-theme="light"]) .app-printcraft { --app-ink: #3fd6b8; }
}
/* Plan cell: a color wash pooling under the index tab and fading into the
@@ -9,7 +9,6 @@ import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import LandingContext from "@/components/landing-context";
import Analytics from "@/components/analytics";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
@@ -139,7 +138,6 @@ export default function RootLayout({
</MotionProvider>
<TunerPanel />
<LandingContext />
<Analytics />
</body>
</html>
);
@@ -1,41 +0,0 @@
"use client";
import { useEffect } from "react";
import Script from "next/script";
import { GA_MEASUREMENT_ID, trackClick } from "@/lib/analytics";
// Loads gtag.js after hydration and installs the one delegated click
// listener behind the site's link and data-track events (lib/analytics.ts).
// Renders nothing when no measurement id is configured.
export default function Analytics() {
useEffect(() => {
if (!GA_MEASUREMENT_ID) return;
// Capture phase: runs before click-to-play facades swap themselves out,
// and before any handler can stop propagation. auxclick covers
// middle-click "open in new tab".
const onClick = (event: MouseEvent) => {
if (event.type === "auxclick" && event.button !== 1) return;
trackClick(event.target);
};
document.addEventListener("click", onClick, true);
document.addEventListener("auxclick", onClick, true);
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("auxclick", onClick, true);
};
}, []);
if (!GA_MEASUREMENT_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments);};gtag("js",new Date());gtag("config",${JSON.stringify(GA_MEASUREMENT_ID)});`}
</Script>
</>
);
}
@@ -2,20 +2,17 @@ import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
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,
@@ -26,12 +23,10 @@ export default function AppCard({
compact?: boolean;
}) {
const [hero] = app.shots;
const release = craftAppRelease(app);
return (
<Link
href={craftAppPath(app)}
data-reveal
{...trackAttrs("app_select", { app_name: app.slug })}
className={twMerge(
"group relative flex h-full flex-col bg-bg",
appThemeClass(app),
@@ -52,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,43 +1,33 @@
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";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
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 pair up, and an odd one out
// leads at full width; 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);
const leadFullWidth = !portrait && shots.length % 2 === 1;
return (
<SectionShell id="gallery">
<SectionEyebrow
@@ -45,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", leadFullWidth && 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
@@ -73,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">
@@ -80,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")}
@@ -98,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">
@@ -117,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>
@@ -205,7 +157,6 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
value={command}
variant="ghost"
className="h-9 border-l border-line px-3"
{...trackAttrs("copy_command", { app_name: app.slug })}
/>
</div>
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
@@ -228,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);
@@ -265,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">
@@ -1,6 +1,5 @@
import { twMerge } from "tailwind-merge";
import { Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
// 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
@@ -41,7 +40,6 @@ export default function ShareBar({
text: string;
className?: string;
}) {
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
return (
<div
className={twMerge(
@@ -58,7 +56,6 @@ export default function ShareBar({
copiedLabel="Link copied"
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
/>
{TARGETS.map((target) => (
<Button
@@ -69,7 +66,6 @@ export default function ShareBar({
aria-label={`Share on ${target.name}`}
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
>
{target.name}
</Button>
@@ -4,7 +4,6 @@ import { useState, type FormEvent } from "react";
import { CheckIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Input, Label } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
// Submissions go to a Google Form whose responses land in a Google Sheet.
@@ -84,7 +83,6 @@ export default function BetaForm() {
// Google Forms sends no CORS headers, so this is fire-and-forget: the
// response is opaque and "request completed" counts as success.
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
trackEvent("generate_lead", { form_name: "beta", user_type: userType ?? "" });
setSubmitted(true);
} catch {
setSubmitError(
@@ -3,7 +3,6 @@
import { useState, type FormEvent } from "react";
import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { Button, Input, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { login, signup } from "@/lib/api";
import {
getLandingUrl,
@@ -119,9 +118,8 @@ function SignupForm({
maybeReferralCode: getReferralCode(),
});
setLoading(false);
if (!result.success) return setError(result.errorMessage || "Failed to create account");
trackEvent("sign_up", { method: "email", signup_source: signupSource });
onSuccess();
if (result.success) onSuccess();
else setError(result.errorMessage || "Failed to create account");
};
return (
@@ -164,9 +162,8 @@ function LoginForm({ onSuccess }: { onSuccess: () => void }) {
setLoading(true);
const result = await login({ usernameOrEmail, password });
setLoading(false);
if (!result.success) return setError(result.errorMessage || "Invalid credentials");
trackEvent("login", { method: "email" });
onSuccess();
if (result.success) onSuccess();
else setError(result.errorMessage || "Invalid credentials");
};
return (
@@ -3,7 +3,6 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { trackAttrs } from "@/lib/analytics";
import type { ExampleVideo } from "@/lib/campaign-data";
// Click-to-play facades: the mp4s (several MB each) are only requested
@@ -70,11 +69,6 @@ function ExampleCard({
onClick={() => setActive(true)}
aria-label={`Play example: ${example.label}`}
className="group absolute inset-0 h-full w-full"
{...trackAttrs("video_start", {
video_provider: "self_hosted",
video_title: example.label,
video_url: example.src,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -9,7 +9,6 @@ import {
XIcon,
} from "lucide-react";
import { Button, Select, Switch } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { generateVideo, getVideoJob } from "@/lib/api";
import { H3, H3_GATE_PERKS, H3_SAMPLE_PROMPTS } from "@/lib/campaign-data";
import { webappUrl } from "@/lib/links";
@@ -102,7 +101,6 @@ export default function H3PromptBox() {
setSubmitting(false);
if (!result.success) {
patch(id, { status: "failed", error: result.errorMessage });
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
return;
}
@@ -116,15 +114,12 @@ export default function H3PromptBox() {
if (job.status === "complete") {
patch(id, { status: "complete", videoUrl: job.videoUrl });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "complete" });
} else if (job.status === "failed") {
patch(id, { status: "failed", error: job.error });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
} else if (attempts >= POLL_MAX_ATTEMPTS) {
patch(id, { status: "failed", error: "Generation timed out" });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "timeout" });
} else {
setTimeout(poll, POLL_INTERVAL_MS);
}
@@ -141,14 +136,6 @@ export default function H3PromptBox() {
setError(`Prompt exceeds the ${MAX_PROMPT_LENGTH} character limit for this model`);
return;
}
trackEvent("prompt_submit", {
model: H3.modelId,
logged_in: loggedIn,
aspect_ratio: aspectRatio,
resolution,
duration_seconds: Number(duration),
sound,
});
if (!loggedIn) {
setGateOpen(true);
return;
@@ -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
@@ -2,7 +2,6 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { trackAttrs } from "@/lib/analytics";
// Click-to-load YouTube embed: a thumbnail until the visitor asks for the
// player, so the landing never pays the iframe cost up front.
@@ -33,11 +32,6 @@ export default function LiteYouTube({
onClick={() => setActivated(true)}
className="group absolute inset-0 h-full w-full"
aria-label={`Play video: ${title}`}
{...trackAttrs("video_start", {
video_provider: "youtube",
video_title: title,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
@@ -36,7 +35,6 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
}
async function download() {
trackEvent("media_download", { media_type: kind, file_extension: mediaFormat(media) });
setDownloading(true);
setMessage("");
const controller = new AbortController();
@@ -104,10 +102,7 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<h2 className="hud-label text-faint">Prompt</h2>
{prompt?.maybe_positive_prompt && (
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
onClick={() => {
copy(prompt.maybe_positive_prompt!, "prompt");
trackEvent("copy_prompt", { media_type: kind });
}}>
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</button>
)}
@@ -148,10 +143,7 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<Button onClick={download} loading={downloading} className="w-full">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
</Button>
<Button variant="secondary" className="w-full" onClick={() => {
copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link");
trackEvent("share", { method: "copy_link", content_type: kind, item_id: media.token });
}}>
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
{copied === "link" ? "Link copied" : "Copy share link"}
</Button>
@@ -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}</>;
}
@@ -11,7 +11,6 @@ import {
import { twMerge } from "tailwind-merge";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Button, Modal } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { mediaUrl } from "@/lib/links";
import {
PRESS_KIT_CATEGORIES,
@@ -149,10 +148,6 @@ function AssetCard({
onClick={onPlay}
aria-label={`Play ${asset.title}`}
className="group relative aspect-video w-full overflow-hidden bg-bg-sunken"
{...trackAttrs("video_start", {
video_provider: asset.type === "embed" ? "youtube" : "self_hosted",
video_title: asset.title,
})}
>
{thumbnail}
</button>
@@ -180,7 +175,6 @@ function AssetCard({
rel="noopener noreferrer"
variant="secondary"
className="w-full"
{...trackAttrs("press_kit_download", { asset_name: asset.title })}
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{asset.downloadLabel ?? "Download"}
@@ -3,7 +3,6 @@
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
import { useAccount } from "@/lib/use-account";
import CreditsModal from "./credits-modal";
@@ -29,10 +28,7 @@ export default function CreditsCta() {
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(true);
trackEvent("view_credit_packs", {});
}}
onClick={() => setOpen(true)}
className="mt-6"
>
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
@@ -4,7 +4,6 @@ import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { creditsPackCheckout } from "@/lib/api";
type CreditPack = {
@@ -36,20 +35,6 @@ export default function CreditsModal({
const purchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
setError(null);
trackEvent("begin_checkout", {
currency: "USD",
value: pack.priceUsd,
checkout_type: "credits",
items: [
{
item_id: pack.id,
item_name: `${pack.total.toLocaleString("en-US")} credits`,
item_category: "credits",
price: pack.priceUsd,
quantity: 1,
},
],
});
const result = await creditsPackCheckout(pack.id);
if (result.success) {
window.location.href = result.data.checkoutUrl;
@@ -11,7 +11,6 @@ import {
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge, Button, TabSelector } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import {
getPortalUrl,
subscriptionCheckout,
@@ -86,26 +85,9 @@ export default function PricingTable({
if (plan.slug === activePlanSlug) return;
setProcessingPlan(plan.slug);
setError(null);
const checkoutType = !user ? "signup" : hasActivePlan ? "switch" : "subscribe";
const price = cadence === "yearly" ? plan.yearlyPrice : plan.monthlyPrice;
trackEvent("begin_checkout", {
currency: "USD",
value: price,
checkout_type: checkoutType,
items: [
{
item_id: plan.slug,
item_name: plan.name,
item_category: "subscription",
item_variant: cadence,
price,
quantity: 1,
},
],
});
const body = { plan: plan.slug, cadence };
const redirected = redirect(
checkoutType === "signup"
!user
? await userSignupSubscriptionCheckout({
...body,
maybeReferralUrl: getReferrer(),
@@ -113,7 +95,7 @@ export default function PricingTable({
maybeReferralUsername: getReferralUsername(),
maybeReferralCode: getReferralCode(),
})
: checkoutType === "switch"
: hasActivePlan
? await switchPlan(body)
: await subscriptionCheckout(body),
);
@@ -121,7 +103,6 @@ export default function PricingTable({
};
const managePlan = async () => {
trackEvent("manage_subscription", {});
setManaging(true);
setError(null);
if (!redirect(await getPortalUrl())) setManaging(false);
@@ -134,11 +115,7 @@ export default function PricingTable({
<TabSelector
tabs={BILLING_TABS}
activeTab={cadence}
onTabChange={(id) => {
if (id === cadence) return;
setCadence(id as BillingCadence);
trackEvent("billing_cadence_change", { cadence: id });
}}
onTabChange={(id) => setCadence(id as BillingCadence)}
tabClassName="w-24"
/>
<Badge
@@ -386,7 +363,6 @@ function ContactButtons({ className }: { className?: string }) {
try {
await navigator.clipboard.writeText(CONTACT_EMAIL);
setCopied(true);
trackEvent("contact_click", { method: "copy_email" });
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard unavailable (insecure context) — the mailto still works.
@@ -86,7 +86,6 @@ export function Button({
</>
);
// Link buttons carry data-cta so analytics can report them as CTA clicks.
if (href) {
const anchorProps =
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -97,7 +96,6 @@ export function Button({
target={target}
rel={rel}
className={classes}
data-cta=""
{...anchorProps}
>
{content}
@@ -105,7 +103,7 @@ export function Button({
);
}
return (
<Link href={href} className={classes} data-cta="" {...anchorProps}>
<Link href={href} className={classes} {...anchorProps}>
{content}
</Link>
);
@@ -1,232 +0,0 @@
// Google Analytics 4 (gtag.js). Off unless NEXT_PUBLIC_GA_MEASUREMENT_ID is
// set at build time, so local dev stays out of the reports.
//
// Page views come from GA4 Enhanced Measurement (it records App Router
// navigations via history changes), along with scrolls, outbound clicks and
// common file downloads. On top of that, the site sends the events below.
// Most link clicks need no per-component code: one delegated listener
// (see components/analytics.tsx) classifies them by href, and elements
// tagged with trackAttrs() report their event when clicked. Every delegated
// event also carries `link_location` (nav, footer, modal or the enclosing
// section id).
import { SOCIAL_LINKS, WEBAPP_URL } from "./links";
export const GA_MEASUREMENT_ID =
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID?.trim() || undefined;
export type AnalyticsItem = {
item_id: string;
item_name: string;
item_category: "subscription" | "credits";
item_variant?: string;
price: number;
quantity: number;
};
type AnalyticsEvents = {
// Link clicks, classified by href.
app_download: {
app_name: string;
app_version: string;
platform: "macos" | "windows" | "linux" | "other";
file_name: string;
};
webapp_click: { destination: string; link_text: string };
social_click: { network: string };
contact_click: { method: "mailto" | "copy_email" };
cta_click: { link_url: string; link_text: string };
// Clicks on elements tagged with trackAttrs().
video_start: { video_provider: string; video_title: string; video_url?: string };
share: { method: string; content_type: string; item_id: string };
copy_command: { app_name: string };
app_select: { app_name: string };
press_kit_download: { asset_name: string };
// Sent from component code.
generate_lead: { form_name: string; user_type: string };
sign_up: { method: "email"; signup_source: string };
login: { method: "email" };
prompt_submit: {
model: string;
logged_in: boolean;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
sound: boolean;
};
video_generation: { model: string; status: "complete" | "failed" | "timeout" };
billing_cadence_change: { cadence: string };
view_credit_packs: Record<string, never>;
begin_checkout: {
currency: "USD";
value: number;
checkout_type: "signup" | "subscribe" | "switch" | "credits";
items: AnalyticsItem[];
};
manage_subscription: Record<string, never>;
copy_prompt: { media_type: string };
media_download: { media_type: string; file_extension: string };
};
export type AnalyticsEvent = keyof AnalyticsEvents;
type ParamValue = string | number | boolean | AnalyticsItem[] | undefined;
type EventParams = Record<string, ParamValue>;
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
export function trackEvent<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): void {
send(event, params);
}
/**
* Data attributes that make an element report `event` when clicked. Works in
* server components (no handler needed); spread onto the element or Button.
*/
export function trackAttrs<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): Record<`data-track${string}`, string> {
const attrs: Record<`data-track${string}`, string> = { "data-track": event };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) attrs[`data-track-${key}`] = String(value);
}
return attrs;
}
/** Delegated click handler: reports tagged elements and classified links. */
export function trackClick(target: EventTarget | null): void {
if (!(target instanceof Element)) return;
const tagged = target.closest<HTMLElement>("[data-track]");
if (tagged?.dataset.track) {
send(tagged.dataset.track, {
...taggedParams(tagged),
link_location: linkLocation(tagged),
});
return;
}
const anchor = target.closest<HTMLAnchorElement>("a[href]");
const link = anchor && classifyLink(anchor);
if (link) send(link.event, { ...link.params, link_location: linkLocation(anchor) });
}
function send(event: string, params: EventParams): void {
if (!GA_MEASUREMENT_ID) return;
window.gtag?.("event", event, params);
}
const WEBAPP_ORIGIN = new URL(WEBAPP_URL).origin;
// Hostname → network, derived from SOCIAL_LINKS.
const SOCIAL_NETWORKS = new Map(
Object.entries(SOCIAL_LINKS).map(([name, href]) => [
bareHostname(new URL(href).hostname),
name.toLowerCase(),
]),
);
// github.com/<org>/<repo>/releases/download/<tag>/<file>: the ArtCraft
// installers and every Crafting App download.
const RELEASE_ASSET_PATH = /^\/[^/]+\/([^/]+)\/releases\/download\/([^/]+)\/([^/]+)$/;
type ClassifiedLink = {
[E in AnalyticsEvent]: { event: E; params: AnalyticsEvents[E] };
}[AnalyticsEvent];
function classifyLink(anchor: HTMLAnchorElement): ClassifiedLink | null {
let url: URL;
try {
url = new URL(anchor.href);
} catch {
return null;
}
if (url.protocol === "mailto:") {
return { event: "contact_click", params: { method: "mailto" } };
}
const release = url.hostname === "github.com" && RELEASE_ASSET_PATH.exec(url.pathname);
if (release) {
const [, repo, tag, file] = release;
const fileName = decodeURIComponent(file);
return {
event: "app_download",
params: {
app_name: repo,
app_version: tag.replace(/^\D*/, ""),
platform: downloadPlatform(fileName),
file_name: fileName,
},
};
}
if (url.origin === WEBAPP_ORIGIN) {
return {
event: "webapp_click",
params: { destination: url.pathname, link_text: linkText(anchor) },
};
}
const network = SOCIAL_NETWORKS.get(bareHostname(url.hostname));
if (network) return { event: "social_click", params: { network } };
// Remaining Button links (internal CTAs, other outbound buttons). Plain
// text links are left to page views and Enhanced Measurement.
if (anchor.hasAttribute("data-cta")) {
return {
event: "cta_click",
params: {
link_url: url.origin === window.location.origin ? url.pathname + url.hash : url.href,
link_text: linkText(anchor),
},
};
}
return null;
}
function linkLocation(element: Element): string {
if (element.closest("#site-nav")) return "nav";
if (element.closest("footer")) return "footer";
if (element.closest('[role="dialog"]')) return "modal";
return element.closest("section[id]")?.id || "page";
}
// data-track-<param> attributes surface in the dataset as track<Param>.
function taggedParams(element: HTMLElement): EventParams {
const params: EventParams = {};
for (const [key, value] of Object.entries(element.dataset)) {
if (key.length > 5 && key.startsWith("track")) {
params[key.charAt(5).toLowerCase() + key.slice(6)] = value;
}
}
return params;
}
function downloadPlatform(fileName: string): AnalyticsEvents["app_download"]["platform"] {
if (/\.dmg$|macos/i.test(fileName)) return "macos";
if (/\.(exe|msi)$|windows/i.test(fileName)) return "windows";
if (/\.(appimage|deb|rpm)$|linux/i.test(fileName)) return "linux";
return "other";
}
// GA truncates parameter values at 100 characters.
function linkText(anchor: HTMLAnchorElement): string {
const text = anchor.getAttribute("aria-label") || anchor.textContent || "";
return text.replace(/\s+/g, " ").trim().slice(0, 100);
}
function bareHostname(hostname: string): string {
return hostname.replace(/^www\./, "");
}
@@ -1,216 +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 an app: change its version in CRAFT_APP_RELEASES (each app has its
// own, e.g. `craftRelease("0.4.0")`).
// 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 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 FreeBSD build, 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;
/** Fills `{slug}` in filenames. Defaults to the app's slug; a release from before a rename keeps the old one. */
fileSlug?: 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}-windows-arm64.msi",
group: "Windows",
label: "Installer (ARM64)",
description: "Windows 11 on ARM laptops.",
arch: "aarch64",
},
{
file: "{slug}-{version}-windows-arm64-portable.zip",
group: "Windows",
label: "Portable (ARM64)",
description: "Unzip and run on Windows on ARM.",
arch: "aarch64",
},
{
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.flatpak",
group: "Linux",
label: "Flatpak (x86_64)",
description: "Sandboxed. Install with flatpak install --user.",
arch: "x86_64",
},
{
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.flatpak",
group: "Linux",
label: "Flatpak (ARM64)",
description: "Sandboxed, on 64-bit ARM.",
arch: "aarch64",
},
{
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}-{version}-freebsd-x86_64.tar.gz",
group: "Other",
label: "FreeBSD (x86_64)",
description: "Plain binaries for FreeBSD 14.",
},
{
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 };
}
// 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: craftRelease("0.5.0"),
vectorcraft: craftRelease("0.6.0"),
filmcraft: craftRelease("0.4.0"),
lightcraft: craftRelease("0.4.0"),
pdfcraft: craftRelease("0.4.0"),
effectcraft: craftRelease("0.6.0"),
designcraft: craftRelease("0.4.0"),
};
@@ -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"
| "pdfcraft"
| "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,32 +159,26 @@ export const CRAFTING_APPS: CraftApp[] = [
{
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in VectorCraft",
alt: "VectorCraft editing the Neon Drive poster, with the title's live outer glow settings in the Appearance panel and its character settings in the Properties 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 and layers",
alt: "VectorCraft showing three live blend ribbons clipped to the artboard, one selected with its key paths, and the Layers panel listing the clip group and blends",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Pen and direct selection",
alt: "VectorCraft direct-selecting the anchor points and bezier handles of a crescent moon, with the contextual task bar below it",
},
{
file: "perspective.webp",
source: "shot-5-perspective.png",
caption: "Perspective grid, still editable",
alt: "VectorCraft with two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset",
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: "VectorCraft in its light theme with four artboards: shape 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: "DrawCraft direct-selecting the anchors and handles of a crescent moon path",
},
],
},
@@ -334,8 +311,8 @@ export const CRAFTING_APPS: CraftApp[] = [
],
},
{
slug: "pdfcraft",
prefix: "Pdf",
slug: "printcraft",
prefix: "Print",
category: "PDF workbench",
status: "Early alpha",
headline: ["The ", "open-source", " PDF workbench."],
@@ -373,176 +350,32 @@ export const CRAFTING_APPS: CraftApp[] = [
shots: [
{
file: "hero.webp",
source: "pdfcraft-viewer.png",
source: "printcraft-viewer.png",
caption: "Viewer with threaded comments",
alt: "PdfCraft showing a showcase PDF cover with the All tools panel and threaded comments",
alt: "PrintCraft showing a showcase PDF cover with the tools sidebar and threaded comments",
},
{
file: "organize.webp",
source: "pdfcraft-organize.png",
source: "printcraft-organize.png",
caption: "Organize pages like cards",
alt: "PdfCraft page organizer with three pages selected and the page tools in the toolbar above",
alt: "PrintCraft page organizer with three pages selected for rotate, delete and extract",
},
{
file: "palette.webp",
source: "pdfcraft-palette.png",
source: "printcraft-palette.png",
caption: "A command palette for every tool",
alt: "PdfCraft command palette searching for page and listing the page tools",
alt: "PrintCraft command palette filtering page tools by the word page",
},
{
file: "twoup.webp",
source: "pdfcraft-twoup.png",
source: "printcraft-twoup.png",
caption: "Two-up Read mode, dark theme",
alt: "PdfCraft two-up Read mode in the dark theme showing typeset specimen pages",
},
],
},
{
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,
alt: "PrintCraft two-up Read mode in the dark theme showing typeset specimen pages",
},
],
},
];
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. */
@@ -618,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}", release.fileSlug ?? 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",
@@ -0,0 +1,4 @@
# Google OAuth Configuration
# Get the Client ID from the Google Cloud Console:
# https://console.cloud.google.com/apis/credentials
VITE_GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com
@@ -0,0 +1,143 @@
# Website → Webapp Migration
The marketing site (`artcraft-website`) is being split apart from the app
(`artcraft-webapp`). Anything that isn't pure marketing content belongs in
the webapp at `app.getartcraft.com`.
The migration is **gated by a single boolean** so it can be reverted while
we soak the new redirect behavior in production. When we're confident the
webapp is handling everything cleanly, do the **cleanup pass** below to
permanently delete everything that's only kept around for the rollback path.
---
## The switch
`src/config/links.ts`:
```ts
export const USE_WEBAPP_FOR_APP_FEATURES = true;
```
- `true` (default): legacy paths redirect to the webapp; navbar shows
"Launch App"; in-page links go cross-origin to `app.getartcraft.com`.
- `false`: everything runs locally on the marketing site (the original
pre-split behavior).
In dev, `WEBAPP_URL` auto-switches to `http://localhost:4201/` so cross-app
links work against the local webapp Vite server.
---
## What's behind the flag
### Pages that exist locally only as a fallback
When `USE_WEBAPP_FOR_APP_FEATURES = true`, these routes mount
`<WebappRedirect to="…" />` instead of the page component. The page files
are kept on disk so we can roll back by flipping the flag.
- `src/pages/login/`
- `src/pages/signup/`
- `src/pages/forgot-password/`
- `src/pages/create-image/`
- `src/pages/create-video/`
- `src/pages/create-vfx/` (the `/background-change` route)
- `src/pages/library/`
- `src/pages/referrals/`
- `src/pages/welcome/`
- `src/pages/onboarding/`
- `src/pages/checkout/` (success + cancel)
### Components only used by the above
- `src/components/auth/` (SignupForm, GoogleLoginButton)
- `src/components/credits-modal.tsx`
- `src/components/generation-gallery/`
- `src/components/lightbox/lightbox.tsx` (the modal `Lightbox`; the
`LightboxDetails` + `shared.ts` files in the same folder are still used
by the public `/media` page and must be kept)
- `src/components/navbar/task-queue.tsx`
- `src/components/prompt-box/`
- `src/components/settings-modal/`
### Lib files only used by the above
- `src/lib/cost-estimate-api.ts`
- `src/lib/enter-to-generate-store.ts`
- `src/lib/omni-gen-hooks.ts`
- `src/lib/prompts-cache.ts`
- `src/lib/recreate.ts`
### Files with conditional flag branches (need cleanup, not deletion)
- `src/app/app.tsx` — drop the `appOrWebapp()` helper, drop the local route
imports, leave only the `<WebappRedirect />` routes
- `src/components/navbar/navbar.tsx` — drop the `!USE_WEBAPP_FOR_APP_FEATURES`
branches (Login + Sign up buttons in desktop and mobile); keep only Launch
App for logged-in users and the Pricing + Launch App combo for logged-out
users
- `src/components/footer/footer.tsx` — drop the conditional Image/Video
links in the Pages column
- `src/components/download-modal.tsx` — drop the inline `SignupForm` flow
and the `view`/`navigate` state; keep only the webapp `/signup` and
`/login` link buttons
- `src/pages/landing3/landing3.tsx` — `appLink("/create-image")` /
`appLink("/create-video")` can become bare `webappUrl("…")` calls (or
inline `${WEBAPP_URL}create-image`)
- `src/pages/media/media.tsx` — drop the local
`applyRecreateFromMediaToken` branch and the `useNavigate` import; keep
only the `window.location.href = …` webapp redirect
- `src/pages/pricing/pricing.tsx` — the "Buy Credits" section + `CreditsModal`
is currently shown unconditionally; once `credits-modal.tsx` is deleted,
remove the section, the `creditsModalOpen` state, the `isLoggedIn` state,
and the session-check `useEffect`
### The flag itself
`src/config/links.ts` — drop `USE_WEBAPP_FOR_APP_FEATURES`, drop the
`appLink()` helper (use `webappUrl()` directly everywhere). Keep
`WEBAPP_URL`, `webappUrl()`, `SOCIAL_LINKS`, `SUPPORT_EMAIL`, and the dev
override.
---
## Cleanup pass — what to do when we're ready to commit to the webapp split
1. **Confirm the flag has been `true` in production for long enough** that
we're sure no real users hit the local routes.
2. **Delete** every directory and file listed under "Pages that exist
locally only as a fallback", "Components only used by the above", and
"Lib files only used by the above".
3. **Strip the flag branches** from each file under "Files with conditional
flag branches".
4. **Remove `USE_WEBAPP_FOR_APP_FEATURES`** and `appLink()` from
`src/config/links.ts`.
5. **Run** `npx tsc -p tsconfig.app.json --noEmit` from
`frontend/apps/artcraft-website` and fix any references the cleanup
exposed. (Pre-existing errors in `download-button.tsx` `LINUX` and
`pricing-table.tsx` `maybeReferralUsername` are unrelated and can be
ignored.)
6. **Verify** in dev with `nx serve @frontend/artcraft-website` that every
navbar/footer/landing/media flow still works and that legacy URLs still
redirect to the webapp via `WebappRedirect`.
---
## What stays on the marketing site no matter what
These are pure marketing content and are **not** behind the flag:
- `/` → `landing3`
- `/landing3`
- `/seedance-2` → `landing-sd2`
- `/download`
- `/media`, `/media/:id` (public viewer; the lightbox's "Recreate" button
does cross-origin to the webapp, but the viewer itself stays here)
- `/press-kit`
- `/tutorials`, `/tutorials/:slug`
- `/faq`, `/faq/:slug`
- `/news`, `/news/:slug`
- `/support`
- `/pricing` — Stripe checkout flow stays here (PricingTable → Stripe →
`/checkout/success` → WebappRedirect → webapp)
+157
View File
@@ -0,0 +1,157 @@
/background-change /index.html 200
/background-change/ /index.html 200
/background-change/* /index.html 200
/checkout_cancel /index.html 200
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
/onboarding /index.html 200
/onboarding/ /index.html 200
/onboarding/* /index.html 200
/portal_closed /index.html 200
/portal_closed/ /index.html 200
/portal_closed/* /index.html 200
/checkout_success /index.html 200
/checkout_success/ /index.html 200
/checkout_success/* /index.html 200
/checkout* /index.html 200
/checkout/* /index.html 200
/create-image /index.html 200
/create-image/ /index.html 200
/create-image/* /index.html 200
/create-video /index.html 200
/create-video/ /index.html 200
/create-video/* /index.html 200
/support /index.html 200
/support/ /index.html 200
/support/* /index.html 200
/library /index.html 200
/library/ /index.html 200
/library/* /index.html 200
/download /index.html 200
/download/ /index.html 200
/tutorials /index.html 200
/tutorials/ /index.html 200
/login /index.html 200
/login/ /index.html 200
/login/* /index.html 200
/media /index.html 200
/media/ /index.html 200
/media/* /index.html 200
# Prerendered: serve the per-route index.html so social crawlers get the
# correct OG/title tags (see scripts/prerender-meta.mjs). The SPA still boots
# from this file and React Router takes over for users.
/pricing /pricing/index.html 200
/pricing/ /pricing/index.html 200
/pricing/* /index.html 200
/beta /beta/index.html 200
/beta/ /beta/index.html 200
/beta/* /index.html 200
/faq /index.html 200
/faq/ /index.html 200
/faq/* /index.html 200
/news /index.html 200
/news/ /index.html 200
/news/* /index.html 200
/press-kit /index.html 200
/press-kit/ /index.html 200
/press-kit/* /index.html 200
/referrals /index.html 200
/referrals/ /index.html 200
/referrals/* /index.html 200
# Redirects for growth measurement
/reddit /?utm_source=reddit 301
/news.json /index.html 200
/signup /index.html 200
/signup/ /index.html 200
/signup/* /index.html 200
/forgot-password /index.html 200
/forgot-password/ /index.html 200
/forgot-password/* /index.html 200
/welcome /index.html 200
/welcome/ /index.html 200
/welcome/* /index.html 200
/password-reset /index.html 200
/password-reset/ /index.html 200
/password-reset/* /index.html 200
# Prerendered: exact paths serve their per-route index.html (correct OG tags
# for social crawlers); deep sub-paths fall back to the SPA shell. See
# scripts/prerender-meta.mjs. NOTE: these exact-match serving rules MUST come
# before the /seedance2* alias below, otherwise that wildcard would 301
# /seedance2-5 away to /seedance-2 (first match wins in _redirects).
/seedance2-5 /seedance2-5/index.html 200
/seedance2-5/ /seedance2-5/index.html 200
/seedance2-5/* /index.html 200
/seedance-2 /seedance-2/index.html 200
/seedance-2/ /seedance-2/index.html 200
/seedance-2/* /index.html 200
/minimax-h3 /minimax-h3/index.html 200
/minimax-h3/ /minimax-h3/index.html 200
/minimax-h3/* /index.html 200
/creators/jboogxcreative /creators/jboogxcreative/index.html 200
/creators/jboogxcreative/ /creators/jboogxcreative/index.html 200
/creators/* /index.html 200
# Similar URLs — creator spotlight aliases
/jboogx /creators/jboogxcreative 301
/jboogx/ /creators/jboogxcreative 301
/jboogxcreative /creators/jboogxcreative 301
/jboogxcreative/ /creators/jboogxcreative 301
# Similar URLs: MiniMax H3 aliases
/minimax /minimax-h3 301
/minimax/ /minimax-h3 301
/minimaxh3 /minimax-h3 301
/minimaxh3* /minimax-h3 301
/minimax-h-3 /minimax-h3 301
/minimax-h-3* /minimax-h3 301
/hailuo /minimax-h3 301
/hailuo/ /minimax-h3 301
# Google ads breakage
/seedance-2{ignore} /seedance-2 301
/seedance-2{ignore}* /seedance-2 301
/seedance-2%7Bignore%7D /seedance-2 301
/seedance-2%7Bignore%7D* /seedance-2 301
# Similar URLs — Seedance 2.5 aliases (kept above the broad /seedance2* rule)
/seedance-2-5 /seedance2-5 301
/seedance-2-5* /seedance2-5 301
/seedance-2.5 /seedance2-5 301
/seedance-2.5* /seedance2-5 301
/seedance25 /seedance2-5 301
/seedance25* /seedance2-5 301
# Similar URLs — Seedance 2.0 aliases
/seedance2 /seedance-2 301
/seedance2* /seedance-2 301
/seedance-2.0 /seedance-2 301
/seedance-2.0* /seedance-2 301
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
if (window.location.pathname === "/login/desktop") {
const policy = document.createElement("meta");
policy.name = "referrer";
policy.content = "no-referrer";
document.head.appendChild(policy);
} else {
const analytics = document.createElement("script");
analytics.async = true;
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
document.head.appendChild(analytics);
gtag("js", new Date());
gtag("config", "G-VKW89949V0");
}
</script>
<meta charset="utf-8" />
<title>ArtCraft. AI Video and Images. Fast and Open Desktop App.</title>
<meta
name="description"
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
/>
<meta
name="keywords"
content="ArtCraft, AI, AI Video, AI Images, Open Source"
/>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,29 @@
# Netlify settings used to discover this file:
# Base directory: frontend
# Package directory: apps/artcraft-website (relative to the base directory)
# Manage the build command and publish directory here; leave those UI fields empty.
# Paths below are relative to the frontend workspace.
[build]
command = "bash ./apps/artcraft-website/script/netlify_build.sh"
publish = "apps/artcraft-website/dist/"
[[headers]]
for = "/build/*"
[headers.values]
"Cache-Control" = "public, max-age=31560000, immutable"
# [[redirects]]
# from = "/ingest/static/*"
# to = "https://us-assets.i.posthog.com/static/:splat"
# host = "us-assets.i.posthog.com"
# status = 200
# force = true
#
# [[redirects]]
# from = "/ingest/*"
# to = "https://us.i.posthog.com/:splat"
# host = "us.i.posthog.com"
# status = 200
# force = true
@@ -0,0 +1,8 @@
{
"name": "@frontend/artcraft-website",
"version": "0.0.1",
"private": true,
"dependencies": {
"react-masonry-css": "^1.0.16"
}
}
@@ -0,0 +1,15 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more