Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3b299715 | ||
|
|
17373314ae | ||
|
|
931a8b925c | ||
|
|
0e1991de6d | ||
|
|
d950e57352 | ||
|
|
652146ad96 | ||
|
|
dafe769cda | ||
|
|
ad9b10ba94 | ||
|
|
3880127f68 |
No files matched your search
@@ -34,7 +34,6 @@ artcraft-services/
|
||||
├── frontend/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
@@ -42,9 +41,9 @@ artcraft-services/
|
||||
|
||||
## Updating Desktop Download Links
|
||||
|
||||
Desktop release links are configured independently in **three places**. When
|
||||
Desktop release links are configured independently in **two places**. When
|
||||
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
|
||||
`MAC_VERSION` in all three unless the request explicitly targets one platform or
|
||||
`MAC_VERSION` in both unless the request explicitly targets one platform or
|
||||
app:
|
||||
|
||||
1. **Current public website (Next.js):**
|
||||
@@ -52,11 +51,7 @@ app:
|
||||
This controls the installer links and visible version at
|
||||
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
|
||||
(newest first), then update both version selectors.
|
||||
2. **Legacy website (Vite):**
|
||||
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
|
||||
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
|
||||
Updating only this file does **not** update the current public website.
|
||||
3. **Browser webapp:**
|
||||
2. **Browser webapp:**
|
||||
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
|
||||
Update both version constants; these links are used by the welcome and
|
||||
checkout-success pages at `app.getartcraft.com`.
|
||||
@@ -65,7 +60,7 @@ Keep previous releases for rollback. The Next.js download page reads its visible
|
||||
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
|
||||
not hardcode a separate version in the page component.
|
||||
|
||||
Check that all three configs produce the requested release tag and installer
|
||||
Check that both configs produce the requested release tag and installer
|
||||
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
|
||||
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
|
||||
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
|
||||
|
||||
@@ -104,7 +104,7 @@ and signals for state, Three.js for 3D scenes, and shared UI and generation tool
|
||||
| Path | Purpose |
|
||||
|----------------------------------|-----------------------------------------------|
|
||||
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
|
||||
| `frontend/apps/artcraft-website` | Product website at `getartcraft.com` |
|
||||
| `frontend/apps/artcraft-website-next` | Product website at `getartcraft.com` (Next.js) |
|
||||
| `frontend/libs/api` | HTTP clients, API host selection, and models |
|
||||
| `frontend/libs/omni-gen` | Shared generation logic |
|
||||
| `frontend/libs/components` | Reusable UI, editors, and generation controls |
|
||||
@@ -190,14 +190,11 @@ The browser app runs at `http://localhost:4201`. `VITE_USE_LOCAL_API=true` selec
|
||||
the hosted API. API host selection lives in
|
||||
[`StorytellerApiHostStore`](./frontend/libs/api/src/lib/config/StorytellerApiHostStore.ts).
|
||||
|
||||
From `frontend`, build the web app or run the product website with:
|
||||
From `frontend`, build the web app with `npx nx build artcraft-webapp`. The
|
||||
product website is a standalone Next.js app (not part of the Nx workspace); run it
|
||||
from the repository root with `./script/website/unix_frontend_website_dev.sh`.
|
||||
|
||||
```bash
|
||||
npx nx build artcraft-webapp
|
||||
npx nx dev artcraft-website
|
||||
```
|
||||
|
||||
The website runs at `http://localhost:4200`. Repository-root launchers are in
|
||||
The website runs at `http://localhost:3000`. Repository-root launchers are in
|
||||
[`script/website`](./script/website). For desktop development, use the
|
||||
[ArtCraft desktop repository](https://github.com/storytold/artcraft).
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ From the services repository:
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
|
||||
cd frontend
|
||||
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
|
||||
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
|
||||
```
|
||||
|
||||
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
|
||||
|
||||
@@ -35,6 +35,6 @@ VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
|
||||
The web app runs at `http://localhost:4201`, using the local backend. To develop
|
||||
against the hosted API, omit `VITE_USE_LOCAL_API=true`.
|
||||
|
||||
For the product website, run `npx nx dev artcraft-website` from `frontend`; it uses
|
||||
`http://localhost:4200`. Repository-root launchers live under [`script/website`](../script/website).
|
||||
For the product website, run `./script/website/unix_frontend_website_dev.sh` from the
|
||||
repository root; it is a standalone Next.js app on `http://localhost:3000`. Repository-root launchers live under [`script/website`](../script/website).
|
||||
See the [frontend README](../frontend/README.md) for builds and troubleshooting.
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::info;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
|
||||
@@ -20,20 +21,37 @@ pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
|
||||
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
|
||||
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
|
||||
|
||||
// NB: Every cached prepared statement stays open on the server until its connection evicts it
|
||||
// or closes, and the server caps the total across ALL clients (`max_prepared_stmt_count`,
|
||||
// 16382 on the managed DB). sqlx's default of 100 per connection let `replicas *
|
||||
// MYSQL_MAX_CONNECTIONS * 100` blow past that cap, failing new prepares with MySQL error 1461.
|
||||
// Keep `replicas * MYSQL_MAX_CONNECTIONS * MYSQL_STATEMENT_CACHE_CAPACITY` well under it.
|
||||
let statement_cache_capacity = easyenv::get_env_num::<usize>("MYSQL_STATEMENT_CACHE_CAPACITY", 25)?;
|
||||
|
||||
// NB: Recycling connections releases any server-side statements orphaned by cancelled queries
|
||||
// (eg. a client disconnect between prepare and close). sqlx's default lifetime is 30 minutes.
|
||||
let max_lifetime_seconds = easyenv::get_env_num::<u64>("MYSQL_MAX_LIFETIME_SECONDS", 600)?;
|
||||
|
||||
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
|
||||
// log the connection string (it contains credentials).
|
||||
info!(
|
||||
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
|
||||
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}, \
|
||||
statement_cache_capacity={}, max_lifetime_seconds={}",
|
||||
max_connections, min_connections, acquire_timeout_seconds,
|
||||
statement_cache_capacity, max_lifetime_seconds,
|
||||
);
|
||||
|
||||
let connect_options = MySqlConnectOptions::from_str(&db_connection_string)?
|
||||
.statement_cache_capacity(statement_cache_capacity);
|
||||
|
||||
let pool_options = MySqlPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
|
||||
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds))
|
||||
.max_lifetime(Some(Duration::from_secs(max_lifetime_seconds)));
|
||||
|
||||
let pool = pool_options
|
||||
.connect(&db_connection_string)
|
||||
.connect_with(connect_options)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
|
||||
@@ -31,7 +31,7 @@ From the **repository root**, the launcher scripts (these preflight your
|
||||
environment, install dependencies, and free the dev port first):
|
||||
|
||||
```bash
|
||||
./script/website/unix_frontend_website_dev.sh # artcraft-website (marketing site), port 4200
|
||||
./script/website/unix_frontend_website_dev.sh # artcraft-website-next (marketing site), port 3000
|
||||
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
|
||||
```
|
||||
|
||||
@@ -46,14 +46,12 @@ by its full or relative path:
|
||||
Or directly from this directory:
|
||||
|
||||
```bash
|
||||
nx dev artcraft-website
|
||||
npx nx dev @frontend/artcraft-webapp
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npx nx build artcraft-website
|
||||
npx nx build artcraft-webapp
|
||||
```
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import react from '@vitejs/plugin-react';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import topLevelAwait from 'vite-plugin-top-level-await';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched with
|
||||
// fetch(), but the CDNs only send CORS headers for the production origin —
|
||||
@@ -71,21 +69,6 @@ function cdnProxyPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
// Custom plugin to generate news.json on dev server start
|
||||
function generateNewsPlugin() {
|
||||
return {
|
||||
name: 'generate-news',
|
||||
buildStart() {
|
||||
try {
|
||||
const scriptPath = path.resolve(__dirname, '../../scripts/generate-news-json.mjs');
|
||||
execSync(`node "${scriptPath}"`, { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.warn('Failed to generate news.json:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/artcraft-webapp',
|
||||
@@ -110,7 +93,7 @@ export default defineConfig(() => ({
|
||||
port: 4301,
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [cdnProxyPlugin(), generateNewsPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
|
||||
plugins: [cdnProxyPlugin(), nxViteTsPaths(), react(), wasm(), topLevelAwait()],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
# Manage the build command and publish directory here; leave those UI fields empty.
|
||||
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
|
||||
# legacy-route redirects declared in next.config.ts. Other media is in public/.
|
||||
#
|
||||
# Google Analytics: set NEXT_PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX) in the
|
||||
# Netlify UI, scoped to the Production context so deploy previews don't
|
||||
# report. It is inlined at build time, so redeploy after changing it.
|
||||
|
||||
[build]
|
||||
command = "npm run build"
|
||||
|
||||
@@ -32,6 +32,18 @@ const nextConfig: NextConfig = {
|
||||
destination: "/apps/vectorcraft",
|
||||
permanent: true,
|
||||
},
|
||||
// PrintCraft was renamed PdfCraft. Old page links and share images
|
||||
// (cached by social sites) follow it.
|
||||
{
|
||||
source: "/apps/printcraft",
|
||||
destination: "/apps/pdfcraft",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/images/apps/printcraft/:file",
|
||||
destination: "/images/apps/pdfcraft/:file",
|
||||
permanent: true,
|
||||
},
|
||||
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
|
||||
// the canonical route is /media/<token>. Mirrored in netlify.toml so
|
||||
// the edge answers it before the Next runtime.
|
||||
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 78 KiB |
@@ -22,6 +22,7 @@ import { AppIcon, ColorAccent, appThemeClass } from "@/components/apps/app-wordm
|
||||
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,
|
||||
@@ -108,6 +109,7 @@ export default function CraftingAppsPage() {
|
||||
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}
|
||||
|
||||
@@ -321,14 +321,14 @@ html[data-ruler="on"]::-webkit-scrollbar {
|
||||
.app-vectorcraft { --app: #e8573f; --app-ink: #b83a24; }
|
||||
.app-filmcraft { --app: #8b5cf6; --app-ink: #6a3fd6; }
|
||||
.app-lightcraft { --app: #f2a516; --app-ink: #8a5800; }
|
||||
.app-printcraft { --app: #12a58a; --app-ink: #0a7563; }
|
||||
.app-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-printcraft { --app-ink: #3fd6b8; }
|
||||
: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) {
|
||||
@@ -336,7 +336,7 @@ html[data-ruler="on"]::-webkit-scrollbar {
|
||||
: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-printcraft { --app-ink: #3fd6b8; }
|
||||
: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; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -138,6 +139,7 @@ export default function RootLayout({
|
||||
</MotionProvider>
|
||||
<TunerPanel />
|
||||
<LandingContext />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -30,6 +31,7 @@ export default function AppCard({
|
||||
<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),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -31,11 +32,12 @@ import { ColorAccent } from "./app-wordmark";
|
||||
const CELL_HEADING_CLASSES =
|
||||
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
|
||||
|
||||
// The hero carries shot 01. Window captures lead with a full-width shot,
|
||||
// then pair; portrait pages (layout spreads) sit four across.
|
||||
// The hero carries shot 01. 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
|
||||
@@ -54,7 +56,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
|
||||
<figure
|
||||
key={shot.file}
|
||||
data-reveal
|
||||
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
|
||||
className={twMerge("bg-bg", leadFullWidth && i === 0 && "md:col-span-2")}
|
||||
>
|
||||
<a
|
||||
href={craftShotSourceUrl(app, shot)}
|
||||
@@ -203,6 +205,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
|
||||
value={command}
|
||||
variant="ghost"
|
||||
className="h-9 border-l border-line px-3"
|
||||
{...trackAttrs("copy_command", { app_name: app.slug })}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -40,6 +41,7 @@ export default function ShareBar({
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -56,6 +58,7 @@ export default function ShareBar({
|
||||
copiedLabel="Link copied"
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
|
||||
/>
|
||||
{TARGETS.map((target) => (
|
||||
<Button
|
||||
@@ -66,6 +69,7 @@ export default function ShareBar({
|
||||
aria-label={`Share on ${target.name}`}
|
||||
variant="ghost"
|
||||
className={CELL_CLASSES}
|
||||
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
|
||||
>
|
||||
{target.name}
|
||||
</Button>
|
||||
|
||||
@@ -4,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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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\./, "");
|
||||
}
|
||||
@@ -3,17 +3,14 @@ import type { CraftAppSlug } from "./crafting-apps";
|
||||
// GitHub releases for the Crafting Apps, which drive every download button on
|
||||
// the /apps pages.
|
||||
//
|
||||
// To bump every app: change CRAFTING_APPS_LATEST_VERSION.
|
||||
// To pin one app to another version: give it its own release in
|
||||
// CRAFT_APP_RELEASES, e.g. `craftRelease("0.1.1")`.
|
||||
// To 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 const CRAFTING_APPS_LATEST_VERSION = "0.2.0";
|
||||
|
||||
export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux";
|
||||
|
||||
/** Order of the per-platform download buttons. */
|
||||
@@ -22,7 +19,7 @@ export const CRAFT_DESKTOP_PLATFORMS: CraftDesktopPlatform[] = ["macOS", "Window
|
||||
export type CraftReleaseAsset = {
|
||||
/** Asset filename; `{slug}` and `{version}` are filled in per app. */
|
||||
file: string;
|
||||
/** Group in the full file list. "Other" holds the CLI, web build and checksums. */
|
||||
/** Group 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;
|
||||
@@ -43,6 +40,8 @@ export type CraftRelease = {
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -92,6 +91,20 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
|
||||
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",
|
||||
@@ -100,6 +113,13 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
|
||||
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",
|
||||
@@ -129,6 +149,13 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
|
||||
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",
|
||||
@@ -150,6 +177,12 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [
|
||||
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",
|
||||
@@ -171,15 +204,13 @@ export function craftRelease(
|
||||
return { version, assets };
|
||||
}
|
||||
|
||||
const LATEST = craftRelease(CRAFTING_APPS_LATEST_VERSION);
|
||||
|
||||
// Every app must be listed, so a new slug fails the type check until it is.
|
||||
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
|
||||
photocraft: LATEST,
|
||||
vectorcraft: LATEST,
|
||||
filmcraft: LATEST,
|
||||
lightcraft: LATEST,
|
||||
printcraft: LATEST,
|
||||
effectcraft: LATEST,
|
||||
designcraft: LATEST,
|
||||
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"),
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export type CraftAppSlug =
|
||||
| "vectorcraft"
|
||||
| "filmcraft"
|
||||
| "lightcraft"
|
||||
| "printcraft"
|
||||
| "pdfcraft"
|
||||
| "effectcraft"
|
||||
| "designcraft";
|
||||
|
||||
@@ -177,25 +177,31 @@ export const CRAFTING_APPS: CraftApp[] = [
|
||||
file: "hero.webp",
|
||||
source: "shot-1-neon.png",
|
||||
caption: "Neon Drive — made in VectorCraft",
|
||||
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
|
||||
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 between editable spines",
|
||||
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
|
||||
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 feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
|
||||
},
|
||||
{
|
||||
file: "bezier.webp",
|
||||
source: "shot-4-bezier.png",
|
||||
caption: "Direct selection on bezier anchors",
|
||||
alt: "VectorCraft direct-selecting the anchors and handles of a crescent moon path",
|
||||
alt: "VectorCraft in its light theme with four artboards: shape booleans, a gradient mesh, a live radial repeat and an envelope distort",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -328,8 +334,8 @@ export const CRAFTING_APPS: CraftApp[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "printcraft",
|
||||
prefix: "Print",
|
||||
slug: "pdfcraft",
|
||||
prefix: "Pdf",
|
||||
category: "PDF workbench",
|
||||
status: "Early alpha",
|
||||
headline: ["The ", "open-source", " PDF workbench."],
|
||||
@@ -367,27 +373,27 @@ export const CRAFTING_APPS: CraftApp[] = [
|
||||
shots: [
|
||||
{
|
||||
file: "hero.webp",
|
||||
source: "printcraft-viewer.png",
|
||||
source: "pdfcraft-viewer.png",
|
||||
caption: "Viewer with threaded comments",
|
||||
alt: "PrintCraft showing a showcase PDF cover with the tools sidebar and threaded comments",
|
||||
alt: "PdfCraft showing a showcase PDF cover with the All tools panel and threaded comments",
|
||||
},
|
||||
{
|
||||
file: "organize.webp",
|
||||
source: "printcraft-organize.png",
|
||||
source: "pdfcraft-organize.png",
|
||||
caption: "Organize pages like cards",
|
||||
alt: "PrintCraft page organizer with three pages selected for rotate, delete and extract",
|
||||
alt: "PdfCraft page organizer with three pages selected and the page tools in the toolbar above",
|
||||
},
|
||||
{
|
||||
file: "palette.webp",
|
||||
source: "printcraft-palette.png",
|
||||
source: "pdfcraft-palette.png",
|
||||
caption: "A command palette for every tool",
|
||||
alt: "PrintCraft command palette filtering page tools by the word page",
|
||||
alt: "PdfCraft command palette searching for page and listing the page tools",
|
||||
},
|
||||
{
|
||||
file: "twoup.webp",
|
||||
source: "printcraft-twoup.png",
|
||||
source: "pdfcraft-twoup.png",
|
||||
caption: "Two-up Read mode, dark theme",
|
||||
alt: "PrintCraft two-up Read mode in the dark theme showing typeset specimen pages",
|
||||
alt: "PdfCraft two-up Read mode in the dark theme showing typeset specimen pages",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -649,7 +655,7 @@ export function craftReleaseDownloads(
|
||||
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
|
||||
return release.assets.map(({ file, ...asset }) => {
|
||||
const fileName = file
|
||||
.replaceAll("{slug}", app.slug)
|
||||
.replaceAll("{slug}", release.fileSlug ?? app.slug)
|
||||
.replaceAll("{version}", release.version);
|
||||
return { ...asset, fileName, href: `${base}/${fileName}` };
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Google OAuth Configuration
|
||||
# Get the Client ID from the Google Cloud Console:
|
||||
# https://console.cloud.google.com/apis/credentials
|
||||
VITE_GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com
|
||||
@@ -1,143 +0,0 @@
|
||||
# Website → Webapp Migration
|
||||
|
||||
The marketing site (`artcraft-website`) is being split apart from the app
|
||||
(`artcraft-webapp`). Anything that isn't pure marketing content belongs in
|
||||
the webapp at `app.getartcraft.com`.
|
||||
|
||||
The migration is **gated by a single boolean** so it can be reverted while
|
||||
we soak the new redirect behavior in production. When we're confident the
|
||||
webapp is handling everything cleanly, do the **cleanup pass** below to
|
||||
permanently delete everything that's only kept around for the rollback path.
|
||||
|
||||
---
|
||||
|
||||
## The switch
|
||||
|
||||
`src/config/links.ts`:
|
||||
|
||||
```ts
|
||||
export const USE_WEBAPP_FOR_APP_FEATURES = true;
|
||||
```
|
||||
|
||||
- `true` (default): legacy paths redirect to the webapp; navbar shows
|
||||
"Launch App"; in-page links go cross-origin to `app.getartcraft.com`.
|
||||
- `false`: everything runs locally on the marketing site (the original
|
||||
pre-split behavior).
|
||||
|
||||
In dev, `WEBAPP_URL` auto-switches to `http://localhost:4201/` so cross-app
|
||||
links work against the local webapp Vite server.
|
||||
|
||||
---
|
||||
|
||||
## What's behind the flag
|
||||
|
||||
### Pages that exist locally only as a fallback
|
||||
|
||||
When `USE_WEBAPP_FOR_APP_FEATURES = true`, these routes mount
|
||||
`<WebappRedirect to="…" />` instead of the page component. The page files
|
||||
are kept on disk so we can roll back by flipping the flag.
|
||||
|
||||
- `src/pages/login/`
|
||||
- `src/pages/signup/`
|
||||
- `src/pages/forgot-password/`
|
||||
- `src/pages/create-image/`
|
||||
- `src/pages/create-video/`
|
||||
- `src/pages/create-vfx/` (the `/background-change` route)
|
||||
- `src/pages/library/`
|
||||
- `src/pages/referrals/`
|
||||
- `src/pages/welcome/`
|
||||
- `src/pages/onboarding/`
|
||||
- `src/pages/checkout/` (success + cancel)
|
||||
|
||||
### Components only used by the above
|
||||
|
||||
- `src/components/auth/` (SignupForm, GoogleLoginButton)
|
||||
- `src/components/credits-modal.tsx`
|
||||
- `src/components/generation-gallery/`
|
||||
- `src/components/lightbox/lightbox.tsx` (the modal `Lightbox`; the
|
||||
`LightboxDetails` + `shared.ts` files in the same folder are still used
|
||||
by the public `/media` page and must be kept)
|
||||
- `src/components/navbar/task-queue.tsx`
|
||||
- `src/components/prompt-box/`
|
||||
- `src/components/settings-modal/`
|
||||
|
||||
### Lib files only used by the above
|
||||
|
||||
- `src/lib/cost-estimate-api.ts`
|
||||
- `src/lib/enter-to-generate-store.ts`
|
||||
- `src/lib/omni-gen-hooks.ts`
|
||||
- `src/lib/prompts-cache.ts`
|
||||
- `src/lib/recreate.ts`
|
||||
|
||||
### Files with conditional flag branches (need cleanup, not deletion)
|
||||
|
||||
- `src/app/app.tsx` — drop the `appOrWebapp()` helper, drop the local route
|
||||
imports, leave only the `<WebappRedirect />` routes
|
||||
- `src/components/navbar/navbar.tsx` — drop the `!USE_WEBAPP_FOR_APP_FEATURES`
|
||||
branches (Login + Sign up buttons in desktop and mobile); keep only Launch
|
||||
App for logged-in users and the Pricing + Launch App combo for logged-out
|
||||
users
|
||||
- `src/components/footer/footer.tsx` — drop the conditional Image/Video
|
||||
links in the Pages column
|
||||
- `src/components/download-modal.tsx` — drop the inline `SignupForm` flow
|
||||
and the `view`/`navigate` state; keep only the webapp `/signup` and
|
||||
`/login` link buttons
|
||||
- `src/pages/landing3/landing3.tsx` — `appLink("/create-image")` /
|
||||
`appLink("/create-video")` can become bare `webappUrl("…")` calls (or
|
||||
inline `${WEBAPP_URL}create-image`)
|
||||
- `src/pages/media/media.tsx` — drop the local
|
||||
`applyRecreateFromMediaToken` branch and the `useNavigate` import; keep
|
||||
only the `window.location.href = …` webapp redirect
|
||||
- `src/pages/pricing/pricing.tsx` — the "Buy Credits" section + `CreditsModal`
|
||||
is currently shown unconditionally; once `credits-modal.tsx` is deleted,
|
||||
remove the section, the `creditsModalOpen` state, the `isLoggedIn` state,
|
||||
and the session-check `useEffect`
|
||||
|
||||
### The flag itself
|
||||
|
||||
`src/config/links.ts` — drop `USE_WEBAPP_FOR_APP_FEATURES`, drop the
|
||||
`appLink()` helper (use `webappUrl()` directly everywhere). Keep
|
||||
`WEBAPP_URL`, `webappUrl()`, `SOCIAL_LINKS`, `SUPPORT_EMAIL`, and the dev
|
||||
override.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup pass — what to do when we're ready to commit to the webapp split
|
||||
|
||||
1. **Confirm the flag has been `true` in production for long enough** that
|
||||
we're sure no real users hit the local routes.
|
||||
2. **Delete** every directory and file listed under "Pages that exist
|
||||
locally only as a fallback", "Components only used by the above", and
|
||||
"Lib files only used by the above".
|
||||
3. **Strip the flag branches** from each file under "Files with conditional
|
||||
flag branches".
|
||||
4. **Remove `USE_WEBAPP_FOR_APP_FEATURES`** and `appLink()` from
|
||||
`src/config/links.ts`.
|
||||
5. **Run** `npx tsc -p tsconfig.app.json --noEmit` from
|
||||
`frontend/apps/artcraft-website` and fix any references the cleanup
|
||||
exposed. (Pre-existing errors in `download-button.tsx` `LINUX` and
|
||||
`pricing-table.tsx` `maybeReferralUsername` are unrelated and can be
|
||||
ignored.)
|
||||
6. **Verify** in dev with `nx serve @frontend/artcraft-website` that every
|
||||
navbar/footer/landing/media flow still works and that legacy URLs still
|
||||
redirect to the webapp via `WebappRedirect`.
|
||||
|
||||
---
|
||||
|
||||
## What stays on the marketing site no matter what
|
||||
|
||||
These are pure marketing content and are **not** behind the flag:
|
||||
|
||||
- `/` → `landing3`
|
||||
- `/landing3`
|
||||
- `/seedance-2` → `landing-sd2`
|
||||
- `/download`
|
||||
- `/media`, `/media/:id` (public viewer; the lightbox's "Recreate" button
|
||||
does cross-origin to the webapp, but the viewer itself stays here)
|
||||
- `/press-kit`
|
||||
- `/tutorials`, `/tutorials/:slug`
|
||||
- `/faq`, `/faq/:slug`
|
||||
- `/news`, `/news/:slug`
|
||||
- `/support`
|
||||
- `/pricing` — Stripe checkout flow stays here (PricingTable → Stripe →
|
||||
`/checkout/success` → WebappRedirect → webapp)
|
||||
@@ -1,157 +0,0 @@
|
||||
/background-change /index.html 200
|
||||
/background-change/ /index.html 200
|
||||
/background-change/* /index.html 200
|
||||
|
||||
/checkout_cancel /index.html 200
|
||||
/checkout_cancel/ /index.html 200
|
||||
/checkout_cancel/* /index.html 200
|
||||
|
||||
/onboarding /index.html 200
|
||||
/onboarding/ /index.html 200
|
||||
/onboarding/* /index.html 200
|
||||
|
||||
/portal_closed /index.html 200
|
||||
/portal_closed/ /index.html 200
|
||||
/portal_closed/* /index.html 200
|
||||
|
||||
/checkout_success /index.html 200
|
||||
/checkout_success/ /index.html 200
|
||||
/checkout_success/* /index.html 200
|
||||
|
||||
/checkout* /index.html 200
|
||||
/checkout/* /index.html 200
|
||||
|
||||
/create-image /index.html 200
|
||||
/create-image/ /index.html 200
|
||||
/create-image/* /index.html 200
|
||||
|
||||
/create-video /index.html 200
|
||||
/create-video/ /index.html 200
|
||||
/create-video/* /index.html 200
|
||||
|
||||
/support /index.html 200
|
||||
/support/ /index.html 200
|
||||
/support/* /index.html 200
|
||||
|
||||
/library /index.html 200
|
||||
/library/ /index.html 200
|
||||
/library/* /index.html 200
|
||||
|
||||
/download /index.html 200
|
||||
/download/ /index.html 200
|
||||
|
||||
/tutorials /index.html 200
|
||||
/tutorials/ /index.html 200
|
||||
|
||||
/login /index.html 200
|
||||
/login/ /index.html 200
|
||||
/login/* /index.html 200
|
||||
|
||||
/media /index.html 200
|
||||
/media/ /index.html 200
|
||||
/media/* /index.html 200
|
||||
|
||||
# Prerendered: serve the per-route index.html so social crawlers get the
|
||||
# correct OG/title tags (see scripts/prerender-meta.mjs). The SPA still boots
|
||||
# from this file and React Router takes over for users.
|
||||
/pricing /pricing/index.html 200
|
||||
/pricing/ /pricing/index.html 200
|
||||
/pricing/* /index.html 200
|
||||
|
||||
/beta /beta/index.html 200
|
||||
/beta/ /beta/index.html 200
|
||||
/beta/* /index.html 200
|
||||
|
||||
/faq /index.html 200
|
||||
/faq/ /index.html 200
|
||||
/faq/* /index.html 200
|
||||
|
||||
/news /index.html 200
|
||||
/news/ /index.html 200
|
||||
/news/* /index.html 200
|
||||
|
||||
/press-kit /index.html 200
|
||||
/press-kit/ /index.html 200
|
||||
/press-kit/* /index.html 200
|
||||
|
||||
/referrals /index.html 200
|
||||
/referrals/ /index.html 200
|
||||
/referrals/* /index.html 200
|
||||
|
||||
# Redirects for growth measurement
|
||||
/reddit /?utm_source=reddit 301
|
||||
|
||||
/news.json /index.html 200
|
||||
|
||||
/signup /index.html 200
|
||||
/signup/ /index.html 200
|
||||
/signup/* /index.html 200
|
||||
|
||||
/forgot-password /index.html 200
|
||||
/forgot-password/ /index.html 200
|
||||
/forgot-password/* /index.html 200
|
||||
|
||||
/welcome /index.html 200
|
||||
/welcome/ /index.html 200
|
||||
/welcome/* /index.html 200
|
||||
|
||||
/password-reset /index.html 200
|
||||
/password-reset/ /index.html 200
|
||||
/password-reset/* /index.html 200
|
||||
|
||||
# Prerendered: exact paths serve their per-route index.html (correct OG tags
|
||||
# for social crawlers); deep sub-paths fall back to the SPA shell. See
|
||||
# scripts/prerender-meta.mjs. NOTE: these exact-match serving rules MUST come
|
||||
# before the /seedance2* alias below, otherwise that wildcard would 301
|
||||
# /seedance2-5 away to /seedance-2 (first match wins in _redirects).
|
||||
/seedance2-5 /seedance2-5/index.html 200
|
||||
/seedance2-5/ /seedance2-5/index.html 200
|
||||
/seedance2-5/* /index.html 200
|
||||
|
||||
/seedance-2 /seedance-2/index.html 200
|
||||
/seedance-2/ /seedance-2/index.html 200
|
||||
/seedance-2/* /index.html 200
|
||||
|
||||
/minimax-h3 /minimax-h3/index.html 200
|
||||
/minimax-h3/ /minimax-h3/index.html 200
|
||||
/minimax-h3/* /index.html 200
|
||||
|
||||
/creators/jboogxcreative /creators/jboogxcreative/index.html 200
|
||||
/creators/jboogxcreative/ /creators/jboogxcreative/index.html 200
|
||||
/creators/* /index.html 200
|
||||
|
||||
# Similar URLs — creator spotlight aliases
|
||||
/jboogx /creators/jboogxcreative 301
|
||||
/jboogx/ /creators/jboogxcreative 301
|
||||
/jboogxcreative /creators/jboogxcreative 301
|
||||
/jboogxcreative/ /creators/jboogxcreative 301
|
||||
|
||||
# Similar URLs: MiniMax H3 aliases
|
||||
/minimax /minimax-h3 301
|
||||
/minimax/ /minimax-h3 301
|
||||
/minimaxh3 /minimax-h3 301
|
||||
/minimaxh3* /minimax-h3 301
|
||||
/minimax-h-3 /minimax-h3 301
|
||||
/minimax-h-3* /minimax-h3 301
|
||||
/hailuo /minimax-h3 301
|
||||
/hailuo/ /minimax-h3 301
|
||||
|
||||
# Google ads breakage
|
||||
/seedance-2{ignore} /seedance-2 301
|
||||
/seedance-2{ignore}* /seedance-2 301
|
||||
/seedance-2%7Bignore%7D /seedance-2 301
|
||||
/seedance-2%7Bignore%7D* /seedance-2 301
|
||||
|
||||
# Similar URLs — Seedance 2.5 aliases (kept above the broad /seedance2* rule)
|
||||
/seedance-2-5 /seedance2-5 301
|
||||
/seedance-2-5* /seedance2-5 301
|
||||
/seedance-2.5 /seedance2-5 301
|
||||
/seedance-2.5* /seedance2-5 301
|
||||
/seedance25 /seedance2-5 301
|
||||
/seedance25* /seedance2-5 301
|
||||
|
||||
# Similar URLs — Seedance 2.0 aliases
|
||||
/seedance2 /seedance-2 301
|
||||
/seedance2* /seedance-2 301
|
||||
/seedance-2.0 /seedance-2 301
|
||||
/seedance-2.0* /seedance-2 301
|
||||
@@ -1,52 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
if (window.location.pathname === "/login/desktop") {
|
||||
const policy = document.createElement("meta");
|
||||
policy.name = "referrer";
|
||||
policy.content = "no-referrer";
|
||||
document.head.appendChild(policy);
|
||||
} else {
|
||||
const analytics = document.createElement("script");
|
||||
analytics.async = true;
|
||||
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
|
||||
document.head.appendChild(analytics);
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<title>ArtCraft. AI Video and Images. Fast and Open Desktop App.</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="ArtCraft is an Open Desktop app for generating AI Video and Images. You own ArtCraft!"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="ArtCraft, AI, AI Video, AI Images, Open Source"
|
||||
/>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
# Netlify settings used to discover this file:
|
||||
# Base directory: frontend
|
||||
# Package directory: apps/artcraft-website (relative to the base directory)
|
||||
# Manage the build command and publish directory here; leave those UI fields empty.
|
||||
# Paths below are relative to the frontend workspace.
|
||||
|
||||
[build]
|
||||
command = "bash ./apps/artcraft-website/script/netlify_build.sh"
|
||||
publish = "apps/artcraft-website/dist/"
|
||||
|
||||
[[headers]]
|
||||
for = "/build/*"
|
||||
|
||||
[headers.values]
|
||||
"Cache-Control" = "public, max-age=31560000, immutable"
|
||||
|
||||
# [[redirects]]
|
||||
# from = "/ingest/static/*"
|
||||
# to = "https://us-assets.i.posthog.com/static/:splat"
|
||||
# host = "us-assets.i.posthog.com"
|
||||
# status = 200
|
||||
# force = true
|
||||
#
|
||||
# [[redirects]]
|
||||
# from = "/ingest/*"
|
||||
# to = "https://us.i.posthog.com/:splat"
|
||||
# host = "us.i.posthog.com"
|
||||
# status = 200
|
||||
# force = true
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "@frontend/artcraft-website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react-masonry-css": "^1.0.16"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const { join } = require('path');
|
||||
|
||||
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
|
||||
// option from your application's configuration (i.e. project.json).
|
||||
//
|
||||
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {
|
||||
config: join(__dirname, 'tailwind.config.js'),
|
||||
},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #4e7bfb;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 483 B |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 19 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 22 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 22 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72.22 65.68">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 781 B |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Flux</title><path d="M0 20.683L12.01 2.5 24 20.683h-2.233L12.009 5.878 3.471 18.806h12.122l1.239 1.877H0z"></path><path d="M8.069 16.724l2.073-3.115 2.074 3.115H8.069zM18.24 20.683l-5.668-8.707h2.177l5.686 8.707h-2.196zM19.74 11.676l2.13-3.19 2.13 3.19h-4.26z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 438 B |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ByteDance</title><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z"></path><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z"></path><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z"></path><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 873 B |
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Fal</title><path clip-rule="evenodd" d="M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 697 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M64 144C64 117.5 85.5 96 112 96L208 96C234.5 96 256 117.5 256 144L256 160L384 160L384 144C384 117.5 405.5 96 432 96L528 96C554.5 96 576 117.5 576 144L576 240C576 266.5 554.5 288 528 288L432 288C405.5 288 384 266.5 384 240L384 224L256 224L256 240C256 247.3 254.3 254.3 251.4 260.5L320 352L400 352C426.5 352 448 373.5 448 400L448 496C448 522.5 426.5 544 400 544L304 544C277.5 544 256 522.5 256 496L256 400C256 392.7 257.7 385.7 260.6 379.5L192 288L112 288C85.5 288 64 266.5 64 240L64 144z"/></svg>
|
||||
|
Before Width: | Height: | Size: 717 B |