Compare commits

..
Author SHA1 Message Date
BFlat d62179c36a Merge branch 'main' into feature/router-priority 2025-06-21 02:22:14 +07:00
bflatastic 783f6f9223 login fixes with zustand states and model router priority tab in settings with drag and drop reordering 2025-06-21 02:18:13 +07:00
ArEnScandCrossPr0duct 744df6bcb8 Fixed issue with image background removal and drag and drop. (#689)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-20 12:55:26 -04:00
bflatastic 9a5ab77d3c don't return profile dropdown 2025-06-20 23:07:18 +07:00
Brandon Thomas 3e42ccc5e5 add artcraft/storyteller host to app info for debugging (#688) 2025-06-20 08:36:47 -04:00
BFlat 81bf7d712c feature/pricing modal (#684)
- pricing modal with tiers: free, plus, pro and max
- set up and ready to integrate with stripe
- small bug fix with the library modal buttons being squished

- upgrade button in TopBar is commented out for now
2025-06-20 12:36:37 +07:00
ArEnScandCrossPr0duct e9fe8bdf0d Fix/login (#686)
Moved Login to top level.
Minor bug fix later., logout after login in and exiting, log out and the button
won't trigger the modal

---------

Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-20 01:07:18 -04:00
Brandon Thomas 627fd6beeb roll back Cargo.lock 2025-06-20 00:46:06 -04:00
Brandon Thomas 77788a74e9 fix imports 2025-06-19 21:30:22 -04:00
Brandon Thomas 691f27eb23 misc: refactor client, move endpoints, more models, local testing, etc. etc. (#683) 2025-06-19 20:41:46 -04:00
Brandon Thomas 9008e1f4f4 logging for fal webhook handler 2025-06-19 16:04:38 -04:00
77 changed files with 3556 additions and 2075 deletions
Generated
+1960 -1641
View File
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,9 @@
"allow": [
{
"url": "https://*"
},
{
"url": "http://localhost:12345"
}
]
},
@@ -1,10 +1,13 @@
use crate::core::commands::enqueue::image::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::image::handle_image_fal::handle_image_fal;
use crate::core::commands::enqueue::image::handle_image_sora::handle_image_sora;
use crate::core::commands::enqueue::image::internal_image_error::InternalImageError;
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
use crate::core::commands::response::failure_response_wrapper::{CommandErrorResponseWrapper, CommandErrorStatus};
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
@@ -55,8 +58,6 @@ use storyteller_client::media_files::upload_image_media_file_from_file::upload_i
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, Emitter, Manager, State};
use tempfile::NamedTempFile;
use crate::core::commands::enqueue::image::handle_image_artcraft::handle_image_artcraft;
use crate::core::commands::enqueue::object::handle_object_artcraft::handle_object_artcraft;
#[derive(Deserialize)]
pub struct EnqueueTextToImageRequest {
@@ -114,6 +115,7 @@ pub async fn enqueue_text_to_image_command(
app: AppHandle,
request: EnqueueTextToImageRequest,
app_data_root: State<'_, AppDataRoot>,
app_env_configs: State<'_, AppEnvConfigs>,
fal_creds_manager: State<'_, FalCredentialManager>,
storyteller_creds_manager: State<'_, StorytellerCredentialManager>,
fal_task_queue: State<'_, FalTaskQueue>,
@@ -129,6 +131,7 @@ pub async fn enqueue_text_to_image_command(
&app_data_root,
&fal_creds_manager,
&storyteller_creds_manager,
&app_env_configs,
&fal_task_queue,
&sora_creds_manager,
&sora_task_queue,
@@ -176,6 +179,7 @@ pub async fn handle_request(
app_data_root: &AppDataRoot,
fal_creds_manager: &FalCredentialManager,
storyteller_creds_manager: &StorytellerCredentialManager,
app_env_configs: &AppEnvConfigs,
fal_task_queue: &FalTaskQueue,
sora_creds_manager: &SoraCredentialManager,
sora_task_queue: &SoraTaskQueue,
@@ -197,7 +201,7 @@ pub async fn handle_request(
if fal_creds_manager.has_apparent_api_token()? {
handle_image_fal(&app, request, fal_creds_manager, fal_task_queue).await?;
} else {
handle_image_artcraft(request, &app, app_data_root, storyteller_creds_manager).await?;
handle_image_artcraft(request, &app, app_env_configs, app_data_root, storyteller_creds_manager).await?;
}
Ok(())
@@ -4,6 +4,7 @@ use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -37,6 +38,7 @@ use tauri::{AppHandle, State};
pub async fn handle_image_artcraft(
request: EnqueueTextToImageRequest,
app: &AppHandle,
app_env_configs: &AppEnvConfigs,
app_data_root: &AppDataRoot,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<(), InternalImageError> {
@@ -82,7 +84,7 @@ pub async fn handle_image_artcraft(
num_images: None,
};
let result = generate_flux_1_dev_text_to_image(
&ApiHost::Storyteller,
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
@@ -107,7 +109,7 @@ pub async fn handle_image_artcraft(
num_images: None,
};
let result = generate_flux_1_schnell_text_to_image(
&ApiHost::Storyteller,
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
@@ -132,7 +134,7 @@ pub async fn handle_image_artcraft(
num_images: None,
};
let result = generate_flux_pro_11_text_to_image(
&ApiHost::Storyteller,
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
@@ -160,7 +162,7 @@ pub async fn handle_image_artcraft(
num_images: None,
};
let result = generate_flux_pro_11_ultra_text_to_image(
&ApiHost::Storyteller,
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
@@ -7,6 +7,7 @@ use crate::core::commands::response::failure_response_wrapper::{CommandErrorResp
use crate::core::commands::response::shorthand::Response;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
@@ -66,25 +67,7 @@ pub struct EnqueueImageToVideoRequest {
pub image_media_token: Option<MediaFileToken>,
/// The model to use.
pub model: Option<EnqueueImageToVideoModel>,
}
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum EnqueueImageToVideoModel {
#[deprecated(note = "Use `kling_1_6_pro` instead")]
#[serde(rename = "kling1_6")]
Kling16,
#[serde(rename = "kling_1_6_pro")]
Kling16Pro,
#[serde(rename = "kling_2_1_pro")]
Kling21Pro,
#[serde(rename = "kling_2_1_master")]
Kling21Master,
#[serde(rename = "seedance_1_0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
pub model: Option<VideoModel>,
}
#[derive(Serialize)]
@@ -1,11 +1,12 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::{EnqueueImageTo3dObjectModel, EnqueueImageTo3dObjectRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::{EnqueueImageToVideoModel, EnqueueImageToVideoRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::video::internal_video_error::InternalVideoError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -17,26 +18,26 @@ use crate::services::storyteller::state::storyteller_credential_manager::Storyte
use anyhow::anyhow;
use artcraft_api_defs::generate::object::generate_hunyuan_2_0_image_to_3d::GenerateHunyuan20ImageTo3dRequest;
use artcraft_api_defs::generate::video::generate_kling_1_6_pro_image_to_video::{GenerateKling16ProAspectRatio, GenerateKling16ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterAspectRatio, GenerateKling21MasterImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProAspectRatio, GenerateKling21ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2AspectRatio, GenerateVeo2ImageToVideoRequest};
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::queue::enqueue_hunyuan2_image_to_3d::{enqueue_hunyuan2_image_to_3d, Hunyuan2Args};
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
use fal_client::requests::queue::image_gen::enqueue_recraft3_text_to_image::{enqueue_recraft3_text_to_image, Recraft3TextToImageArgs};
use fal_client::requests::queue::video_gen::enqueue_kling_16_pro_image_to_video::Kling16ProAspectRatio;
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::Veo2AspectRatio;
use idempotency::uuid::generate_random_uuid;
use log::{error, info, warn};
use storyteller_client::generate::object::generate_hunyuan_3d_2_0_image_to_3d::generate_hunyuan3d_2_0_image_to_3d;
use storyteller_client::generate::video::generate_kling_16_pro_image_to_video::generate_kling_16_pro_image_to_video;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video::{GenerateKling21MasterAspectRatio, GenerateKling21MasterImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_kling_2_1_pro_image_to_video::{GenerateKling21ProAspectRatio, GenerateKling21ProImageToVideoRequest};
use artcraft_api_defs::generate::video::generate_seedance_1_0_lite_image_to_video::GenerateSeedance10LiteImageToVideoRequest;
use artcraft_api_defs::generate::video::generate_veo_2_image_to_video::{GenerateVeo2AspectRatio, GenerateVeo2ImageToVideoRequest};
use fal_client::requests::webhook::video::enqueue_veo_2_image_to_video_webhook::Veo2AspectRatio;
use storyteller_client::generate::video::generate_kling_21_master_image_to_video::generate_kling_21_master_image_to_video;
use storyteller_client::generate::video::generate_kling_21_pro_image_to_video::generate_kling_21_pro_image_to_video;
use storyteller_client::generate::video::generate_seedance_1_0_lite_image_to_video::generate_seedance_1_0_lite_image_to_video;
use storyteller_client::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
use storyteller_client::utils::api_host::ApiHost;
use tauri::{AppHandle, State};
pub async fn handle_video_artcraft(
request: EnqueueImageToVideoRequest,
@@ -78,10 +79,7 @@ pub async fn handle_video_artcraft(
None => {
return Err(InternalVideoError::NoModelSpecified);
}
Some(
EnqueueImageToVideoModel::Kling16 |
EnqueueImageToVideoModel::Kling16Pro
) => {
Some(VideoModel::Kling16Pro) => {
info!("enqueue Kling 1.6 Pro with Artcraft API");
selected_model = Some(GenerationModel::Kling1_6);
let request = GenerateKling16ProImageToVideoRequest {
@@ -107,7 +105,7 @@ pub async fn handle_video_artcraft(
}
}
}
Some(EnqueueImageToVideoModel::Kling21Master) => {
Some(VideoModel::Kling21Master) => {
info!("enqueue Kling 2.1 Master with Artcraft API");
selected_model = Some(GenerationModel::Kling21Master);
let request = GenerateKling21MasterImageToVideoRequest {
@@ -133,7 +131,8 @@ pub async fn handle_video_artcraft(
}
}
}
Some(EnqueueImageToVideoModel::Kling21Pro) => {
Some(VideoModel::Kling21Pro) => {
info!("enqueue Kling 2.1 Pro with Artcraft API");
selected_model = Some(GenerationModel::Kling21Pro);
let request = GenerateKling21ProImageToVideoRequest {
@@ -159,7 +158,7 @@ pub async fn handle_video_artcraft(
}
}
}
Some(EnqueueImageToVideoModel::Seedance10Lite) => {
Some(VideoModel::Seedance10Lite) => {
info!("enqueue Seedance 1.0 Lite with Artcraft API");
selected_model = Some(GenerationModel::Seedance10Lite);
let request = GenerateSeedance10LiteImageToVideoRequest {
@@ -185,7 +184,7 @@ pub async fn handle_video_artcraft(
}
}
}
Some(EnqueueImageToVideoModel::Veo2) => {
Some(VideoModel::Veo2) => {
info!("enqueue Veo 2 with Artcraft API");
selected_model = Some(GenerationModel::Veo2);
let request = GenerateVeo2ImageToVideoRequest {
@@ -1,10 +1,11 @@
use crate::core::commands::enqueue::image::enqueue_text_to_image_command::{EnqueueTextToImageModel, EnqueueTextToImageRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::{EnqueueImageToVideoModel, EnqueueImageToVideoRequest};
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::EnqueueImageToVideoRequest;
use crate::core::commands::enqueue::video::internal_video_error::InternalVideoError;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::model::video_models::VideoModel;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::utils::download_media_file_to_temp_dir::download_media_file_to_temp_dir;
use crate::core::utils::save_base64_image_to_temp_dir::save_base64_image_to_temp_dir;
@@ -68,10 +69,7 @@ pub async fn handle_video_fal(
None => {
return Err(InternalVideoError::NoModelSpecified);
}
Some(
EnqueueImageToVideoModel::Kling16 |
EnqueueImageToVideoModel::Kling16Pro
) => {
Some(VideoModel::Kling16Pro) => {
info!("enqueue Kling 1.6 image to video with Kling API");
selected_model = Some(GenerationModel::Kling1_6);
enqueue_kling_16_pro_image_to_video(Kling16ProArgs {
@@ -1,5 +1,6 @@
use crate::core::commands::response::shorthand::InfallibleResponse;
use crate::core::commands::response::success_response_wrapper::SerializeMarker;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::os_platform::OsPlatform;
use crate::services::fal::commands::fal_background_removal_command::FalBackgroundRemovalSuccessResponse;
use base64::prelude::BASE64_STANDARD;
@@ -10,31 +11,38 @@ use image::ImageReader;
use log::info;
use serde_derive::Serialize;
use std::io::Cursor;
use tauri::State;
#[derive(Debug, Serialize)]
pub struct BuildInfoResponse {
pub struct AppInfoResponse {
pub build_timestamp: DateTime<Utc>,
pub git_commit_id: Option<String>,
pub git_commit_short_id: Option<String>,
pub git_commit_timestamp: Option<DateTime<Utc>>,
pub storyteller_host: String,
}
impl SerializeMarker for BuildInfoResponse {}
impl SerializeMarker for AppInfoResponse {}
#[tauri::command]
pub fn get_build_info_command() -> InfallibleResponse<BuildInfoResponse> {
info!("get_build_info_command called...");
pub fn get_app_info_command(
app_env_configs: State<'_, AppEnvConfigs>,
) -> InfallibleResponse<AppInfoResponse> {
info!("get_app_info_command called...");
let build_timestamp = build_metadata::build_timestamp();
let git_commit_id = build_metadata::git_commit_id();
let git_commit_short_id = build_metadata::git_commit_short_id();
let git_commit_timestamp = build_metadata::git_commit_timestamp();
BuildInfoResponse {
let storyteller_host = app_env_configs.storyteller_host.to_api_hostname();
AppInfoResponse {
build_timestamp,
git_commit_id: git_commit_id.map(|s| s.to_string()),
git_commit_short_id: git_commit_short_id.map(|s| s.to_string()),
git_commit_timestamp,
storyteller_host,
}.into()
}
@@ -1,6 +1,6 @@
pub mod app_preferences;
pub mod enqueue;
pub mod flip_image;
pub mod get_build_info_command;
pub mod get_app_info_command;
pub mod platform_info_command;
pub mod response;
+1
View File
@@ -1,6 +1,7 @@
pub mod artcraft_error;
pub mod commands;
pub mod events;
pub mod model;
pub mod state;
pub mod threads;
pub mod utils;
@@ -0,0 +1,2 @@
pub mod image_models;
pub mod video_models;
@@ -0,0 +1,22 @@
use serde_derive::Deserialize;
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
#[derive(Deserialize, Debug, Copy, Clone)]
#[serde(rename_all = "snake_case")]
pub enum VideoModel {
#[serde(rename = "kling_1.6_pro")]
Kling16Pro,
#[serde(rename = "kling_2.1_pro")]
Kling21Pro,
#[serde(rename = "kling_2.1_master")]
Kling21Master,
#[serde(rename = "seedance_1.0_lite")]
Seedance10Lite,
#[serde(rename = "veo_2")]
Veo2,
}
@@ -0,0 +1,38 @@
use log::info;
use errors::AnyhowResult;
use storyteller_client::utils::api_host::ApiHost;
use crate::core::state::app_env_configs::app_env_configs_serializeable::{AppEnvConfigsSerializable, StorytellerApiHost};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
#[derive(Clone)]
pub struct AppEnvConfigs {
pub storyteller_host: ApiHost,
}
impl AppEnvConfigs {
pub fn load_from_filesystem(root: &AppDataRoot) -> AnyhowResult<Self> {
println!("Loading app environmental configs from filesystem...");
let input = AppEnvConfigsSerializable::load_from_filesystem(root)?;
let storyteller = input.as_ref()
.map(|i| i.storyteller_host)
.flatten();
let storyteller_api = match storyteller {
Some(StorytellerApiHost::Localhost) => ApiHost::Localhost {
port: input.as_ref()
.and_then(|i| i.storyteller_port)
.unwrap_or(12345)
},
Some(StorytellerApiHost::Production) => ApiHost::Storyteller,
_ => ApiHost::Storyteller,
};
println!("Using storyteller API host: {:?}", storyteller_api);
Ok(Self {
storyteller_host: storyteller_api,
})
}
}
@@ -0,0 +1,34 @@
use serde_derive::{Deserialize, Serialize};
use errors::AnyhowResult;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
const CURRENT_VERSION: &str = "1";
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerApiHost {
Production,
Localhost,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct AppEnvConfigsSerializable {
/// Versioning string.
pub version: Option<String>,
pub storyteller_port: Option<u32>,
pub storyteller_host: Option<StorytellerApiHost>,
}
impl AppEnvConfigsSerializable {
pub fn load_from_filesystem(app_data_root: &AppDataRoot) -> AnyhowResult<Option<Self>> {
let filename = app_data_root.settings_dir().get_app_env_configs_path();
if !filename.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(filename)?;
let data: Self = serde_json::from_str(&contents)?;
Ok(Some(data))
}
}
@@ -0,0 +1,2 @@
pub mod app_env_configs;
pub mod app_env_configs_serializeable;
@@ -31,7 +31,11 @@ impl AppSettingsDir {
pub fn get_app_preferences_path(&self) -> PathBuf {
self.path.join("app_preferences.json")
}
pub fn get_app_env_configs_path(&self) -> PathBuf {
self.path.join("env_configs.json")
}
pub fn write_app_preferences(&self, prefs: &AppPreferences) -> AnyhowResult<()> {
let serializable = prefs.to_serializable();
@@ -1,3 +1,4 @@
pub mod app_env_configs;
pub mod app_preferences;
pub mod app_startup_time;
pub mod data_dir;
+7 -2
View File
@@ -9,8 +9,9 @@ use crate::core::commands::enqueue::image::enqueue_text_to_image_command::enqueu
use crate::core::commands::enqueue::object::enqueue_image_to_3d_object_command::enqueue_image_to_3d_object_command;
use crate::core::commands::enqueue::video::enqueue_image_to_video_command::enqueue_image_to_video_command;
use crate::core::commands::flip_image::flip_image;
use crate::core::commands::get_build_info_command::get_build_info_command;
use crate::core::commands::get_app_info_command::get_app_info_command;
use crate::core::commands::platform_info_command::platform_info_command;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_preferences::app_preferences_manager::load_app_preferences_or_default;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::main_window_position::MainWindowPosition;
@@ -71,6 +72,9 @@ pub fn run() {
let fal_task_queue = FalTaskQueue::new();
let fal_task_queue_2 = fal_task_queue.clone();
let app_env_configs = AppEnvConfigs::load_from_filesystem(&app_data_root)
.expect("AppEnvConfigs should be loaded from disk");
println!("Initializing backend runtime...");
@@ -155,6 +159,7 @@ pub fn run() {
Ok(())
})
.manage(app_data_root)
.manage(app_env_configs)
.manage(app_preferences)
.manage(fal_creds_manager)
.manage(fal_task_queue)
@@ -170,8 +175,8 @@ pub fn run() {
fal_hunyuan_image_to_3d_command,
fal_kling_image_to_video_command,
flip_image,
get_app_info_command,
get_app_preferences_command,
get_build_info_command,
get_fal_api_key_command,
open_sora_login_command,
platform_info_command,
@@ -1,68 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
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, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_1_dev_text_to_image::{GenerateFlux1DevTextToImageRequest, GenerateFlux1DevTextToImageResponse, GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_flux_1_dev_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFlux1DevTextToImageRequest,
) -> Result<GenerateFlux1DevTextToImageResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
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);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_1_DEV_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,68 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
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, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_flux_1_schnell_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFlux1SchnellTextToImageRequest,
) -> Result<GenerateFlux1SchnellTextToImageResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
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);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,68 +1,19 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
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, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_pro_11_text_to_image::{GenerateFluxPro11TextToImageRequest, GenerateFluxPro11TextToImageResponse, GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_flux_pro_11_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFluxPro11TextToImageRequest,
) -> Result<GenerateFluxPro11TextToImageResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
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);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_PRO_11_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,68 +1,18 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
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, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::basic_json_post_request::basic_json_post_request;
use artcraft_api_defs::generate::image::generate_flux_pro_11_ultra_text_to_image::{GenerateFluxPro11UltraTextToImageRequest, GenerateFluxPro11UltraTextToImageResponse, GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
pub async fn generate_flux_pro_11_ultra_text_to_image(
api_host: &ApiHost,
maybe_creds: Option<&StorytellerCredentialSet>,
request: GenerateFluxPro11UltraTextToImageRequest,
) -> Result<GenerateFluxPro11UltraTextToImageResponse, StorytellerError> {
let url = get_route(api_host);
info!("Requesting {:?}", &url);
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);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost) -> String {
let api_hostname = api_host.to_api_hostname();
format!("https://{}{}", api_hostname, GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH)
Ok(basic_json_post_request(
api_host,
GENERATE_FLUX_PRO_11_ULTRA_TEXT_TO_IMAGE_PATH,
maybe_creds,
request,
).await?)
}
@@ -1,5 +1,5 @@
#[derive(Clone)]
#[derive(Clone, Debug)]
pub enum ApiHost {
Storyteller,
FakeYou,
@@ -14,4 +14,12 @@ impl ApiHost {
ApiHost::Localhost { port } => format!("localhost:{}", port),
}
}
pub fn to_api_hostname_and_scheme(&self) -> String {
match self {
ApiHost::Storyteller => "https://api.storyteller.ai".to_string(),
ApiHost::FakeYou => "https://api.fakeyou.com".to_string(),
ApiHost::Localhost { port } => format!("http://localhost:{}", port),
}
}
}
@@ -0,0 +1,71 @@
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
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, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use artcraft_api_defs::generate::image::generate_flux_1_schnell_text_to_image::{GenerateFlux1SchnellTextToImageRequest, GenerateFlux1SchnellTextToImageResponse, GENERATE_FLUX_1_SCHNELL_TEXT_TO_IMAGE_PATH};
use log::{debug, info};
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
api_host: &ApiHost,
route_path: &str,
maybe_creds: Option<&StorytellerCredentialSet>,
request: Req,
) -> Result<Res, StorytellerError> {
let url = get_route(api_host, route_path);
info!("Requesting {:?}", &url);
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);
if let Some(creds) = maybe_creds {
if let Some(header) = &creds.maybe_as_cookie_header() {
request_builder = request_builder.header("Cookie", header);
}
}
let request_body = serde_json::to_string(&request)
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
debug!("Request body: {:?}", request_body);
let response = request_builder
.body(request_body)
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response = filter_bad_response(response)
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
let response_body = &response.text()
.await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
debug!("Response body: {:?}", response_body);
let response = serde_json::from_str(&response_body)
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
Ok(response)
}
fn get_route(api_host: &ApiHost, route_path: &str) -> String {
let api_hostname = api_host.to_api_hostname_and_scheme();
format!("{}{}", api_hostname, route_path)
}
@@ -1,4 +1,5 @@
pub mod api_host;
pub mod basic_json_post_request;
pub mod constants;
pub mod filter_bad_response;
pub mod http_get_anonymous;
@@ -527,8 +527,8 @@ use crate::http_server::web_utils::response_success_helpers::*;
GenerateFluxPro11UltraTextToImageResponse,
GenerateHunyuan21ImageTo3dRequest,
GenerateHunyuan21ImageTo3dResponse,
GenerateHunyuan2ImageTo3dRequest,
GenerateHunyuan2ImageTo3dResponse,
GenerateHunyuan20ImageTo3dRequest,
GenerateHunyuan20ImageTo3dResponse,
GenerateKling16ProAspectRatio,
GenerateKling16ProDuration,
GenerateKling16ProImageToVideoRequest,
@@ -2,6 +2,7 @@ use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::webhooks::handle_image_payload::handle_image_payload;
use crate::http_server::endpoints::webhooks::handle_images_payload::handle_images_payload;
use crate::http_server::endpoints::webhooks::handle_model_mesh_payload::handle_model_mesh_payload;
use crate::http_server::endpoints::webhooks::handle_video_payload::handle_video_payload;
use crate::state::server_state::ServerState;
@@ -18,7 +19,6 @@ use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id:
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::mark_fal_generic_inference_job_successfully_done;
use serde_json::Value;
use utoipa::ToSchema;
// 1. tauri --> hit endpoint to enqueue
//
// 2. webhook
@@ -140,17 +140,24 @@ pub async fn fal_webhook_handler(
return Err(FalWebhookError::ServerError);
}
};
let mut maybe_media_token = None;
info!("Fal webhook job record: {:?}", job);
let mut maybe_media_token = None;
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
info!("Handling image payload for job: {:?}", job.job_token);
let token = handle_image_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("images") {
maybe_media_token = handle_images_payload(payload_obj, &job, &server_state).await?;
} else if payload_obj.contains_key("video") {
info!("Handling video payload for job: {:?}", job.job_token);
let token = handle_video_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
info!("Handling model_mesh payload for job: {:?}", job.job_token);
let token = handle_model_mesh_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
}
@@ -167,6 +174,8 @@ pub async fn fal_webhook_handler(
warn!("Error marking job as successfully done: {:?}", err);
FalWebhookError::ServerError
})?;
} else {
warn!("No media token found in payload for job: {:?}", job.job_token);
}
Ok(SimpleGenericJsonSuccess::wrapped(true))
@@ -0,0 +1,124 @@
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::state::server_state::ServerState;
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
use crate::util::http_download_url_to_tempfile::http_download_url_to_tempfile;
use actix_web::web;
use anyhow::anyhow;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use errors::AnyhowResult;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::image_info::image_info::ImageInfo;
use log::{info, warn};
use mimetypes::mimetype_info::mimetype_info::MimetypeInfo;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use serde_json::{Map, Value};
use std::sync::Arc;
use tokens::tokens::media_files::MediaFileToken;
const PREFIX : Option<&str> = Some("artcraft_");
#[derive(Deserialize, Debug)]
pub struct FalWebhookImage {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub height: Option<usize>,
pub width: Option<usize>,
pub url: Option<String>,
}
pub async fn handle_images_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
) -> AnyhowResult<Option<MediaFileToken>> {
let images_value = payload.get("images")
.ok_or_else(|| anyhow!("no `images` key in payload"))?;
info!("Fal Images Payload: {:?}", images_value);
let images: Vec<FalWebhookImage> = serde_json::from_value(images_value.clone())?;
let mut maybe_media_token = None;
for image in images {
let media_token = upload_image(job, server_state, &image).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(media_token); // Set the first media token
}
}
Ok(maybe_media_token)
}
pub async fn upload_image(
job: &FalJobDetails,
server_state: &ServerState,
image: &FalWebhookImage,
) -> AnyhowResult<MediaFileToken> {
let image_url = image.url
.as_deref()
.ok_or_else(|| anyhow!("no `url` in image payload"))?;
//let mime_type = image.content_type
// .as_deref()
// .ok_or_else(|| anyhow!("no `content_type` in image payload"))?;
let file_bytes = http_download_url_to_bytes(image_url)
.await
.map_err(|e| anyhow!("Failed to download image: {:?}", e))?;
let mimetype_info = MimetypeInfo::get_for_bytes(&file_bytes)
.ok_or_else(|| anyhow!("Failed to get mimetype info"))?;
let mime_type = mimetype_info.mime_type();
let media_file_type = MediaFileType::try_from_mime_type(mime_type)
.ok_or_else(|| anyhow!("Unsupported media file type: {}", mime_type))?;
let extension_with_period = mimetype_info.file_extension()
.map(|ext| ext.extension_with_period())
.ok_or_else(|| anyhow!("Failed to get file extension from mimetype info"))?;
let file_size_bytes = file_bytes.len();
let file_hash = sha256_hash_bytes(&file_bytes)?;
let image_info = ImageInfo::decode_image_from_bytes(&file_bytes)?;
let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension_with_period));
info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str());
server_state.public_bucket_client.upload_file_with_content_type_process(
public_upload_path.get_full_object_path_str(),
file_bytes.as_ref(),
&mime_type)
.await?;
let media_token = MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
.public_bucket_directory_hash(&public_upload_path)
.media_file_class(MediaFileClass::Image)
.media_file_type(media_file_type)
.media_file_origin_category(MediaFileOriginCategory::Inference)
//.media_file_origin_product_category(MediaFileOriginProductCategory::Unknown)
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.frame_width(image_info.width())
.frame_height(image_info.height())
.checksum_sha2(&file_hash)
.insert_pool(&server_state.mysql_pool)
.await?;
info!("Image media file uploaded with token: {}", media_token);
Ok(media_token)
}
@@ -1,4 +1,5 @@
pub mod fal_webhook_handler;
pub mod handle_image_payload;
pub mod handle_images_payload;
pub mod handle_model_mesh_payload;
pub mod handle_video_payload;
@@ -2,15 +2,13 @@ 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";
// import ProfileDropdown from "./ProfileDropdown";
import {
disableHotkeyInput,
DomLevels,
enableHotkeyInput,
} from "~/pages/PageEnigma/signals";
export const AuthButtons = ({ loginSignUpPressed }: { loginSignUpPressed: () => void }) => {
export const AuthButtons = ({
loginSignUpPressed,
}: {
loginSignUpPressed: () => void;
}) => {
useSignals();
const { status, userInfo } = authentication;
@@ -19,7 +17,7 @@ export const AuthButtons = ({ loginSignUpPressed }: { loginSignUpPressed: () =>
console.log(status.value);
if (status.value === AUTH_STATUS.LOGGED_IN) {
console.log("SHOWING DROPDOWN");
return <ProfileDropdown />;
return null;
} else {
return (
<>
@@ -28,7 +26,7 @@ export const AuthButtons = ({ loginSignUpPressed }: { loginSignUpPressed: () =>
<Button
className="h-[38px]"
onClick={() => {
loginSignUpPressed()
loginSignUpPressed();
}}
>
Login / Sign Up
@@ -6,6 +6,8 @@ import {
faFilm,
faPaintbrush,
faImage,
faDiamond,
faGem,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { AuthButtons } from "./AuthButtons";
@@ -34,6 +36,7 @@ import {
is3DSceneLoaded,
set3DPageMounted,
} from "~/pages/PageEnigma/Editor/editor";
import { usePricingModalStore } from "@storyteller/ui-pricing-modal";
interface Props {
pageName: string;
loginSignUpPressed: () => void;
@@ -93,6 +96,8 @@ export const TopBar = ({ pageName,loginSignUpPressed }: Props) => {
);
};
const { toggleModal } = usePricingModalStore();
return (
<>
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
@@ -142,6 +147,14 @@ export const TopBar = ({ pageName,loginSignUpPressed }: Props) => {
<div className="flex justify-end gap-3.5">
<div className="flex gap-2">
{/* <Button
variant="primary"
icon={faGem}
onClick={toggleModal}
className="shadow-md shadow-primary-500/50 transition-all duration-300 hover:shadow-md hover:shadow-primary-500/75"
>
Upgrade Now
</Button> */}
<Tooltip content="Settings" position="bottom" delay={300}>
<Button
variant="secondary"
@@ -614,34 +614,21 @@ export const useSceneStore = create<SceneState>((set, get) => ({
},
// Add method for URL-based images
createImageFromUrl: (x: number, y: number, url: string, width?: number, height?: number) => {
// Similar to createImageFromFile but handles URL
const img = new Image();
img.crossOrigin = "Anonymous"; // Handle CORS if necessary
img.onload = () => {
const aspectRatio = img.width / img.height;
const newWidth = width || (height ? height * aspectRatio : 200); // Default width or calculate from height
const newHeight = height || (width ? width / aspectRatio : newWidth / aspectRatio); // Default height or calculate from width
createImageFromUrl: async (x: number, y: number, url: string, width?: number, height?: number) => {
try {
// Fetch the image and convert to blob
const response = await fetch(url);
const blob = await response.blob();
const node = new Node({
id: generateId(),
type: 'image',
x,
y,
width: newWidth,
height: newHeight,
fill: 'transparent',
imageElement: img, // Use the loaded image object
draggable: true,
imageUrl: url // Store the URL as the source
});
get().addNode(node);
};
img.onerror = () => {
console.error("Error loading image from URL:", url);
// Optionally handle the error, e.g., by creating a placeholder or notifying the user
};
img.src = url;
// Create a File from the blob
const filename = url.split('/').pop() || 'image.png';
const file = new File([blob], filename, { type: blob.type });
// Use existing createImage function
get().createImage(x, y, file, width, height);
} catch (error) {
console.error("Error loading image from URL:", url, error);
}
},
// Update the createImage method to handle both URLs and Files
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from "react";
import React, { useContext, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { Controls3D } from "./comps/Controls3D";
@@ -73,20 +73,21 @@ import {
// videoGenerationModels,
// useModelSelectorStore,
} from "@storyteller/ui-model-selector";
import { LoginModal } from "@storyteller/ui-login-modal";
import { LoginModal, useLoginModalStore } from "@storyteller/ui-login-modal";
import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
export const PageEditor = () => {
useSignals();
const [isLoginModalOpen, setIsLoginModalOpen] = useState<boolean>(false);
const { triggerRecheck } = useLoginModalStore();
const { status } = authentication;
useEffect(() => {
if (status.value !== AUTH_STATUS.LOGGED_OUT) {
setIsLoginModalOpen(true);
if (status.value === AUTH_STATUS.LOGGED_OUT) {
triggerRecheck();
}
}, [status.value]);
}, [status.value, triggerRecheck]);
const tabStore = useTabStore();
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
@@ -414,32 +415,30 @@ export const PageEditor = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabStore.activeTabId, editorEngine]);
return (
<div className="w-screen">
<TopBar loginSignUpPressed={()=> {
setIsLoginModalOpen(true)
}} pageName="Edit Scene" />
{isLoginModalOpen && (
<LoginModal
onClose={() => setIsLoginModalOpen(false)}
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
)}
<TopBar
loginSignUpPressed={() => {
console.log("PRESSED");
triggerRecheck();
}}
pageName="Edit Scene"
/>
<LoginModal
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
onArtCraftAuthSuccess={(userInfo: any) => {
authentication.status.value = AUTH_STATUS.LOGGED_IN;
authentication.userInfo.value = userInfo;
}}
/>
{tabStore.activeTabId == "3D" && (
<div>
<OnboardingHelper />
@@ -558,7 +557,7 @@ export const PageEditor = () => {
)}
{tabStore.activeTabId == "IMAGE" && (
<div>
<ImageToVideo
<TextToImage
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
@@ -566,14 +565,12 @@ export const PageEditor = () => {
)}
{tabStore.activeTabId == "VIDEO" && (
<div>
<TextToImage
<ImageToVideo
imageMediaId={topNavMediaId.value}
imageUrl={topNavMediaUrl.value}
/>
</div>
)}
</div>
);
};
@@ -27,6 +27,7 @@ import { useGenerationCompleteEvent } from "./TauriEvents/generations/useGenerat
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import { PricingModal } from "@storyteller/ui-pricing-modal";
import {
isActionReminderOpen,
@@ -132,6 +133,8 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
openAiLogo={currentReminderModalProps.openAiLogo}
/>
)}
<PricingModal />
</EngineProvider>
);
};
@@ -1,15 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
// TODO: Remove these file
import { currentPage } from "~/signals";
import { Pages } from "~/pages/PageEnigma/constants/page";
export const PageEnigmaComponent = () => {
useSignals();
return (
<>
<PageEditor />
</>
);
};
@@ -21,6 +21,10 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
selectedModels[ModelCategory.ImageToVideo] ||
videoGenerationModels[0]?.label;
console.log("Selected model:", selectedModel)
console.log("Image Media Id:", imageMediaId)
console.log("Image URL:", imageUrl)
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => {},
@@ -29,6 +29,12 @@ export default {
white: colors.white,
gray: colors.gray,
black: colors.black,
blue: colors.blue,
green: colors.green,
yellow: colors.yellow,
purple: colors.purple,
pink: colors.pink,
orange: colors.orange,
primary: {
DEFAULT: "#2d81ff",
"50": "#eef6ff",
+8 -2
View File
@@ -65,10 +65,10 @@
"path": "../../libs/components/button-dropdown"
},
{
"path": "../../libs/tauri-api"
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/components/action-reminder-modal"
"path": "../../libs/components/pricing-modal"
},
{
"path": "../../libs/components/toaster"
@@ -82,6 +82,9 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/popover"
},
@@ -106,6 +109,9 @@
{
"path": "../../libs/components/gravatar"
},
{
"path": "../../libs/components/login-modal"
},
{
"path": "../../libs/components/select"
},
+5 -1
View File
@@ -98,6 +98,7 @@ export class UsersApi extends ApiManager {
};
try {
console.log("calling auth fetch")
const response = await this.authFetch<
{ username_or_email: string; password: string },
{
@@ -110,7 +111,8 @@ export class UsersApi extends ApiManager {
method: "POST",
body: body,
});
console.log(response)
console.log("Auth fetch response!!! above")
return {
success: response.success,
data: response.success
@@ -198,3 +200,5 @@ export class UsersApi extends ApiManager {
}
}
}
(window as any).UsersApi = new UsersApi();
+2
View File
@@ -1,4 +1,6 @@
export enum API_TARGETS {
// NB: Comment this out for quick testing in development
//BASE_API = "http://localhost:12345",
BASE_API = "https://api.storyteller.ai",
GOOGLE_API = "https://storage.googleapis.com",
FUNNEL_API = "https://style.storyteller.ai",
@@ -2,6 +2,15 @@
"files": [],
"include": [],
"references": [
{
"path": "../../tauri-api"
},
{
"path": "../../api"
},
{
"path": "../image-input"
},
{
"path": "../gallery-modal"
},
@@ -43,6 +43,15 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../image-input/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
},
@@ -376,7 +376,7 @@ export const GalleryModal = React.memo(
)}
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-2 items-center">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Library"}
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "./tsconfig.lib.json"
},
@@ -41,5 +41,10 @@
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
}
]
}
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../loading-spinner"
},
{
"path": "../../tauri-api"
},
@@ -9,7 +12,7 @@
"path": "../button"
},
{
"path": "../close-button"
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../loading-spinner/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
@@ -50,7 +53,7 @@
"path": "../button/tsconfig.lib.json"
},
{
"path": "../close-button/tsconfig.lib.json"
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -1,2 +1,2 @@
export * from './lib/login-modal';
export * from "./lib/login-modal";
export * from "./lib/useLoginModalStore";
@@ -1,9 +1,7 @@
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { useState, useEffect, useRef } from "react";
import {
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import {
CheckSoraSession,
@@ -12,8 +10,9 @@ import {
} from "@storyteller/tauri-api";
import { ArtCraftSignUp } from "./artcraft-signup";
import { UsersApi } from "@storyteller/api";
import { useLoginModalStore } from "./useLoginModalStore";
interface LoginModalProps {
onClose: () => void;
onClose?: () => void;
videoSrc2D?: string;
videoSrc3D?: string;
onOpenChange?: (isOpen: boolean) => void;
@@ -29,9 +28,9 @@ export function LoginModal({
onArtCraftAuthSuccess,
isSignUp: initialIsSignUp = true,
}: LoginModalProps) {
const { isOpen, recheckTrigger, closeModal } = useLoginModalStore();
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(true);
const [isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false);
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
const [errorMessage, setErrorMessage] = useState("");
@@ -61,26 +60,35 @@ export function LoginModal({
setStep(4); // Always go to the final step after Sora login
});
// Check session on component mount
// Check session on component mount and when recheckTrigger changes
useEffect(() => {
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false);
closeModal();
} else {
setIsOpen(true);
setStep(1); // Always start at step 1
// Reset all modal state to initial values
setStep(1);
setIsLoading(false);
setIsSignUp(initialIsSignUp);
setErrorMessage("");
setShowDiscord(false);
setShowSuccess(false);
setIsLoggedInArtCraft(false);
const { openModal } = useLoginModalStore.getState();
openModal();
}
});
}, []);
}, [recheckTrigger, closeModal, initialIsSignUp]);
useEffect(() => {
if (onOpenChange) onOpenChange(isOpen);
}, [isOpen, onOpenChange]);
const handleClose = () => {
setIsOpen(false);
onClose();
closeModal();
onClose?.();
};
const handleNext = async () => {
@@ -149,7 +157,8 @@ export function LoginModal({
Join Our Community
</h2>
<p className="text-white/70 mb-6 text-center px-24">
Connect with other creators, share your work, and get the latest updates in our Discord community.
Connect with other creators, share your work, and get the latest
updates in our Discord community.
</p>
<div className="flex gap-4">
<Button
@@ -234,15 +243,15 @@ export function LoginModal({
try {
let signupResponse, loginResponse;
if (isSignUp) {
console.log("Sign up!")
console.log("Sign up!");
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
});
console.log(signupResponse)
console.log(signupResponse);
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage ||
@@ -251,17 +260,21 @@ export function LoginModal({
setIsLoading(false);
return;
}
console.log("Path 1");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
console.log(loginResponse);
} else {
console.log("Path 2");
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
console.log(loginResponse);
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
@@ -272,12 +285,12 @@ export function LoginModal({
}
setIsLoggedInArtCraft(true);
console.log("ARTCRAFT LOGIN SUCCeSS")
console.log("ARTCRAFT LOGIN SUCCeSS");
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
console.log("USERINFO")
console.log(userInfo)
console.log("USERINFO");
console.log(userInfo);
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setShowDiscord(true); // Show Discord step after successful login
@@ -309,11 +322,7 @@ export function LoginModal({
return (
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 && (
<Button
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
<Button variant="secondary" onClick={handleBack} disabled={isLoading}>
Back
</Button>
)}
@@ -330,7 +339,8 @@ export function LoginModal({
);
};
return <Transition appear show={isOpen}>
return (
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
<TransitionChild
enter="ease-out duration-300"
@@ -372,6 +382,7 @@ export function LoginModal({
</div>
</div>
</Transition>
);
}
export default LoginModal;
@@ -0,0 +1,18 @@
import { create } from "zustand";
interface LoginModalStore {
isOpen: boolean;
recheckTrigger: number;
openModal: () => void;
closeModal: () => void;
triggerRecheck: () => void;
}
export const useLoginModalStore = create<LoginModalStore>((set) => ({
isOpen: false,
recheckTrigger: 0,
openModal: () => set({ isOpen: true }),
closeModal: () => set({ isOpen: false }),
triggerRecheck: () =>
set((state) => ({ recheckTrigger: state.recheckTrigger + 1 })),
}));
@@ -7,13 +7,36 @@ export type ModelList = Omit<PopoverItem, "selected">[];
export const allModels = {
video: [
{
label: "Kling 2.0",
id: "kling_1.6_pro",
label: "Kling 1.6 Pro",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Good quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "kling_2.1_pro",
label: "Kling 2.1 Pro",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
label: "Runway",
id: "kling_2.1_master",
label: "Kling 2.1 Master",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Master quality model ($$)",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "seedance_1.0_lite",
label: "Seedance 1.0 Lite",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "veo_2",
label: "Google Veo 2",
icon: <FontAwesomeIcon icon={faFilm} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "2 min.", icon: <FontAwesomeIcon icon={faClock} /> }],
@@ -21,24 +44,28 @@ export const allModels = {
],
image: [
{
id: "todo",
label: "GPT Image 1 (GPT-4o)",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Slow, ultra instructive model",
badges: [{ label: "45 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "todo",
label: "Flux Pro Ultra",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "High quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "todo",
label: "Recraft 3",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "Fast and high-quality model",
badges: [{ label: "15 sec.", icon: <FontAwesomeIcon icon={faClock} /> }],
},
{
id: "todo",
label: "Flux.1 Kontext",
icon: <FontAwesomeIcon icon={faImage} className="h-4 w-4" />,
description: "New model with advanced context",
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-pricing-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-pricing-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-pricing-modal",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,3 @@
export * from "./lib/pricing-modal";
export * from "./lib/pricing-config";
export * from "./lib/pricing-modal-store";
@@ -0,0 +1,95 @@
export interface PricingFeature {
text: string;
included: boolean;
tooltip?: string;
}
export interface PricingTier {
id: string;
name: string;
monthlyPrice: number;
yearlyPrice: number;
originalMonthlyPrice?: number;
originalYearlyPrice?: number;
monthlyPriceId?: string;
yearlyPriceId?: string;
features: PricingFeature[];
isPopular?: boolean;
colorScheme: "dark" | "green" | "purple" | "orange";
}
export const pricingConfig = {
header: {
title: "Purchase a subscription",
subtitle:
"Upgrade to gain access to Pro features and generate more, faster.",
},
yearlyDiscount: 20,
tiers: [
{
id: "free",
name: "Free",
monthlyPrice: 0,
yearlyPrice: 0,
features: [
{ text: "Free daily generations", included: true },
{ text: "Limited access to ArtCraft tools", included: true },
],
colorScheme: "dark" as const,
},
{
id: "plus",
name: "Plus",
monthlyPrice: 8,
yearlyPrice: 96,
originalMonthlyPrice: 10,
originalYearlyPrice: 120,
monthlyPriceId: "price_1234567890abcdef", //dummy
yearlyPriceId: "price_abcdef1234567890", //dummy
features: [
{ text: "~1,010 Flux images", included: true },
{ text: "~36,000 real-time images", included: true },
{ text: "~180 enhanced images", included: true },
{ text: "~6 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "green" as const,
},
{
id: "pro",
name: "Pro",
monthlyPrice: 28,
yearlyPrice: 336,
originalMonthlyPrice: 35,
originalYearlyPrice: 420,
monthlyPriceId: "price_fedcba0987654321", //dummy
yearlyPriceId: "price_123456789fedcba0", //dummy
features: [
{ text: "~5,048 Flux images", included: true },
{ text: "~180,000 real-time images", included: true },
{ text: "~900 enhanced images", included: true },
{ text: "~30 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "purple" as const,
},
{
id: "max",
name: "Max",
monthlyPrice: 48,
yearlyPrice: 576,
originalMonthlyPrice: 60,
originalYearlyPrice: 720,
monthlyPriceId: "price_9876543210fedcba", //dummy
yearlyPriceId: "price_abcdef9876543210", //dummy
features: [
{ text: "~15,142 Flux images", included: true },
{ text: "~540,000 real-time images", included: true },
{ text: "~2,700 enhanced images", included: true },
{ text: "~90 training jobs", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "orange" as const,
},
] as PricingTier[],
};
@@ -0,0 +1,42 @@
import { create } from "zustand";
interface SubscriptionState {
currentPlanId: string;
hasActiveSubscription: boolean;
customerId?: string;
subscriptionId?: string;
billingCycle?: "monthly" | "yearly";
}
interface PricingModalState {
isOpen: boolean;
subscription: SubscriptionState;
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
setSubscription: (subscription: Partial<SubscriptionState>) => void;
updateCurrentPlan: (planId: string, hasActiveSubscription: boolean) => void;
}
export const usePricingModalStore = create<PricingModalState>((set) => ({
isOpen: false,
subscription: {
currentPlanId: "free",
hasActiveSubscription: false,
},
openModal: () => set({ isOpen: true }),
closeModal: () => set({ isOpen: false }),
toggleModal: () => set((state) => ({ isOpen: !state.isOpen })),
setSubscription: (subscription) =>
set((state) => ({
subscription: { ...state.subscription, ...subscription },
})),
updateCurrentPlan: (planId, hasActiveSubscription) =>
set((state) => ({
subscription: {
...state.subscription,
currentPlanId: planId,
hasActiveSubscription,
},
})),
}));
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import PricingModal from './pricing-modal';
describe('PricingModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<PricingModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,316 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { pricingConfig, PricingTier } from "./pricing-config";
import { usePricingModalStore } from "./pricing-modal-store";
import { TabSelector } from "@storyteller/ui-tab-selector";
const billingTabs = [
{ id: "yearly", label: "Yearly" },
{ id: "monthly", label: "Monthly" },
];
interface PricingModalProps {
currentPlanId?: string;
hasActiveSubscription?: boolean;
}
export function PricingModal({
currentPlanId,
hasActiveSubscription,
}: PricingModalProps = {}) {
const { isOpen, closeModal, subscription } = usePricingModalStore();
// Use props if provided or fall back to store
const activePlanId = currentPlanId ?? subscription.currentPlanId;
const hasActiveSub =
hasActiveSubscription ?? subscription.hasActiveSubscription;
const [billingType, setBillingType] = useState("yearly");
const isYearly = billingType === "yearly";
const handleUpgrade = async (tierId: string) => {
// TODO: Implement Stripe checkout
const tier = pricingConfig.tiers.find((t) => t.id === tierId);
const priceId = isYearly ? tier?.yearlyPriceId : tier?.monthlyPriceId;
console.log(
`Upgrading to ${tierId} plan with ${
isYearly ? "yearly" : "monthly"
} billing`,
{ priceId, tierId }
);
// Example Stripe checkout implementation:
// await stripe.redirectToCheckout({
// lineItems: [{ price: priceId, quantity: 1 }],
// mode: 'subscription',
// successUrl: `${window.location.origin}/success`,
// cancelUrl: `${window.location.origin}/cancel`,
// });
};
const handleManageSubscription = () => {
// TODO: Redirect to Stripe customer portal
console.log("Managing subscription");
};
const tierHierarchy = { free: 0, basic: 1, pro: 2, max: 3 };
const isCurrentPlan = (tierId: string) => {
return tierId === activePlanId;
};
const getButtonText = (tier: PricingTier) => {
if (isCurrentPlan(tier.id)) {
return "Current Plan";
}
if (activePlanId && activePlanId !== "free") {
const currentTierLevel =
tierHierarchy[activePlanId as keyof typeof tierHierarchy];
const thisTierLevel =
tierHierarchy[tier.id as keyof typeof tierHierarchy];
if (thisTierLevel < currentTierLevel) {
return "Switch Plan";
}
}
return "Upgrade Plan";
};
const getColorSchemeClasses = (
colorScheme: PricingTier["colorScheme"],
isCurrent: boolean
) => {
const baseClasses =
"relative rounded-xl border p-8 h-full flex flex-col transition-all duration-300 ring-[6px] ring-transparent";
switch (colorScheme) {
case "dark":
return twMerge(
baseClasses,
"bg-[#2C2C2C] border-gray-600",
isCurrent && "ring-white/70"
);
case "green":
// Basic
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#002D23] via-[#006B54] to-[#00D28B] border-[#00a873]",
"hover:shadow-[0_0_30px_rgba(0,210,139,0.4)]",
isCurrent && "ring-[#00D28B] shadow-[0_0_30px_rgba(0,210,139,0.4)]"
);
case "purple":
// Pro
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#2D004D] via-[#6400A8] to-[#C03FFF] border-[#9D4CFF]",
"hover:shadow-[0_0_30px_rgba(192,63,255,0.4)]",
isCurrent && "ring-[#C03FFF] shadow-[0_0_30px_rgba(192,63,255,0.4)]"
);
case "orange":
// Max
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#332100] via-[#B35C00] to-[#FFB347] border-[#FF8C00]",
"hover:shadow-[0_0_30px_rgba(255,179,71,0.4)]",
isCurrent && "ring-[#FFB347] shadow-[0_0_30px_rgba(255,179,71,0.4)]"
);
default:
return twMerge(
baseClasses,
"bg-[#2C2C2C] border-gray-600",
"hover:shadow-[0_0_30px_rgba(255,255,255,0.3)]",
isCurrent && "ring-white/70"
);
}
};
const formatPrice = (tier: PricingTier) => {
if (tier.monthlyPrice === 0) {
return {
current: "$0",
original: null,
};
}
if (isYearly) {
const discountedMonthlyPrice = Math.round(tier.yearlyPrice / 12);
const originalMonthlyPrice = tier.originalYearlyPrice
? Math.round(tier.originalYearlyPrice / 12)
: null;
return {
current: `$${discountedMonthlyPrice}`,
original: originalMonthlyPrice ? `$${originalMonthlyPrice}` : null,
};
} else {
const monthlyPrice = tier.originalMonthlyPrice || tier.monthlyPrice;
return {
current: `$${monthlyPrice}`,
original: null,
};
}
};
return (
<Modal
isOpen={isOpen}
onClose={closeModal}
className="rounded-xl bg-[#1A1A1A] max-h-[90vh] max-w-screen-2xl overflow-y-auto flex flex-col"
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={true}
resizable={false}
backdropClassName="bg-black/80"
>
<div className="p-16 py-24 flex-1 overflow-y-auto min-h-0">
{/* Header */}
<div className="text-center mb-10">
<h1 className="text-5xl font-bold text-white mb-4">
{pricingConfig.header.title}
</h1>
<p className="text-gray-400 text-lg mb-6">
{pricingConfig.header.subtitle}
</p>
{/* Billing Toggle */}
<div className="flex items-center justify-center gap-4 mb-8 relative w-fit mx-auto">
<TabSelector
tabs={billingTabs}
activeTab={billingType}
onTabChange={setBillingType}
className="w-fit"
tabClassName="w-24 text-md"
indicatorClassName="bg-white"
selectedTabClassName="text-black"
/>
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -left-6 absolute pointer-events-none">
-{pricingConfig.yearlyDiscount}%
</span>
</div>
</div>
{/* Pricing Tiers */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10">
{pricingConfig.tiers.map((tier) => {
const pricing = formatPrice(tier);
return (
<div
key={tier.id}
className={getColorSchemeClasses(
tier.colorScheme,
isCurrentPlan(tier.id)
)}
>
{/* Current Plan Badge */}
{isCurrentPlan(tier.id) && (
<div className="absolute -top-4 right-5 bg-white text-black px-3 py-1 rounded-full text-md font-semibold shadow-xl">
Active
</div>
)}
{/* Tier Header */}
<div className="text-center mb-8">
<h3 className="text-4xl font-bold text-white mb-4">
{tier.name}
</h3>
<div className="flex items-baseline justify-center gap-2">
{pricing.original && (
<span className="text-white/60 line-through text-2xl">
{pricing.original}
</span>
)}
<span className="text-4xl font-bold text-white">
{pricing.current}
</span>
<span className="text-white/60 text-sm">/month</span>
</div>
<p className="text-gray-300 text-xs mt-1">
{isYearly ? "billed yearly" : "billed monthly"}
</p>
</div>
{/* Features */}
<div className="flex-1 space-y-3 mb-6">
{tier.features.map((feature, index) => (
<div key={index} className="flex items-start gap-3">
<div
className={twMerge(
"w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
feature.included
? "bg-white text-black"
: "bg-transparent border border-gray-400"
)}
>
{feature.included && (
<svg
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<span
className={twMerge(
"text-sm mt-0.5",
feature.included ? "text-white" : "text-gray-400"
)}
>
{feature.text}
</span>
</div>
))}
</div>
{/* CTA Button */}
<Button
onClick={() => handleUpgrade(tier.id)}
disabled={isCurrentPlan(tier.id)}
className="w-full bg-white text-black hover:bg-white/90 h-12 rounded-xl"
>
{getButtonText(tier)}
</Button>
</div>
);
})}
</div>
{/* Manage Subscription Button - only show if user has a paid plan */}
{hasActiveSub && activePlanId !== "free" && (
<div className="flex justify-center mt-8">
<Button
onClick={handleManageSubscription}
className="bg-transparent border border-white/25 text-white hover:bg-white/10 px-8 py-3 rounded-xl"
>
Manage your subscription
</Button>
</div>
)}
</div>
</Modal>
);
}
// Additional interfaces for Stripe integration
export interface SubscriptionData {
currentPlanId: string;
hasActiveSubscription: boolean;
customerId?: string;
subscriptionId?: string;
billingCycle?: "monthly" | "yearly";
nextBillingDate?: Date;
}
export default PricingModal;
@@ -0,0 +1,19 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,53 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,49 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/pricing-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-pricing-modal',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -196,6 +196,8 @@ export const PromptBoxImage = ({
const handleEnqueue = async () => {
setIsEnqueueing(true);
console.log("Prompting");
setTimeout(() => {
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
// the backend hangs on a given request.
@@ -183,6 +183,8 @@ export const PromptBoxVideo = ({
const handleEnqueue = async () => {
setIsEnqueueing(true);
console.log("Handle Enqueue");
setTimeout(() => {
// TODO(bt,2025-05-08): This is a hack so we don't accidentally wind up with a permanently disabled prompt box if
// the backend hangs on a given request.
@@ -1,21 +1,21 @@
import { useEffect, useState } from "react";
import {
GetBuildInfo,
GetBuildInfoPayload,
GetAppInfo,
GetAppInfoPayload,
} from "@storyteller/tauri-api";
import { Label } from "@storyteller/ui-label";
interface AboutSettingsPaneProps {}
export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
const [buildInfo, setBuildInfo] = useState<
GetBuildInfoPayload | undefined
const [appInfo, setAppInfo] = useState<
GetAppInfoPayload | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const prefs = await GetBuildInfo();
setBuildInfo(prefs.payload);
const prefs = await GetAppInfo();
setAppInfo(prefs.payload);
};
fetchData();
}, []);
@@ -28,22 +28,27 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
<Label htmlFor="play-sounds">
Build Timestamp
</Label>
<div>{buildInfo?.build_timestamp}</div>
<div>{appInfo?.build_timestamp}</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Git Commit ID</Label>
<div>{buildInfo?.git_commit_id}</div>
<div>{appInfo?.git_commit_id}</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Git Commit Short ID</Label>
<div>{buildInfo?.git_commit_short_id}</div>
<div>{appInfo?.git_commit_short_id}</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Git Commit Timestamp</Label>
<div>{buildInfo?.git_commit_timestamp}</div>
<div>{appInfo?.git_commit_timestamp}</div>
</div>
<div className="space-y-1">
<Label htmlFor="success-sound">Artcraft Host</Label>
<div>{appInfo?.storyteller_host}</div>
</div>
</div>
</>
@@ -5,6 +5,7 @@ import { Button } from "@storyteller/ui-button";
import { UsersApi } from "@storyteller/api";
import { UserInfo } from "@storyteller/api";
import { useLoginModalStore } from "@storyteller/ui-login-modal";
const usersApi = new UsersApi();
@@ -15,6 +16,7 @@ export interface ArtcraftAccountBlockProps {
export const ArtcraftAccountBlock = ({
globalAccountLogoutCallback,
}: ArtcraftAccountBlockProps) => {
const { triggerRecheck } = useLoginModalStore();
const [artcraftSession, setArtcraftSession] = useState<UserInfo | undefined>(
undefined
);
@@ -50,8 +52,9 @@ export const ArtcraftAccountBlock = ({
setIsLoggedIn(false);
setIsCheckingArtcraftSession(false);
globalAccountLogoutCallback(); // TODO: This resets the old global application state
triggerRecheck(); // Trigger modal to recheck session and potentially open
} else {
window.location.href = "/login"; // TODO(bt,2025-05-08): Once we have in-page routing, get rid of this.
triggerRecheck(); // Open login modal instead of redirecting
}
};
@@ -0,0 +1,176 @@
import { useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGripVertical } from "@fortawesome/pro-solid-svg-icons";
interface RouterItem {
id: string;
name: string;
emoji: string;
}
interface SortableItemProps {
id: string;
name: string;
emoji: string;
isUpdating?: boolean;
}
const SortableItem = ({
id,
name,
emoji,
isUpdating = false,
}: SortableItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
flex items-center justify-between rounded-lg bg-white/5 p-3 transition-colors duration-200
${
isUpdating
? "cursor-wait opacity-60"
: "cursor-move hover:bg-white/10"
}
${isDragging ? "opacity-50 shadow-lg" : ""}
`}
>
<div className="flex items-center gap-3">
<span className="text-lg">{emoji}</span>
<span className="font-medium">{name}</span>
</div>
<div className="flex items-center">
<FontAwesomeIcon
icon={faGripVertical}
className="text-white/40 text-sm"
/>
</div>
</div>
);
};
export const RouterPrioritySettingsPane = () => {
const [items, setItems] = useState<RouterItem[]>([
{ id: "artcraft-3d", name: "ArtCraft 3D", emoji: "🎨" },
{ id: "gpt-image-1", name: "GPT Image 1 (GPT-4o)", emoji: "🤖" },
{ id: "flux-pro-ultra", name: "Flux Pro Ultra", emoji: "⚡" },
{ id: "recraft-3", name: "Recraft 3", emoji: "🌟" },
{ id: "flux-kontext", name: "Flux.1 Kontext", emoji: "🔮" },
]);
const [isUpdating, setIsUpdating] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const updateRouterPriorityOnBackend = async (newOrder: RouterItem[]) => {
try {
setIsUpdating(true);
console.log("Updating router priority on backend:", newOrder);
// Simulate API call delay
await new Promise((resolve) => setTimeout(resolve, 500)); //remove this - BFlat
//api call here
console.log("Router priority updated successfully");
} catch (error) {
console.error("Failed to update router priority:", error);
} finally {
setIsUpdating(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
setItems((prevItems) => {
const oldIndex = prevItems.findIndex((item) => item.id === active.id);
const newIndex = prevItems.findIndex((item) => item.id === over?.id);
const newOrder = arrayMove(prevItems, oldIndex, newIndex);
// Send update to backend
updateRouterPriorityOnBackend(newOrder);
return newOrder;
});
}
};
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
Model Router Priority
{isUpdating && (
<span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded-full animate-pulse">
Updating...
</span>
)}
</h3>
<p className="text-sm text-white/70 mb-4">
Drag and drop to reorder model priority. Higher items will be tried
first.
</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{items.map((item) => (
<SortableItem
key={item.id}
id={item.id}
name={item.name}
emoji={item.emoji}
isUpdating={isUpdating}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
};
@@ -6,12 +6,14 @@ import {
faCog,
faVolumeHigh,
faCircleInfo,
faRoute,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
import { AboutSettingsPane } from "./panes/AboutSettingsPane";
import { RouterPrioritySettingsPane } from "./panes/RouterPrioritySettingsPane";
interface SettingsModalProps {
isOpen: boolean;
@@ -20,8 +22,12 @@ interface SettingsModalProps {
initialSection?: SettingsSection;
}
//type SettingsSection = "misc" | "audio" | "accounts" | "video" | "image";
type SettingsSection = "general" | "accounts" | "alerts" | "about";
type SettingsSection =
| "general"
| "accounts"
| "alerts"
| "about"
| "router_priority";
export const SettingsModal = ({
isOpen,
@@ -37,6 +43,11 @@ export const SettingsModal = ({
{ id: "accounts" as const, label: "Accounts", icon: faUser },
{ id: "alerts" as const, label: "Alerts", icon: faVolumeHigh },
{ id: "about" as const, label: "About", icon: faCircleInfo },
{
id: "router_priority" as const,
label: "Model Router Priority",
icon: faRoute,
},
//{ id: "video" as const, label: "Video", icon: faVideo },
//{ id: "image" as const, label: "Image", icon: faImage },
];
@@ -55,6 +66,8 @@ export const SettingsModal = ({
);
case "about":
return <AboutSettingsPane />;
case "router_priority":
return <RouterPrioritySettingsPane />;
}
};
@@ -15,6 +15,9 @@ interface TabSelectorProps {
className?: string;
disabled?: boolean;
disabledMessage?: string;
tabClassName?: string;
indicatorClassName?: string;
selectedTabClassName?: string;
}
export const TabSelector: React.FC<TabSelectorProps> = ({
@@ -24,6 +27,9 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
className,
disabled,
disabledMessage,
tabClassName,
indicatorClassName,
selectedTabClassName,
}) => {
// Find the index of the active tab
const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab);
@@ -63,7 +69,10 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
<TabList className="glass glass-no-hover relative inline-flex min-w-fit overflow-x-auto rounded-lg p-0.5 py-1 !shadow-none">
{/* Animated indicator */}
<div
className="absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-primary/30 transition-all duration-200 ease-in-out"
className={twMerge(
"absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-primary/30 transition-all duration-200 ease-in-out",
indicatorClassName
)}
style={{
left: indicatorStyle.left,
width: indicatorStyle.width,
@@ -83,8 +92,11 @@ export const TabSelector: React.FC<TabSelectorProps> = ({
twMerge(
"relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center text-sm font-semibold transition-all duration-200 ease-in-out",
"focus-visible:outline-none focus-visible:ring-0",
selected ? "text-white" : "text-gray-300 hover:text-white",
disabled ? "cursor-not-allowed opacity-60" : ""
selected
? twMerge("text-white", selectedTabClassName)
: "text-gray-300 hover:text-white",
disabled ? "cursor-not-allowed opacity-60" : "",
tabClassName
)
}
>
+1 -1
View File
@@ -14,5 +14,5 @@ export * from "./lib/sora/CheckSoraSession.js";
export * from "./lib/sora/LogoutSoraSession.js";
export * from "./lib/sora/SoraImageRemix.js";
export * from "./lib/sora/useSoraLoginListener.js";
export * from "./lib/system/GetBuildInfo.js";
export * from "./lib/system/GetAppInfo.js";
export * from "./lib/sora/waitForSoraLogin.js";
@@ -1,22 +1,23 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface GetBuildInfoSuccess extends CommandResult {
payload: GetBuildInfoPayload;
export interface GetAppInfoSuccess extends CommandResult {
payload: GetAppInfoPayload;
}
export interface GetBuildInfoPayload {
export interface GetAppInfoPayload {
build_timestamp: string;
git_commit_id?: string | null;
git_commit_short_id?: string | null;
git_commit_timestamp?: string | null;
storyteller_host?: string | null;
}
// Returns the Success and Error variants directly.
// Throws on Network/Tauri errors.
export const GetBuildInfo = async () : Promise<GetBuildInfoSuccess> => {
export const GetAppInfo = async () : Promise<GetAppInfoSuccess> => {
try {
return await invoke("get_build_info_command") as GetBuildInfoSuccess;
return await invoke("get_app_info_command") as GetAppInfoSuccess;
} catch (error) {
// NB: Endpoint should be infalliable
throw error;
+60
View File
@@ -13,6 +13,9 @@
"libs/**/*"
],
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.2",
"@preact/signals-react": "^3.0.1",
"@remix-run/serve": "^2.15.0",
@@ -1466,6 +1469,10 @@
"name": "@storyteller/ui-popover",
"version": "0.0.1"
},
"libs/components/pricing-modal": {
"name": "@storyteller/ui-pricing-modal",
"version": "0.0.1"
},
"libs/components/promptbox": {
"name": "@storyteller/ui-promptbox",
"version": "0.0.1"
@@ -3424,6 +3431,55 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.0.tgz",
@@ -9765,6 +9821,10 @@
"resolved": "libs/components/popover",
"link": true
},
"node_modules/@storyteller/ui-pricing-modal": {
"resolved": "libs/components/pricing-modal",
"link": true
},
"node_modules/@storyteller/ui-promptbox": {
"resolved": "libs/components/promptbox",
"link": true
+3
View File
@@ -5,6 +5,9 @@
"scripts": {},
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.2",
"@preact/signals-react": "^3.0.1",
"@remix-run/serve": "^2.15.0",
+3
View File
@@ -140,6 +140,9 @@
},
{
"path": "./libs/components/image-input"
},
{
"path": "./libs/components/pricing-modal"
}
]
}