Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ba3b299715 Cap MySQL statement cache and connection lifetime in storyteller-web
Production hit MySQL error 1461 (max_prepared_stmt_count = 16382), causing
fast 500s on /v1/prompt/batch and other endpoints. sqlx caches up to 100
prepared statements per connection by default, so 7 replicas x 50
connections could hold ~35k statements, more than twice the server cap.

- statement_cache_capacity defaults to 25 (MYSQL_STATEMENT_CACHE_CAPACITY),
  bringing the ceiling to ~8.75k at current scale.
- max_lifetime defaults to 10 minutes (MYSQL_MAX_LIFETIME_SECONDS), down from
  sqlx's 30, so statements orphaned by cancelled queries are released sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:12:52 -04:00
Brandon Thomas 17373314ae remove old website (#63) 2026-10-09 11:31:28 -04:00
931a8b925c Bump Crafting App downloads to the new releases; add ARM64, Flatpak and FreeBSD (#61)
## What

Moves every `/apps/<app>` page on getartcraft.com (Next.js site) to the
app's latest GitHub release, and adds the builds every release now
ships.

| App | Site | Latest release |
|---|---|---|
| PhotoCraft | 0.3.0 → **0.5.0** | v0.5.0 |
| VectorCraft | 0.5.0 → **0.6.0** | v0.6.0 |
| EffectCraft | 0.4.0 → **0.6.0** | v0.6.0 |
| FilmCraft, LightCraft, PdfCraft, DesignCraft | 0.2.1 → **0.4.0** |
v0.4.0 |

New files in every app's list (all seven releases ship them now):
- **Windows:** Installer (ARM64), Portable (ARM64), previously only
PhotoCraft, VectorCraft and EffectCraft
- **Linux:** Flatpak (x86_64), Flatpak (ARM64)
- **Other:** FreeBSD (x86_64)

`WITH_WINDOWS_ARM64` and `CRAFTING_APPS_LATEST_VERSION` are gone: every
app ships the same file set, and versions differ per app. PdfCraft's
`fileSlug: "printcraft"` override is dropped, since 0.4.0 files are
named `pdfcraft-…`. Only the seven classic apps are on the site; the
newer apps aren't touched.

## Verification
- Every one of the 21 links per app matches a file in that app's
published (non-draft, non-prerelease) release. The only release files
not linked are the two `.AppImage.zsync` update files.
- `npm run typecheck` passes.
- `next dev`: `/apps/photocraft`, `/apps/pdfcraft` and
`/apps/effectcraft` render the new links (Windows ARM64, both Flatpaks,
FreeBSD); no stale version or `printcraft` URLs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:29 -04:00
BFlatandClaude Opus 5.5 0e1991de6d Bump VectorCraft downloads to 0.5.0 (#58)
## What

Moves VectorCraft's `/apps/vectorcraft` downloads from 0.4.0 to
[v0.5.0](https://github.com/storytold/vectorcraft/releases/tag/v0.5.0).
The other Crafting Apps are already on their latest releases:

| App | Site | Latest release |
|---|---|---|
| VectorCraft | 0.4.0 → **0.5.0** | v0.5.0 |
| PhotoCraft | 0.3.0 | v0.3.0 |
| EffectCraft | 0.4.0 | v0.4.0 |
| FilmCraft, LightCraft, PdfCraft, DesignCraft | 0.2.1 | v0.2.1 |

## Verification
- All 19 files the site lists, including the Windows on ARM builds, are
in v0.5.0, and each download URL resolves. The release page returns 200.
- The release also ships a FreeBSD tarball, which the site doesn't list
for any app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:18:42 +07:00
BFlatandClaude Opus 5.5 d950e57352 Rename PrintCraft to PdfCraft on the Crafting Apps pages (#57)
## What

The `storytold/printcraft` repo is now
[`storytold/pdfcraft`](https://github.com/storytold/pdfcraft), and every
Crafting Apps README already links `getartcraft.com/apps/pdfcraft`,
which didn't exist on the site until now. This renames the app on the
site.

- **Slug and name:** `/apps/pdfcraft`, shown as "PdfCraft" in the nav,
footer, `/apps` hub, sitemap, press kit and JSON-LD. Copy, category and
theme colour are unchanged; only the name changes.
- **Legacy links redirect permanently (308):**
  - `/apps/printcraft` → `/apps/pdfcraft`
- `/images/apps/printcraft/<file>` → `/images/apps/pdfcraft/<file>`, so
share images already cached by social sites keep loading.
- **Images:** moved to `public/images/apps/pdfcraft/`. The four
screenshots and the share image were rebuilt from the README's renamed
`pdfcraft-*.png` renders, which are slightly newer than the site's (they
show the in-app Discord button). The "Full size" and press-kit links had
been pointing at the old `printcraft-*.png` names, which no longer
exist; they now point at the new files.
- **Downloads:** v0.2.1 was published before the rename, so its files
are still called `printcraft-0.2.1-*`. `CraftRelease` gains an optional
`fileSlug` for this, and PdfCraft sets it to `printcraft`. Once a
release ships with `pdfcraft-*` names, removing that one line switches
it over.

## Verification
Rendered locally:
- `/apps/printcraft` and the old image URLs return 308 to their PdfCraft
equivalents. Following the redirect lands on the PdfCraft page with its
theme.
- `/apps/pdfcraft`: the title, canonical URL and `og:image` use
pdfcraft. All 17 download links resolve directly on
`storytold/pdfcraft`, without going through GitHub's redirect.
- The sitemap lists `/apps/pdfcraft`, and `/`, `/apps` and `/press-kit`
show no "PrintCraft". All press-kit links for PdfCraft return 200.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main`.

## Note
The screenshots still show "PrintCraft" inside the app (the window tab
and the sample PDF), because the PdfCraft repo renamed the image files
without re-rendering them. They'll update once the README screenshots
are re-captured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:56:47 +07:00
BFlatandClaude Opus 5.5 652146ad96 Refresh VectorCraft screenshots and bump Crafting App downloads (#56)
## What

### VectorCraft screenshots
The images were regenerated from the latest renders in VectorCraft's
README
([storytold/vectorcraft@1c3b7b1](https://github.com/storytold/vectorcraft/commit/1c3b7b16)).
The site's previous hero still showed the old DrawCraft UI.

- `hero`, `ribbons`, `bezier` and `sheet` replaced, and a new
`perspective` shot added for the Perspective Grid. They follow the
README's order.
- Captions and alt text updated to match what each new shot shows.
- `og.jpg` share image rebuilt from the new hero, composed the same way
as before: a centred crop with a 144px icon in the bottom-left corner.
- Encoding matches the existing files (1600×1000 WebP q82, JPEG q85), so
the files are about the same size, 50–94 KB each.

### Gallery layout
`AppGallery` used to show the first shot after the hero at full width
and pair the rest. With an even number of shots, that left an empty cell
at the end. Now the first shot only goes full width when the count is
odd. VectorCraft's four shots form a 2×2 grid, like the README's "A look
around" table. All other apps have three shots, so their layout is
unchanged.

### Download versions
| App | Before | After |
|---|---|---|
| PhotoCraft | 0.2.0 | **0.3.0** |
| VectorCraft | 0.3.1 | **0.4.0** |
| EffectCraft | 0.3.1 | **0.4.0** |
| FilmCraft, LightCraft, PrintCraft, DesignCraft | 0.2.1 | 0.2.1 (still
latest) |

These three releases add **Windows on ARM** builds (`windows-arm64.msi`,
`windows-arm64-portable.zip`). They're now listed in the Windows card
for those apps through a `WITH_WINDOWS_ARM64` asset list. The main
Windows button stays on x64, because browsers on Windows on ARM
generally report themselves as x64.

## Verification
- Every expected asset exists in each release.
- Rendered all seven `/apps/<slug>` pages locally and requested every
GitHub download and release link. All of them resolve, and the "Version"
label shows the new versions.
- Checked the VectorCraft gallery in the browser: a 2×2 grid with all
images loading.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main` (missing local Playwright types and
`media-viewer-3d.tsx`).

## Not in this PR
- **`storytold/printcraft` has been renamed to `storytold/pdfcraft`.**
PrintCraft links still work through GitHub's redirect. Renaming the app
on the site is a separate change.
- Releases also ship FreeBSD tarballs, and PhotoCraft also ships Flatpak
and zsync files. The site doesn't list these.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:51:45 +07:00
dafe769cda PageScene: dispose GPU assets on webapp (#55)
Targets PageScene and other 3D viewers, handles uniforms and textures,
amongst other general editor mount improvements

---------

Co-authored-by: heojunfo <kleague3@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:58:27 -07:00
BFlatandClaude Opus 5.5 ad9b10ba94 Bump Crafting App downloads to their latest releases (#54)
## What

Updates the `/apps/<slug>` download links to each app's latest GitHub
release:

| App | Before | After |
|---|---|---|
| VectorCraft | 0.2.0 | **0.3.1** |
| EffectCraft | 0.2.0 | **0.3.1** |
| FilmCraft | 0.2.0 | **0.2.1** |
| LightCraft | 0.2.0 | **0.2.1** |
| PrintCraft | 0.2.0 | **0.2.1** |
| DesignCraft | 0.2.0 | **0.2.1** |
| PhotoCraft | 0.2.0 | 0.2.0 (still its latest) |

`CRAFTING_APPS_LATEST_VERSION` becomes `0.2.1`, the most common version.
PhotoCraft, VectorCraft and EffectCraft are pinned with
`craftRelease(...)`, following the file's documented convention.

## Verification

- Compared each release's asset list against `CRAFT_RELEASE_ASSETS`:
every expected file exists.
- Rendered all seven app pages locally and requested every GitHub
download/release link (34 per page): all return 200/302.
- The "Version" label and JSON-LD `softwareVersion` show the new
versions.

## Note

PrintCraft 0.2.1 also ships `printcraft-0.2.1-freebsd-x86_64.tar.gz`,
which the site doesn't list. That's left for a follow-up if we want to
offer it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:38:45 +07:00
BFlatandClaude Opus 5.5 3880127f68 Add Google Analytics to the marketing site (#50)
## What

Adds GA4 to `artcraft-website-next`, covering the whole marketing site.

- `src/components/analytics.tsx` loads gtag.js (`afterInteractive`) and
installs one delegated click listener. It's mounted once in the root
layout.
- `src/lib/analytics.ts` holds the typed event catalogue,
`trackEvent()`, `trackAttrs()` (data attributes that work in server
components) and the link classifier.
- Analytics is **off unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set**, so
local dev and deploy previews stay out of the reports.

## Why

The new site had no analytics. This gives us download, signup and
checkout funnels, plus CTA performance by page section.

## Events

GA4 Enhanced Measurement handles page views (App Router history
changes), scrolls, outbound clicks and common file downloads. On top of
that:

| Event | Trigger |
|---|---|
| `app_download` | GitHub release asset clicks (ArtCraft + Crafting
Apps): app, version, platform, file |
| `webapp_click` | Links to app.getartcraft.com: destination path |
| `cta_click` | Other `Button` links (tagged `data-cta`) |
| `app_select` | App cards and icons on `/apps` and sibling cards on app
pages: app name |
| `social_click`, `contact_click` | `SOCIAL_LINKS` hosts, mailto / copy
email |
| `begin_checkout` | Plan selection (`signup`/`subscribe`/`switch`) and
credit packs, with GA4 ecommerce `items` |
| `billing_cadence_change`, `view_credit_packs`, `manage_subscription` |
Pricing actions |
| `prompt_submit`, `video_generation` | H3 promptbox (`logged_in`;
complete/failed/timeout) |
| `sign_up`, `login` | Auth gate success |
| `generate_lead` | Beta form submission (honeypot hits excluded) |
| `video_start`, `share`, `copy_command`, `copy_prompt`,
`media_download`, `press_kit_download` | Video facades, share bars, copy
buttons, media/press downloads |

Every delegated click event also carries `link_location`: nav, footer,
modal, or the enclosing `section[id]`.

## Reviewer notes

- `Button` now renders `data-cta=""` on its anchor variants. There is no
visual change.
- `PricingTable.choosePlan` now branches on the computed `checkoutType`
rather than repeating the `user`/`hasActivePlan` conditions. The
behaviour is unchanged.
- `AuthGateModal` forms now return early on error instead of using
if/else, so the success event goes on the success path.

## Deploy

Set `NEXT_PUBLIC_GA_MEASUREMENT_ID` in Netlify, **Production context
only**, then redeploy. The ID is inlined at build time. This is
documented in `netlify.toml`.

After deploying:
- Mark key events in GA: `app_download`, `begin_checkout`, `sign_up`,
`generate_lead`, `webapp_click`.
- Register custom dimensions (e.g. `link_location`, `app_name`,
`platform`, `destination`, `checkout_type`) so the params show in
reports.

## Testing

Ran the dev server with a dummy measurement ID and checked `dataLayer`
on these pages:
- `/`: nav, footer, CTA, social, mailto and YouTube events.
- `/download`: Windows and macOS installers.
- `/apps/photocraft`: Linux downloads, share, copy command, GitHub link,
and `app_select` on the sibling cards (also checked on the `/apps` icon
row and lineup cards).
- `/pricing`: cadence toggle.
- `/minimax-h3`: logged-out `prompt_submit` and example `video_start`.

Not exercised in the browser: the beta form (submitting would post a
real Google Form entry), signup/login, checkout, the credits modal, the
media page, the press kit, email copy and manage plan.

`tsc` shows no new errors. Existing errors in `media-viewer-3d.tsx` and
the Playwright test files are unchanged. The production build was not
run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:00:50 +07:00
Brandon Thomas 03dea1c660 app downloads 2 (#49) 2026-10-05 06:38:56 -04:00
Brandon Thomas b5272188f7 App downloads (#48) 2026-10-05 06:17:51 -04:00
bflatastic b02f710c76 page scroll fix 2026-10-04 21:15:58 +07:00
BFlat 708de238c5 Add VectorCraft, EffectCraft, DesignCraft and app icons to Craft Apps pages (#47)
## Summary

Updates the Craft Apps pages to cover the org's seven Craft repos and
adds each app's icon.

- **VectorCraft replaces DrawCraft.** The repo was renamed, so the page
moves to `/apps/vectorcraft`. `/apps/drawcraft` permanently redirects
there, because every Craft README's app table still links the old URL.
- **New pages:** `/apps/effectcraft` (motion graphics & VFX) and
`/apps/designcraft` (page layout & publishing), each with copy,
screenshots and a share image.
- **App icons:**
  - A row in the `/apps` header linking to every app.
- On each lineup card and sibling-app card, overlapping the bottom of
the screenshot.
  - Above the wordmark in each app's hero.
- **PhotoCraft installers (`v0.1.1-rc.4`):**
  - A download button in the hero.
- macOS, Windows and Linux buttons, plus an "All builds and checksums"
link.
  - An "Installers ready" badge on its card.
  - `softwareVersion` and `downloadUrl` in its JSON-LD.
- **Share images:** each app's image has its icon in the corner, and the
`/apps` share image is the seven icons in a row.
- **Press kit:** a new "Crafting Apps icons" section, linking to each
repo's 1024px PNG.

## Details
- **Data file:** everything is still driven by
`src/lib/crafting-apps.ts`. An app's `release` field (version plus a
filename suffix per platform) produces its download buttons, so other
apps need one data entry when they ship.
- **Icons:** each repo's 512px render, converted to a 256px WebP with
transparency, about 25 KB each.
- **DesignCraft gallery:** its magazine pages are portrait, so they show
four across instead of in the 16:10 layout.
- **Hub copy:** the app count is worked out from the data ("Seven
apps"). The lineup is four columns wide on large screens, so seven cards
plus the Discord cell fill two even rows.
- **Colors:** EffectCraft and DesignCraft get their icon tile colors as
accents, with AA-contrast text colors in light and dark.
- **Adobe names:** the copy still never names Adobe products.

## Testing
- A clean `npm ci && npm run build` (what Netlify runs) passes with a
clean TypeScript check, and all seven app pages are generated as static
HTML.
- `next start` serves every page and asset with a 200, `/apps/drawcraft`
returns a 308 to `/apps/vectorcraft`, and an unknown slug returns 404.
- Checked visually at 1440px and at a 390px mobile viewport: no broken
images or horizontal overflow, and the mobile icon row wraps four over
three.
- The PhotoCraft download links and every 1024px icon URL in the press
kit resolve.
2026-10-02 23:06:56 +07:00
Brandon Thomas fc78787f30 better website hero text (#46) 2026-10-01 20:22:51 -04:00
BFlat e92253c5b4 Add Craft Apps marketing pages to the website (#45)
## Summary

Marketing pages on getartcraft.com for the five Craft Apps: PhotoCraft,
DrawCraft, FilmCraft, LightCraft and PrintCraft. Everything is driven by
one data file, `src/lib/crafting-apps.ts`.

- **`/apps` hub:** lineup cards for all five apps, shared principles,
and Discord calls to action.
- **`/apps/<slug>`:**
  - Hero screenshot with Discord, GitHub and share links.
  - Six highlights and a screenshot gallery.
- "Get it": a Discord waitlist for installers, next to build-from-source
steps with a copy button.
  - Cards linking to the sibling apps.
- **Nav:** a "Craft Apps" link after a divider following Support, and a
Discord button on desktop.
- **Footer:** a "Craft Apps" column.
- **Sitemap:** entries for the hub and all five app pages.
- **Press kit:** all 20 app screenshots, with full-resolution downloads.

## Branding
- **Colors:** each app has its own accent (`.app-*` in `globals.css`),
AA-contrast in light and dark.
- **Wordmarks:** text only, with "Craft" set in the app's color. These
pages don't use the serif accent font.
- **Share previews:** a 1200×630 image per app plus one for the hub, and
`SoftwareApplication` JSON-LD on each app page.
- **Adobe:** the copy never names Adobe products.

## Notes
- No installers exist yet in any of the five repos, so each page sends
visitors to Discord and to building from source. When an app ships a
release, adding `downloads` to its entry in `crafting-apps.ts` replaces
that block with download buttons.
- Screenshots come from each repo's `docs/images`, show only
public-domain artwork, and are converted to WebP at 1600px.
- **Small refactors:**
- New shared `DiscordButton`, now also used by the community strip and
the press kit.
  - New `CopyButton` in the UI kit.
  - `SITE_URL`/`siteUrl` moved into `lib/links.ts`.
  - New `kicker`/`mediaTag` props on `CampaignHero`.

## Testing
- A clean `npm ci && npm run build`, the same build Netlify runs,
passes: TypeScript is clean, `/apps` is static, and all five app pages
prerender as static HTML.
- `next start` serves all six routes plus their images, and an unknown
slug returns 404.
- Each page's Open Graph and Twitter tags render with the per-app image.
- Checked visually at 1440px and 1024px (the nav fits) and at a 390px
mobile viewport.
2026-10-01 22:56:48 +07:00
381 changed files with 3753 additions and 29096 deletions

No files matched your search

+4 -9
View File
@@ -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
+5 -8
View File
@@ -104,7 +104,7 @@ and signals for state, Three.js for 3D scenes, and shared UI and generation tool
| Path | Purpose |
|----------------------------------|-----------------------------------------------|
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
| `frontend/apps/artcraft-website` | 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).
-1
View File
@@ -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
+2 -2
View File
@@ -35,6 +35,6 @@ VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
The web app runs at `http://localhost:4201`, using the local backend. To develop
against the hosted API, omit `VITE_USE_LOCAL_API=true`.
For the product website, run `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)
+1 -3
View File
@@ -31,7 +31,7 @@ From the **repository root**, the launcher scripts (these preflight your
environment, install dependencies, and free the dev port first):
```bash
./script/website/unix_frontend_website_dev.sh # artcraft-website (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
```
+1 -18
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

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&apos;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&rsquo;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)
-157
View File
@@ -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
-52
View File
@@ -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: {},
},
}
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more