Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3b299715 | ||
|
|
17373314ae | ||
|
|
931a8b925c | ||
|
|
0e1991de6d | ||
|
|
d950e57352 | ||
|
|
652146ad96 | ||
|
|
dafe769cda | ||
|
|
ad9b10ba94 | ||
|
|
3880127f68 | ||
|
|
03dea1c660 | ||
|
|
b5272188f7 | ||
|
|
b02f710c76 | ||
|
|
708de238c5 | ||
|
|
fc78787f30 | ||
|
|
e92253c5b4 |
No files matched your search
@@ -34,7 +34,6 @@ 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
|
||||
@@ -42,9 +41,9 @@ artcraft-services/
|
||||
|
||||
## Updating Desktop Download Links
|
||||
|
||||
Desktop release links are configured independently in **three places**. When
|
||||
Desktop release links are configured independently in **two places**. When
|
||||
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
|
||||
`MAC_VERSION` in all three unless the request explicitly targets one platform or
|
||||
`MAC_VERSION` in both unless the request explicitly targets one platform or
|
||||
app:
|
||||
|
||||
1. **Current public website (Next.js):**
|
||||
@@ -52,11 +51,7 @@ 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. **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:**
|
||||
2. **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`.
|
||||
@@ -65,7 +60,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 all three configs produce the requested release tag and installer
|
||||
Check that both 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
|
||||
|
||||
@@ -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` | Product website at `getartcraft.com` |
|
||||
| `frontend/apps/artcraft-website-next` | Product website at `getartcraft.com` (Next.js) |
|
||||
| `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,14 +190,11 @@ 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 or run the product website with:
|
||||
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`.
|
||||
|
||||
```bash
|
||||
npx nx build artcraft-webapp
|
||||
npx nx dev artcraft-website
|
||||
```
|
||||
|
||||
The website runs at `http://localhost:4200`. Repository-root launchers are in
|
||||
The website runs at `http://localhost:3000`. Repository-root launchers are in
|
||||
[`script/website`](./script/website). For desktop development, use the
|
||||
[ArtCraft desktop repository](https://github.com/storytold/artcraft).
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ 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
|
||||
|
||||
@@ -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 `npx nx dev artcraft-website` from `frontend`; it uses
|
||||
`http://localhost:4200`. Repository-root launchers live under [`script/website`](../script/website).
|
||||
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).
|
||||
See the [frontend README](../frontend/README.md) for builds and troubleshooting.
|
||||
@@ -1,10 +1,11 @@
|
||||
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::MySqlPoolOptions;
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
|
||||
@@ -20,20 +21,37 @@ 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={}",
|
||||
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}, \
|
||||
statement_cache_capacity={}, max_lifetime_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));
|
||||
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds))
|
||||
.max_lifetime(Some(Duration::from_secs(max_lifetime_seconds)));
|
||||
|
||||
let pool = pool_options
|
||||
.connect(&db_connection_string)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
|
||||
@@ -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 (marketing site), port 4200
|
||||
./script/website/unix_frontend_website_dev.sh # artcraft-website-next (marketing site), port 3000
|
||||
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
|
||||
```
|
||||
|
||||
@@ -46,14 +46,12 @@ 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
|
||||
```
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ 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 —
|
||||
@@ -71,21 +69,6 @@ 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',
|
||||
@@ -110,7 +93,7 @@ export default defineConfig(() => ({
|
||||
port: 4301,
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [cdnProxyPlugin(), generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
|
||||
plugins: [cdnProxyPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
# 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,6 +25,25 @@ 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.
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,213 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AppFamily,
|
||||
AppGallery,
|
||||
AppGetIt,
|
||||
} from "@/components/apps/app-sections";
|
||||
import {
|
||||
AppHeroDownloadButton,
|
||||
AppHeroOtherDownloadsLink,
|
||||
} from "@/components/apps/app-downloads";
|
||||
import {
|
||||
AppIcon,
|
||||
AppTab,
|
||||
AppWordmark,
|
||||
ColorAccent,
|
||||
appThemeClass,
|
||||
} from "@/components/apps/app-wordmark";
|
||||
import ShareBar from "@/components/apps/share-bar";
|
||||
import { ClosingCta, CampaignSection, TipGrid } from "@/components/campaign/sections";
|
||||
import CampaignHero from "@/components/campaign/campaign-hero";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
CRAFTING_APPS,
|
||||
craftAppIndex,
|
||||
craftAppName,
|
||||
craftAppOgImage,
|
||||
craftAppPath,
|
||||
craftAppIconUrl,
|
||||
craftAppRelease,
|
||||
craftAppRepo,
|
||||
craftReleaseDownloads,
|
||||
craftReleasePageUrl,
|
||||
craftShotUrl,
|
||||
getCraftApp,
|
||||
} from "@/lib/crafting-apps";
|
||||
import { SITE_URL, siteUrl } from "@/lib/links";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return CRAFTING_APPS.map((app) => ({ slug: app.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const app = getCraftApp(slug);
|
||||
if (!app) return {};
|
||||
const name = craftAppName(app);
|
||||
const title = `${name}: ${app.headline.join("")}`;
|
||||
return {
|
||||
title,
|
||||
description: app.lede,
|
||||
alternates: { canonical: craftAppPath(app) },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: craftAppPath(app),
|
||||
siteName: "ArtCraft",
|
||||
title,
|
||||
description: app.pitch,
|
||||
images: [{ url: craftAppOgImage(app), width: 1200, height: 630, alt: app.shots[0].alt }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CraftAppPage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const app = getCraftApp(slug);
|
||||
if (!app) notFound();
|
||||
|
||||
const name = craftAppName(app);
|
||||
const release = craftAppRelease(app);
|
||||
const [hero] = app.shots;
|
||||
const [before, accent, after] = app.headline;
|
||||
const shareUrl = siteUrl(craftAppPath(app));
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name,
|
||||
applicationCategory: app.schemaCategory,
|
||||
operatingSystem: app.platforms.join(", "),
|
||||
description: app.lede,
|
||||
url: shareUrl,
|
||||
image: siteUrl(craftAppOgImage(app)),
|
||||
thumbnailUrl: siteUrl(craftAppIconUrl(app)),
|
||||
screenshot: app.shots.map((shot) => siteUrl(craftShotUrl(app, shot))),
|
||||
sameAs: craftAppRepo(app),
|
||||
...(release && {
|
||||
softwareVersion: release.version,
|
||||
downloadUrl: craftReleasePageUrl(app, release),
|
||||
}),
|
||||
isAccessibleForFree: true,
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
publisher: { "@type": "Organization", name: "ArtCraft", url: SITE_URL },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={appThemeClass(app)}>
|
||||
<RevealManager />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<CampaignHero
|
||||
id={app.slug}
|
||||
label={name}
|
||||
annotation={`${app.category} · ${app.status}`}
|
||||
kicker={
|
||||
<span className="flex 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>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
{before}
|
||||
<ColorAccent>{accent}</ColorAccent>
|
||||
{after}
|
||||
</>
|
||||
}
|
||||
lede={app.lede}
|
||||
media={
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={craftShotUrl(app, hero)}
|
||||
alt={hero.alt}
|
||||
fetchPriority="high"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
}
|
||||
mediaCaption={hero.caption}
|
||||
mediaTag="Screenshot"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{release && (
|
||||
<AppHeroDownloadButton
|
||||
name={name}
|
||||
recommended={craftReleaseDownloads(app, release).filter(
|
||||
(download) => download.recommended,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<DiscordButton
|
||||
size="lg"
|
||||
variant={release ? "secondary" : "primary"}
|
||||
>
|
||||
Join the Discord
|
||||
</DiscordButton>
|
||||
<Button
|
||||
href={craftAppRepo(app)}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
{release && <AppHeroOtherDownloadsLink />}
|
||||
<p className="hud-label text-faint">
|
||||
{app.platforms.join(" · ")} · Free and open source
|
||||
</p>
|
||||
<ShareBar url={shareUrl} text={`${name}: ${app.pitch}`} />
|
||||
</div>
|
||||
</CampaignHero>
|
||||
|
||||
<CampaignSection
|
||||
id="highlights"
|
||||
index="02"
|
||||
label="Highlights"
|
||||
annotation={app.category}
|
||||
title={
|
||||
<>
|
||||
What's <ColorAccent>inside</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TipGrid items={app.features} columns={3} />
|
||||
</CampaignSection>
|
||||
|
||||
<AppGallery app={app} index="03" />
|
||||
<AppGetIt app={app} index="04" />
|
||||
<AppFamily app={app} index="05" />
|
||||
|
||||
<ClosingCta
|
||||
eyebrow="Open source · Free"
|
||||
title={
|
||||
<>
|
||||
Help shape <AppWordmark app={app} />.
|
||||
</>
|
||||
}
|
||||
lede={`Report bugs, request features and get early builds of ${name} in the ArtCraft Discord.`}
|
||||
>
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
</ClosingCta>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BotIcon,
|
||||
CodeXmlIcon,
|
||||
CpuIcon,
|
||||
GlobeIcon,
|
||||
HardDriveIcon,
|
||||
KeyboardIcon,
|
||||
} from "lucide-react";
|
||||
import AppCard from "@/components/apps/app-card";
|
||||
import ShareBar from "@/components/apps/share-bar";
|
||||
import {
|
||||
CampaignSection,
|
||||
ClosingCta,
|
||||
TipGrid,
|
||||
} from "@/components/campaign/sections";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { AppIcon, ColorAccent, appThemeClass } 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.`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
alternates: { canonical: "/apps" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/apps",
|
||||
siteName: "ArtCraft",
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
images: [{ url: CRAFTING_APPS_OG_IMAGE, width: 1200, height: 630 }],
|
||||
},
|
||||
};
|
||||
|
||||
// Order matches CRAFTING_APPS_PRINCIPLES.
|
||||
const PRINCIPLE_ICONS = [
|
||||
<CodeXmlIcon key="open" aria-hidden className="h-5 w-5" />,
|
||||
<CpuIcon key="native" aria-hidden className="h-5 w-5" />,
|
||||
<KeyboardIcon key="familiar" aria-hidden className="h-5 w-5" />,
|
||||
<HardDriveIcon key="local" aria-hidden className="h-5 w-5" />,
|
||||
<BotIcon key="agents" aria-hidden className="h-5 w-5" />,
|
||||
<GlobeIcon key="web" aria-hidden className="h-5 w-5" />,
|
||||
];
|
||||
|
||||
export default function CraftingAppsPage() {
|
||||
return (
|
||||
<>
|
||||
<RevealManager />
|
||||
|
||||
<PageHeader
|
||||
id="apps"
|
||||
index="01"
|
||||
label="Crafting Apps"
|
||||
annotation={`${CRAFTING_APPS_COUNT_WORD} apps · Open source · Pure Rust`}
|
||||
title={
|
||||
<>
|
||||
{CRAFTING_APPS_COUNT_WORD} apps. One <ColorAccent>craft</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
lede={`${COVERAGE}. Native, open-source apps from the ArtCraft team, built in Rust and free to use.`}
|
||||
>
|
||||
<div data-reveal className="mt-8 flex flex-wrap gap-3">
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
<Button
|
||||
href={CRAFTING_APPS_GITHUB_ORG}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
Browse on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
<ShareBar
|
||||
url={siteUrl("/apps")}
|
||||
text={`Crafting Apps: ${CRAFTING_APPS_COUNT_WORD.toLowerCase()} 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">
|
||||
<SectionEyebrow
|
||||
index="02"
|
||||
label="The lineup"
|
||||
annotation="Pick a craft to explore"
|
||||
/>
|
||||
<div
|
||||
data-reveal-group
|
||||
className="grid gap-px bg-line sm:grid-cols-2 xl:grid-cols-4"
|
||||
>
|
||||
{CRAFTING_APPS.map((app) => (
|
||||
<AppCard key={app.slug} app={app} index={craftAppIndex(app)} />
|
||||
))}
|
||||
<div data-reveal className="flex flex-col bg-bg p-6 md:p-8">
|
||||
<p className="hud-label text-faint">Next up</p>
|
||||
<h3 className="mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong">
|
||||
More crafts <ColorAccent>on the way</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-3 leading-relaxed text-muted">
|
||||
Tell us what to rebuild next, test early builds and follow
|
||||
development with the team in the ArtCraft Discord.
|
||||
</p>
|
||||
<div className="mt-auto pt-8">
|
||||
<DiscordButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
<CampaignSection
|
||||
id="principles"
|
||||
index="03"
|
||||
label="Principles"
|
||||
annotation="Shared by every craft"
|
||||
title={
|
||||
<>
|
||||
Built the <ColorAccent>hard</ColorAccent> way.
|
||||
</>
|
||||
}
|
||||
lede="Every Crafting App is written from scratch in Rust and held to the same rules."
|
||||
>
|
||||
<TipGrid
|
||||
items={CRAFTING_APPS_PRINCIPLES}
|
||||
columns={3}
|
||||
icons={PRINCIPLE_ICONS}
|
||||
/>
|
||||
</CampaignSection>
|
||||
|
||||
<ClosingCta
|
||||
eyebrow="Open source · Free"
|
||||
title={
|
||||
<>
|
||||
Build it <ColorAccent>with us</ColorAccent>.
|
||||
</>
|
||||
}
|
||||
lede="Early builds, roadmaps and the people making the Crafting Apps all live in the ArtCraft Discord."
|
||||
>
|
||||
<DiscordButton size="lg">Join the Discord</DiscordButton>
|
||||
</ClosingCta>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +313,34 @@ 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:
|
||||
`--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-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; }
|
||||
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
|
||||
:root[data-theme="dark"] .app-vectorcraft { --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; }
|
||||
@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-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; }
|
||||
}
|
||||
|
||||
/* Plan cell: a color wash pooling under the index tab and fading into the
|
||||
page surface; highlighted plans get a 2px frame in their color. */
|
||||
.plan-card {
|
||||
|
||||
@@ -9,13 +9,13 @@ 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";
|
||||
import { SITE_URL } from "@/lib/links";
|
||||
import "./globals.css";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
|
||||
// Display face: variable Archivo with its width axis loaded — headings run
|
||||
// slightly expanded (font-stretch) for the industrial-grotesque look, and
|
||||
// the hero wordmark renders the same family at its poster extreme
|
||||
@@ -139,6 +139,7 @@ export default function RootLayout({
|
||||
</MotionProvider>
|
||||
<TunerPanel />
|
||||
<LandingContext />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import RevealManager from "@/components/reveal-manager";
|
||||
import { SectionShell } from "@/components/landing/section-shell";
|
||||
import { Accent, PageHeader } from "@/components/page/page-header";
|
||||
import PressKitGallery from "@/components/press-kit/press-kit-gallery";
|
||||
import { Button } from "@/components/ui";
|
||||
import { SOCIAL_LINKS } from "@/lib/links";
|
||||
import { PRESS_CONTACT_PHONE } from "@/lib/press-kit-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -55,16 +53,9 @@ export default function PressKitPage() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
className="mt-8"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
<DiscordButton size="lg" className="mt-8">
|
||||
Contact us on Discord
|
||||
</Button>
|
||||
</DiscordButton>
|
||||
</div>
|
||||
</SectionShell>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { CRAFTING_APPS, craftAppPath } from "@/lib/crafting-apps";
|
||||
import { getFaqItems, getNewsPosts, getTutorialItems } from "@/lib/content";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
import { SITE_URL } from "@/lib/links";
|
||||
|
||||
type Entry = MetadataRoute.Sitemap[number];
|
||||
|
||||
@@ -19,6 +19,12 @@ const STATIC_ROUTES: { path: string; priority: number; changeFrequency: Entry["c
|
||||
{ path: "/seedance2-5", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/minimax-h3", priority: 0.8, changeFrequency: "weekly" },
|
||||
{ path: "/creators/jboogxcreative", priority: 0.6, changeFrequency: "monthly" },
|
||||
{ path: "/apps", priority: 0.8, changeFrequency: "weekly" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
path: craftAppPath(app),
|
||||
priority: 0.8,
|
||||
changeFrequency: "weekly" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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";
|
||||
|
||||
// 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.
|
||||
export default function AppCard({
|
||||
app,
|
||||
index,
|
||||
compact = false,
|
||||
}: {
|
||||
app: CraftApp;
|
||||
index: string;
|
||||
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),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 z-10 h-0.5 origin-left scale-x-0 bg-(--app) transition-transform duration-300 group-hover:scale-x-100"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-3">
|
||||
<AppTab>{index}</AppTab>
|
||||
<p className="hud-label truncate text-faint">{app.category}</p>
|
||||
</div>
|
||||
<div className="relative aspect-[16/10] overflow-hidden border-b border-line bg-bg-sunken">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={craftShotUrl(app, hero)}
|
||||
alt={hero.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
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"} />
|
||||
</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} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="hud-label mt-auto flex items-center gap-1.5 pt-6 text-muted group-hover:text-ink">
|
||||
Explore {craftAppName(app)}
|
||||
<ArrowUpRightIcon
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRightIcon, TerminalIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Badge, Button, CopyButton } from "@/components/ui";
|
||||
import { 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.
|
||||
|
||||
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.
|
||||
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
|
||||
index={index}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{shots.map((shot, i) => (
|
||||
<figure
|
||||
key={shot.file}
|
||||
data-reveal
|
||||
className={twMerge("bg-bg", leadFullWidth && 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]",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={craftShotUrl(app, shot)}
|
||||
alt={shot.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="hud-label absolute right-3 bottom-3 flex items-center gap-1 bg-bg/80 px-2 py-1 text-ink opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
Full size
|
||||
<ArrowUpRightIcon aria-hidden className="h-3 w-3" />
|
||||
</span>
|
||||
</a>
|
||||
<figcaption
|
||||
className={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",
|
||||
)}
|
||||
>
|
||||
<span className="hud-label text-muted">{shot.caption}</span>
|
||||
<span className="hud-label text-faint">
|
||||
{String(i + 2).padStart(2, "0")}
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Platform cards (your system first-class, every other build one click
|
||||
// away) above release details and build-from-source. Versions and files come
|
||||
// from crafting-app-releases.ts; a null release there drops the cards and
|
||||
// shows the Discord waitlist instead.
|
||||
export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
|
||||
const name = craftAppName(app);
|
||||
const command = craftAppBuildCommand(app);
|
||||
const desktop = app.platforms.filter((platform) => platform !== "Web");
|
||||
const release = craftAppRelease(app);
|
||||
const downloads = release ? craftReleaseDownloads(app, release) : [];
|
||||
const extras = downloads.filter((download) => download.group === "Other");
|
||||
|
||||
return (
|
||||
<SectionShell id="get-it">
|
||||
<SectionEyebrow
|
||||
index={index}
|
||||
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">Release</p>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Version <ColorAccent>{release.version}</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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="hud-label text-faint">Installers</p>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Installers are <ColorAccent>on the way</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-4 max-w-md leading-relaxed text-muted">
|
||||
Native installers for {formatList(desktop)} are coming. Join
|
||||
the ArtCraft Discord to get them first, try early builds and
|
||||
talk with the team building {name}.
|
||||
</p>
|
||||
<DiscordButton size="lg" className="mt-8">
|
||||
Get notified on Discord
|
||||
</DiscordButton>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 pt-10">
|
||||
{app.platforms.map((platform) => (
|
||||
<Badge key={platform} label={platform} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal className="flex min-w-0 flex-col bg-bg p-6 md:p-10">
|
||||
<p className="hud-label text-faint">Build from source</p>
|
||||
<h3 className={CELL_HEADING_CLASSES}>
|
||||
Run it <ColorAccent>today</ColorAccent>.
|
||||
</h3>
|
||||
<p className="mt-4 max-w-md leading-relaxed text-muted">
|
||||
Needs Rust {app.rustVersion} or newer. New to Rust? Install it with{" "}
|
||||
<a
|
||||
href="https://rustup.rs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink underline decoration-line-strong underline-offset-4 hover:decoration-current"
|
||||
>
|
||||
rustup
|
||||
</a>
|
||||
, then run:
|
||||
</p>
|
||||
<figure className="mt-6 border border-line bg-bg-sunken">
|
||||
<div className="flex items-center justify-between border-b border-line pl-4">
|
||||
<figcaption className="hud-label flex items-center gap-2 text-faint">
|
||||
<TerminalIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
Terminal
|
||||
</figcaption>
|
||||
<CopyButton
|
||||
value={command}
|
||||
variant="ghost"
|
||||
className="h-9 border-l border-line px-3"
|
||||
{...trackAttrs("copy_command", { app_name: app.slug })}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
</figure>
|
||||
<Button
|
||||
href={craftAppRepo(app)}
|
||||
variant="secondary"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-8"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
View source on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return (
|
||||
<SectionShell id="family">
|
||||
<SectionEyebrow index={index} label="The family" annotation="Crafting Apps" />
|
||||
<div className="flex flex-col gap-4 px-6 py-12 md:flex-row md:items-end md:justify-between md:px-10 md:py-16">
|
||||
<h2
|
||||
data-reveal
|
||||
className="max-w-2xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl"
|
||||
>
|
||||
More from the <ColorAccent>family</ColorAccent>.
|
||||
</h2>
|
||||
<Link
|
||||
href="/apps"
|
||||
className="hud-label flex items-center gap-1.5 text-muted hover:text-ink"
|
||||
>
|
||||
All Crafting Apps
|
||||
<ArrowUpRightIcon aria-hidden className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
data-reveal-group
|
||||
className="grid gap-px border-t border-line bg-line sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{siblings.map((sibling) => (
|
||||
<AppCard
|
||||
key={sibling.slug}
|
||||
app={sibling}
|
||||
index={craftAppIndex(sibling)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Scopes the app's color identity (`--app`, `--app-ink` in globals.css).
|
||||
export function appThemeClass(app: CraftApp): string {
|
||||
return `app-${app.slug}`;
|
||||
}
|
||||
|
||||
// Text wordmark: the display face throughout, "Craft" picked out in the
|
||||
// app's ink color. Needs an appThemeClass ancestor (or pass `themed`).
|
||||
export function AppWordmark({
|
||||
app,
|
||||
themed = false,
|
||||
className,
|
||||
}: {
|
||||
app: CraftApp;
|
||||
themed?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"font-display font-medium tracking-[-0.03em] text-ink-strong",
|
||||
themed && appThemeClass(app),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{app.prefix}
|
||||
<ColorAccent>Craft</ColorAccent>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The Crafting Apps pages' contrast word: same face as the headline, set in
|
||||
// the app's ink color (the site accent outside an app scope) instead of the
|
||||
// serif italic used elsewhere on the site.
|
||||
export function ColorAccent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-(--app-ink,var(--accent-ink))">{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Saturated index tab in the app's color (black text passes AA on every app).
|
||||
export function AppTab({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="hud-label bg-(--app) px-2 py-1 font-bold text-black">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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
|
||||
// on narrow screens. Intent URLs need no SDKs or tracking scripts.
|
||||
const TARGETS: { name: string; href: (url: string, text: string) => string }[] = [
|
||||
{
|
||||
name: "X",
|
||||
href: (url, text) =>
|
||||
`https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
|
||||
},
|
||||
{
|
||||
name: "Reddit",
|
||||
href: (url, text) =>
|
||||
`https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`,
|
||||
},
|
||||
{
|
||||
name: "Bluesky",
|
||||
href: (url, text) =>
|
||||
`https://bsky.app/intent/compose?text=${encodeURIComponent(`${text} ${url}`)}`,
|
||||
},
|
||||
{
|
||||
name: "LinkedIn",
|
||||
href: (url) =>
|
||||
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||||
},
|
||||
];
|
||||
|
||||
// Copy and network cells share one ghost-button treatment.
|
||||
const CELL_CLASSES = "h-9 bg-bg px-3";
|
||||
|
||||
export default function ShareBar({
|
||||
url,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
/** Absolute URL to share. */
|
||||
url: string;
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-fit flex-wrap gap-px border border-line bg-line",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="hud-label flex h-9 items-center bg-bg px-3 text-faint">
|
||||
Share
|
||||
</span>
|
||||
<CopyButton
|
||||
value={url}
|
||||
label="Copy link"
|
||||
copiedLabel="Link copied"
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
|
||||
/>
|
||||
{TARGETS.map((target) => (
|
||||
<Button
|
||||
key={target.name}
|
||||
href={target.href(url, text)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Share on ${target.name}`}
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
|
||||
>
|
||||
{target.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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.
|
||||
@@ -83,6 +84,7 @@ 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,6 +3,7 @@
|
||||
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,
|
||||
@@ -118,8 +119,9 @@ function SignupForm({
|
||||
maybeReferralCode: getReferralCode(),
|
||||
});
|
||||
setLoading(false);
|
||||
if (result.success) onSuccess();
|
||||
else setError(result.errorMessage || "Failed to create account");
|
||||
if (!result.success) return setError(result.errorMessage || "Failed to create account");
|
||||
trackEvent("sign_up", { method: "email", signup_source: signupSource });
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -162,8 +164,9 @@ function LoginForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
setLoading(true);
|
||||
const result = await login({ usernameOrEmail, password });
|
||||
setLoading(false);
|
||||
if (result.success) onSuccess();
|
||||
else setError(result.errorMessage || "Invalid credentials");
|
||||
if (!result.success) return setError(result.errorMessage || "Invalid credentials");
|
||||
trackEvent("login", { method: "email" });
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,25 +7,36 @@ export default function CampaignHero({
|
||||
id,
|
||||
label,
|
||||
annotation,
|
||||
kicker,
|
||||
title,
|
||||
lede,
|
||||
children,
|
||||
media,
|
||||
mediaCaption,
|
||||
mediaTag = "Reel",
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
annotation?: string;
|
||||
/** Small line above the headline (e.g. a product wordmark). */
|
||||
kicker?: ReactNode;
|
||||
title: ReactNode;
|
||||
lede: ReactNode;
|
||||
children?: ReactNode;
|
||||
media?: ReactNode;
|
||||
mediaCaption?: string;
|
||||
/** Right-hand label on the media strip. */
|
||||
mediaTag?: string;
|
||||
}) {
|
||||
return (
|
||||
<SectionShell id={id}>
|
||||
<SectionEyebrow index="01" label={label} annotation={annotation} />
|
||||
<div data-reveal-group className="flex flex-col items-center px-6 py-14 text-center md:px-10 md:py-20">
|
||||
{kicker && (
|
||||
<div data-reveal className="mb-6">
|
||||
{kicker}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
data-reveal
|
||||
className="max-w-4xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl lg:text-7xl"
|
||||
@@ -45,7 +56,7 @@ export default function CampaignHero({
|
||||
<figure className="border-t border-line">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2 md:px-10">
|
||||
<figcaption className="hud-label text-faint">{mediaCaption}</figcaption>
|
||||
<p className="hud-label text-faint">Reel</p>
|
||||
<p className="hud-label text-faint">{mediaTag}</p>
|
||||
</div>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
|
||||
{media}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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
|
||||
@@ -69,6 +70,11 @@ 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,6 +9,7 @@ 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";
|
||||
@@ -101,6 +102,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -114,12 +116,15 @@ 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);
|
||||
}
|
||||
@@ -136,6 +141,14 @@ 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDownIcon, InfoIcon } from "lucide-react";
|
||||
import { GitHubIcon, DiscordIcon } from "@/components/icons";
|
||||
import DiscordButton from "@/components/discord-button";
|
||||
import { GitHubIcon } from "@/components/icons";
|
||||
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
|
||||
import { Accent } from "@/components/page/page-header";
|
||||
import { Button } from "@/components/ui";
|
||||
@@ -161,10 +162,7 @@ export function CommunityCta({ id = "community" }: { id?: string }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button href={SOCIAL_LINKS.DISCORD} target="_blank" rel="noopener noreferrer">
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
Join Discord
|
||||
</Button>
|
||||
<DiscordButton />
|
||||
<Button
|
||||
href={SOCIAL_LINKS.GITHUB}
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import { Button, type ButtonProps } from "@/components/ui";
|
||||
import { SOCIAL_LINKS } from "@/lib/links";
|
||||
|
||||
// The site-wide "come talk to us" CTA: always the ArtCraft Discord, always
|
||||
// a new tab.
|
||||
export default function DiscordButton({
|
||||
children = "Join Discord",
|
||||
...rest
|
||||
}: Omit<ButtonProps, "href" | "target" | "rel" | "external">) {
|
||||
return (
|
||||
<Button
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...rest}
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
Controllable AI{" "}
|
||||
Capable tools{" "}
|
||||
<span className="font-serif italic font-normal text-muted">
|
||||
for artists.
|
||||
</span>
|
||||
@@ -46,9 +46,8 @@ export default function Hero() {
|
||||
data-reveal
|
||||
className="relative mt-4 max-w-md text-lg leading-relaxed text-muted"
|
||||
>
|
||||
Artists need and deserve unparalleled control and precision.
|
||||
ArtCraft’s got you covered — compose in real 3D, then
|
||||
render with AI.
|
||||
ArtCraft builds open source tools for artists. We pride ourselves
|
||||
on freedom of control and expression.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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.
|
||||
@@ -32,6 +33,11 @@ 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,6 +4,7 @@ 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";
|
||||
|
||||
@@ -35,6 +36,7 @@ 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();
|
||||
@@ -102,7 +104,10 @@ 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")}>
|
||||
onClick={() => {
|
||||
copy(prompt.maybe_positive_prompt!, "prompt");
|
||||
trackEvent("copy_prompt", { media_type: kind });
|
||||
}}>
|
||||
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
@@ -143,7 +148,10 @@ 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")}>
|
||||
<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 });
|
||||
}}>
|
||||
{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,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Lenis from "lenis";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
@@ -41,5 +42,44 @@ export default function MotionProvider({
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -148,6 +149,10 @@ 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>
|
||||
@@ -175,6 +180,7 @@ 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,6 +3,7 @@
|
||||
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";
|
||||
@@ -28,7 +29,10 @@ export default function CreditsCta() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
trackEvent("view_credit_packs", {});
|
||||
}}
|
||||
className="mt-6"
|
||||
>
|
||||
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 = {
|
||||
@@ -35,6 +36,20 @@ 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,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Badge, Button, TabSelector } from "@/components/ui";
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
import {
|
||||
getPortalUrl,
|
||||
subscriptionCheckout,
|
||||
@@ -85,9 +86,26 @@ 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(
|
||||
!user
|
||||
checkoutType === "signup"
|
||||
? await userSignupSubscriptionCheckout({
|
||||
...body,
|
||||
maybeReferralUrl: getReferrer(),
|
||||
@@ -95,7 +113,7 @@ export default function PricingTable({
|
||||
maybeReferralUsername: getReferralUsername(),
|
||||
maybeReferralCode: getReferralCode(),
|
||||
})
|
||||
: hasActivePlan
|
||||
: checkoutType === "switch"
|
||||
? await switchPlan(body)
|
||||
: await subscriptionCheckout(body),
|
||||
);
|
||||
@@ -103,6 +121,7 @@ export default function PricingTable({
|
||||
};
|
||||
|
||||
const managePlan = async () => {
|
||||
trackEvent("manage_subscription", {});
|
||||
setManaging(true);
|
||||
setError(null);
|
||||
if (!redirect(await getPortalUrl())) setManaging(false);
|
||||
@@ -115,7 +134,11 @@ export default function PricingTable({
|
||||
<TabSelector
|
||||
tabs={BILLING_TABS}
|
||||
activeTab={cadence}
|
||||
onTabChange={(id) => setCadence(id as BillingCadence)}
|
||||
onTabChange={(id) => {
|
||||
if (id === cadence) return;
|
||||
setCadence(id as BillingCadence);
|
||||
trackEvent("billing_cadence_change", { cadence: id });
|
||||
}}
|
||||
tabClassName="w-24"
|
||||
/>
|
||||
<Badge
|
||||
@@ -363,6 +386,7 @@ 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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { CRAFTING_APPS, craftAppName, craftAppPath } from "@/lib/crafting-apps";
|
||||
import { SOCIAL_LINKS, SUPPORT_EMAIL, WEBAPP_URL } from "@/lib/links";
|
||||
|
||||
const LINK_COLUMNS: {
|
||||
@@ -13,6 +14,16 @@ const LINK_COLUMNS: {
|
||||
{ name: "Launch App", href: WEBAPP_URL, external: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Craft Apps",
|
||||
links: [
|
||||
{ name: "All Craft Apps", href: "/apps" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
name: craftAppName(app),
|
||||
href: craftAppPath(app),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Resources",
|
||||
links: [
|
||||
@@ -39,8 +50,8 @@ export default function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-line">
|
||||
<div className="mx-auto max-w-[1280px] border-x border-line">
|
||||
<div className="grid gap-px bg-line md:grid-cols-[2fr_1fr_1fr_1fr]">
|
||||
<div className="bg-bg p-6 md:p-10">
|
||||
<div className="grid gap-px bg-line md:grid-cols-4 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr]">
|
||||
<div className="bg-bg p-6 md:col-span-4 md:p-10 lg:col-span-1">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt="ArtCraft"
|
||||
|
||||
@@ -7,10 +7,13 @@ import * as NavigationMenu from "@radix-ui/react-navigation-menu";
|
||||
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SOCIAL_LINKS, WEBAPP_URL, webappUrl } from "@/lib/links";
|
||||
import { DiscordIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import ThemeToggle from "./theme-toggle";
|
||||
|
||||
type NavLeaf = { name: string; href: string };
|
||||
// `dividerBefore` sets a hairline before the entry, separating ArtCraft
|
||||
// product links from the rest of the family.
|
||||
type NavLeaf = { name: string; href: string; dividerBefore?: boolean };
|
||||
type NavGroup = { name: string; href?: string; children: NavLeaf[] };
|
||||
type NavEntry = NavLeaf | NavGroup;
|
||||
|
||||
@@ -29,6 +32,7 @@ const NAV_ITEMS: NavEntry[] = [
|
||||
},
|
||||
{ name: "Download", href: "/download" },
|
||||
{ name: "Support", href: "/support" },
|
||||
{ name: "Craft Apps", href: "/apps", dividerBefore: true },
|
||||
];
|
||||
|
||||
// Marathon-style: the inversion highlight sits on the label only, not the cell.
|
||||
@@ -104,7 +108,10 @@ export default function SiteNav() {
|
||||
return (
|
||||
<NavigationMenu.Item
|
||||
key={entry.name}
|
||||
className="flex items-stretch"
|
||||
className={twMerge(
|
||||
"flex items-stretch",
|
||||
entry.dividerBefore && "ml-1.5 border-l border-line pl-1.5",
|
||||
)}
|
||||
>
|
||||
<NavigationMenu.Link asChild>
|
||||
{isExternalHref(entry.href) ? (
|
||||
@@ -194,6 +201,16 @@ export default function SiteNav() {
|
||||
>
|
||||
<span className={NAV_LABEL_CLASSES}>Pricing</span>
|
||||
</Link>
|
||||
<a
|
||||
href={SOCIAL_LINKS.DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Join the ArtCraft Discord"
|
||||
title="Join the ArtCraft Discord"
|
||||
className="flex w-12 items-center justify-center border-l border-line text-muted hover:bg-invert-bg hover:text-invert-fg"
|
||||
>
|
||||
<DiscordIcon className="h-4 w-4" />
|
||||
</a>
|
||||
<ThemeToggle className="w-12 border-l border-line" />
|
||||
<Button
|
||||
href={WEBAPP_URL}
|
||||
@@ -238,6 +255,7 @@ export default function SiteNav() {
|
||||
const leafClassName = twMerge(
|
||||
DROPDOWN_ITEM_CLASSES,
|
||||
"py-3",
|
||||
entry.dividerBefore && "border-t border-line",
|
||||
isCurrent ? "text-ink" : "text-muted active:text-ink",
|
||||
);
|
||||
const leafLabel = (
|
||||
|
||||
@@ -86,6 +86,7 @@ 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>;
|
||||
@@ -96,6 +97,7 @@ export function Button({
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={classes}
|
||||
data-cta=""
|
||||
{...anchorProps}
|
||||
>
|
||||
{content}
|
||||
@@ -103,7 +105,7 @@ export function Button({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={classes} {...anchorProps}>
|
||||
<Link href={href} className={classes} data-cta="" {...anchorProps}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button, type ButtonProps } from "./button";
|
||||
|
||||
const FEEDBACK_MS = 1500;
|
||||
|
||||
type CopyState = "idle" | "copied" | "failed";
|
||||
|
||||
export interface CopyButtonProps
|
||||
extends Omit<ButtonProps, "onClick" | "href" | "children"> {
|
||||
value: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
// Button that writes `value` to the clipboard and confirms inline. The
|
||||
// label snaps back after a beat; repeat clicks restart the timer.
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
copiedLabel = "Copied",
|
||||
...rest
|
||||
}: CopyButtonProps) {
|
||||
const [state, setState] = useState<CopyState>("idle");
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
async function copy() {
|
||||
let next: CopyState = "copied";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
next = "failed";
|
||||
}
|
||||
setState(next);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setState("idle"), FEEDBACK_MS);
|
||||
}
|
||||
|
||||
const Icon = state === "copied" ? CheckIcon : CopyIcon;
|
||||
const text =
|
||||
state === "copied" ? copiedLabel : state === "failed" ? "Copy blocked" : label;
|
||||
|
||||
return (
|
||||
<Button onClick={copy} {...rest}>
|
||||
<Icon aria-hidden className="h-3.5 w-3.5" />
|
||||
<span aria-live="polite">{text}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyButton;
|
||||
@@ -5,6 +5,7 @@ export { Badge, type BadgeProps } from "./badge";
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
export { Checkbox, type CheckboxProps } from "./checkbox";
|
||||
export { CloseButton, type CloseButtonProps } from "./close-button";
|
||||
export { CopyButton, type CopyButtonProps } from "./copy-button";
|
||||
export { Input, type InputProps } from "./input";
|
||||
export { Label, type LabelProps } from "./label";
|
||||
export { Modal, type ModalProps } from "./modal";
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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\./, "");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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"),
|
||||
};
|
||||
@@ -0,0 +1,674 @@
|
||||
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,
|
||||
// each in its own storytold/<slug> repo. One entry drives the /apps hub card,
|
||||
// the /apps/<slug> page, the nav, footer, sitemap, ruler and press kit.
|
||||
//
|
||||
// Copy rule: describe the category, never name third-party products.
|
||||
// Facts (counts, timings, formats) come from each repo's README — keep them
|
||||
// in sync when the READMEs change.
|
||||
//
|
||||
// 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.
|
||||
|
||||
export type CraftAppSlug =
|
||||
| "photocraft"
|
||||
| "vectorcraft"
|
||||
| "filmcraft"
|
||||
| "lightcraft"
|
||||
| "pdfcraft"
|
||||
| "effectcraft"
|
||||
| "designcraft";
|
||||
|
||||
export type CraftPlatform = "macOS" | "Windows" | "Linux" | "Web";
|
||||
|
||||
export type CraftShot = {
|
||||
/** File under public/images/apps/<slug>/. */
|
||||
file: string;
|
||||
/** Full-resolution original in the repo's docs/images/ (press kit). */
|
||||
source: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
/** 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: string;
|
||||
category: string;
|
||||
status: string;
|
||||
/** Headline split around its one color-accented contrast word. */
|
||||
headline: [before: string, accent: string, after: string];
|
||||
/** One line for cards and share text. */
|
||||
pitch: string;
|
||||
lede: string;
|
||||
/** schema.org applicationCategory. */
|
||||
schemaCategory: "DesignApplication" | "MultimediaApplication" | "BusinessApplication";
|
||||
platforms: CraftPlatform[];
|
||||
rustVersion: string;
|
||||
features: Tip[];
|
||||
/** First shot is the hero. */
|
||||
shots: [CraftShot, ...CraftShot[]];
|
||||
};
|
||||
|
||||
/** One release file, resolved to a URL for a specific app. */
|
||||
export type CraftDownload = Omit<CraftReleaseAsset, "file"> & {
|
||||
fileName: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const CRAFTING_APPS_GITHUB_ORG = "https://github.com/storytold";
|
||||
|
||||
export const CRAFTING_APPS: CraftApp[] = [
|
||||
{
|
||||
slug: "photocraft",
|
||||
prefix: "Photo",
|
||||
category: "Image editor",
|
||||
status: "Early alpha",
|
||||
headline: ["The image editor you already ", "know", " how to use."],
|
||||
pitch: "The open-source image editor you already know how to use.",
|
||||
lede: "Layers, masks, adjustment layers, layer styles, type, vectors and brushes in a native app written entirely in Rust. Open source, offline, and yours.",
|
||||
schemaCategory: "DesignApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Familiar by design",
|
||||
body: "The menus, shortcuts, panels and tools your hands already know. Productive on day one, no retraining.",
|
||||
},
|
||||
{
|
||||
title: "Native GPU canvas",
|
||||
body: "Renders on Metal, Vulkan, DirectX 12 and WebGPU. No Electron and no web view, just a fast native compositor.",
|
||||
},
|
||||
{
|
||||
title: "Real layered files",
|
||||
body: "Opens and saves layered PSD and PSB documents. 134 of 135 real-world test files round-trip byte for byte.",
|
||||
},
|
||||
{
|
||||
title: "Non-destructive editing",
|
||||
body: "16 adjustment layers, 70+ live-preview filters, smart objects with smart filters, and a full set of layer styles.",
|
||||
},
|
||||
{
|
||||
title: "On-device selection",
|
||||
body: "Select subjects and objects and refine masks with tools that run on your machine, not someone else's server.",
|
||||
},
|
||||
{
|
||||
title: "Serious color",
|
||||
body: "8, 16 and 32-bit documents in RGB, CMYK, Lab and Grayscale, with ICC color management and soft proofing.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "photocraft-demo.jpg",
|
||||
caption: "Curves and type layers — The Great Wave",
|
||||
alt: "PhotoCraft editing The Great Wave off Kanagawa with a Curves adjustment and type layers in the Layers panel",
|
||||
},
|
||||
{
|
||||
file: "adjustments.webp",
|
||||
source: "photocraft-adjustments.jpg",
|
||||
caption: "Levels with a live histogram",
|
||||
alt: "PhotoCraft applying a Levels adjustment layer to Impression, Sunrise with the histogram panel open",
|
||||
},
|
||||
{
|
||||
file: "layer-styles.webp",
|
||||
source: "photocraft-layer-styles.jpg",
|
||||
caption: "Layer styles on live type",
|
||||
alt: "PhotoCraft's Layer Style dialog adding an outer glow and stroke to the word Earthrise",
|
||||
},
|
||||
{
|
||||
file: "masks.webp",
|
||||
source: "photocraft-masks.jpg",
|
||||
caption: "Selections and masked adjustments",
|
||||
alt: "PhotoCraft with an elliptical selection and a Hue/Saturation adjustment on Girl with a Pearl Earring",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "vectorcraft",
|
||||
prefix: "Vector",
|
||||
category: "Vector illustration",
|
||||
status: "In development",
|
||||
headline: ["Vector illustration, ", "reimagined", " in pure Rust."],
|
||||
pitch: "Fast, open-source vector illustration, reimagined in pure Rust.",
|
||||
lede: "The pen, panels and shortcuts working illustrators expect, in a native app that keeps up with you. Open source, and in your browser too.",
|
||||
schemaCategory: "DesignApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "The workflow you know",
|
||||
body: "Pen, direct selection, shape booleans, snapping guides and an appearance panel, laid out the way you expect.",
|
||||
},
|
||||
{
|
||||
title: "Genuinely fast",
|
||||
body: "20,000 shapes render in about 27 ms at retina resolution, and the interface runs at 120 fps.",
|
||||
},
|
||||
{
|
||||
title: "Booleans that never fail",
|
||||
body: "Exact curve booleans mean no more “cannot perform operation”. Unlimited undo means no more fear.",
|
||||
},
|
||||
{
|
||||
title: "Live, editable effects",
|
||||
body: "Blends, envelope distort, gradient mesh, live radial, grid and mirror repeats, glows and drop shadows — all still editable.",
|
||||
},
|
||||
{
|
||||
title: "Image tracing",
|
||||
body: "Turn raster images into clean vector paths with 12 tracing presets.",
|
||||
},
|
||||
{
|
||||
title: "Open formats",
|
||||
body: "Native JSON documents, SVG and PDF import and export, PNG, JPEG and WebP output, and multi-size export for screens.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "shot-1-neon.png",
|
||||
caption: "Neon Drive — made in 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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "filmcraft",
|
||||
prefix: "Film",
|
||||
category: "Video editor",
|
||||
status: "In development",
|
||||
headline: ["Professional video editing, ", "rebuilt", " from scratch."],
|
||||
pitch: "Professional, open-source video editing, rebuilt from scratch in Rust.",
|
||||
lede: "Source and program monitors, a real timeline and every trim mode, in a native editor written in pure Rust — down to its own codecs.",
|
||||
schemaCategory: "MultimediaApplication",
|
||||
platforms: ["macOS", "Windows", "Linux"],
|
||||
rustVersion: "1.95",
|
||||
features: [
|
||||
{
|
||||
title: "A timeline you already know",
|
||||
body: "Source and program monitors, three-point editing, every trim mode, and J/K/L dynamic trimming.",
|
||||
},
|
||||
{
|
||||
title: "Frame-exact time",
|
||||
body: "An integer timebase keeps 23.976 and 29.97 drop-frame exact. No drift, no rounding, no mystery frames.",
|
||||
},
|
||||
{
|
||||
title: "Its own codecs",
|
||||
body: "No FFmpeg inside. Native H.264, HEVC, ProRes, VP9, AAC and Opus, with bit-exact decoders.",
|
||||
},
|
||||
{
|
||||
title: "Color grading",
|
||||
body: "Curves, color wheels, HSL secondaries, LUTs, HDR and log workflows, plus scopes to check your work.",
|
||||
},
|
||||
{
|
||||
title: "Effects and keyframes",
|
||||
body: "About 55 video effects and 30 transitions, keyframes with value and velocity graphs, and a linear-light GPU compositor.",
|
||||
},
|
||||
{
|
||||
title: "Broadcast-ready audio",
|
||||
body: "EBU R128 loudness meters, a mixer with automation, and native DSP effects.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "color.webp",
|
||||
source: "filmcraft-color.png",
|
||||
caption: "Scopes and timeline — Charade (1963)",
|
||||
alt: "FilmCraft with video scopes, the program monitor showing Charade (1963), and a multi-track timeline",
|
||||
},
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "filmcraft-hero.png",
|
||||
caption: "Effect controls — Night of the Living Dead (1968)",
|
||||
alt: "FilmCraft's effect controls and timeline editing Night of the Living Dead (1968)",
|
||||
},
|
||||
{
|
||||
file: "keyframes.webp",
|
||||
source: "filmcraft-keyframes.png",
|
||||
caption: "Keyframes with value and velocity graphs",
|
||||
alt: "FilmCraft keyframing scale with value and velocity graphs on a title shot",
|
||||
},
|
||||
{
|
||||
file: "export.webp",
|
||||
source: "filmcraft-export.png",
|
||||
caption: "Export with destination presets",
|
||||
alt: "FilmCraft export screen with H.264 settings and destination presets",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "lightcraft",
|
||||
prefix: "Light",
|
||||
category: "Photo library & raw developer",
|
||||
status: "In development",
|
||||
headline: ["Your photos. Your pixels. Your ", "machine", "."],
|
||||
pitch: "An open-source photo library and raw developer that runs on your machine.",
|
||||
lede: "A fast photo library and non-destructive raw developer, written from scratch in pure Rust. No account, no cloud, no telemetry, no subscription.",
|
||||
schemaCategory: "MultimediaApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Non-destructive by design",
|
||||
body: "A scene-referred, wide-gamut, 32-bit float pipeline. Your originals are never touched.",
|
||||
},
|
||||
{
|
||||
title: "Every control you reach for",
|
||||
body: "Light, color, effects, tone curve, an 8-band color mixer, 3-way color grading wheels and 18 built-in presets.",
|
||||
},
|
||||
{
|
||||
title: "Precise masking",
|
||||
body: "Brush, linear and radial gradients, luminance and color ranges, plus sky, subject and background masks.",
|
||||
},
|
||||
{
|
||||
title: "Native raw decoding",
|
||||
body: "Pure-Rust decoders for DNG, CR2, ARW, NEF, RAF (including X-Trans), RW2, PEF and ORF, with more on the way.",
|
||||
},
|
||||
{
|
||||
title: "A library that keeps up",
|
||||
body: "Albums, ratings, flags, field search and virtualized grids built for big catalogs.",
|
||||
},
|
||||
{
|
||||
title: "Instant feedback",
|
||||
body: "The GPU develop pipeline updates a 24 MP raw in about 4 ms per slider move.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "hero-tetons.jpg",
|
||||
caption: "Develop — The Tetons and the Snake River",
|
||||
alt: "LightCraft developing Ansel Adams' The Tetons and the Snake River with light and effects sliders",
|
||||
},
|
||||
{
|
||||
file: "grading.webp",
|
||||
source: "grading-migrant-mother.jpg",
|
||||
caption: "Color grading wheels — Migrant Mother",
|
||||
alt: "LightCraft color grading wheels applied to Dorothea Lange's Migrant Mother",
|
||||
},
|
||||
{
|
||||
file: "masking.webp",
|
||||
source: "masking.jpg",
|
||||
caption: "Sky and radial gradient masks",
|
||||
alt: "LightCraft masking panel with a radial gradient mask warming a sunset sky",
|
||||
},
|
||||
{
|
||||
file: "library.webp",
|
||||
source: "grid-demo.jpg",
|
||||
caption: "Library with albums and ratings",
|
||||
alt: "LightCraft library grid with albums, ratings and the develop panel",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "pdfcraft",
|
||||
prefix: "Pdf",
|
||||
category: "PDF workbench",
|
||||
status: "Early alpha",
|
||||
headline: ["The ", "open-source", " PDF workbench."],
|
||||
pitch: "The open-source PDF workbench: read, organize, combine, split and secure.",
|
||||
lede: "Read, organize, combine, split and secure PDFs in a fast, native app written in Rust from the ground up. No account, no telemetry, no cloud.",
|
||||
schemaCategory: "BusinessApplication",
|
||||
platforms: ["macOS", "Windows", "Linux", "Web"],
|
||||
rustVersion: "1.90",
|
||||
features: [
|
||||
{
|
||||
title: "Faithful rendering",
|
||||
body: "World scripts, vertical Japanese, color emoji and transparency. Zero crashes across a 983-file test corpus.",
|
||||
},
|
||||
{
|
||||
title: "Search as you type",
|
||||
body: "Instant find, and text selection that follows the document's real reading order.",
|
||||
},
|
||||
{
|
||||
title: "Organize like cards",
|
||||
body: "Rotate, delete, insert and reorder pages, with deep undo behind every change.",
|
||||
},
|
||||
{
|
||||
title: "Combine, extract, split",
|
||||
body: "Assemble and break apart documents while keeping links, form fields, layers and bookmarks intact.",
|
||||
},
|
||||
{
|
||||
title: "Fearless saves",
|
||||
body: "Incremental, atomic saves with autosave and crash recovery.",
|
||||
},
|
||||
{
|
||||
title: "Built-in security",
|
||||
body: "Opens everything from RC4 to AES-256 encryption and honors author permissions.",
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "pdfcraft-viewer.png",
|
||||
caption: "Viewer with threaded comments",
|
||||
alt: "PdfCraft showing a showcase PDF cover with the All tools panel and threaded comments",
|
||||
},
|
||||
{
|
||||
file: "organize.webp",
|
||||
source: "pdfcraft-organize.png",
|
||||
caption: "Organize pages like cards",
|
||||
alt: "PdfCraft page organizer with three pages selected and the page tools in the toolbar above",
|
||||
},
|
||||
{
|
||||
file: "palette.webp",
|
||||
source: "pdfcraft-palette.png",
|
||||
caption: "A command palette for every tool",
|
||||
alt: "PdfCraft command palette searching for page and listing the page tools",
|
||||
},
|
||||
{
|
||||
file: "twoup.webp",
|
||||
source: "pdfcraft-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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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. */
|
||||
export function formatList(items: string[]): string {
|
||||
return LIST_FORMAT.format(items);
|
||||
}
|
||||
|
||||
const BROWSER_APP_NAMES = CRAFTING_APPS.filter((app) =>
|
||||
app.platforms.includes("Web"),
|
||||
).map(craftAppName);
|
||||
|
||||
// Family-wide rules, shown on the /apps hub.
|
||||
export const CRAFTING_APPS_PRINCIPLES: Tip[] = [
|
||||
{
|
||||
title: "Open source",
|
||||
body: "Every line is on GitHub under permissive licenses. Read it, fork it, build on it.",
|
||||
},
|
||||
{
|
||||
title: "Native, not wrapped",
|
||||
body: "Pure Rust compiled to real desktop apps for macOS, Windows and Linux. No Electron, no web views.",
|
||||
},
|
||||
{
|
||||
title: "Familiar from day one",
|
||||
body: "Layouts, tools and shortcuts that working professionals already know, so there's nothing to relearn.",
|
||||
},
|
||||
{
|
||||
title: "Your files, your machine",
|
||||
body: "Everything runs locally on your own files. No cloud round-trips between you and your work.",
|
||||
},
|
||||
{
|
||||
title: "Agent-ready",
|
||||
body: "Drive every app from a CLI, a JSON control channel or an MCP server, built for automation and AI agents.",
|
||||
},
|
||||
{
|
||||
title: "Also in your browser",
|
||||
body: `${formatList(BROWSER_APP_NAMES)} also compile to WebAssembly and run in a browser tab.`,
|
||||
},
|
||||
];
|
||||
|
||||
export const CRAFTING_APPS_OG_IMAGE = mediaUrl("/images/apps/og.jpg");
|
||||
|
||||
export function getCraftApp(slug: string): CraftApp | undefined {
|
||||
return CRAFTING_APPS.find((app) => app.slug === slug);
|
||||
}
|
||||
|
||||
export function craftAppName(app: CraftApp): string {
|
||||
return `${app.prefix}Craft`;
|
||||
}
|
||||
|
||||
/** Two-digit lineup position, e.g. "03". */
|
||||
export function craftAppIndex(app: CraftApp): string {
|
||||
return String(CRAFTING_APPS.indexOf(app) + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function craftAppPath(app: CraftApp): string {
|
||||
return `/apps/${app.slug}`;
|
||||
}
|
||||
|
||||
export function craftAppRepo(app: CraftApp): string {
|
||||
return `${CRAFTING_APPS_GITHUB_ORG}/${app.slug}`;
|
||||
}
|
||||
|
||||
/** Site-relative path; use craftShotUrl for an <img> src. */
|
||||
export function craftShotPath(app: CraftApp, shot: CraftShot): string {
|
||||
return `/images/apps/${app.slug}/${shot.file}`;
|
||||
}
|
||||
|
||||
export function craftShotUrl(app: CraftApp, shot: CraftShot): string {
|
||||
return mediaUrl(craftShotPath(app, shot));
|
||||
}
|
||||
|
||||
export function craftShotSourceUrl(app: CraftApp, shot: CraftShot): string {
|
||||
return `https://raw.githubusercontent.com/storytold/${app.slug}/main/docs/images/${shot.source}`;
|
||||
}
|
||||
|
||||
export function 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`);
|
||||
}
|
||||
|
||||
export function craftAppBuildCommand(app: CraftApp): string {
|
||||
return [
|
||||
`git clone ${craftAppRepo(app)}.git`,
|
||||
`cd ${app.slug}`,
|
||||
`cargo run --release -p ${app.slug}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CRAFTING_APPS, craftAppName } from "./crafting-apps";
|
||||
import { cdnMediaUrl, mediaUrl } from "./links";
|
||||
|
||||
// Product feature roster, ported from the shipping landing page. Copy is the
|
||||
@@ -120,9 +121,20 @@ export const RULER_SECTIONS: RulerSection[] = [
|
||||
{ id: "seedance-2", label: "SEEDANCE 2.0" },
|
||||
{ id: "seedance-2-5", label: "SEEDANCE 2.5" },
|
||||
{ id: "minimax-h3", label: "MINIMAX H3" },
|
||||
// Crafting Apps: the /apps hub header and each /apps/<slug> hero.
|
||||
{ id: "apps", label: "CRAFTING APPS" },
|
||||
...CRAFTING_APPS.map((app) => ({
|
||||
id: app.slug,
|
||||
label: craftAppName(app).toUpperCase(),
|
||||
})),
|
||||
{ id: "creator", label: "SPOTLIGHT" },
|
||||
{ id: "overview", label: "OVERVIEW" },
|
||||
{ id: "highlights", label: "HIGHLIGHTS" },
|
||||
{ id: "lineup", label: "LINEUP" },
|
||||
{ id: "principles", label: "PRINCIPLES" },
|
||||
{ id: "gallery", label: "SCREENSHOTS" },
|
||||
{ id: "get-it", label: "GET IT" },
|
||||
{ id: "family", label: "THE FAMILY" },
|
||||
{ id: "examples", label: "EXAMPLES" },
|
||||
{ id: "tips", label: "PROMPT TIPS" },
|
||||
{ id: "campaign-faq", label: "FAQ" },
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// Cross-app link configuration, ported from the Vite site's config/links.ts.
|
||||
// The marketing site links out to the webapp for all product features.
|
||||
|
||||
// Canonical origin for metadata, sitemaps and share links.
|
||||
export const SITE_URL = "https://getartcraft.com";
|
||||
|
||||
/** Absolute URL for a site path; already-absolute URLs (media CDN) pass through. */
|
||||
export function siteUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export const WEBAPP_URL =
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.getartcraft.com/";
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
// - downloadUrl: the downloadable file (e.g. Cloudflare R2 URL)
|
||||
// - containThumbnail: letterbox the thumbnail with padding (good for logos)
|
||||
|
||||
import {
|
||||
CRAFTING_APPS,
|
||||
craftAppIconPath,
|
||||
craftAppIconSourceUrl,
|
||||
craftAppName,
|
||||
craftShotPath,
|
||||
craftShotSourceUrl,
|
||||
} from "./crafting-apps";
|
||||
|
||||
export type PressKitAssetType = "video" | "image" | "embed" | "link";
|
||||
|
||||
export type PressKitAsset = {
|
||||
@@ -101,9 +110,31 @@ 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",
|
||||
assets: [],
|
||||
// Crafting Apps: every shot, downloading the full-resolution original.
|
||||
assets: CRAFTING_APPS.flatMap((app) =>
|
||||
app.shots.map((shot) => ({
|
||||
type: "image" as const,
|
||||
title: `${craftAppName(app)}: ${shot.caption}`,
|
||||
description: shot.alt,
|
||||
thumbnail: craftShotPath(app, shot),
|
||||
downloadUrl: craftShotSourceUrl(app, shot),
|
||||
downloadLabel: "Full resolution",
|
||||
})),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
# 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
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,157 +0,0 @@
|
||||
/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
|
||||
@@ -1,52 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@frontend/artcraft-website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react-masonry-css": "^1.0.16"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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: {},
|
||||
},
|
||||
}
|
||||