mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 04:00:10 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9005b0b04 |
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO analytics_app_sessions\nSET\n session_token = ?,\n app_namespace = ?,\n user_token = ?,\n app_version = ?,\n os_platform = ?,\n os_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n first_active_at = NOW(),\n last_active_at = NOW()\nON DUPLICATE KEY UPDATE\n app_namespace = ?,\n user_token = ?,\n app_version = ?,\n os_platform = ?,\n os_version = ?,\n session_duration_seconds = ?,\n ip_address = ?,\n measurement_count = measurement_count + 1,\n last_active_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 15
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff39576a5ec6a23c67f6f61e7eba8392e70b05c42b540bea260a9a4cdc478a5b"
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Track user activity over time
|
||||
-- User clients ping the endpoint that writes this data.
|
||||
CREATE TABLE analytics_app_active_users (
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
drop table analytics_app_sessions;
|
||||
@@ -1,65 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Track user session durations
|
||||
-- User clients ping the endpoint that writes this data.
|
||||
CREATE TABLE analytics_app_sessions (
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Generated client-side
|
||||
-- Wide enough for a UUID, but typically one of our internal tokens.
|
||||
session_token VARCHAR(36) NOT NULL,
|
||||
|
||||
-- If we host multiple sites with distinct categories, this will enable us to
|
||||
-- segregate analytics.
|
||||
app_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who sent the event.
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The "user agent" or app version string of the client.
|
||||
app_version VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The OS platform for the client.
|
||||
os_platform VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The OS platform version for the client.
|
||||
os_version VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- How long the current user's session has been open
|
||||
session_duration_seconds INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The user's last known IP address.
|
||||
ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Incrementing count of events.
|
||||
-- NB: This is kind of an "hours spent" measure, but will lose information if we ever
|
||||
-- change the ping cadence (eg. 1 minute pings --> 5 minute pings).
|
||||
measurement_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== RECORD TIMESTAMPS ==========
|
||||
|
||||
-- Written at create only.
|
||||
first_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Updated on every ping.
|
||||
last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (session_token),
|
||||
INDEX idx_app_namespace (app_namespace),
|
||||
INDEX idx_user_token (user_token),
|
||||
INDEX idx_os_platform (os_platform),
|
||||
INDEX idx_os_version (os_version),
|
||||
INDEX idx_app_version (app_version),
|
||||
INDEX idx_last_active_at (last_active_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,65 +0,0 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Track user session durations
|
||||
-- User clients ping the endpoint that writes this data.
|
||||
CREATE TABLE analytics_app_sessions (
|
||||
-- Not used for anything except replication.
|
||||
id BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Generated client-side
|
||||
-- Wide enough for a UUID, but typically one of our internal tokens.
|
||||
session_token VARCHAR(36) NOT NULL,
|
||||
|
||||
-- If we host multiple sites with distinct categories, this will enable us to
|
||||
-- segregate analytics.
|
||||
app_namespace VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Who sent the event.
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The "user agent" or app version string of the client.
|
||||
app_version VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The OS platform for the client.
|
||||
os_platform VARCHAR(16) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The OS platform version for the client.
|
||||
os_version VARCHAR(64) DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- How long the current user's session has been open
|
||||
session_duration_seconds INT(10) UNSIGNED DEFAULT NULL,
|
||||
|
||||
-- Updated on every write.
|
||||
-- The user's last known IP address.
|
||||
ip_address VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Incrementing count of events.
|
||||
-- NB: This is kind of an "hours spent" measure, but will lose information if we ever
|
||||
-- change the ping cadence (eg. 1 minute pings --> 5 minute pings).
|
||||
measurement_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- ========== RECORD TIMESTAMPS ==========
|
||||
|
||||
-- Written at create only.
|
||||
first_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Updated on every ping.
|
||||
last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- ========== INDICES ==========
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (session_token),
|
||||
INDEX idx_app_namespace (app_namespace),
|
||||
INDEX idx_user_token (user_token),
|
||||
INDEX idx_os_platform (os_platform),
|
||||
INDEX idx_os_version (os_version),
|
||||
INDEX idx_app_version (app_version),
|
||||
INDEX idx_last_active_at (last_active_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -1,15 +1,10 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use tokens::tokens::app_session::AppSessionToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const LOG_ACTIVE_USER_PATH: &str = "/v1/analytics/active_user";
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||
pub struct LogAppActiveUserRequest {
|
||||
/// Clientside-generated session token.
|
||||
/// If present, it'll be validated against an expected format.
|
||||
pub maybe_app_session_token: Option<AppSessionToken>,
|
||||
|
||||
/// An override for the application platform/OS (windows, mac, linux).
|
||||
pub maybe_os_platform: Option<String>,
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ pub async fn log_active_user(
|
||||
|
||||
let mut query = HashMap::new();
|
||||
|
||||
if let Some(token) = request.maybe_app_session_token {
|
||||
query.insert("maybe_app_session_token".to_string(), token.to_string());
|
||||
}
|
||||
|
||||
if let Some(os_platform) = request.maybe_os_platform {
|
||||
query.insert("maybe_os_platform".to_string(), os_platform);
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_post_request::basic_json_post_request;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::{GenerateVeo3FastImageToVideoRequest, GenerateVeo3FastImageToVideoResponse, GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH};
|
||||
|
||||
pub async fn generate_veo_3_fast_image_to_video(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
request: GenerateVeo3FastImageToVideoRequest,
|
||||
) -> Result<GenerateVeo3FastImageToVideoResponse, StorytellerError> {
|
||||
Ok(basic_json_post_request(
|
||||
api_host,
|
||||
GENERATE_VEO_3_FAST_IMAGE_TO_VIDEO_URL_PATH,
|
||||
maybe_creds,
|
||||
request,
|
||||
).await?)
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::basic_json_post_request::basic_json_post_request;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3ImageToVideoRequest, GenerateVeo3ImageToVideoResponse, GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH};
|
||||
|
||||
pub async fn generate_veo_3_image_to_video(
|
||||
api_host: &ApiHost,
|
||||
maybe_creds: Option<&StorytellerCredentialSet>,
|
||||
request: GenerateVeo3ImageToVideoRequest,
|
||||
) -> Result<GenerateVeo3ImageToVideoResponse, StorytellerError> {
|
||||
Ok(basic_json_post_request(
|
||||
api_host,
|
||||
GENERATE_VEO_3_IMAGE_TO_VIDEO_URL_PATH,
|
||||
maybe_creds,
|
||||
request,
|
||||
).await?)
|
||||
}
|
||||
@@ -2,6 +2,4 @@ pub mod generate_kling_16_pro_image_to_video;
|
||||
pub mod generate_kling_21_master_image_to_video;
|
||||
pub mod generate_kling_21_pro_image_to_video;
|
||||
pub mod generate_seedance_1_0_lite_image_to_video;
|
||||
pub mod generate_veo_2_image_to_video;
|
||||
pub mod generate_veo_3_fast_image_to_video;
|
||||
pub mod generate_veo_3_image_to_video;
|
||||
pub mod generate_veo_2_image_to_video;
|
||||
@@ -25,7 +25,7 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
|
||||
// TODO: Please stop using this URL Builder library. It's not very safe or intuitive.
|
||||
let url = get_route(api_host, route_path, query_params);
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
info!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
|
||||
-6
@@ -41,12 +41,6 @@ pub enum VideoModel {
|
||||
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
-59
@@ -13,8 +13,6 @@ use artcraft_api_defs::generate::video::generate_kling_2_1_master_image_to_video
|
||||
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 artcraft_api_defs::generate::video::generate_veo_3_fast_image_to_video::GenerateVeo3FastImageToVideoRequest;
|
||||
use artcraft_api_defs::generate::video::generate_veo_3_image_to_video::{GenerateVeo3AspectRatio, GenerateVeo3ImageToVideoRequest};
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use fal_client::requests::queue::image_gen::enqueue_flux_pro_11_ultra_text_to_image::{enqueue_flux_pro_11_ultra_text_to_image, FluxPro11UltraTextToImageArgs};
|
||||
@@ -27,8 +25,6 @@ use storyteller_client::endpoints::generate::video::generate_kling_21_master_ima
|
||||
use storyteller_client::endpoints::generate::video::generate_kling_21_pro_image_to_video::generate_kling_21_pro_image_to_video;
|
||||
use storyteller_client::endpoints::generate::video::generate_seedance_1_0_lite_image_to_video::generate_seedance_1_0_lite_image_to_video;
|
||||
use storyteller_client::endpoints::generate::video::generate_veo_2_image_to_video::generate_veo_2_image_to_video;
|
||||
use storyteller_client::endpoints::generate::video::generate_veo_3_fast_image_to_video::generate_veo_3_fast_image_to_video;
|
||||
use storyteller_client::endpoints::generate::video::generate_veo_3_image_to_video::generate_veo_3_image_to_video;
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tauri::AppHandle;
|
||||
|
||||
@@ -206,61 +202,6 @@ pub async fn handle_video_artcraft(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VideoModel::Veo3) => {
|
||||
info!("enqueue Veo 3 with Artcraft API");
|
||||
selected_model = Some(GenerationModel::Veo3);
|
||||
let request = GenerateVeo3ImageToVideoRequest {
|
||||
uuid_idempotency_token,
|
||||
media_file_token: request.image_media_token,
|
||||
aspect_ratio: Some(GenerateVeo3AspectRatio::WideSixteenNine),
|
||||
prompt: request.prompt,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
generate_audio: None,
|
||||
};
|
||||
let result = generate_veo_3_image_to_video(
|
||||
&app_env_configs.storyteller_host,
|
||||
Some(&creds),
|
||||
request,
|
||||
).await;
|
||||
match result {
|
||||
Ok(enqueued) => {
|
||||
info!("Successfully enqueued Artcraft Veo 3 video generation");
|
||||
enqueued.inference_job_token
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to use Artcraft Veo 3 video generation: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(VideoModel::Veo3Fast) => {
|
||||
info!("enqueue Veo 3 Fast with Artcraft API");
|
||||
selected_model = Some(GenerationModel::Veo3Fast);
|
||||
let request = GenerateVeo3FastImageToVideoRequest {
|
||||
uuid_idempotency_token,
|
||||
media_file_token: request.image_media_token,
|
||||
prompt: request.prompt,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
generate_audio: None,
|
||||
};
|
||||
let result = generate_veo_3_fast_image_to_video(
|
||||
&app_env_configs.storyteller_host,
|
||||
Some(&creds),
|
||||
request,
|
||||
).await;
|
||||
match result {
|
||||
Ok(enqueued) => {
|
||||
info!("Successfully enqueued Artcraft Veo 3 video generation");
|
||||
enqueued.inference_job_token
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to use Artcraft Veo 3 video generation: {:?}", err);
|
||||
return Err(GenerateError::from(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("Successfully enqueued video generation");
|
||||
|
||||
@@ -71,8 +71,6 @@ impl TaskEnqueueSuccess{
|
||||
Some(GenerationModel::Kling21Master) => Some(TaskModelType::Kling21Master),
|
||||
Some(GenerationModel::Seedance10Lite) => Some(TaskModelType::Seedance10Lite),
|
||||
Some(GenerationModel::Veo2) => Some(TaskModelType::Veo2),
|
||||
Some(GenerationModel::Veo3) => Some(TaskModelType::Veo3),
|
||||
Some(GenerationModel::Veo3Fast) => Some(TaskModelType::Veo3Fast),
|
||||
Some(GenerationModel::Hunyuan3d2_0) => Some(TaskModelType::Hunyuan3d2_0),
|
||||
Some(GenerationModel::Hunyuan3d2_1) => Some(TaskModelType::Hunyuan3d2_1),
|
||||
Some(GenerationModel::Midjourney) => Some(TaskModelType::Midjourney), // NB: This is a generic Midjourney model, version unknown.
|
||||
|
||||
@@ -59,10 +59,6 @@ pub enum GenerationModel {
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
|
||||
use crate::core::events::generation_events::common::GenerationServiceProvider;
|
||||
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
|
||||
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_url_to_temp_dir::download_url_to_temp_dir;
|
||||
use crate::services::fal::state::fal_credential_manager::FalCredentialManager;
|
||||
@@ -16,6 +15,7 @@ use storyteller_client::recipes::upload_media_file_from_file::upload_media_file_
|
||||
use storyteller_client::utils::api_host::ApiHost;
|
||||
use tauri::AppHandle;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
|
||||
|
||||
pub async fn fal_task_polling_thread(
|
||||
app_handle: AppHandle,
|
||||
|
||||
+2
-10
@@ -14,7 +14,7 @@ use enums::common::job_status_plus::JobStatusPlus;
|
||||
use enums::tauri::tasks::task_status::TaskStatus;
|
||||
use enums::tauri::tasks::task_type::TaskType;
|
||||
use errors::AnyhowResult;
|
||||
use log::{debug, error, info};
|
||||
use log::{error, info};
|
||||
use os_info::Info;
|
||||
use reqwest::Url;
|
||||
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs, Task};
|
||||
@@ -27,7 +27,6 @@ use storyteller_client::error::storyteller_error::StorytellerError;
|
||||
use storyteller_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
|
||||
use storyteller_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
|
||||
use tauri::AppHandle;
|
||||
use tokens::tokens::app_session::AppSessionToken;
|
||||
|
||||
// TODO: Configure this with the build and increment.
|
||||
const CLIENT_NAME : &str = "artcraft";
|
||||
@@ -41,17 +40,12 @@ pub async fn storyteller_activity_thread(
|
||||
storyteller_creds_manager: StorytellerCredentialManager,
|
||||
) -> ! {
|
||||
let startup = Instant::now();
|
||||
let app_session_token = AppSessionToken::generate();
|
||||
|
||||
info!("Session started at {:?} with token: {:?}", startup, app_session_token);
|
||||
|
||||
loop {
|
||||
let res = polling_loop(
|
||||
&app_env_configs,
|
||||
&storyteller_creds_manager,
|
||||
startup,
|
||||
&artcraft_platform_info,
|
||||
&app_session_token,
|
||||
).await;
|
||||
if let Err(err) = res {
|
||||
error!("An error occurred: {:?}", err);
|
||||
@@ -66,7 +60,6 @@ async fn polling_loop(
|
||||
storyteller_creds_manager: &StorytellerCredentialManager,
|
||||
startup: Instant,
|
||||
artcraft_platform_info: &ArtcraftPlatformInfo,
|
||||
app_session_token: &AppSessionToken,
|
||||
) -> AnyhowResult<()> {
|
||||
loop {
|
||||
let creds = storyteller_creds_manager.get_credentials()?;
|
||||
@@ -88,7 +81,6 @@ async fn polling_loop(
|
||||
let time_since_startup = Instant::now().duration_since(startup);
|
||||
|
||||
let request = LogAppActiveUserRequest {
|
||||
maybe_app_session_token: Some(app_session_token.clone()),
|
||||
maybe_app_name: Some(CLIENT_NAME.to_string()),
|
||||
maybe_app_version: Some(artcraft_platform_info.artcraft_version.clone()),
|
||||
maybe_os_platform: Some(artcraft_platform_info.os_platform.as_str().to_owned()),
|
||||
@@ -96,7 +88,7 @@ async fn polling_loop(
|
||||
maybe_session_duration_seconds: Some(time_since_startup.as_secs()),
|
||||
};
|
||||
|
||||
debug!("Logging active user with storyteller.");
|
||||
info!("Logging active user with storyteller.");
|
||||
|
||||
let result = log_active_user(
|
||||
&app_env_configs.storyteller_host,
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod upsert_analytics_app_active_user;
|
||||
pub mod upsert_analytics_app_session;
|
||||
pub mod upsert_analytics_app_active_user;
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
use crate::errors::mysql_error::MysqlError;
|
||||
use crate::errors::subtypes::upsert_error::UpsertError;
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use sqlx::mysql::MySqlArguments;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::query::Query;
|
||||
use sqlx::MySql;
|
||||
use tokens::tokens::app_session::AppSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct UpsertAnalyticsAppSession<'a> {
|
||||
pub app_session_token: &'a AppSessionToken,
|
||||
// TODO: Swap PaymentsNamespace (artcraft,fakeyou,etc.) for an AnalyticsNamespace enum.
|
||||
pub namespace: PaymentsNamespace,
|
||||
pub user_token: &'a UserToken,
|
||||
pub ip_address: &'a str,
|
||||
pub app_version: Option<&'a str>,
|
||||
pub os_platform: Option<&'a str>,
|
||||
pub os_version: Option<&'a str>,
|
||||
pub session_duration_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl <'a> UpsertAnalyticsAppSession<'a> {
|
||||
fn query(&self) -> Query<MySql, MySqlArguments> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO analytics_app_sessions
|
||||
SET
|
||||
session_token = ?,
|
||||
app_namespace = ?,
|
||||
user_token = ?,
|
||||
app_version = ?,
|
||||
os_platform = ?,
|
||||
os_version = ?,
|
||||
session_duration_seconds = ?,
|
||||
ip_address = ?,
|
||||
measurement_count = measurement_count + 1,
|
||||
first_active_at = NOW(),
|
||||
last_active_at = NOW()
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_namespace = ?,
|
||||
user_token = ?,
|
||||
app_version = ?,
|
||||
os_platform = ?,
|
||||
os_version = ?,
|
||||
session_duration_seconds = ?,
|
||||
ip_address = ?,
|
||||
measurement_count = measurement_count + 1,
|
||||
last_active_at = NOW()
|
||||
"#,
|
||||
// Insert case
|
||||
self.app_session_token.as_str(),
|
||||
self.namespace.to_str(),
|
||||
self.user_token.as_str(),
|
||||
self.app_version,
|
||||
self.os_platform,
|
||||
self.os_version,
|
||||
self.session_duration_seconds,
|
||||
self.ip_address,
|
||||
// Update case
|
||||
self.namespace.to_str(),
|
||||
self.user_token.as_str(),
|
||||
self.app_version,
|
||||
self.os_platform,
|
||||
self.os_version,
|
||||
self.session_duration_seconds,
|
||||
self.ip_address,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn upsert_with_connection(
|
||||
&self,
|
||||
mysql_connection: &mut PoolConnection<MySql>
|
||||
) -> Result<(), MysqlError<UpsertError>> {
|
||||
let _query_result = self.query()
|
||||
.execute(&mut **mysql_connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,6 @@ pub enum TaskModelType {
|
||||
Seedance10Lite,
|
||||
#[serde(rename = "veo_2")]
|
||||
Veo2,
|
||||
#[serde(rename = "veo_3")]
|
||||
Veo3,
|
||||
#[serde(rename = "veo_3_fast")]
|
||||
Veo3Fast,
|
||||
|
||||
// 3D Object generation models
|
||||
#[serde(rename = "hunyuan_3d_2.0")]
|
||||
@@ -87,8 +83,6 @@ impl TaskModelType {
|
||||
Self::Kling21Master => "kling_2.1_master",
|
||||
Self::Seedance10Lite => "seedance_1.0_lite",
|
||||
Self::Veo2 => "veo_2",
|
||||
Self::Veo3 => "veo_3",
|
||||
Self::Veo3Fast => "veo_3_fast",
|
||||
// 3D Object generation models
|
||||
Self::Hunyuan3d2_0 => "hunyuan_3d_2.0",
|
||||
Self::Hunyuan3d2_1 => "hunyuan_3d_2.1",
|
||||
@@ -115,8 +109,6 @@ impl TaskModelType {
|
||||
"kling_2.1_master" => Ok(Self::Kling21Master),
|
||||
"seedance_1.0_lite" => Ok(Self::Seedance10Lite),
|
||||
"veo_2" => Ok(Self::Veo2),
|
||||
"veo_3" => Ok(Self::Veo3),
|
||||
"veo_3_fast" => Ok(Self::Veo3Fast),
|
||||
// 3D Object generation models
|
||||
"hunyuan_3d_2.0" => Ok(Self::Hunyuan3d2_0),
|
||||
"hunyuan_3d_2.1" => Ok(Self::Hunyuan3d2_1),
|
||||
@@ -146,8 +138,6 @@ impl TaskModelType {
|
||||
Self::Kling21Master,
|
||||
Self::Seedance10Lite,
|
||||
Self::Veo2,
|
||||
Self::Veo3,
|
||||
Self::Veo3Fast,
|
||||
// 3D Object generation models
|
||||
Self::Hunyuan3d2_0,
|
||||
Self::Hunyuan3d2_1,
|
||||
@@ -183,8 +173,6 @@ mod tests {
|
||||
assert_serialization(TaskModelType::Kling21Master, "kling_2.1_master");
|
||||
assert_serialization(TaskModelType::Seedance10Lite, "seedance_1.0_lite");
|
||||
assert_serialization(TaskModelType::Veo2, "veo_2");
|
||||
assert_serialization(TaskModelType::Veo3, "veo_3");
|
||||
assert_serialization(TaskModelType::Veo3Fast, "veo_3_fast");
|
||||
// 3D Object generation models
|
||||
assert_serialization(TaskModelType::Hunyuan3d2_0, "hunyuan_3d_2.0");
|
||||
assert_serialization(TaskModelType::Hunyuan3d2_1, "hunyuan_3d_2.1");
|
||||
@@ -210,8 +198,6 @@ mod tests {
|
||||
assert_eq!(TaskModelType::Kling21Master.to_str(), "kling_2.1_master");
|
||||
assert_eq!(TaskModelType::Seedance10Lite.to_str(), "seedance_1.0_lite");
|
||||
assert_eq!(TaskModelType::Veo2.to_str(), "veo_2");
|
||||
assert_eq!(TaskModelType::Veo3.to_str(), "veo_3");
|
||||
assert_eq!(TaskModelType::Veo3Fast.to_str(), "veo_3_fast");
|
||||
// 3D Object generation models
|
||||
assert_eq!(TaskModelType::Hunyuan3d2_0.to_str(), "hunyuan_3d_2.0");
|
||||
assert_eq!(TaskModelType::Hunyuan3d2_1.to_str(), "hunyuan_3d_2.1");
|
||||
@@ -237,8 +223,6 @@ mod tests {
|
||||
assert_eq!(TaskModelType::from_str("kling_2.1_master").unwrap(), TaskModelType::Kling21Master);
|
||||
assert_eq!(TaskModelType::from_str("seedance_1.0_lite").unwrap(), TaskModelType::Seedance10Lite);
|
||||
assert_eq!(TaskModelType::from_str("veo_2").unwrap(), TaskModelType::Veo2);
|
||||
assert_eq!(TaskModelType::from_str("veo_3").unwrap(), TaskModelType::Veo3);
|
||||
assert_eq!(TaskModelType::from_str("veo_3_fast").unwrap(), TaskModelType::Veo3Fast);
|
||||
// 3D Object generation models
|
||||
assert_eq!(TaskModelType::from_str("hunyuan_3d_2.0").unwrap(), TaskModelType::Hunyuan3d2_0);
|
||||
assert_eq!(TaskModelType::from_str("hunyuan_3d_2.1").unwrap(), TaskModelType::Hunyuan3d2_1);
|
||||
@@ -247,7 +231,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = TaskModelType::all_variants();
|
||||
assert_eq!(variants.len(), 20);
|
||||
assert_eq!(variants.len(), 18);
|
||||
// Image models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Dev));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Flux1Schnell));
|
||||
@@ -266,8 +250,6 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Kling21Master));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Seedance10Lite));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo2));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Veo3Fast));
|
||||
// 3D Object generation models
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_0));
|
||||
assert_eq!(variants.pop_first(), Some(TaskModelType::Hunyuan3d2_1));
|
||||
|
||||
@@ -23,7 +23,6 @@ use strum::EnumIter;
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
pub(crate) enum TokenPrefix {
|
||||
AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables.
|
||||
AppSession, // NB: These are generated client-side (!)
|
||||
AuditLog,
|
||||
BatchGeneration,
|
||||
BetaKey,
|
||||
@@ -106,7 +105,6 @@ impl PrefixGenerator for TokenPrefix {
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::AnonymousVisitorTracking => "avt_",
|
||||
Self::AppSession => "app_session_",
|
||||
Self::AuditLog => "audit_",
|
||||
Self::BatchGeneration => "batch_g_",
|
||||
Self::BetaKey => "beta_key_",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for users.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct AppSessionToken(pub String);
|
||||
|
||||
impl_mysql_token_from_row!(AppSessionToken);
|
||||
impl_string_token!(AppSessionToken);
|
||||
impl_crockford_generator!(AppSessionToken, 32usize, TokenPrefix::AppSession, CrockfordMixed);
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod anonymous_visitor_tracking;
|
||||
pub mod api_tokens_external;
|
||||
pub mod api_tokens_internal;
|
||||
pub mod app_session;
|
||||
pub mod audit_logs;
|
||||
pub mod batch_generations;
|
||||
pub mod beta_keys;
|
||||
|
||||
+1
-29
@@ -5,14 +5,11 @@ use crate::state::server_state::ServerState;
|
||||
use actix_helpers::extractors::get_request_user_agent::get_request_user_agent;
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::info;
|
||||
use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LogAppActiveUserResponse};
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use log::{info, warn};
|
||||
use mysql_queries::queries::analytics_active_users::upsert_analytics_app_active_user::UpsertAnalyticsAppActiveUser;
|
||||
use mysql_queries::queries::analytics_active_users::upsert_analytics_app_session::UpsertAnalyticsAppSession;
|
||||
use tokens::tokens::app_session::AppSessionToken;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const CLIENT_WAIT_FOR_RETRY_MILLIS: u64 = 1_000 * 60 * 1; // Every minute
|
||||
@@ -103,23 +100,6 @@ pub async fn log_app_active_user_handler(
|
||||
};
|
||||
|
||||
upsert.upsert_with_connection(&mut mysql_connection).await?;
|
||||
|
||||
if let Some(token) = request.maybe_app_session_token.as_ref() {
|
||||
validate_app_session_token_format(token)?;
|
||||
|
||||
let upsert = UpsertAnalyticsAppSession{
|
||||
app_session_token: token,
|
||||
namespace: PaymentsNamespace::Artcraft,
|
||||
user_token: &user_token,
|
||||
ip_address: &ip_address,
|
||||
app_version: app_version.as_deref(),
|
||||
os_platform: request.maybe_os_platform.as_deref(),
|
||||
os_version: request.maybe_os_version.as_deref(),
|
||||
session_duration_seconds: request.maybe_session_duration_seconds,
|
||||
};
|
||||
|
||||
upsert.upsert_with_connection(&mut mysql_connection).await?;
|
||||
}
|
||||
|
||||
Ok(Json(LogAppActiveUserResponse {
|
||||
success: true,
|
||||
@@ -127,11 +107,3 @@ pub async fn log_app_active_user_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_app_session_token_format(app_session_token: &AppSessionToken) -> Result<(), CommonWebError> {
|
||||
if !app_session_token.as_str().starts_with(AppSessionToken::token_prefix()) {
|
||||
warn!("App session token has invalid prefix: {}", app_session_token.as_str());
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("Invalid app session token format".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/tutorials /index.html 200
|
||||
/tutorials/ /index.html 200
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB |
@@ -8,9 +8,9 @@ nx build artcraft-website
|
||||
echo "Change to project dir"
|
||||
pushd apps/artcraft-website/
|
||||
|
||||
echo "Copy netlify configs into dist"
|
||||
#echo "Copy netlify configs into dist"
|
||||
#cp _headers dist/
|
||||
cp _redirects dist/
|
||||
#cp _redirects dist/
|
||||
|
||||
#echo "Copy netlify 404.html page into dist"
|
||||
#cp "404.html" dist/
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Route, Routes } from "react-router-dom";
|
||||
import Download from "../pages/download";
|
||||
import Navbar from "../components/navbar";
|
||||
import Landing2 from "../pages/landing2";
|
||||
import TutorialsPage from "../pages/tutorials";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -12,7 +11,6 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing2 />} />
|
||||
<Route path="/download" element={<Download />} />
|
||||
<Route path="/tutorials" element={<TutorialsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
faTiktok,
|
||||
faDiscord,
|
||||
faYoutube,
|
||||
faGithub,
|
||||
faGithubAlt,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import { SOCIAL_LINKS } from "../../config/links";
|
||||
|
||||
@@ -29,11 +27,6 @@ const navigation = {
|
||||
href: SOCIAL_LINKS.TIKTOK,
|
||||
icon: (props: any) => <FontAwesomeIcon icon={faTiktok} {...props} />,
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
href: SOCIAL_LINKS.GITHUB,
|
||||
icon: (props: any) => <FontAwesomeIcon icon={faGithubAlt} {...props} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DiscordButton } from "../discord-button";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Tutorials", href: "/tutorials" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const location = useLocation();
|
||||
const [underlineAnim, setUnderlineAnim] = useState<
|
||||
Record<string, "in" | "out" | null>
|
||||
>({});
|
||||
const outTimersRef = useRef<Record<string, number | undefined>>({});
|
||||
|
||||
const handleEnter = (href: string) => {
|
||||
if (outTimersRef.current[href] !== undefined) {
|
||||
window.clearTimeout(outTimersRef.current[href]);
|
||||
delete outTimersRef.current[href];
|
||||
}
|
||||
setUnderlineAnim((prev) => ({ ...prev, [href]: "in" }));
|
||||
};
|
||||
|
||||
const handleLeave = (href: string) => {
|
||||
setUnderlineAnim((prev) => ({ ...prev, [href]: "out" }));
|
||||
outTimersRef.current[href] = window.setTimeout(() => {
|
||||
setUnderlineAnim((prev) => ({ ...prev, [href]: null }));
|
||||
delete outTimersRef.current[href];
|
||||
}, 260);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -64,49 +37,23 @@ export default function Navbar() {
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isCurrent = location.pathname === item.href;
|
||||
const anim = underlineAnim[item.href] ?? null;
|
||||
return (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
className={twMerge(
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white",
|
||||
"relative rounded-md text-[15px] font-semibold transition-all"
|
||||
)}
|
||||
onMouseEnter={() => !isCurrent && handleEnter(item.href)}
|
||||
onMouseLeave={() => !isCurrent && handleLeave(item.href)}
|
||||
>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
|
||||
isCurrent ? "" : ""
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"link-underline block h-full w-full bg-primary/90",
|
||||
isCurrent
|
||||
? "visible-line"
|
||||
: anim === "in"
|
||||
? "animate-in"
|
||||
: anim === "out"
|
||||
? "animate-out"
|
||||
: "hidden-line"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* <div className="hidden md:ml-7 md:flex md:items-center md:space-x-3">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
aria-current={item.current ? "page" : undefined}
|
||||
className={twMerge(
|
||||
item.current
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-white/80 hover:text-white",
|
||||
"rounded-md px-3 py-2 text-[15px] font-medium transition-all"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
|
||||
@@ -149,31 +96,6 @@ export default function Navbar() {
|
||||
))}
|
||||
</div>
|
||||
</DisclosurePanel> */}
|
||||
<style>{`
|
||||
.link-underline {
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0) translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
.visible-line {
|
||||
transform: scaleX(1) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.hidden-line {
|
||||
transform: scaleX(0) translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes underline-in {
|
||||
0% { transform: scaleX(0) translateX(0); opacity: 0.8; }
|
||||
100% { transform: scaleX(1) translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes underline-out {
|
||||
0% { transform: scaleX(1) translateX(0); opacity: 1; }
|
||||
100% { transform: scaleX(1) translateX(100%); opacity: 0; }
|
||||
}
|
||||
.animate-in { animation: underline-in 220ms ease-out forwards; }
|
||||
.animate-out { animation: underline-out 220ms ease-in forwards; }
|
||||
`}</style>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export const SOCIAL_LINKS = {
|
||||
DISCORD: "https://discord.gg/75svZP2Vje",
|
||||
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
|
||||
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
|
||||
GITHUB: "https://github.com/storytold",
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./landing";
|
||||
export * from "./landing2";
|
||||
export * from "./download";
|
||||
export * from "./tutorials";
|
||||
|
||||
@@ -23,8 +23,7 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
|
||||
//const MAC_VERSION = "2025.07.28";
|
||||
//const MAC_VERSION = "2025.08.23"; // NB: The "08.22 (twenty two)" release is broken.
|
||||
//const MAC_VERSION = "2025.09.23";
|
||||
//const MAC_VERSION = "2025.09.24"; // Works! Good release.
|
||||
const MAC_VERSION = "2025.09.27";
|
||||
const MAC_VERSION = "2025.09.24";
|
||||
|
||||
// Current Windows version (update this when a new version is released)
|
||||
// If a rollback is necessary, roll back to the last known good date.
|
||||
@@ -34,8 +33,7 @@ const MAC_VERSION = "2025.09.27";
|
||||
//const WINDOWS_VERSION = "2025.07.28";
|
||||
//const WINDOWS_VERSION = "2025.08.22";
|
||||
//const WINDOWS_VERSION = "2025.09.23";
|
||||
//const WINDOWS_VERSION = "2025.09.24"; // Works! Good release.
|
||||
const WINDOWS_VERSION = "2025.09.27";
|
||||
const WINDOWS_VERSION = "2025.09.24";
|
||||
|
||||
// Link to the current production Mac build
|
||||
const MAC_LINK = `https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_${MAC_VERSION}.dmg`;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./tutorials";
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { defaultTutorials, TutorialItem } from "@storyteller/ui-tutorial-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronLeft, faPlay } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const categoriesFrom = (items: TutorialItem[]): string[] => {
|
||||
const set = new Set<string>();
|
||||
for (const t of items) if (t.category) set.add(t.category);
|
||||
return ["All", ...Array.from(set)];
|
||||
};
|
||||
|
||||
const websiteThumb = (url: string): string => {
|
||||
// Map shared modal thumbnail paths to website public path
|
||||
return url.startsWith("/resources/images/")
|
||||
? url.replace("/resources/images/", "/images/")
|
||||
: url;
|
||||
};
|
||||
|
||||
export const TutorialsPage = () => {
|
||||
const [activeCategory, setActiveCategory] = useState<string>("All");
|
||||
const [selected, setSelected] = useState<TutorialItem | null>(null);
|
||||
|
||||
const tutorials = defaultTutorials;
|
||||
const categories = useMemo(() => categoriesFrom(tutorials), [tutorials]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (activeCategory === "All") return tutorials;
|
||||
return tutorials.filter((t) => t.category === activeCategory);
|
||||
}, [activeCategory, tutorials]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
|
||||
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-8 sm:mb-12">
|
||||
<h1 className="text-4xl sm:text-6xl font-bold mb-3">Tutorials</h1>
|
||||
<p className="text-white/70 text-base sm:text-lg">
|
||||
Learn tips, tricks, and workflows for ArtCraft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{!selected && (
|
||||
<div className="flex flex-wrap gap-3 justify-center mb-8">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={twMerge(
|
||||
"px-4 py-2 rounded-xl border",
|
||||
activeCategory === cat
|
||||
? "bg-primary/30 border-primary/90"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!selected ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(item)}
|
||||
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
|
||||
>
|
||||
<div className="aspect-video w-full overflow-hidden relative">
|
||||
<img
|
||||
src={websiteThumb(item.thumbnailUrl)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="inline-flex items-center gap-2 text-white/90 text-sm font-medium">
|
||||
<FontAwesomeIcon icon={faPlay} />
|
||||
Watch
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 text-sm text-white/90 flex items-center justify-between">
|
||||
<span>{item.title}</span>
|
||||
{item.category && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10 border border-white/10">
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-3 flex items-center gap-4 md:flex-row flex-col">
|
||||
<Button
|
||||
onClick={() => setSelected(null)}
|
||||
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium border-none"
|
||||
variant="action"
|
||||
icon={faChevronLeft}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="text-lg font-bold text-base-fg">
|
||||
Tutorial: {selected.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
|
||||
<iframe
|
||||
title={selected.title}
|
||||
src={selected.videoUrl.replace(
|
||||
"youtu.be/",
|
||||
"www.youtube.com/embed/"
|
||||
)}
|
||||
className="h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialsPage;
|
||||
@@ -37,9 +37,6 @@
|
||||
"src/components/footer"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tutorial-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button/tsconfig.lib.json"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/components/tutorial-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/button"
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB |
@@ -19,7 +19,6 @@ import {
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
|
||||
import { getSelectedImageModel } from "@storyteller/ui-model-selector";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
|
||||
|
||||
@@ -452,9 +451,6 @@ const PageDraw = () => {
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import toast from "react-hot-toast";
|
||||
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
|
||||
import { UploaderStates } from "~/enums";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
export interface BaseSelectorImage {
|
||||
url: string;
|
||||
@@ -176,9 +175,6 @@ export const BaseImageSelector = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
<GalleryModal
|
||||
isOpen={!!isGalleryModalOpen}
|
||||
onClose={handleGalleryClose}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { HistoryStack, ImageBundle } from "./HistoryStack";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
|
||||
|
||||
@@ -474,9 +473,6 @@ const PageEdit = () => {
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,7 +77,6 @@ import PageDraw from "../PageDraw/PageDraw";
|
||||
import { useTabStore } from "../Stores/TabState";
|
||||
import PageEdit from "../PageEdit/PageEdit";
|
||||
import { ImageModel } from "@storyteller/model-list";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Stage3D;
|
||||
|
||||
@@ -552,9 +551,6 @@ export const PageEditor = () => {
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { useEffect, useState } from "react";
|
||||
import * as gpu from "detect-gpu";
|
||||
import { UsersApi } from "~/Classes/ApiManager";
|
||||
import { TurnOnGpu } from "~/pages/PageEnigma/TurnOnGpu";
|
||||
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
|
||||
import {
|
||||
precisionSelectedValue,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
|
||||
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
|
||||
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
|
||||
import { LoadingDots } from "@storyteller/ui-loading";
|
||||
import {
|
||||
PricingModal,
|
||||
CreditsModal,
|
||||
@@ -103,6 +105,13 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
InstallSounds();
|
||||
});
|
||||
|
||||
if (validGpu === "unknown") {
|
||||
return <LoadingDots />;
|
||||
}
|
||||
if (validGpu === "error") {
|
||||
return <TurnOnGpu />;
|
||||
}
|
||||
|
||||
const currentReminderModalProps = actionReminderProps.value;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import * as CopyToClipboard from "react-copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCopy } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
export const TurnOnGpu = () => {
|
||||
const [copied1, setCopied1] = useState(false);
|
||||
const [copied2, setCopied2] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied1) {
|
||||
setTimeout(() => setCopied1(false), 2000);
|
||||
}
|
||||
}, [copied1]);
|
||||
useEffect(() => {
|
||||
if (copied2) {
|
||||
setTimeout(() => setCopied2(false), 2000);
|
||||
}
|
||||
}, [copied2]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 h-screen overflow-y-auto p-16">
|
||||
<div>
|
||||
Hardware acceleration is <b>required</b> for StoryTeller Studio to run
|
||||
properly. To check if this is enabled, please follow the steps for your
|
||||
browser below.
|
||||
</div>
|
||||
<div className="my-4">
|
||||
<b>Google Chrome: </b>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<div>
|
||||
Click the three stacked dots in upper right of the browser window.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Click the arrow next to <em>"Advanced"</em> and select{" "}
|
||||
<em>"System."</em>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Make sure the box next to{" "}
|
||||
<em>"Use hardware acceleration when available"</em> is
|
||||
toggled on.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Close and restart the browser for the change to take effect.
|
||||
</div>
|
||||
</li>
|
||||
<ul>
|
||||
<li>
|
||||
<div>
|
||||
You can also copy and paste this link{" "}
|
||||
<CopyToClipboard.CopyToClipboard
|
||||
text="chrome://settings/system"
|
||||
onCopy={() => setCopied1(true)}
|
||||
>
|
||||
<span className="text-blue">
|
||||
chrome://settings/system
|
||||
{copied1 ? (
|
||||
<span style={{ color: "red" }}>Copied.</span>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
</span>
|
||||
</CopyToClipboard.CopyToClipboard>
|
||||
into the address bar. This will bring you right to the system
|
||||
settings.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ol>
|
||||
<div className="my-4">
|
||||
<b>Firefox:</b>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<div>
|
||||
Click on <em>"Firefox"</em> then{" "}
|
||||
<em>"Preferences."</em>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
In the <em>"General"</em> panel, go to the
|
||||
<em>"Performance" </em>section.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Uncheck the box next to
|
||||
<em>"Use recommended performance settings"</em> and make
|
||||
sure the box next to{" "}
|
||||
<em>"Use hardware acceleration when available"</em> is
|
||||
selected.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Close and restart the browser for the change to take effect.
|
||||
</div>
|
||||
</li>
|
||||
<ul>
|
||||
<li>
|
||||
<div>
|
||||
If you need help, please follow this link:{" "}
|
||||
<a
|
||||
href="https://support.mozilla.org/en-US/kb/performance-settings?as=u&utm_source=inproduct"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue underline"
|
||||
>
|
||||
https://support.mozilla.org/en-US/kb/performance-settings?as=u&utm_source=inproduct
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ol>
|
||||
<div className="my-4">
|
||||
<b>Microsoft Edge:</b>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<div>
|
||||
Click the "3 dots" menu icon in the upper right of the
|
||||
browser window
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Click on <em>"Settings"</em> then{" "}
|
||||
<em>"System"</em> on the left side.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Toggle on{" "}
|
||||
<em>"Use hardware acceleration when available"</em> if
|
||||
this is not enabled.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Close and restart the browser for the change to take effect.
|
||||
</div>
|
||||
</li>
|
||||
<ul>
|
||||
<li>
|
||||
<div>
|
||||
You can also copy and paste this link{" "}
|
||||
<CopyToClipboard.CopyToClipboard
|
||||
text="edge://settings/system"
|
||||
onCopy={() => setCopied2(true)}
|
||||
>
|
||||
<span className="text-blue">
|
||||
edge://settings/system
|
||||
{copied2 ? (
|
||||
<span style={{ color: "red" }}>Copied.</span>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
</span>
|
||||
</CopyToClipboard.CopyToClipboard>
|
||||
into the address bar. This will bring you right to the system
|
||||
settings.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ol>
|
||||
<div className="my-4">
|
||||
<b>Safari (Mac Only)</b>
|
||||
</div>
|
||||
<div>
|
||||
As of macOS Catalina (version 10.15), Hardware acceleration is
|
||||
automatically enabled, and there is no way turn this on and off. If you
|
||||
are using an older version of MacOS, please use the steps below:
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<div>
|
||||
Click on the "Safari" then "Settings" >{" "}
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
Click "Advanced" on the top right of the Settings window
|
||||
and ensure "Use hardware acceleration" is enabled
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
If you are still having an issue with enabling hardware acceleration, we
|
||||
recommend you update your graphics card drivers, if possible. If these
|
||||
updates do not help, you may have to consider upgrading your graphics
|
||||
card or playing on a different device.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { Badge } from "@storyteller/ui-badge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.TextToImage;
|
||||
|
||||
@@ -187,7 +186,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
>
|
||||
<button
|
||||
onClick={() => resetBatches()}
|
||||
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
|
||||
className="bg-red/20 hover:bg-red/30 rounded-md px-3 py-1 text-xs text-white/70 transition-colors"
|
||||
>
|
||||
Clear session
|
||||
</button>
|
||||
@@ -225,9 +224,6 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
// import { Badge } from "@storyteller/ui-badge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage";
|
||||
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
|
||||
|
||||
@@ -212,9 +211,6 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
triggerLabel="Model"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<TutorialModalButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,9 +76,6 @@
|
||||
{
|
||||
"path": "../../libs/components/promptbox"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tutorial-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/model-selector"
|
||||
},
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheck,
|
||||
faEllipsis,
|
||||
faPencil,
|
||||
faTrashCan,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import galleryDnd from "./galleryDnd";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryItem } from "./gallery-modal";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import {
|
||||
showActionReminder,
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
|
||||
type ModalMode = "select" | "view";
|
||||
|
||||
@@ -28,8 +17,6 @@ interface GalleryDraggableItemProps {
|
||||
onImageError: () => void;
|
||||
disableTooltipAndBadge?: boolean;
|
||||
imageFit?: "cover" | "contain";
|
||||
onDeleted?: (id: string) => void;
|
||||
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
@@ -41,40 +28,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
onImageError,
|
||||
disableTooltipAndBadge = false,
|
||||
imageFit = "cover",
|
||||
onDeleted,
|
||||
onEditClicked,
|
||||
}) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragStarted = useRef(false);
|
||||
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
|
||||
|
||||
const handleDelete = () => {
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: "Delete this media?",
|
||||
message: (
|
||||
<p className="text-sm text-white/70">
|
||||
This will permanently remove the media from your library. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
),
|
||||
primaryActionText: "Delete",
|
||||
secondaryActionText: "Cancel",
|
||||
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
|
||||
onPrimaryAction: async () => {
|
||||
try {
|
||||
const res = await mediaFilesApi.DeleteMediaFileByToken({
|
||||
mediaFileToken: item.id,
|
||||
});
|
||||
if (res.success) {
|
||||
onDeleted?.(item.id);
|
||||
}
|
||||
} finally {
|
||||
isActionReminderOpen.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
// Disable dragging for video items
|
||||
@@ -121,65 +77,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
activeFilter === "video" ? "aspect-square" : "aspect-square"
|
||||
)}
|
||||
>
|
||||
{/* dropdown menu */}
|
||||
<div
|
||||
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-75"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="end"
|
||||
mode="default"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faEllipsis} 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="min-w-28 p-1"
|
||||
closeOnUnhover
|
||||
>
|
||||
{(close) => (
|
||||
<div className="flex flex-col">
|
||||
{item.mediaClass === "image" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (onEditClicked && (item.fullImage || item.thumbnail)) {
|
||||
await onEditClicked(
|
||||
item.fullImage || item.thumbnail!,
|
||||
item.id
|
||||
);
|
||||
}
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
|
||||
<span>Edit image</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
|
||||
<span className="text-red">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
{/* Media class badge on hover */}
|
||||
{!disableTooltipAndBadge && item.mediaClass && (
|
||||
<div className="pointer-events-none absolute left-2 top-2 z-20 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">
|
||||
<div className="pointer-events-none absolute right-2 top-2 z-20 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" ? "3D" : item.mediaClass}
|
||||
</div>
|
||||
)}
|
||||
@@ -227,7 +127,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
@@ -299,7 +199,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const GalleryModal = React.memo(
|
||||
|
||||
// NB: We've got some kind of cursoring issue where subsequent pages are not requested.
|
||||
// For now, let's set the pagination limit really high.
|
||||
const pageSize = 100;
|
||||
const pageSize = 1000;
|
||||
|
||||
const imageUrl = lightboxImageSignal.value?.fullImage || "";
|
||||
|
||||
@@ -339,10 +339,6 @@ export const GalleryModal = React.memo(
|
||||
onUseSelected?.(selectedItems);
|
||||
}, [groupItemsByDate, selectedItemIds, onUseSelected]);
|
||||
|
||||
const handleItemDeleted = useCallback((id: string) => {
|
||||
setAllItems((prev) => prev.filter((it) => it.id !== id));
|
||||
}, []);
|
||||
|
||||
useSignals();
|
||||
|
||||
// Synchronize lightbox image when external media id signal is set
|
||||
@@ -613,8 +609,6 @@ export const GalleryModal = React.memo(
|
||||
}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
imageFit={imageFit}
|
||||
onDeleted={handleItemDeleted}
|
||||
onEditClicked={onEditClicked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
{
|
||||
"path": "../slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../popover"
|
||||
},
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../loading-spinner"
|
||||
},
|
||||
@@ -20,15 +26,6 @@
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "../../api"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../popover"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
{
|
||||
"path": "../slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
@@ -61,15 +67,6 @@
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../action-reminder-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../tooltip/tsconfig.lib.json"
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ interface PopoverMenuProps {
|
||||
align?: "start" | "center" | "end";
|
||||
panelActionLabel?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
closeOnUnhover?: boolean;
|
||||
}
|
||||
|
||||
export const PopoverMenu = ({
|
||||
@@ -72,7 +71,6 @@ export const PopoverMenu = ({
|
||||
align = "start",
|
||||
panelActionLabel,
|
||||
onOpenChange,
|
||||
closeOnUnhover = false,
|
||||
}: PopoverMenuProps) => {
|
||||
const selectedItem = items.find((item) => item.selected);
|
||||
|
||||
@@ -89,7 +87,7 @@ export const PopoverMenu = ({
|
||||
const className = twMerge(
|
||||
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
|
||||
"flex gap-2 items-center justify-center outline-none",
|
||||
"transition-all duration-100",
|
||||
"transition-all duration-150",
|
||||
"bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90 border border-ui-controls-border",
|
||||
"active:scale-95 transform",
|
||||
buttonClassName
|
||||
@@ -121,7 +119,7 @@ export const PopoverMenu = ({
|
||||
}, []);
|
||||
|
||||
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
if (mode !== "hoverSelect") return;
|
||||
|
||||
setIsHovering(true);
|
||||
if (hoverTimeoutRef.current) {
|
||||
@@ -129,7 +127,7 @@ export const PopoverMenu = ({
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!open && mode === "hoverSelect") {
|
||||
if (!open) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
openFn();
|
||||
}, 100);
|
||||
@@ -137,7 +135,7 @@ export const PopoverMenu = ({
|
||||
};
|
||||
|
||||
const handleButtonMouseLeave = (closeFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
if (mode !== "hoverSelect") return;
|
||||
|
||||
setIsHovering(false);
|
||||
|
||||
@@ -145,18 +143,15 @@ export const PopoverMenu = ({
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
if (!isHovering) {
|
||||
closeFn();
|
||||
}
|
||||
},
|
||||
mode === "hoverSelect" ? 300 : 150
|
||||
);
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
if (!isHovering) {
|
||||
closeFn();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handlePanelMouseEnter = () => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
if (mode !== "hoverSelect") return;
|
||||
setIsHovering(true);
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
@@ -165,19 +160,16 @@ export const PopoverMenu = ({
|
||||
};
|
||||
|
||||
const handlePanelMouseLeave = (closeFn: () => void) => {
|
||||
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
|
||||
if (mode !== "hoverSelect") return;
|
||||
setIsHovering(false);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
closeFn();
|
||||
},
|
||||
mode === "hoverSelect" ? 300 : 150
|
||||
);
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
closeFn();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -229,19 +221,19 @@ export const PopoverMenu = ({
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-75 ease-out"
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom={
|
||||
position === "bottom"
|
||||
? "translate-y-1 opacity-0"
|
||||
: "-translate-y-1 opacity-0"
|
||||
? "translate-y-2 opacity-0"
|
||||
: "-translate-y-2 opacity-0"
|
||||
}
|
||||
enterTo="translate-y-0 opacity-100"
|
||||
leave="transition duration-75 ease-in"
|
||||
leave="transition duration-100 ease-in"
|
||||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo={
|
||||
position === "bottom"
|
||||
? "translate-y-1 opacity-0"
|
||||
: "-translate-y-1 opacity-0"
|
||||
? "translate-y-2 opacity-0"
|
||||
: "-translate-y-2 opacity-0"
|
||||
}
|
||||
>
|
||||
<PopoverPanel
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# @storyteller/ui-tutorial-modal
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test @storyteller/ui-tutorial-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@storyteller/ui-tutorial-modal",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./lib/tutorial-modal";
|
||||
export * from "./lib/tutorials";
|
||||
@@ -1,27 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { TutorialItem } from "./tutorials";
|
||||
|
||||
type TutorialView = "grid" | "video";
|
||||
|
||||
type TutorialModalStore = {
|
||||
view: TutorialView;
|
||||
selected: TutorialItem | null;
|
||||
progressSecondsById: Record<string, number>;
|
||||
setGrid: () => void;
|
||||
viewTutorial: (item: TutorialItem) => void;
|
||||
getProgress: (id: string) => number;
|
||||
setProgress: (id: string, seconds: number) => void;
|
||||
};
|
||||
|
||||
export const useTutorialModalStore = create<TutorialModalStore>((set, get) => ({
|
||||
view: "grid",
|
||||
selected: null,
|
||||
progressSecondsById: {},
|
||||
setGrid: () => set({ view: "grid", selected: null }),
|
||||
viewTutorial: (item) => set({ view: "video", selected: item }),
|
||||
getProgress: (id) => get().progressSecondsById[id] ?? 0,
|
||||
setProgress: (id, seconds) =>
|
||||
set((state) => ({
|
||||
progressSecondsById: { ...state.progressSecondsById, [id]: seconds },
|
||||
})),
|
||||
}));
|
||||
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
@@ -1,12 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import StorytellerUiTutorialModal from './tutorial-modal';
|
||||
|
||||
describe('StorytellerUiTutorialModal', () => {
|
||||
|
||||
it('should render successfully', () => {
|
||||
const { baseElement } = render(<StorytellerUiTutorialModal />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { faBook, faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { defaultTutorials, TutorialItem } from "./tutorials.js";
|
||||
import { useTutorialModalStore } from "./tutorial-modal-store";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export type TutorialModalButtonProps = {
|
||||
items?: TutorialItem[];
|
||||
panelTitle?: string;
|
||||
className?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function TutorialModalButton({
|
||||
items,
|
||||
panelTitle = "Tutorials",
|
||||
className,
|
||||
onOpenChange,
|
||||
}: TutorialModalButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
|
||||
const view = useTutorialModalStore((s) => s.view);
|
||||
const selected = useTutorialModalStore((s) => s.selected);
|
||||
const setGrid = useTutorialModalStore((s) => s.setGrid);
|
||||
const viewTutorial = useTutorialModalStore((s) => s.viewTutorial);
|
||||
const getProgress = useTutorialModalStore((s) => s.getProgress);
|
||||
const setProgress = useTutorialModalStore((s) => s.setProgress);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
onOpenChange?.(true);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------
|
||||
// YouTube Player setup for restoring and saving progress
|
||||
// ------------------------------------------------------
|
||||
const playerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<any | null>(null);
|
||||
const progressTimerRef = useRef<number | null>(null);
|
||||
|
||||
const extractYouTubeId = (url: string): string | null => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname.replace(/^www\./, "");
|
||||
if (host === "youtube.com" && u.pathname.startsWith("/embed/")) {
|
||||
return u.pathname.split("/")[2] ?? null;
|
||||
}
|
||||
if (host.includes("youtube.com") && u.pathname.startsWith("/shorts/")) {
|
||||
return u.pathname.split("/")[2] ?? null;
|
||||
}
|
||||
if (host.includes("youtube.com") && u.pathname === "/watch") {
|
||||
return u.searchParams.get("v");
|
||||
}
|
||||
if (host === "youtu.be") {
|
||||
return u.pathname.slice(1) || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadYouTubeIframeAPI = async (): Promise<void> => {
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as any;
|
||||
if (w.YT && w.YT.Player) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const existing = document.getElementById("youtube-iframe-api");
|
||||
if (existing) {
|
||||
const check = () =>
|
||||
w.YT && w.YT.Player ? resolve() : setTimeout(check, 50);
|
||||
check();
|
||||
return;
|
||||
}
|
||||
const tag = document.createElement("script");
|
||||
tag.id = "youtube-iframe-api";
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(tag);
|
||||
w.onYouTubeIframeAPIReady = () => resolve();
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || view !== "video" || !selected) return;
|
||||
const run = async () => {
|
||||
await loadYouTubeIframeAPI();
|
||||
const w = window as any;
|
||||
const videoId = extractYouTubeId(selected.videoUrl);
|
||||
if (!videoId) return;
|
||||
|
||||
if (!playerContainerRef.current) return;
|
||||
// Clear any previous iframe content
|
||||
playerContainerRef.current.innerHTML = "";
|
||||
|
||||
const startSeconds = getProgress(selected.id) || 0;
|
||||
playerRef.current = new w.YT.Player(playerContainerRef.current, {
|
||||
videoId,
|
||||
playerVars: {
|
||||
rel: 0,
|
||||
modestbranding: 1,
|
||||
start: Math.floor(startSeconds),
|
||||
playsinline: 1,
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
// Start polling current time
|
||||
progressTimerRef.current = window.setInterval(() => {
|
||||
try {
|
||||
const t = Math.floor(
|
||||
playerRef.current?.getCurrentTime?.() ?? 0
|
||||
);
|
||||
if (Number.isFinite(t)) setProgress(selected.id, t);
|
||||
} catch {}
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
if (progressTimerRef.current) {
|
||||
window.clearInterval(progressTimerRef.current);
|
||||
progressTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
playerRef.current?.destroy?.();
|
||||
} catch {}
|
||||
playerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, view, selected?.id]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
aria-label="Open tutorials"
|
||||
onClick={handleOpen}
|
||||
variant="action"
|
||||
title={view === "grid" ? "Tutorials" : selected?.title ?? "Tutorials"}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faBook} className="text-base-fg" />
|
||||
<span className="text-base-fg">Tutorials</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={handleClose}
|
||||
title={view === "video" ? undefined : panelTitle}
|
||||
titleIcon={faBook}
|
||||
accessibleTitle={panelTitle}
|
||||
className="max-w-5xl"
|
||||
>
|
||||
{view === "grid" && (
|
||||
<>
|
||||
{tutorials.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-white/60">
|
||||
No tutorials yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tutorials.map((item: TutorialItem) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => viewTutorial(item)}
|
||||
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
|
||||
>
|
||||
<div className="aspect-video w-full overflow-hidden">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 text-sm text-white/90">
|
||||
{item.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "video" && selected && (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={setGrid}
|
||||
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium"
|
||||
variant="action"
|
||||
icon={faChevronLeft}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="text-lg font-bold text-base-fg">
|
||||
Tutorial: {selected.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
|
||||
<div ref={playerContainerRef} className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TutorialModalButton;
|
||||
@@ -1,37 +0,0 @@
|
||||
export type TutorialItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
videoUrl: string;
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export enum TutorialCategory {
|
||||
Editor3D = "3D Editor",
|
||||
Canvas2D = "2D Canvas",
|
||||
}
|
||||
|
||||
export const defaultTutorials: TutorialItem[] = [
|
||||
{
|
||||
id: "turn-images-into-objects",
|
||||
title: "Turn Images into 3D Objects",
|
||||
thumbnailUrl:
|
||||
"/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg",
|
||||
videoUrl: "https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa",
|
||||
category: TutorialCategory.Editor3D,
|
||||
},
|
||||
{
|
||||
id: "2d-editor-basics",
|
||||
title: "2D Editor Basics",
|
||||
thumbnailUrl: "/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg",
|
||||
videoUrl: "https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo",
|
||||
category: TutorialCategory.Canvas2D,
|
||||
},
|
||||
{
|
||||
id: "3d-editor-posing",
|
||||
title: "3D Editor & Posing",
|
||||
thumbnailUrl: "/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg",
|
||||
videoUrl: "https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O",
|
||||
category: TutorialCategory.Editor3D,
|
||||
},
|
||||
];
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/// <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/tutorial-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-tutorial-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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -62,28 +62,4 @@ export const VIDEO_MODELS: VideoModel[] = [
|
||||
startFrame: true,
|
||||
endFrame: false,
|
||||
}),
|
||||
new VideoModel({
|
||||
id: "veo_3",
|
||||
tauriId: "veo_3",
|
||||
fullName: "Google Veo 3",
|
||||
category: "video",
|
||||
creator: ModelCreator.Google,
|
||||
selectorName: "Google Veo 3",
|
||||
selectorDescription: "Slow, high-quality model",
|
||||
selectorBadges: ["2 min."],
|
||||
startFrame: true,
|
||||
endFrame: false,
|
||||
}),
|
||||
new VideoModel({
|
||||
id: "veo_3_fast",
|
||||
tauriId: "veo_3_fast",
|
||||
fullName: "Google Veo 3 Fast",
|
||||
category: "video",
|
||||
creator: ModelCreator.Google,
|
||||
selectorName: "Google Veo 3 Fast",
|
||||
selectorDescription: "High-quality model (faster)",
|
||||
selectorBadges: ["2 min."],
|
||||
startFrame: true,
|
||||
endFrame: false,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SubscriptionPlanDetails } from "./subscription-plan-details.js";
|
||||
|
||||
export const FREE_PLAN: SubscriptionPlanDetails = {
|
||||
export const FREE_PLAN : SubscriptionPlanDetails = {
|
||||
slug: "free",
|
||||
name: "Free",
|
||||
isPaidPlan: false,
|
||||
@@ -9,14 +9,11 @@ export const FREE_PLAN: SubscriptionPlanDetails = {
|
||||
features: [
|
||||
{ text: "Free daily generations", included: true },
|
||||
{ text: "Limited access to ArtCraft tools", included: true },
|
||||
{ text: "~15 Midjourney images", included: true },
|
||||
{ text: "~10 GPT-Image-1 images", included: true },
|
||||
{ text: "~1 minute Kling video", included: true },
|
||||
],
|
||||
colorScheme: "dark" as const,
|
||||
};
|
||||
|
||||
export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
|
||||
FREE_PLAN,
|
||||
{
|
||||
slug: "artcraft_basic",
|
||||
@@ -27,10 +24,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 10,
|
||||
originalYearlyPrice: 120,
|
||||
features: [
|
||||
{ text: "~1,000 Flux images", included: true },
|
||||
{ text: "~300 Midjourney images", included: true },
|
||||
{ text: "~100 GPT-Image-1 images", included: true },
|
||||
{ text: "~10 minutes Kling video", included: true },
|
||||
{ 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,
|
||||
@@ -44,10 +41,10 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 35,
|
||||
originalYearlyPrice: 420,
|
||||
features: [
|
||||
{ text: "~5,000 Flux images", included: true },
|
||||
{ text: "~1,000 Midjourney images", included: true },
|
||||
{ text: "~300 GPT-Image-1 images", included: true },
|
||||
{ text: "~30 minutes Kling video", included: true },
|
||||
{ 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,
|
||||
@@ -61,15 +58,16 @@ export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
|
||||
originalMonthlyPrice: 60,
|
||||
originalYearlyPrice: 720,
|
||||
features: [
|
||||
{ text: "~15,000 Flux images", included: true },
|
||||
{ text: "~3,000 Midjourney images", included: true },
|
||||
{ text: "~500 GPT-Image-1 images", included: true },
|
||||
{ text: "~60 minutes Kling video", included: true },
|
||||
{ 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,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export const SUBSCRIPTION_PLANS_BY_SLUG: Map<string, SubscriptionPlanDetails> =
|
||||
new Map(SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan]));
|
||||
export const SUBSCRIPTION_PLANS_BY_SLUG : Map<string, SubscriptionPlanDetails> = new Map(
|
||||
SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan])
|
||||
);
|
||||
|
||||
Generated
+380
-119
File diff suppressed because it is too large
Load Diff
@@ -167,9 +167,6 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/state/subscription"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/tutorial-modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user