Compare commits

...
Author SHA1 Message Date
bflatastic 3849a18c8e api update 2 2026-06-15 22:23:31 +07:00
bflatastic ce7b180c7e api update 2026-06-15 21:26:33 +07:00
bflatastic 3f5bc6edf8 Webapp: settings modal feature flagged api key section 2026-06-15 21:16:44 +07:00
Brandon Thomas 0de867ad13 upgrade tauri 2.8 -> 2.11 (#1645) 2026-06-14 22:20:29 -04:00
Brandon Thomas a1cb1b2629 enhance database connection pooling (#1644) 2026-06-14 18:09:42 -04:00
BFlat 8da68b009c Desktop create pages redesign: shared generation feed, embedded model selector, gallery folders sidebar (#1635)
## Summary

Reworks the desktop (Tauri) app's Create Image / Create Video pages to
match the webapp,
extracts the webapp's generation gallery into a shared library used by
both apps, embeds the
model selector into every prompt box, adds a folders sidebar + marquee
selection to the gallery
modal, restyles the login/pricing/credits modals to the webapp look, and
turns the Apps page
into the default landing tab.

### Shared generation-list lib

- Extracted the webapp's generation feed into
`@storyteller/ui-generation-list`: list + masonry
grid views, pending/failed/completed rows and cards, thumbnail handling,
gallery/prompt data
hooks, and the shared grid/list view store + toggle. Host-specific
actions (recreate,
make-video, share, download, toasts) inject via render props — webapp
markup unchanged, its
  old files are now thin wrappers.

### Desktop create pages

- Prompt box fixed at the bottom, `max-w-5xl` like the webapp; pages
renamed to
  "Create Image" / "Create Video".
- Generation feed: in-progress/failed from the Tauri task queue, history
from the library API,
rendered as grid or list (toggle in the TopBar next to credits). Typed
Tauri completion events
  push finished media in instantly; the poll reconciles and dedupes.
- Row/card quick actions (recreate, make video, copy link, download)
backed by a shared
  `desktopMediaActions` module extracted from the TopBar gallery wiring.
- Hero + background act as a splash that fades when the feed loads;
bottom gradient behind the
prompt box. Batch generations show "Generating N images" on the single
pending chip.
- Video prompt box now expands vertically as far as the image prompt box
(viewport-relative).

### Embedded model selector

- `ClassyModelSelector` gained `variant="embedded"`: a compact pill that
opens the webapp-style
rich-list popover while keeping the desktop's provider selection.
Embedded on all five
prompt-input pages; floating selectors removed; legacy "10 sec." badges
dropped.

### Gallery modal

- Left sidebar lists the user's folders (drag-and-drop, right-click
management, new-folder
button) and shows on the Folders tab too, highlighting the active
folder's root branch.
- Marquee (drag-rectangle) selection in view mode, with edge
auto-scroll; starts only on blank
  background so it doesn't interfere with drag-to-folder/canvas.

### Restyled modals (desktop)

- Login/signup: webapp 2-column layout (form left, Vimeo showcase
right), no Google button
(Google auth isn't wired for Tauri), `artcraft-icon` logo, eye toggles.
The dismiss button is
removed so login is required; the post-auth Discord + Success onboarding
steps are kept, as is
  the existing Tauri auth flow.
- Pricing + credits: match the webapp visually (unified monochrome plan
cards with
Most Popular / Best Value highlight frames; `#161618` credit-pack cards
with Popular badge).
All billing stays on the existing Tauri `invoke` calls — no billing
logic changed.

### Apps page / navigation

- The Apps page is now the first tab and the default landing tab, with a
Home icon. Restyled to
the webapp home: "What will you craft today?" hero, Create / Edit
sections, gradient-hover
cards sized to their content, full-width scrollbar. The TopBar
quick-menu header reads
  "Create" instead of "Generate".

### Fixes

- Video Editor no longer crashes on the desktop app (`Tooltip must be
used within
TooltipProvider`): the editor shell now mounts its own Radix
`TooltipProvider`. The webapp got
one app-wide via `SidebarProvider`; the Tauri app had none, so panel
tooltips threw.

### Misc

- `getCreatorIconPathForModelId()` added to `@storyteller/model-list`
(model id → creator icon
  path, desktop/webapp aware).
2026-06-13 17:25:40 -04:00
Brandon Thomas e4bc031fbb modernize require_user_session_extended() (#1643) 2026-06-13 17:03:21 -04:00
122 changed files with 6596 additions and 3771 deletions
Generated
+494 -564
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -342,11 +342,11 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
tauri-plugin-upload = "2.3.1"
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = "2.8.0"
tauri-plugin-upload = "2.4.0"
tempdir = "0.3.7"
tempfile = "3.27.0"
thiserror = "2.0.18"
+1 -1
View File
@@ -19,7 +19,7 @@ name = "artcraft_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
# Workspace hack (faster compile times)
@@ -1,8 +1,7 @@
use chrono::{DateTime, Utc};
use enums::common::payments_namespace::PaymentsNamespace;
use errors::AnyhowResult;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use sqlx::{Executor, MySql};
pub struct ActiveUserSubscription {
pub user_token: String,
@@ -19,10 +18,12 @@ pub struct ActiveUserSubscription {
pub subscription_expires_at: DateTime<Utc>,
}
pub async fn list_active_user_subscriptions(
mysql_connection: &mut PoolConnection<MySql>,
pub async fn list_active_user_subscriptions<'e, 'c : 'e, E>(
mysql_executor: E,
user_token: &str
) -> AnyhowResult<Vec<ActiveUserSubscription>> {
) -> AnyhowResult<Vec<ActiveUserSubscription>>
where E: 'e + Executor<'c, Database = MySql>
{
// NB: "status=incomplete" subscriptions can happen when a user submits credit card info,
// but the CRC is wrong. Eg. what happened with ftx on 2022.11.18.
//
@@ -45,7 +46,7 @@ WHERE
"#,
user_token,
)
.fetch_all(&mut **mysql_connection)
.fetch_all(mysql_executor)
.await?;
let records = records.into_iter()
@@ -1,15 +1,36 @@
use std::time::Duration;
use log::info;
use errors::AnyhowResult;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;
pub async fn connect_to_database() -> AnyhowResult<MySqlPool>{
pub async fn connect_to_database() -> AnyhowResult<MySqlPool> {
let db_connection_string = env_get_mysql_connection_string_or_default();
let pool_options = MySqlPoolOptions::new()
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 5)?);
// NB: The managed DB's connection ceiling is far above what we use, so the binding
// constraint is this per-pod pool. Keep `replicas * MYSQL_MAX_CONNECTIONS` (plus the
// background jobs' small pools) comfortably under the server's `max_connections`.
let max_connections = easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 20)?;
let min_connections = easyenv::get_env_num("MYSQL_MIN_CONNECTIONS", 0)?;
// TODO(bt,2024-09-17): Configure logging, etc. after we've upgraded sqlx
// NB: Fail fast under pool saturation rather than stacking up on sqlx's 30s default — a
// shorter timeout sheds load and surfaces the problem instead of hiding it behind long waits.
let acquire_timeout_seconds = easyenv::get_env_num::<u64>("MYSQL_ACQUIRE_TIMEOUT_SECONDS", 10)?;
// NB: Log the settings before building/connecting so a connect failure has context. We do NOT
// log the connection string (it contains credentials).
info!(
"Building MySQL pool: max_connections={}, min_connections={}, acquire_timeout_seconds={}",
max_connections, min_connections, acquire_timeout_seconds,
);
let pool_options = MySqlPoolOptions::new()
.max_connections(max_connections)
.min_connections(min_connections)
.acquire_timeout(Duration::from_secs(acquire_timeout_seconds));
let pool = pool_options
.connect(&db_connection_string)
@@ -2,7 +2,6 @@ use std::fmt::{Display, Formatter};
use std::sync::Arc;
use crate::http_server::session::session_checker_error::SessionCheckerError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_payload_error::AvtCookiePayloadError;
use actix_artcraft::sessions::user_sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
use actix_http::StatusCode;
@@ -317,15 +316,6 @@ impl From<serde_json::Error> for CommonWebError {
}
}
impl From<RequireUserSessionError> for CommonWebError {
fn from(value: RequireUserSessionError) -> Self {
match value {
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
RequireUserSessionError::ServerError => Self::from_error(value),
}
}
}
impl From<HttpUserSessionPayloadError> for CommonWebError {
fn from(err: HttpUserSessionPayloadError) -> Self {
if err.is_server_error() {
@@ -545,22 +535,6 @@ mod tests {
assert!(source.is_some());
}
#[test]
fn require_user_session_not_authorized_maps_to_401() {
let error: CommonWebError = RequireUserSessionError::NotAuthorized.into();
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
assert!(!error.is_server_error());
}
#[test]
fn require_user_session_server_error_wraps_cause() {
let error: CommonWebError = RequireUserSessionError::ServerError.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
assert!(error.cause().is_some());
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
}
#[test]
fn serde_json_error_converts_to_500() {
let bad_json = "not json at all{{{";
@@ -28,8 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
@@ -114,15 +113,10 @@ pub async fn enqueue_face_fusion_workflow_handler(
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
&mut *mysql_connection).await?;
// ==================== PAID PLAN + PRIORITY ==================== //
@@ -29,8 +29,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
@@ -122,15 +121,10 @@ pub async fn enqueue_live_portrait_workflow_handler(
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
&mut *mysql_connection).await?;
// ==================== PAID PLAN + PRIORITY ==================== //
@@ -19,13 +19,13 @@ use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::create::insert_media_file_from_studio_scene_render::{insert_media_file_from_studio_scene_render, InsertStudioSceneRenderArgs};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::extract_frames_from_zip::{extract_frames_from_zip, ExtractFramesError};
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::ffmpeg_frames_to_mp4::ffmpeg_frames_to_mp4;
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
const DEFAULT_FPS : u8 = 12;
@@ -111,11 +111,14 @@ pub async fn upload_studio_shot_media_file_handler(
// ==================== READ SESSION ==================== //
// NB: We require a moderator to upload PMX files.
let user_session = require_user_session_extended_using_connection(&http_request, &server_state.session_checker, &mut mysql_connection)
let user_session = require_user_session_extended(&http_request, &server_state.session_checker, &mut *mysql_connection)
.await
.map_err(|e| {
error!("User session error: {:?}", e);
MediaFileUploadError::NotAuthorized
match e {
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
_ => MediaFileUploadError::ServerError,
}
})?;
let maybe_avt_token = server_state
@@ -28,8 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::validations::validate_model_title::validate_model_title;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
#[derive(Deserialize, ToSchema)]
@@ -82,15 +81,14 @@ pub async fn enqueue_gptsovits_model_download_handler(
let maybe_avt_token = server_state.avt_cookie_manager
.get_avt_token_from_request(&http_request);
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
})?;
&mut *mysql_connection).await?;
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
// so release it now rather than holding a pool slot for the rest of the handler.
drop(mysql_connection);
// ==================== PAID PLAN + PRIORITY ==================== //
@@ -143,14 +143,20 @@ pub async fn omni_gen_image_generate_handler(
// ==================== PIPELINE ==================== //
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
drop(mysql_connection);
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
mysql_connection: &mut mysql_connection,
user_token,
resolved_media: &resolved_media,
}).await?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// ==================== DEBUG LOG: FAL REQUEST ==================== //
if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response {
@@ -25,18 +25,20 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesA
pub struct RunPipelineV2Args<'a> {
pub router_builder: &'a GenerateImageRequestBuilder,
pub server_state: &'a ServerState,
pub mysql_connection: &'a mut sqlx::pool::PoolConnection<sqlx::MySql>,
pub user_token: &'a UserToken,
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
}
// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a
// short-lived connection only for the wallet deduction. Holding a pooled connection across the
// external call is what starves the pool and causes `PoolTimedOut`.
pub async fn run_pipeline_v2(
args: RunPipelineV2Args<'_>,
) -> Result<ImagePipelineResult, CommonWebError> {
let RunPipelineV2Args {
router_builder,
server_state,
mysql_connection,
user_token,
resolved_media,
} = args;
@@ -55,17 +57,24 @@ pub async fn run_pipeline_v2(
let apriori_job_token = InferenceJobToken::generate();
let maybe_wallet_ledger_entry_token = if cost > 0 {
// NB: Short-lived connection — released before the external call below.
let mut billing_connection = server_state.mysql_pool.acquire().await
.map_err(|err| {
warn!("Failed to acquire MySQL connection for image billing: {:?}", err);
CommonWebError::from_error(err)
})?;
let deduction = attempt_wallet_deduction_else_common_web_error(
user_token,
Some(apriori_job_token.as_str()),
cost,
mysql_connection,
&mut billing_connection,
).await?;
Some(deduction.ledger_entry_token)
} else {
None
};
// NB: No pooled DB connection is held across this external generation call.
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
Ok(ImagePipelineResult {
@@ -200,16 +200,22 @@ pub async fn omni_gen_video_generate_handler(
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
drop(mysql_connection);
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
router_builder: &router_builder,
server_state: &server_state,
mysql_connection: &mut mysql_connection,
user_token,
media_file_to_url_map: &media_file_to_url_map,
kinovi_character_id_map: &kinovi_character_id_map,
kinovi_account,
}).await?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// ==================== DEBUG LOG: FAL REQUEST ==================== //
if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response {
@@ -3,7 +3,6 @@ use std::convert::TryFrom;
use std::collections::HashMap;
use log::{error, info, warn};
use sqlx::pool::PoolConnection;
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_provider::RouterProvider;
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
@@ -28,18 +27,20 @@ use crate::state::server_state::ServerState;
pub struct RunPipelineV2Args<'a> {
pub router_builder: &'a GenerateVideoRequestBuilder,
pub server_state: &'a ServerState,
pub mysql_connection: &'a mut PoolConnection<sqlx::MySql>,
pub user_token: &'a UserToken,
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
pub kinovi_account: KinoviAccount,
}
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
let RunPipelineV2Args {
router_builder,
server_state,
mysql_connection,
user_token,
media_file_to_url_map,
kinovi_character_id_map,
@@ -137,11 +138,19 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
// 3. Bill wallet
let billing = bill_wallet(user_token, cost, mysql_connection).await?;
// 3. Bill wallet (short-lived connection — released before the external call below).
let billing = {
let mut billing_connection = server_state.mysql_pool.acquire().await
.map_err(|err| {
error!("Failed to acquire MySQL connection for billing: {:?}", err);
CommonWebError::from_error(err)
})?;
bill_wallet(user_token, cost, &mut billing_connection).await?
};
// 4. Upload media (if draft) and generate video.
// The entire block is wrapped so Kinovi failures trigger a refund.
// NB: No pooled DB connection is held across this call.
let result = upload_and_generate(
draft_or_request,
server_state,
@@ -156,10 +165,15 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await;
if let Err(refund_err) = result {
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
match server_state.mysql_pool.acquire().await {
Ok(mut refund_connection) => {
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
}
}
Err(acquire_err) => {
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
}
}
}
}
@@ -192,6 +192,10 @@ pub async fn enqueue_studio_gen2_handler(
CommonWebError::BadInputWithSimpleMessage("invalid idempotency token".to_string())
})?;
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
// so release it now rather than holding a pool slot for the rest of the handler.
drop(mysql_connection);
// ==================== LOOK UP MODEL INFO ==================== //
// TODO(bt): CHECK DATABASE FOR TOKENS!
@@ -9,7 +9,7 @@ use std::sync::Arc;
use crate::http_server::common_responses::tag_info::TagInfo;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
@@ -94,14 +94,10 @@ pub async fn set_tags_for_entity_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|_err| {
CommonWebError::NotAuthorized
})?;
&mut *mysql_connection).await?;
if user_session.role.is_banned {
return Err(CommonWebError::NotAuthorized);
@@ -11,7 +11,7 @@ use log::warn;
use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse};
use crate::http_server::validations::validate_passwords::validate_passwords;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::users::user::update::update_password::{update_password, UpdatePasswordArgs};
@@ -54,14 +54,10 @@ pub async fn change_password_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|_err| {
CommonWebError::NotAuthorized
})?;
&mut *mysql_connection).await?;
if user_session.role.is_banned {
return Err(CommonWebError::NotAuthorized);
@@ -10,7 +10,7 @@ use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError};
@@ -53,14 +53,10 @@ pub async fn edit_email_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|_err| {
CommonWebError::NotAuthorized
})?;
&mut *mysql_connection).await?;
if user_session.role.is_banned {
return Err(CommonWebError::NotAuthorized);
@@ -12,7 +12,7 @@ use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameR
use crate::http_server::validations::is_reserved_username::is_reserved_username;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::validations::validate_username::validate_username;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::users::user::update::update_username::{update_username, UpdateUsernameArgs, UpdateUsernameError};
@@ -63,14 +63,10 @@ pub async fn edit_username_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|_err| {
CommonWebError::NotAuthorized
})?;
&mut *mysql_connection).await?;
if user_session.role.is_banned {
return Err(CommonWebError::NotAuthorized);
@@ -11,8 +11,7 @@ use utoipa::ToSchema;
use crate::configs::supported_languages_for_models::{get_canonicalized_language_tag_for_model, get_primary_language_subtag};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
use crate::state::server_state::ServerState;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::model_weights::weights_types::WeightsType;
@@ -95,18 +94,10 @@ pub async fn update_weight_handler(
CommonWebError::from_error(err)
})?;
let user_session = require_user_session_extended_using_connection(
let user_session = require_user_session_extended(
&http_request,
&server_state.session_checker,
&mut mysql_connection)
.await
.map_err(|err| match err {
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
_ => {
warn!("get user session error: {:?}", err);
CommonWebError::from_error(err)
},
})?;
&mut *mysql_connection).await?;
let weight_token = path.weight_token.clone();
@@ -6,13 +6,13 @@
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::HttpRequest;
use log::warn;
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, get_user_session_by_token_pooled_connection, SessionUserRecord};
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, SessionUserRecord};
use mysql_queries::queries::users::user_sessions::get_user_session_by_token_light::{get_user_session_by_token_light, SessionRecord};
use mysql_queries::queries::users::user_subscriptions::list_active_user_subscriptions::list_active_user_subscriptions;
use redis_caching::redis_ttl_cache::{RedisTtlCache, RedisTtlCacheConnection};
use redis_common::redis_cache_keys::RedisCacheKeys;
use sqlx::pool::PoolConnection;
use sqlx::{Executor, MySql, MySqlPool};
use sqlx::{Executor, MySql, MySqlConnection, MySqlPool};
use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails};
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
@@ -209,6 +209,18 @@ impl SessionChecker {
request: &HttpRequest,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
{
self.maybe_get_user_session_extended_from_executor(request, &mut **mysql_connection).await
}
// NB: This takes a concrete `&mut MySqlConnection` rather than a generic `Executor` because
// the extended lookup runs two queries; it reborrows the connection for each. (A by-value
// `E: Executor` would be consumed by the first query.)
pub async fn maybe_get_user_session_extended_from_executor(
&self,
request: &HttpRequest,
mysql_executor: &mut MySqlConnection,
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
{
let session_payload= match self.cookie_manager.decode_session_payload_from_request(request)? {
None => return Ok(None),
@@ -217,7 +229,7 @@ impl SessionChecker {
// TODO: Fire both requests off simultaneously.
let user_session = {
match get_user_session_by_token_pooled_connection(mysql_connection, &session_payload.session_token).await? {
match get_user_session_by_token(&mut *mysql_executor, &session_payload.session_token).await? {
None => return Ok(None),
Some(u) => u,
}
@@ -226,7 +238,7 @@ impl SessionChecker {
// TODO: Cache this so we don't hit the database twice.
let subscriptions =
list_active_user_subscriptions(
mysql_connection,
&mut *mysql_executor,
user_session.user_token.as_str()
).await
.map_err(SessionCheckerError::OtherError)?;
@@ -1,3 +1,3 @@
pub mod require_moderator;
pub mod require_user_session;
pub mod require_user_session_extended_using_connection;
pub mod require_user_session_extended;
@@ -0,0 +1,40 @@
use actix_web::HttpRequest;
use log::warn;
use sqlx::MySqlConnection;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
use crate::http_server::session::session_checker::SessionChecker;
/// Pass an in-flight connection (`&mut *connection`) to reuse one the handler already holds.
/// (The extended lookup runs two queries, so it takes a concrete `&mut MySqlConnection` — which
/// it reborrows for each — rather than a by-value `Executor` that the first query would consume.)
pub async fn require_user_session_extended(
http_request: &HttpRequest,
session_checker: &SessionChecker,
mysql_executor: &mut MySqlConnection,
) -> Result<UserSessionExtended, CommonWebError>
{
let maybe_user_session = session_checker
.maybe_get_user_session_extended_from_executor(http_request, mysql_executor)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from_error(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
warn!("not logged in");
return Err(CommonWebError::NotAuthorized);
}
};
if user_session.role.is_banned {
warn!("user is banned: {:?}", user_session.user_token.as_str());
return Err(CommonWebError::NotAuthorized);
}
Ok(user_session)
}
@@ -1,58 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use actix_web::HttpRequest;
use log::warn;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
use crate::http_server::session::session_checker::SessionChecker;
#[derive(Debug)]
pub enum RequireUserSessionError {
ServerError,
NotAuthorized,
}
impl Display for RequireUserSessionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ServerError => write!(f, "ServerError"),
Self::NotAuthorized => write!(f, "NotAuthorized"),
}
}
}
impl Error for RequireUserSessionError {}
pub async fn require_user_session_extended_using_connection(
http_request: &HttpRequest,
session_checker: &SessionChecker,
mysql_connection: &mut PoolConnection<MySql>,
) -> Result<UserSessionExtended, RequireUserSessionError> {
let maybe_user_session = session_checker
.maybe_get_user_session_extended_from_connection(&http_request, mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
RequireUserSessionError::ServerError
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => {
warn!("not logged in");
return Err(RequireUserSessionError::NotAuthorized);
}
};
if user_session.role.is_banned {
warn!("user is banned: {:?}", user_session.user_token.as_str());
return Err(RequireUserSessionError::NotAuthorized);
}
Ok(user_session)
}
+1 -1
View File
@@ -27,7 +27,7 @@ ios = { level = "full", notes = "" }
[build-dependencies]
#tauri-plugin = { workspace = true, features = ["build"] }
tauri-plugin = { version = "2.2.0", features = ["build"] }
tauri-plugin = { version = "2.6.2", features = ["build"] }
#schemars = { workspace = true }
schemars = { version = "0.8" }
serde = { workspace = true }
@@ -91,7 +91,7 @@ sqlx-sqlite = { version = "0.7.3", default-features = false, features = ["any",
stable_deref_trait = { version = "1.2.0" }
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
time = { version = "0.3.36", features = ["local-offset", "macros", "serde-well-known"] }
tokio = { version = "1.45.1", features = ["full"] }
tokio-stream = { version = "0.1.16", features = ["fs"] }
@@ -171,8 +171,8 @@ stable_deref_trait = { version = "1.2.0" }
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit-mut"] }
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
tauri-build = { version = "2.1.1" }
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
tauri-build = { version = "2.6.2" }
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
time-macros = { version = "0.2.18", default-features = false, features = ["formatting", "parsing", "serde"] }
tokio = { version = "1.45.1", features = ["full"] }
tokio-stream = { version = "0.1.16", features = ["fs"] }
@@ -196,6 +196,16 @@ export function CreateMediaPageShell({
</div>
)}
{/* Bottom fade behind the floating prompt box so it stays legible
over the feed. Sits under the prompt box (z-30). */}
{hasContent && (
<div
aria-hidden
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
/>
)}
{promptBox}
</div>
</div>
@@ -1,182 +1,2 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
// video thumbnails 404 until the render job finishes (so we retry on a timer
// and on tab refocus), and broken image URLs fall back to a placeholder.
// ── Shared visibility listener for video thumbnail retries ────────────────
const visibilityCallbacks = new Set<() => void>();
function onTabVisible(cb: () => void) {
visibilityCallbacks.add(cb);
if (visibilityCallbacks.size === 1) {
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
}
return () => {
visibilityCallbacks.delete(cb);
if (visibilityCallbacks.size === 0) {
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
}
};
}
function fireVisibilityCallbacks() {
if (document.hidden) return;
visibilityCallbacks.forEach((cb) => cb());
}
// ── Retry constants ────────────────────────────────────────────────────────
const MAX_RETRIES = 20;
const RETRY_INTERVAL = 5000;
// ── Component ──────────────────────────────────────────────────────────────
interface GalleryThumbnailProps {
thumbnail: string | null;
alt: string;
isVideo: boolean;
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
fallbackIcon: IconDefinition;
// Classes for the <img> element (sizing / object-fit).
imgClassName?: string;
fallbackIconClassName?: string;
// The "Loading thumbnail…" caption is too large for small list rows.
showRetryLabel?: boolean;
// Called on successful load — the card uses it to measure aspect ratio.
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
}
export function GalleryThumbnail({
thumbnail,
alt,
isVideo,
fallbackIcon,
imgClassName = "block h-full w-full object-cover",
fallbackIconClassName = "text-xl text-white/20",
showRetryLabel = true,
onLoad,
}: GalleryThumbnailProps) {
// "retrying" flips to true only after the first error, so videos with ready
// thumbnails render the normal <img> path with zero overhead. While retrying,
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
const [retrying, setRetrying] = useState(false);
const retryImgRef = useRef<HTMLImageElement>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const retryCountRef = useRef(0);
const kickRetry = useCallback(() => {
if (!retryImgRef.current || !thumbnail) return;
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
}, [thumbnail]);
const scheduleRetry = useCallback(() => {
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
if (document.hidden) return;
retryTimerRef.current = setTimeout(() => {
retryCountRef.current++;
kickRetry();
}, RETRY_INTERVAL);
}, [thumbnail, kickRetry]);
// Subscribe to the shared visibility listener while a video card is retrying.
useEffect(() => {
if (!isVideo || !thumbnail || !retrying) return;
const unsubscribe = onTabVisible(() => {
retryCountRef.current = 0;
kickRetry();
});
return () => {
unsubscribe();
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [isVideo, thumbnail, retrying, kickRetry]);
// Reset retry state when the thumbnail URL changes.
useEffect(() => {
if (!isVideo) return;
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}, [isVideo, thumbnail]);
const handleLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo && retrying) {
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}
onLoad?.(e);
},
[isVideo, retrying, onLoad],
);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo) {
setRetrying(true);
scheduleRetry();
} else {
const target = e.currentTarget;
if (target.dataset.fallback) return;
target.dataset.fallback = "1";
target.src = PLACEHOLDER_IMAGES.DEFAULT;
target.style.opacity = "0.3";
}
},
[isVideo, scheduleRetry],
);
if (retrying) {
return (
<>
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg text-white/30"
/>
{showRetryLabel && (
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
)}
</div>
{/* Hidden img retries in the background via ref — zero re-renders */}
<img
ref={retryImgRef}
src={thumbnail!}
alt=""
className="absolute h-0 w-0 opacity-0"
aria-hidden
onLoad={handleLoad}
onError={handleError}
/>
</>
);
}
if (thumbnail) {
return (
<img
src={thumbnail}
alt={alt}
loading="lazy"
decoding="async"
className={imgClassName}
onLoad={handleLoad}
onError={handleError}
/>
);
}
return (
<div className="flex h-full w-full items-center justify-center">
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
</div>
);
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { GalleryThumbnail } from "@storyteller/ui-generation-list";
@@ -1,53 +1,2 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
useGalleryViewStore,
type GalleryViewMode,
} from "../../lib/gallery-view-store";
const OPTIONS: {
mode: GalleryViewMode;
icon: IconDefinition;
label: string;
}[] = [
{ mode: "grid", icon: faGrid2, label: "Grid view" },
{ mode: "list", icon: faListUl, label: "List view" },
];
// Segmented grid/list switch for the create-page galleries. Lives in the
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
export function GalleryViewToggle() {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
return (
<div
role="group"
aria-label="Gallery layout"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
>
{OPTIONS.map(({ mode, icon, label }) => {
const active = viewMode === mode;
return (
<Tooltip key={mode} content={label} position="bottom" delay={300}>
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={() => setViewMode(mode)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
active
? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80"
}`}
>
<FontAwesomeIcon icon={icon} />
</button>
</Tooltip>
);
})}
</div>
);
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
@@ -1,29 +1,26 @@
import Masonry from "react-masonry-css";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingCard } from "./PendingCard";
import { FailedCard } from "./FailedCard";
import { GalleryCard } from "./GalleryCard";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationGridView,
type GalleryItem,
type RecreateSlotContext,
} from "@storyteller/ui-generation-list";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { useGalleryItemActions } from "./useGalleryItemActions";
import type { GenerationGalleryProps } from "./types";
// ── Grid layout constants ─────────────────────────────────────────────────
const BREAKPOINT_COLS = {
default: 4,
1280: 4,
900: 3,
640: 2,
};
// 12px gap on both axes (≈ Tailwind gap-3).
// ml-[-12px] on container offsets the first column's pl-[8px].
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
const COLUMN_CLASS = "pl-[8px]";
// ── Component ──────────────────────────────────────────────────────────────
// Thin wrapper over the shared masonry grid: injects the webapp-only
// affordances (recreate navigation, share/download actions) through the
// view's render-prop seams. Layout and card markup live in
// @storyteller/ui-generation-list and are shared with the desktop app.
export function GenerationGalleryGrid({
inProgressJobs,
@@ -33,80 +30,163 @@ export function GenerationGalleryGrid({
galleryItems,
newlyCompletedTokens,
hasMore,
isLoading,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
}: GenerationGalleryProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
return (
<GenerationGridView
inProgressJobs={inProgressJobs}
failedJobs={failedJobs}
onDismissFailed={onDismissFailed}
newlyCompletedItems={newlyCompletedItems}
galleryItems={galleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={hasMore}
isLoading={isLoading}
isInitialLoading={isInitialLoading}
onLoadMore={onLoadMore}
onGalleryItemClick={onGalleryItemClick}
renderRecreate={(ctx) => <CardRecreateButton {...ctx} />}
renderGalleryActions={(item) => (
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
)}
/>
);
}
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
// Recreate affordance for pending + failed cards. The pending card shows a
// hover-revealed icon button by the prompt; the failed card a labeled button
// next to Dismiss.
function CardRecreateButton({
promptToken,
mediaClass,
kind,
}: RecreateSlotContext) {
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
);
if (isInitialLoading) {
if (kind === "failed") {
return (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={isRecreating ? "animate-spin" : ""}
/>
Recreate
</button>
);
}
return (
<>
<Masonry
breakpointCols={BREAKPOINT_COLS}
className={MASONRY_CLASS}
columnClassName={COLUMN_CLASS}
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
{mergedEntries.map((entry) => (
<div key={entry.key} className="mb-[8px]">
{entry.kind === "pending" && (
<PendingCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
/>
)}
{entry.kind === "failed" && (
<FailedCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
onDismiss={onDismissFailed}
/>
)}
{entry.kind === "gallery" && (
<GalleryCard
item={entry.item}
onClick={onGalleryItemClick}
enableMakeVideo={enableMakeVideo}
/>
)}
</div>
))}
</Masonry>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
);
}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
// Hover quick actions for completed cards (recreate / make-video / share /
// download), styled for the card's dark action pill.
function CardActions({
item,
enableMakeVideo,
}: {
item: GalleryItem;
enableMakeVideo?: boolean;
}) {
const {
recreateMediaClass,
canMakeVideo,
isRecreating,
isDownloading,
shareCopied,
handleRecreate,
handleMakeVideo,
handleShare,
handleDownload,
} = useGalleryItemActions(item, { enableMakeVideo });
const buttonClass =
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
return (
<>
{recreateMediaClass && (
<Tooltip content="Recreate" position="top">
<button
type="button"
className={buttonClass}
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{canMakeVideo && (
<Tooltip content="Make Video" position="top">
<button
type="button"
className={buttonClass}
onClick={handleMakeVideo}
aria-label="Make Video"
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
</button>
</Tooltip>
)}
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
<button
type="button"
className={buttonClass}
onClick={handleShare}
aria-label="Share"
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
className="text-sm"
/>
</button>
</Tooltip>
{item.fullImage && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className={buttonClass}
aria-label="Download"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</>
);
@@ -1,19 +1,30 @@
import { useCallback, useMemo } from "react";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingRow } from "./PendingRow";
import { FailedRow } from "./FailedRow";
import { GalleryRow } from "./GalleryRow";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
import { usePrompts } from "../../lib/prompts-cache";
import { useMediaPromptTokens } from "../../lib/media-prompt-token-cache";
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
GenerationListView,
type GalleryItem,
type RecreateSlotContext,
} from "@storyteller/ui-generation-list";
import { toast } from "../toast/toast";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import {
useGalleryItemActions,
type GalleryItemActions,
} from "./useGalleryItemActions";
import type { GenerationGalleryProps } from "./types";
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
// per generation, mirroring the masonry grid's merged in-progress / failed /
// completed feed.
// Thin wrapper over the shared list view: injects the webapp-only affordances
// (recreate navigation, share/download actions, toasts) through the view's
// render-prop seams. Layout and row markup live in
// @storyteller/ui-generation-list and are shared with the desktop app.
export function GenerationGalleryList({
inProgressJobs,
@@ -23,131 +34,176 @@ export function GenerationGalleryList({
galleryItems,
newlyCompletedTokens,
hasMore,
isLoading,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
enableMakeVideo,
}: GenerationGalleryProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
// Show the real prompt text + model on completed rows instead of the
// "Image Generation" placeholder. The profile media-list endpoint doesn't
// return prompt tokens yet, so for items missing one we resolve it through
// the batch media-files endpoint (media token → prompt token), then feed the
// prompt token into the prompts cache (prompt token → text + model). Both
// steps are batched + cached. Pending/failed entries already carry a prompt.
const mediaTokensNeedingPrompt = useMemo(
() =>
mergedEntries.flatMap((entry) =>
entry.kind === "gallery" && !entry.item.promptToken
? [entry.item.id]
: [],
),
[mergedEntries],
);
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
// For an item: its (non-empty) prompt token, and whether the media→prompt
// lookup has finished. `resolved` lets rows show a skeleton while loading and
// fall back to the label once we know there's no prompt.
const promptStateFor = useCallback(
(item: { id: string; promptToken?: string }) => {
const direct = item.promptToken;
const resolved = direct != null || resolvedPromptTokens.has(item.id);
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
return { promptToken, resolved };
},
[resolvedPromptTokens],
);
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const entry of mergedEntries) {
if (entry.kind !== "gallery") continue;
const { promptToken } = promptStateFor(entry.item);
if (promptToken) tokens.push(promptToken);
}
return tokens;
}, [mergedEntries, promptStateFor]);
const promptsMap = usePrompts(promptTokens);
if (isInitialLoading) {
return (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl sm:px-4">
<div className="flex flex-col divide-y divide-white/[0.04]">
{mergedEntries.map((entry) => {
if (entry.kind === "pending") {
return (
<PendingRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
/>
);
}
if (entry.kind === "failed") {
return (
<FailedRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
promptToken={entry.job.promptToken}
recreateMediaClass={entry.job.mediaClass}
onDismiss={onDismissFailed}
/>
);
}
const { promptToken, resolved } = promptStateFor(entry.item);
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
// Loading while the media→prompt lookup is pending, or it found a
// prompt token whose text hasn't arrived yet.
const loading = !resolved || (!!promptToken && !prompt);
return (
<GalleryRow
key={entry.key}
item={entry.item}
onClick={onGalleryItemClick}
enableMakeVideo={enableMakeVideo}
title={prompt?.maybe_positive_prompt?.trim() || undefined}
modelId={prompt?.maybe_model_type || undefined}
loading={loading}
/>
);
})}
</div>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
<GenerationListView
inProgressJobs={inProgressJobs}
failedJobs={failedJobs}
onDismissFailed={onDismissFailed}
newlyCompletedItems={newlyCompletedItems}
galleryItems={galleryItems}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={hasMore}
isLoading={isLoading}
isInitialLoading={isInitialLoading}
onLoadMore={onLoadMore}
onGalleryItemClick={onGalleryItemClick}
renderRecreate={(ctx) => <RowRecreateButton {...ctx} />}
renderGalleryActions={(item, { modelId }) => (
<GalleryRowHoverActions
item={item}
modelId={modelId}
enableMakeVideo={enableMakeVideo}
/>
)}
</div>
onCopyPromptResult={handleCopyResult}
/>
);
}
function handleCopyResult(success: boolean) {
if (success) {
toast.success("Prompt copied");
} else {
toast.error("Unable to copy prompt");
}
}
// Recreate button for pending + failed rows. Pending rows render it inside the
// lib's hover-revealed cluster; failed rows show it inline (dimmer tint).
function RowRecreateButton({
promptToken,
mediaClass,
kind,
}: RecreateSlotContext) {
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
mediaClass,
);
return (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className={
kind === "pending"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
);
}
function GalleryRowHoverActions({
item,
modelId,
enableMakeVideo,
}: {
item: GalleryItem;
modelId?: string;
enableMakeVideo?: boolean;
}) {
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
return <GalleryRowActions actions={actions} hasDownload={!!item.fullImage} />;
}
// Recreate / make-video / share / download buttons, shared by the desktop
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
// don't also open the lightbox.
function GalleryRowActions({
actions,
hasDownload,
}: {
actions: GalleryItemActions;
hasDownload: boolean;
}) {
const {
recreateMediaClass,
canMakeVideo,
isRecreating,
isDownloading,
shareCopied,
handleRecreate,
handleMakeVideo,
handleShare,
handleDownload,
} = actions;
const buttonClass =
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
return (
<>
{recreateMediaClass && (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className={buttonClass}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{canMakeVideo && (
<Tooltip content="Make Video" position="top">
<button
type="button"
onClick={handleMakeVideo}
aria-label="Make Video"
className={buttonClass}
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
</button>
</Tooltip>
)}
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
<button
type="button"
onClick={handleShare}
aria-label="Share"
className={buttonClass}
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
className="text-sm"
/>
</button>
</Tooltip>
{hasDownload && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
aria-label="Download"
className={buttonClass}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</>
);
}
@@ -2,12 +2,6 @@ export { GenerationGallery } from "./GenerationGallery";
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
export { GenerationGalleryList } from "./GenerationGalleryList";
export { GalleryViewToggle } from "./GalleryViewToggle";
export { GalleryCard } from "./GalleryCard";
export { GalleryRow } from "./GalleryRow";
export { PendingCard, type PendingCardProps } from "./PendingCard";
export { PendingRow } from "./PendingRow";
export { FailedCard, type FailedCardProps } from "./FailedCard";
export { FailedRow } from "./FailedRow";
export { useGalleryData, type GalleryItem } from "./useGalleryData";
export {
useGenerationJobs,
@@ -1,32 +1,5 @@
// Progress + time-left derivation shared by the pending card and pending row.
export interface PendingStatus {
progressPercent: number | null;
timeLabel: string | null;
}
export function derivePendingStatus(
progress?: number,
estimatedTimeLeftMs?: number,
): PendingStatus {
const progressPercent =
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
const isAlmostDone = progress != null && progress >= 95;
const timeLabel = isAlmostDone
? "Almost done..."
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
? formatTimeLeft(estimatedTimeLeftMs)
: null;
return { progressPercent, timeLabel };
}
function formatTimeLeft(ms: number): string {
const totalSeconds = Math.ceil(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
if (hours > 0) return `~${hours}h`;
if (minutes > 0) return `~${minutes}m`;
return `~${seconds}s`;
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
derivePendingStatus,
type PendingStatus,
} from "@storyteller/ui-generation-list";
@@ -1,170 +1,2 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
GalleryModalApi,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
// ── Types ──────────────────────────────────────────────────────────────────
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage: string | null;
createdAt: string;
mediaClass: string;
modelId?: string;
batchImageToken?: string;
// Token for the generation's prompt record. The list view resolves it
// (via the shared prompts cache) to show the real prompt + model.
promptToken?: string;
}
const PAGE_SIZE = 40;
const getLabel = (item: any) => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
return "3D Mesh";
default:
return "Generation";
}
};
// ── Hook ───────────────────────────────────────────────────────────────────
export function useGalleryData(options: {
username: string | null;
filterMediaClasses: FilterMediaClasses[];
excludeUploads?: boolean;
}) {
const { username, filterMediaClasses, excludeUploads } = options;
const [items, setItems] = useState<GalleryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const isLoadingRef = useRef(false);
const api = useMemo(() => new GalleryModalApi(), []);
const mapApiItem = useCallback((item: any): GalleryItem => {
const isDimensional = item.media_class === "dimensional";
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
fullImage: item.media_links?.cdn_url || null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
modelId: item.maybe_model_type || undefined,
batchImageToken: item.maybe_batch_token,
promptToken: item.maybe_prompt_token || undefined,
};
}, []);
const loadItems = useCallback(
async (reset = false) => {
if (!username) return;
if (isLoadingRef.current) return;
isLoadingRef.current = true;
setIsLoading(true);
try {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: filterMediaClasses,
include_user_uploads: !excludeUploads,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!(excludeUploads && item.origin_category === "upload"),
)
.map(mapApiItem);
if (reset) {
setItems(newItems);
} else {
setItems((prev) => [...prev, ...newItems]);
}
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
} catch {
// ignore
}
setIsLoading(false);
setIsInitialLoading(false);
isLoadingRef.current = false;
},
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
);
// Initial load + filter change. When logged out (no username), clear the
// loading flag so the shell renders the empty state instead of a spinner.
useEffect(() => {
setItems([]);
setPageIndex(0);
isLoadingRef.current = false;
if (!username) {
setHasMore(false);
setIsInitialLoading(false);
return;
}
setHasMore(true);
setIsInitialLoading(true);
loadItems(true);
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = useCallback(() => {
if (hasMore && !isLoadingRef.current) {
loadItems();
}
}, [hasMore, loadItems]);
const refresh = useCallback(() => {
setItems([]);
setPageIndex(0);
setHasMore(true);
isLoadingRef.current = false;
loadItems(true);
}, [loadItems]);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
return {
items,
isLoading,
isInitialLoading,
hasMore,
loadMore,
refresh,
removeItem,
};
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { useGalleryData, type GalleryItem } from "@storyteller/ui-generation-list";
@@ -1,98 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import type { FailedJob, InProgressJob } from "./useGenerationJobs";
import type { GalleryItem } from "./useGalleryData";
// Shared between the grid and list views: both merge the in-progress / failed /
// completed streams into a single time-sorted feed and lazy-load more on scroll.
export type GalleryEntry =
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
export interface MergedEntriesInput {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
}
export function useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
}: MergedEntriesInput): GalleryEntry[] {
return useMemo(() => {
const entries: GalleryEntry[] = [];
for (const job of inProgressJobs) {
entries.push({
kind: "pending",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const job of failedJobs) {
entries.push({
kind: "failed",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const item of newlyCompletedItems) {
entries.push({
kind: "gallery",
key: `new-${item.id}`,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
for (const item of galleryItems) {
if (newlyCompletedTokens.has(item.id)) continue;
entries.push({
kind: "gallery",
key: item.id,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
entries.sort((a, b) => b.createdAt - a.createdAt);
return entries;
}, [
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
]);
}
// Returns a ref to attach to a sentinel element near the end of the feed;
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
export function useInfiniteScrollSentinel(
hasMore: boolean,
onLoadMore: () => void,
) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, onLoadMore]);
return sentinelRef;
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
type GalleryEntry,
type MergedEntriesInput,
} from "@storyteller/ui-generation-list";
@@ -11,35 +11,11 @@ import type { GalleryItem } from "./useGalleryData";
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
// ── Types ──────────────────────────────────────────────────────────────────
// The job shapes moved to the shared generation-list lib; re-exported here so
// existing import sites keep working.
export interface InProgressJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
progress: number;
estimatedTimeLeftMs?: number;
createdAt: string;
batchCount?: number;
// Prompt token + media class enable the "Recreate" action while the job is
// still running, mirroring the failed/completed cards.
promptToken?: string;
mediaClass: "image" | "video";
}
export interface FailedJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
failureReason?: string;
failureMessage?: string;
status: string;
createdAt: string;
promptToken?: string;
refImageUrl?: string;
mediaClass: "image" | "video";
}
export type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
import type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
// ── Constants ──────────────────────────────────────────────────────────────
@@ -0,0 +1,567 @@
import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSpinnerThird,
faCopy,
faCheck,
faKey,
faTrash,
} from "@fortawesome/pro-solid-svg-icons";
import {
UserApiKeysApi,
type ApiKeyInfo,
type CreatedApiKey,
type UserInfo,
} from "@storyteller/api";
import { toast } from "../toast/toast";
const NAME_MAX = 255;
const DESCRIPTION_MAX = 512;
const PAGE_SIZE = 20;
const INPUT_CLASS =
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
interface ApiKeySectionProps {
user: UserInfo;
}
export function ApiKeySection(_props: ApiKeySectionProps) {
const [api] = useState(() => new UserApiKeysApi());
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
// Rows fetched so far (including deleted ones we hide), drives the next
// offset. The backend returns no total count, so we offer "Load more" while
// the last page came back full.
const [fetchedCount, setFetchedCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [creating, setCreating] = useState(false);
const [editingToken, setEditingToken] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<ApiKeyInfo | null>(null);
// The full secret, shown exactly once right after a key is created.
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
// `reset` reloads from offset 0; otherwise appends the next page.
const load = async (reset: boolean) => {
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
const offset = reset ? 0 : fetchedCount;
const response = await api.ListApiKeys({ limit: PAGE_SIZE, offset });
setLoading(false);
setLoadingMore(false);
if (response.success && response.data) {
const fetched = response.data.api_keys;
// The list includes soft-deleted keys; only show live ones.
const live = fetched.filter((k) => k.maybe_deleted_at === null);
setKeys((prev) => (reset ? live : [...prev, ...live]));
setFetchedCount((prev) => (reset ? fetched.length : prev + fetched.length));
setHasMore(fetched.length === PAGE_SIZE);
} else {
toast.error(response.errorMessage ?? "Could not load API keys.");
}
};
useEffect(() => {
load(true);
// Load once on mount; pagination drives subsequent loads explicitly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCreated = (created: CreatedApiKey) => {
setCreating(false);
// Reveal the full secret once; the truncated key then appears in the list.
setCreatedKey(created);
load(true);
};
const handleDeleteConfirmed = async () => {
if (!pendingDelete) return;
const target = pendingDelete;
setPendingDelete(null);
const response = await api.DeleteApiKey({ token: target.token });
if (response.success) {
toast.success("API key deleted.");
// Drop it locally so the list updates without a full reload.
setKeys((prev) => prev.filter((k) => k.token !== target.token));
} else {
toast.error(response.errorMessage ?? "Could not delete API key.");
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">API keys</p>
<p className="text-xs opacity-70">
Create and manage keys to access the API programmatically.
</p>
</div>
{!creating && (
<Button
type="button"
variant="primary"
className="h-9 px-3 shrink-0"
onClick={() => setCreating(true)}
>
Create key
</Button>
)}
</div>
{createdKey && (
<NewKeyReveal
created={createdKey}
onDismiss={() => setCreatedKey(null)}
/>
)}
{creating && (
<CreateKeyForm
api={api}
onCreated={handleCreated}
onCancel={() => setCreating(false)}
/>
)}
<hr className="border-ui-panel-border" />
{loading ? (
<div className="py-6 text-center text-xs opacity-60">
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
</div>
) : keys.length === 0 ? (
<div className="py-6 text-center text-xs opacity-60">
You don't have any API keys yet.
</div>
) : (
<div className="flex flex-col gap-3">
{keys.map((item) => (
<ApiKeyRow
key={item.token}
api={api}
item={item}
isEditing={editingToken === item.token}
onOpenEdit={() => setEditingToken(item.token)}
onCloseEdit={() => setEditingToken(null)}
onUpdated={(updated) => {
setEditingToken(null);
setKeys((prev) =>
prev.map((k) => (k.token === updated.token ? updated : k)),
);
}}
onRequestDelete={() => setPendingDelete(item)}
/>
))}
</div>
)}
{hasMore && (
<div className="flex justify-center pt-1">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
disabled={loadingMore}
onClick={() => load(false)}
>
{loadingMore ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Load more"
)}
</Button>
</div>
)}
<DeleteConfirmModal
item={pendingDelete}
onCancel={() => setPendingDelete(null)}
onConfirm={handleDeleteConfirmed}
/>
</div>
);
}
function CreateKeyForm({
api,
onCreated,
onCancel,
}: {
api: UserApiKeysApi;
onCreated: (created: CreatedApiKey) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const trimmedName = name.trim();
const canSubmit = trimmedName.length > 0;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit) {
setError("Enter a name for the key.");
return;
}
setSubmitting(true);
const trimmedDescription = description.trim();
const response = await api.CreateApiKey({
name: trimmedName,
maybeDescription:
trimmedDescription.length > 0 ? trimmedDescription : undefined,
});
setSubmitting(false);
if (response.success && response.data) {
toast.success("API key created.");
onCreated(response.data);
} else {
setError(response.errorMessage ?? "Could not create API key.");
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (required)"
maxLength={NAME_MAX}
autoFocus
inputClassName={INPUT_CLASS}
/>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
maxLength={DESCRIPTION_MAX}
inputClassName={INPUT_CLASS}
/>
{error && <FormError message={error} />}
<FormActions
onCancel={onCancel}
submitting={submitting}
disabled={!canSubmit}
submitLabel="Create"
/>
</form>
);
}
function NewKeyReveal({
created,
onDismiss,
}: {
created: CreatedApiKey;
onDismiss: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(created.api_key);
setCopied(true);
toast.success("Copied to clipboard.");
} catch {
toast.error("Could not copy. Select and copy it manually.");
}
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<FontAwesomeIcon icon={faKey} className="opacity-80" />
<span>API key created</span>
</div>
<p className="text-xs opacity-80">
Copy your key now. For security, you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
{created.api_key}
</code>
<Button
type="button"
variant="secondary"
className="h-9 px-3 shrink-0"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className="mr-1.5" />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<div className="flex justify-end pt-0.5">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
onClick={onDismiss}
>
Done
</Button>
</div>
</div>
);
}
function ApiKeyRow({
api,
item,
isEditing,
onOpenEdit,
onCloseEdit,
onUpdated,
onRequestDelete,
}: {
api: UserApiKeysApi;
item: ApiKeyInfo;
isEditing: boolean;
onOpenEdit: () => void;
onCloseEdit: () => void;
onUpdated: (updated: ApiKeyInfo) => void;
onRequestDelete: () => void;
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-ui-panel-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{item.name}</p>
{item.maybe_description && (
<p className="truncate text-xs opacity-70">
{item.maybe_description}
</p>
)}
<div className="flex items-center gap-2 pt-1">
{/* Truncated, display-only — the full secret is only shown on create. */}
<code className="min-w-0 truncate rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-white/70">
{item.truncated_api_key}
</code>
<span className="text-[10px] uppercase tracking-wider text-white/40">
{formatDate(item.created_at)}
</span>
</div>
</div>
{!isEditing && (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="secondary"
className="h-8 px-3"
onClick={onOpenEdit}
>
Edit
</Button>
<Button
type="button"
variant="secondary"
className="h-8 px-2.5 text-red-400"
onClick={onRequestDelete}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</div>
)}
</div>
{isEditing && (
<EditDescriptionForm
api={api}
item={item}
onUpdated={onUpdated}
onCancel={onCloseEdit}
/>
)}
</div>
);
}
function EditDescriptionForm({
api,
item,
onUpdated,
onCancel,
}: {
api: UserApiKeysApi;
item: ApiKeyInfo;
onUpdated: (updated: ApiKeyInfo) => void;
onCancel: () => void;
}) {
const initial = item.maybe_description ?? "";
const [value, setValue] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const trimmed = value.trim();
const isDirty = trimmed !== initial.trim();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!isDirty) {
onCancel();
return;
}
setSubmitting(true);
const nextDescription = trimmed.length > 0 ? trimmed : null;
const response = await api.UpdateApiKey({
token: item.token,
maybeDescription: nextDescription,
});
setSubmitting(false);
if (response.success) {
toast.success("API key updated.");
// Update endpoint returns only { success }; merge the new description in.
onUpdated({ ...item, maybe_description: nextDescription });
} else {
setError(response.errorMessage ?? "Could not update API key.");
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 pt-1">
<Input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Description (optional)"
maxLength={DESCRIPTION_MAX}
autoFocus
inputClassName={INPUT_CLASS}
/>
{error && <FormError message={error} />}
<FormActions
onCancel={onCancel}
submitting={submitting}
disabled={!isDirty}
submitLabel="Save"
/>
</form>
);
}
function DeleteConfirmModal({
item,
onCancel,
onConfirm,
}: {
item: ApiKeyInfo | null;
onCancel: () => void;
onConfirm: () => void;
}) {
const [deleting, setDeleting] = useState(false);
const handleConfirm = async () => {
setDeleting(true);
await onConfirm();
setDeleting(false);
};
return (
<Modal isOpen={item !== null} onClose={onCancel} className="max-w-sm">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">Delete API key</h3>
<p className="text-sm opacity-70">
Delete "{item?.name}"? Any application using this key will stop
working. This can't be undone.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
className="h-9 px-3"
onClick={onCancel}
disabled={deleting}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
className="h-9 px-4 bg-red-500 hover:bg-red-600"
onClick={handleConfirm}
disabled={deleting}
>
{deleting ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Delete"
)}
</Button>
</div>
</div>
</Modal>
);
}
function FormActions({
onCancel,
submitting,
disabled,
submitLabel,
}: {
onCancel: () => void;
submitting: boolean;
disabled: boolean;
submitLabel: string;
}) {
return (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
variant="secondary"
className="h-9 px-3"
onClick={onCancel}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
className="h-9 px-4"
disabled={submitting || disabled}
>
{submitting ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
submitLabel
)}
</Button>
</div>
);
}
function FormError({ message }: { message: string }) {
return <p className="text-xs text-red-400 leading-tight">{message}</p>;
}
function formatDate(raw: string): string {
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return raw;
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
@@ -1,36 +1,65 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCog, faUser } from "@fortawesome/pro-solid-svg-icons";
import { faCog, faUser, faKey } from "@fortawesome/pro-solid-svg-icons";
import { Switch } from "@storyteller/ui-switch";
import { USER_FEATURE_FLAGS } from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
type Tab = "general" | "account";
type Tab = "general" | "account" | "apiKeys";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
const TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
const BASE_TABS: { id: Tab; label: string; icon: typeof faCog }[] = [
{ id: "general", label: "General", icon: faCog },
{ id: "account", label: "Account", icon: faUser },
];
const API_KEYS_TAB: { id: Tab; label: string; icon: typeof faCog } = {
id: "apiKeys",
label: "API Keys",
icon: faKey,
};
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { user } = useSession();
const [tab, setTab] = useState<Tab>("general");
const hasApiKeyFlag = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.API_KEY,
);
const tabs = useMemo(
() => (hasApiKeyFlag ? [...BASE_TABS, API_KEYS_TAB] : BASE_TABS),
[hasApiKeyFlag],
);
useEffect(() => {
if (isOpen) setTab("general");
}, [isOpen]);
const activeLabel = TABS.find((t) => t.id === tab)?.label ?? "";
// If the only-conditional tab disappears (e.g. flag revoked while open),
// fall back to a visible tab.
useEffect(() => {
if (!tabs.some((t) => t.id === tab)) setTab("general");
}, [tabs, tab]);
const activeLabel = tabs.find((t) => t.id === tab)?.label ?? "";
return (
<Modal isOpen={isOpen} onClose={onClose} className="max-w-3xl" childPadding={false}>
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
childPadding={false}
>
<div className="h-[100dvh] sm:h-[560px]">
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
@@ -39,7 +68,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
{TABS.map((t) => (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
@@ -64,6 +93,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
{tab === "general" && <GeneralPanel />}
{tab === "account" && <AccountPanel />}
{tab === "apiKeys" && <ApiKeysPanel />}
</div>
</div>
</div>
@@ -82,12 +112,16 @@ function GeneralPanel() {
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter
adds a new line. When off (default), both Enter and
Shift+Enter add a new line, use the button to submit.
When on, pressing Enter submits the prompt and Shift+Enter adds a
new line. When off (default), both Enter and Shift+Enter add a new
line, use the button to submit.
</p>
</div>
<Switch enabled={enterToGenerate} setEnabled={setEnterToGenerate} offClassName="bg-white/20" />
<Switch
enabled={enterToGenerate}
setEnabled={setEnterToGenerate}
offClassName="bg-white/20"
/>
</div>
</div>
);
@@ -116,3 +150,25 @@ function AccountPanel() {
</div>
);
}
function ApiKeysPanel() {
const { user, authChecked } = useSession();
if (!authChecked) {
return <div className="pt-3 text-xs opacity-60">Loading API keys...</div>;
}
if (!user) {
return (
<div className="pt-3 text-xs opacity-60">
You need to be signed in to manage API keys.
</div>
);
}
return (
<div className="pt-3">
<ApiKeySection user={user} />
</div>
);
}
@@ -1,27 +1,2 @@
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
// ago") for gallery list rows. Kept dependency-free — the app has no date
// library, and this avoids pulling one in for a single label.
const MINUTE = 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
const WEEK = DAY * 7;
const MONTH = DAY * 30;
const YEAR = DAY * 365;
const pluralize = (value: number, unit: string): string =>
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
export function formatTimeAgo(input: string | number | Date): string {
const date = input instanceof Date ? input : new Date(input);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
if (seconds < 45) return "just now";
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
return pluralize(Math.round(seconds / YEAR), "year");
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
export { formatTimeAgo } from "@storyteller/ui-generation-list";
@@ -1,23 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// View mode for the create-page generation galleries. Shared between the
// create-image / create-video pages and the TopBar toggle, and persisted so
// the user's preference survives reloads and carries across both pages.
export type GalleryViewMode = "grid" | "list";
interface GalleryViewState {
viewMode: GalleryViewMode;
setViewMode: (mode: GalleryViewMode) => void;
}
export const useGalleryViewStore = create<GalleryViewState>()(
persist(
(set) => ({
viewMode: "grid",
setViewMode: (viewMode) => set({ viewMode }),
}),
{ name: "artcraft-gallery-view" },
),
);
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's store instance.
export {
useGalleryViewStore,
type GalleryViewMode,
} from "@storyteller/ui-generation-list";
@@ -1,104 +1,3 @@
import { useEffect, useState } from "react";
import { MediaFilesApi } from "@storyteller/api";
// Workaround until the profile media-list endpoint returns prompt tokens:
// resolve media token → prompt token via the batch media-files endpoint
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
// in-flight dedupe, and a subscribe-on-load hook.
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
const tokenCache = new Map<string, string>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: MediaFilesApi | null = null;
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
const CHUNK_SIZE = 50;
function getApi(): MediaFilesApi {
if (!api) api = new MediaFilesApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
const missing = mediaTokens.filter(
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const chunks: string[][] = [];
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
chunks.push(missing.slice(i, i + CHUNK_SIZE));
}
const responses = await Promise.all(
chunks.map((chunk) =>
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
),
);
for (const response of responses) {
if (!response.success || !response.data) continue;
for (const file of response.data) {
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
}
}
} catch {
// Swallow — the finally block marks the tokens resolved (empty) so the
// loading skeleton clears and we don't refetch in a loop.
} finally {
// Mark every requested token resolved. Tokens that errored or weren't
// returned get "" (no prompt) so the row falls back to its label instead
// of showing a skeleton forever. Always notify so skeletons clear.
for (const t of missing) {
if (!tokenCache.has(t)) tokenCache.set(t, "");
inFlight.delete(t);
}
notifySubscribers();
}
}
// Returns mediaToken → promptToken for every token whose media→prompt lookup
// has completed; the value is "" when the media has no prompt. A token that is
// absent from the map is still resolving (use `.has()` to distinguish that
// from a resolved-but-promptless item). Triggers a batched fetch for any not
// yet resolved.
export function useMediaPromptTokens(
mediaTokens: readonly (string | null | undefined)[],
): Map<string, string> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of mediaTokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, string>();
for (const t of cleaned) {
const promptToken = tokenCache.get(t);
// Include "" entries so callers can tell "resolved, no prompt" (present)
// apart from "still loading" (absent).
if (promptToken !== undefined) result.set(t, promptToken);
}
return result;
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's module-level cache.
export { useMediaPromptTokens } from "@storyteller/ui-generation-list";
@@ -1,73 +1,3 @@
import { useEffect, useState } from "react";
import { PromptsApi } from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
const promptCache = new Map<string, Prompts>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: PromptsApi | null = null;
function getApi(): PromptsApi {
if (!api) api = new PromptsApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(tokens: readonly string[]): Promise<void> {
const missing = tokens.filter(
(t) => t && !promptCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const response = await getApi().BatchGetPrompts({ tokens: missing });
if (response.success && response.data) {
for (const p of response.data) {
promptCache.set(p.token, p);
}
notifySubscribers();
}
} catch {
// Failures will be retried when usePrompts re-runs with the same tokens.
} finally {
for (const t of missing) inFlight.delete(t);
}
}
export function getCachedPrompt(token: string): Prompts | undefined {
return promptCache.get(token);
}
export function usePrompts(tokens: readonly (string | null | undefined)[]): Map<string, Prompts> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of tokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, Prompts>();
for (const t of cleaned) {
const p = promptCache.get(t);
if (p) result.set(t, p);
}
return result;
}
// Moved to the shared generation-list lib (used by both webapp and desktop).
// Re-exported so every webapp consumer shares the lib's module-level cache.
export { getCachedPrompt, usePrompts } from "@storyteller/ui-generation-list";
@@ -208,6 +208,28 @@ export default function CreateImage() {
excludeUploads: true,
});
// Map job token → batch count so the pending card/row can show
// "Generating N images" — the batch runs as a single job chip.
const batches = useCreateImageStore((s) => s.batches);
const jobTokenToBatchCount = useMemo(() => {
const map = new Map<string, number>();
for (const batch of batches) {
if (batch.jobToken && batch.requestedCount > 1) {
map.set(batch.jobToken, batch.requestedCount);
}
}
return map;
}, [batches]);
const enrichedInProgress = useMemo(
() =>
jobs.inProgress.map((job) => {
const batchCount = jobTokenToBatchCount.get(job.id);
return batchCount ? { ...job, batchCount } : job;
}),
[jobs.inProgress, jobTokenToBatchCount],
);
const newlyCompletedTokens = useMemo(
() => new Set(jobs.newlyCompleted.map((i) => i.id)),
[jobs.newlyCompleted],
@@ -565,7 +587,7 @@ export default function CreateImage() {
promptForm={mobileForm}
gridContent={
<GenerationGallery
inProgressJobs={jobs.inProgress}
inProgressJobs={enrichedInProgress}
failedJobs={jobs.failed}
onDismissFailed={jobs.dismissFailed}
newlyCompletedItems={jobs.newlyCompleted}
+1 -1
View File
@@ -203,7 +203,7 @@
@layer components {
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
@apply bg-[#303034]/90 !important;
@apply bg-[#303032]/90 !important;
}
}
@@ -49,9 +49,6 @@
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
},
@@ -67,6 +64,9 @@
{
"path": "../../libs/components/pagescene/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
@@ -74,16 +74,22 @@
"path": "../../libs/components/viewer-3d/tsconfig.lib.json"
},
{
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
},
{
"path": "../../libs/components/gravatar/tsconfig.lib.json"
},
{
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../../libs/model-list/tsconfig.lib.json"
},
{
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
"path": "../../libs/components/tooltip/tsconfig.lib.json"
},
{
"path": "../../libs/components/generation-list/tsconfig.lib.json"
},
{
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
@@ -91,9 +97,6 @@
{
"path": "../../libs/components/popover/tsconfig.lib.json"
},
{
"path": "../../libs/components/tooltip/tsconfig.lib.json"
},
{
"path": "../../libs/components/modal/tsconfig.lib.json"
},
+11 -8
View File
@@ -14,9 +14,6 @@
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/pricing-modal"
},
@@ -32,6 +29,9 @@
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/switch"
},
@@ -39,16 +39,22 @@
"path": "../../libs/components/viewer-3d"
},
{
"path": "../../libs/components/action-reminder-modal"
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/components/gravatar"
},
{
"path": "../../libs/components/action-reminder-modal"
},
{
"path": "../../libs/model-list"
},
{
"path": "../../libs/components/loading-spinner"
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/generation-list"
},
{
"path": "../../libs/components/tab-selector"
@@ -56,9 +62,6 @@
{
"path": "../../libs/components/popover"
},
{
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/modal"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1119,10 +1119,21 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex justify-center gap-2.5",
"flex items-center justify-between gap-2.5",
hasResults ? "mt-3" : "mt-3",
)}
>
<ClassyModelSelector
variant="embedded"
items={
variant === "world"
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
}
page={
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
}
/>
<GenerateButton
variant="primary"
icon={undefined}
@@ -1150,23 +1161,7 @@ export const ImageTo3DExperience = ({
</div>
</animated.div>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={
variant === "world"
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
}
page={variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
<CostCalculatorButton
modelPage={
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
@@ -0,0 +1,109 @@
import { useEffect, useState, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
// Shared page chrome for the desktop Generate Image / Generate Video pages,
// mirroring the webapp's CreateMediaPageShell: a centered hero over the
// decorative background, and a scrollable generation feed (padded past the
// fixed promptbox) once there is anything to show.
//
// The hero + background behave like a splash screen: they stay up while the
// feed is still loading (pages remount on tab switches, so this covers the
// reload) and fade out once content arrives. With a genuinely empty library
// they simply stay — they ARE the empty state.
const SPLASH_FADE_MS = 700;
interface DesktopCreatePageShellProps {
hasContent: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
/** Decorative page background (fixed-position); fades out with the hero. */
background?: ReactNode;
/** Height of the fixed promptbox area, used to pad the feed's bottom. */
bottomOffset: number;
listContent: ReactNode;
/** The fixed-position promptbox stack (caller controls its positioning). */
promptBox: ReactNode;
bottomRight?: ReactNode;
}
export function DesktopCreatePageShell({
hasContent,
emptyStateTitle,
emptyStateSubtitle,
background,
bottomOffset,
listContent,
promptBox,
bottomRight,
}: DesktopCreatePageShellProps) {
const [splashState, setSplashState] = useState<
"visible" | "fading" | "hidden"
>("visible");
useEffect(() => {
if (hasContent && splashState === "visible") {
setSplashState("fading");
const t = setTimeout(() => setSplashState("hidden"), SPLASH_FADE_MS);
return () => clearTimeout(t);
}
if (!hasContent && splashState === "hidden") {
// Feed emptied out again (e.g. everything deleted) — bring it back.
setSplashState("visible");
}
return undefined;
}, [hasContent, splashState]);
return (
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background">
<div className="relative h-full w-full">
{hasContent && (
<div
className="h-full w-full overflow-y-auto pt-4"
style={{ paddingBottom: bottomOffset + 24 }}
>
{listContent}
</div>
)}
{splashState !== "hidden" && (
<div
className={twMerge(
"pointer-events-none absolute inset-0 z-10 transition-opacity duration-700",
splashState === "fading" ? "opacity-0" : "opacity-100",
)}
>
<div className="flex h-full w-full flex-col items-center justify-center pb-52">
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl">
<h1 className="text-7xl font-bold text-base-fg">
{emptyStateTitle}
</h1>
<span className="pt-2 text-xl text-base-fg opacity-80">
{emptyStateSubtitle}
</span>
</div>
</div>
{background}
</div>
)}
{/* Bottom fade behind the floating prompt box so it stays legible
over the feed. Sits under the prompt box (z-20). */}
{hasContent && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent"
/>
)}
{promptBox}
{bottomRight && (
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
{bottomRight}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,243 @@
import { useCallback, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { toast } from "@storyteller/ui-toaster";
import {
GenerationListView,
GenerationGridView,
useGalleryViewStore,
type FailedJob,
type GalleryItem,
type InProgressJob,
} from "@storyteller/ui-generation-list";
import {
applyMakeVideoFromImage,
applyRecreateFromPromptToken,
copyShareLink,
downloadMediaFileToDisk,
} from "./desktopMediaActions";
// Desktop twin of the webapp's GenerationGallery: renders the shared feed in
// whichever layout the TopBar toggle picked (grid by default), injecting the
// desktop flavors of the quick actions (recreate / make-video / share /
// download) through the views' render-prop seams.
export interface DesktopGenerationGalleryProps {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
onDismissFailed: (jobToken: string) => void;
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
hasMore: boolean;
isLoading?: boolean;
isInitialLoading: boolean;
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
/** Image page only: show a "Make Video" quick action on image items. */
enableMakeVideo?: boolean;
}
export function DesktopGenerationGallery({
enableMakeVideo,
...feedProps
}: DesktopGenerationGalleryProps) {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const handleCopyResult = useCallback((success: boolean) => {
if (success) {
toast.success("Prompt copied");
} else {
toast.error("Unable to copy prompt");
}
}, []);
if (viewMode === "list") {
return (
<GenerationListView
{...feedProps}
renderGalleryActions={(item, ctx) => (
<ItemActions
item={item}
promptToken={ctx.promptToken ?? item.promptToken}
enableMakeVideo={enableMakeVideo}
variant="row"
/>
)}
onCopyPromptResult={handleCopyResult}
/>
);
}
return (
<div className="px-3">
<GenerationGridView
{...feedProps}
renderGalleryActions={(item, ctx) => (
<ItemActions
item={item}
promptToken={ctx.promptToken ?? item.promptToken}
enableMakeVideo={enableMakeVideo}
variant="card"
/>
)}
/>
</div>
);
}
// Quick actions for a completed item — desktop equivalents of the webapp's
// useGalleryItemActions: recreate re-seeds the create page from the prompt
// record, make-video seeds the video page, share copies the public media
// link, download saves via Tauri. stopPropagation keeps taps from also
// opening the lightbox.
function ItemActions({
item,
promptToken,
enableMakeVideo,
variant,
}: {
item: GalleryItem;
promptToken?: string;
enableMakeVideo?: boolean;
variant: "row" | "card";
}) {
const [isRecreating, setIsRecreating] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const recreateMediaClass: "image" | "video" | null = isVideo
? "video"
: is3D
? null
: "image";
const canRecreate = !!promptToken && !!recreateMediaClass;
const canMakeVideo = !!enableMakeVideo && !isVideo && !is3D;
const buttonClass =
variant === "card"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
: "flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
const handleRecreate = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!promptToken || !recreateMediaClass || isRecreating) return;
setIsRecreating(true);
try {
await applyRecreateFromPromptToken(promptToken, recreateMediaClass);
} finally {
setIsRecreating(false);
}
},
[promptToken, recreateMediaClass, isRecreating],
);
const handleMakeVideo = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const url = item.fullImage || item.thumbnail;
if (!url) return;
applyMakeVideoFromImage(url, item.id);
},
[item.fullImage, item.thumbnail, item.id],
);
const handleShare = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const ok = await copyShareLink(item.id);
if (ok) {
setShareCopied(true);
setTimeout(() => setShareCopied(false), 1500);
}
},
[item.id],
);
const handleDownload = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!item.fullImage || isDownloading) return;
setIsDownloading(true);
try {
await downloadMediaFileToDisk(item.fullImage, item.mediaClass);
} finally {
setIsDownloading(false);
}
},
[item.fullImage, item.mediaClass, isDownloading],
);
return (
<>
{canRecreate && (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className={buttonClass}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{canMakeVideo && (
<Tooltip content="Make Video" position="top">
<button
type="button"
onClick={handleMakeVideo}
aria-label="Make Video"
className={buttonClass}
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
</button>
</Tooltip>
)}
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
<button
type="button"
onClick={handleShare}
aria-label="Share"
className={buttonClass}
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
className="text-sm"
/>
</button>
</Tooltip>
{item.fullImage && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
aria-label="Download"
className={buttonClass}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</>
);
}
@@ -0,0 +1,273 @@
import toast from "react-hot-toast";
import {
FilterMediaClasses,
PromptsApi,
downloadUrlToPath,
promptDownloadLocationIfNeeded,
} from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
import { DownloadUrl } from "@storyteller/tauri-api";
import {
RefImage,
RefVideo,
RefAudio,
usePromptImageStore,
usePromptVideoStore,
} from "@storyteller/ui-promptbox";
import {
useClassyModelSelectorStore,
ModelPage,
} from "@storyteller/ui-model-selector";
import {
IMAGE_MODELS_BY_ID,
VIDEO_MODELS_BY_ID,
CommonAspectRatio,
CommonResolution,
} from "@storyteller/model-list";
import {
galleryModalLightboxVisible,
galleryModalVisibleDuringDrag,
galleryModalVisibleViewMode,
} from "@storyteller/ui-gallery-modal";
import { getCachedPrompt } from "@storyteller/ui-generation-list";
import { useTabStore } from "~/pages/Stores/TabState";
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
// the create pages' generation feed (rows + cards). All of them operate on
// global stores via getState(), so they're safe to call from anywhere.
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
/** Download a media file, prompting for a location when configured to. */
export async function downloadMediaFileToDisk(url: string, mediaClass?: string) {
try {
const chosenPath = await promptDownloadLocationIfNeeded(url);
if (chosenPath === null) {
// User dismissed the picker.
return;
}
if (typeof chosenPath === "string") {
await downloadUrlToPath(url, chosenPath);
} else {
await DownloadUrl(url);
}
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
} else {
toast.success(`Downloaded ${mediaClass}`);
}
} catch (error) {
console.error(">>> Failed to download file:", error);
// NB: Rust/Tauri should now flash a toast instead.
//toast.error("Failed to download file");
}
}
/** Seed the video page with `url` as the starting image and switch to it. */
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
try {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-image"),
mediaToken: mediaToken || "",
};
// Update zustand store for Video directly
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
useTabStore.getState().setActiveTab("VIDEO");
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
}
/** Copy a public share link for the media token. Returns success. */
export async function copyShareLink(mediaToken: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(`${SHARE_URL_BASE}${mediaToken}`);
toast.success("Share link copied");
return true;
} catch {
toast.error("Unable to copy link");
return false;
}
}
/**
* Re-seed the image or video create page from a generation's prompt record
* (prompt text, reference media, settings, model) and switch to that page.
*/
export function applyRecreateFromPromptData(data: {
promptData: Prompts;
mediaClass: string | undefined;
}) {
try {
const { promptData, mediaClass: recreateMediaClass } = data;
const contextImages = promptData.maybe_context_images || [];
// Partition context images by semantic type
const imgRefs: RefImage[] = [];
let endFrameImage: RefImage | undefined;
const vidRefs: RefVideo[] = [];
const audioRefs: RefAudio[] = [];
for (const ci of contextImages) {
const base = {
id: Math.random().toString(36).substring(7),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
switch (ci.semantic) {
case "vid_end_frame":
endFrameImage = { ...base, file: new File([], "recreate-ref") };
break;
case "vid_ref":
vidRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
case "audioref":
audioRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
default:
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
break;
}
}
// Determine input mode from generation_mode or context image semantics
const hasKeyframeSemantics = contextImages.some(
(ci) =>
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
);
const inputMode: "keyframe" | "reference" =
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
? "keyframe"
: promptData.maybe_generation_mode === "reference"
? "reference"
: imgRefs.length > 0
? "reference"
: "keyframe";
const modelStore = useClassyModelSelectorStore.getState();
if (recreateMediaClass === "video") {
const videoStore = usePromptVideoStore.getState();
// Set model first so the UI syncs sizeOptions / durationOptions
const videoModel = promptData.maybe_model_type
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
: undefined;
if (videoModel) {
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
}
if (promptData.maybe_positive_prompt) {
videoStore.setPrompt(promptData.maybe_positive_prompt);
}
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
if (promptData.maybe_generate_audio !== null) {
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
}
if (promptData.maybe_duration_seconds !== null) {
videoStore.setDuration(promptData.maybe_duration_seconds);
}
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
const match = videoModel.sizeOptions.find(
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
);
if (match) {
videoStore.setAspectRatio(match.textLabel);
}
}
// Map API resolution (like "one_k") → video store format (like "1080p")
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
const resolutionMap: Record<string, string> = {
one_k: "1080p",
two_k: "2k",
three_k: "3k",
four_k: "4k",
};
const mapped = resolutionMap[promptData.maybe_resolution];
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
videoStore.setResolution(mapped);
}
}
videoStore.setInputMode(inputMode);
useTabStore.getState().setActiveTab("VIDEO");
} else {
// Default to image
const imageStore = usePromptImageStore.getState();
if (promptData.maybe_positive_prompt) {
imageStore.setPrompt(promptData.maybe_positive_prompt);
}
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
if (promptData.maybe_aspect_ratio) {
imageStore.setCommonAspectRatio(
promptData.maybe_aspect_ratio as CommonAspectRatio,
);
}
if (promptData.maybe_resolution) {
imageStore.setCommonResolution(
promptData.maybe_resolution as CommonResolution,
);
}
if (promptData.maybe_model_type) {
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
}
useTabStore.getState().setActiveTab("IMAGE");
}
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
}
/**
* Recreate from a prompt token: resolve the prompt record (shared cache
* first, then the API), then seed the create page from it.
*/
export async function applyRecreateFromPromptToken(
promptToken: string,
mediaClass: "image" | "video",
) {
let promptData: Prompts | undefined = getCachedPrompt(promptToken);
if (!promptData) {
try {
const res = await new PromptsApi().BatchGetPrompts({
tokens: [promptToken],
});
promptData = res.success && res.data?.[0] ? res.data[0] : undefined;
} catch {
// handled below
}
}
if (!promptData) {
toast.error("Could not load the original prompt.");
return;
}
applyRecreateFromPromptData({ promptData, mediaClass });
}
@@ -0,0 +1,424 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { GetTaskQueue, MarkTaskAsDismissed } from "@storyteller/tauri-api";
import type { TaskQueueItem } from "@storyteller/tauri-api";
import {
useTextToImageGenerationCompleteEvent,
useVideoGenerationCompleteEvent,
} from "@storyteller/tauri-events";
import { MediaFilesApi } from "@storyteller/api";
import {
getMediaThumbnail,
getThumbnailUrl,
THUMBNAIL_SIZES,
} from "@storyteller/common";
import {
ALL_MODELS_LIST,
getModelDisplayName,
getProviderDisplayName,
} from "@storyteller/model-list";
import type {
FailedJob,
GalleryItem,
InProgressJob,
} from "@storyteller/ui-generation-list";
import {
getMetaForTask,
cleanupOldEntries,
} from "../signaled/TopBar/taskEnqueueMeta";
// Desktop twin of the webapp's useGenerationJobs: feeds the shared
// GenerationListView from the Tauri task queue instead of JobsApi. Polls every
// 5s and reloads instantly on Tauri generation events / local enqueues.
const POLL_INTERVAL_MS = 5000;
const FAILED_STATUSES = new Set([
"complete_failure",
"attempt_failed",
"dead",
"cancelled_by_user",
"cancelled_by_provider",
"cancelled_by_us",
]);
const FAILURE_REASON_LABEL: Record<string, string> = {
rule_bans_user_image: "Image violates content policy",
rule_bans_user_image_with_faces: "Images with faces are not allowed",
rule_bans_user_text_prompt: "Text prompt violates content policy",
rule_bans_user_content: "Content violates content policy",
rule_bans_generated_video: "Generated video flagged by content policy",
rule_bans_generated_audio: "Generated audio flagged by content policy",
rule_bans_generated_content: "Generated content flagged by content policy",
generation_failed: "Generation failed",
unknown: "An unknown error occurred",
};
// ── Hook ───────────────────────────────────────────────────────────────────
export function useDesktopGenerationFeed(options: {
mediaType: "image" | "video";
}) {
const { mediaType } = options;
const mediaApiRef = useRef(new MediaFilesApi());
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
const [failed, setFailed] = useState<FailedJob[]>([]);
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
// Snapshot per-task durations so the fake progress stays stable.
const durationsRef = useRef<Map<string, number>>(new Map());
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
const initialLoadDoneRef = useRef(false);
// Locally dismissed failed tasks — hides them until the server-side
// dismissal lands in the next poll.
const dismissedRef = useRef<Set<string>>(new Set());
const load = useCallback(async () => {
try {
const { tasks } = await GetTaskQueue();
const filtered = tasks.filter(
(t) => getTaskMediaType(t) === mediaType && !dismissedRef.current.has(t.id),
);
const newInProgress = filtered
.filter(
(t) => t.task_status === "pending" || t.task_status === "started",
)
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.map((t) => taskToInProgress(t, mediaType, durationsRef.current));
const newFailed = filtered
.filter((t) => FAILED_STATUSES.has(String(t.task_status)))
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
.map((t) => taskToFailed(t, mediaType));
const completedTasks = filtered.filter(
(t) => String(t.task_status) === "complete_success",
);
const completedIdSet = new Set(completedTasks.map((t) => t.id));
// Detect newly completed (skip on first load to avoid flooding —
// history comes from the gallery API instead).
let expandedNewItems: GalleryItem[] = [];
if (initialLoadDoneRef.current) {
const newOnes = completedTasks.filter(
(t) => !prevCompletedIdsRef.current.has(t.id),
);
if (newOnes.length > 0) {
// Await expansion so the pending row and its completed replacement
// commit in the same React render — no "remove then add" gap.
const expanded = await Promise.all(
newOnes.map((t) =>
taskToGalleryItems(t, mediaType, mediaApiRef.current),
),
);
expandedNewItems = expanded.flat();
}
}
initialLoadDoneRef.current = true;
prevCompletedIdsRef.current = completedIdSet;
// Prune duration snapshots for tasks no longer in progress.
const activeIds = new Set(newInProgress.map((j) => j.id));
for (const id of Array.from(durationsRef.current.keys())) {
if (!activeIds.has(id)) durationsRef.current.delete(id);
}
if (expandedNewItems.length > 0) {
setNewlyCompleted((prev) => {
const existingIds = new Set(prev.map((i) => i.id));
const fresh = expandedNewItems.filter((i) => !existingIds.has(i.id));
return [...fresh, ...prev];
});
}
setInProgress(newInProgress);
setFailed(newFailed);
} catch {
// ignore — next poll retries
}
}, [mediaType]);
/** Prepend completed items (deduped) ahead of the next poll. */
const addCompletedItems = useCallback((items: GalleryItem[]) => {
if (items.length === 0) return;
setNewlyCompleted((prev) => {
const existingIds = new Set(prev.map((i) => i.id));
const fresh = items.filter((i) => !existingIds.has(i.id));
if (fresh.length === 0) return prev;
return [...fresh, ...prev];
});
}, []);
// Push-style completion: the Rust backend emits typed completion events
// carrying the generated media inline, so results appear instantly instead
// of waiting on the next task-queue poll. The follow-up load() reconciles
// the pending chip; the poll's own newly-completed detection dedupes by
// media token against these. NB: the tauri-events hooks subscribe once on
// mount, so the callbacks must only capture stable values (mediaType is a
// constant per page; addCompletedItems/load are stable).
useTextToImageGenerationCompleteEvent(async (event) => {
if (mediaType !== "image" || !event.generated_images?.length) return;
const createdAt = new Date().toISOString();
addCompletedItems(
event.generated_images.map((img) => ({
id: img.media_token,
label: "Image Generation",
thumbnail:
getThumbnailUrl(img.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.LARGE,
}) ?? img.cdn_url,
fullImage: img.cdn_url,
createdAt,
mediaClass: "image",
})),
);
load();
});
useVideoGenerationCompleteEvent(async (event) => {
if (mediaType !== "video" || !event.generated_video) return;
const video = event.generated_video;
addCompletedItems([
{
id: video.media_token,
label: "Video Generation",
thumbnail:
getThumbnailUrl(video.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.LARGE,
}) ?? null,
fullImage: video.cdn_url,
createdAt: new Date().toISOString(),
mediaClass: "video",
},
]);
load();
});
// Poll + reload on Tauri generation events and local "task-queue-update"
// dispatches (fired by the pages right after enqueueing).
useEffect(() => {
cleanupOldEntries();
load();
const intervalId = setInterval(load, POLL_INTERVAL_MS);
const handleTaskUpdate = () => load();
window.addEventListener("task-queue-update", handleTaskUpdate);
let cancelled = false;
const unlistenComplete: Promise<UnlistenFn> = listen(
"generation-complete-event",
() => {
if (!cancelled) load();
},
);
const unlistenFailed: Promise<UnlistenFn> = listen(
"generation-failed-event",
() => {
if (!cancelled) load();
},
);
return () => {
cancelled = true;
clearInterval(intervalId);
window.removeEventListener("task-queue-update", handleTaskUpdate);
unlistenComplete.then((f) => f());
unlistenFailed.then((f) => f());
};
}, [load]);
const dismissFailed = useCallback(async (taskId: string) => {
dismissedRef.current.add(taskId);
setFailed((prev) => prev.filter((f) => f.id !== taskId));
try {
await MarkTaskAsDismissed(taskId);
} catch {
// ignore — the local filter already hides it
}
}, []);
return {
inProgress,
failed,
newlyCompleted,
dismissFailed,
refresh: load,
};
}
// ── Task mapping helpers ────────────────────────────────────────────────────
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
const taskTypeStr = t.task_type ? String(t.task_type).toLowerCase() : "";
const modelTypeStr = t.model_type ? String(t.model_type).toLowerCase() : "";
const isSplat =
taskTypeStr.includes("gaussian") ||
modelTypeStr.includes("marble") ||
modelTypeStr.includes("worldlabs");
const is3D =
taskTypeStr.includes("3d") ||
taskTypeStr.includes("object") ||
taskTypeStr.includes("dimensional") ||
isSplat;
if (is3D) return "other";
if (taskTypeStr.includes("video")) return "video";
if (taskTypeStr.includes("image")) return "image";
return "other";
}
function getTaskModelLabel(t: TaskQueueItem): string {
const modelType = t.model_type ? String(t.model_type) : "";
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
const provider = t.provider
? getProviderDisplayName(String(t.provider).toLowerCase())
: undefined;
if (modelDisplay && provider) return `${modelDisplay} · ${provider}`;
return modelDisplay || provider || "Unknown model";
}
function taskToInProgress(
t: TaskQueueItem,
mediaType: "image" | "video",
durations: Map<string, number>,
): InProgressJob {
const now = Date.now();
const createdMs = t.created_at.getTime();
let duration = durations.get(t.id);
if (!duration) {
const model = t.model_type
? ALL_MODELS_LIST.find(
(m) =>
m.tauriId === String(t.model_type) || m.id === String(t.model_type),
)
: undefined;
duration =
model?.progressBarTime ?? (mediaType === "video" ? 900000 : 30000);
durations.set(t.id, duration);
}
const elapsed = now - createdMs;
const progress = Math.min(95, Math.max(0, (elapsed / duration) * 100));
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
createdMs,
);
return {
id: t.id,
prompt: meta?.prompt ?? "",
modelId: t.model_type ? String(t.model_type) : "",
modelLabel: getTaskModelLabel(t),
progress,
estimatedTimeLeftMs: Math.max(0, duration - elapsed),
createdAt: t.created_at.toISOString(),
// One image task can produce a whole batch — surface the requested count
// so the pending chip reads "Generating N images".
batchCount: meta?.batchCount,
mediaClass: mediaType,
};
}
function taskToFailed(
t: TaskQueueItem,
mediaType: "image" | "video",
): FailedJob {
const fr = t.failure_reason;
const failureReason = fr
? FAILURE_REASON_LABEL[fr.failure_type] || fr.failure_message || undefined
: undefined;
const failureMessage =
fr?.failure_message && fr.failure_type !== "unknown"
? fr.failure_message
: undefined;
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
t.created_at.getTime(),
);
return {
id: t.id,
prompt: meta?.prompt ?? "",
modelId: t.model_type ? String(t.model_type) : "",
modelLabel: getTaskModelLabel(t),
failureReason,
failureMessage,
status: String(t.task_status),
createdAt: t.created_at.toISOString(),
refImageUrl: meta?.refImageUrls?.[0],
mediaClass: mediaType,
};
}
/** Map a completed task to its GalleryItem(s), expanding batch siblings. */
async function taskToGalleryItems(
t: TaskQueueItem,
mediaType: "image" | "video",
mediaFilesApi: MediaFilesApi,
): Promise<GalleryItem[]> {
const file = t.completed_item?.primary_media_file;
if (!file) return [];
const meta = getMetaForTask(
t.id,
t.model_type ? String(t.model_type) : undefined,
t.created_at.getTime(),
);
const label =
meta?.prompt ||
(mediaType === "video" ? "Video Generation" : "Image Generation");
const modelId = t.model_type ? String(t.model_type) : undefined;
// Sort by task creation time (not completion time) so the completed row
// occupies the same slot the pending row held — no layout shift.
const createdAt = t.created_at.toISOString();
const primary: GalleryItem = {
id: file.token,
label,
thumbnail:
getThumbnailUrl(file.maybe_thumbnail_url_template, {
width: THUMBNAIL_SIZES.LARGE,
}) ?? null,
fullImage: file.cdn_url || null,
createdAt,
mediaClass: mediaType,
modelId,
batchImageToken: t.completed_item?.maybe_batch_token,
};
const batchToken = t.completed_item?.maybe_batch_token;
if (!batchToken) return [primary];
try {
const batchResponse = await mediaFilesApi.GetMediaFilesByBatchToken({
batchToken,
});
if (!batchResponse.success || !batchResponse.data?.length) {
return [primary];
}
return batchResponse.data
.map((batchFile: any): GalleryItem | null => {
const cdnUrl = batchFile.media_links?.cdn_url;
if (!cdnUrl) return null;
const thumbnail = getMediaThumbnail(batchFile.media_links, mediaType, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: batchFile.token,
label,
thumbnail: thumbnail || cdnUrl,
fullImage: cdnUrl,
createdAt,
mediaClass: mediaType,
modelId,
batchImageToken: batchToken,
};
})
.filter((i): i is GalleryItem => i !== null);
} catch {
return [primary];
}
}
@@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
import { UsersApi } from "@storyteller/api";
// Resolve the current session's username (needed by the gallery list API).
// Mirrors the gallery modal's session fetch; null while loading / logged out.
export function useDesktopUsername(): string | null {
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const session = await new UsersApi().GetSession();
if (!cancelled && session.success && session.data?.user) {
setUsername(session.data.user.username);
}
} catch {
// ignore — stays null and the page shows the empty state
}
})();
return () => {
cancelled = true;
};
}, []);
return username;
}
@@ -13,7 +13,7 @@ export const AppsQuickMenu = () => {
return (
<div className="grid w-[680px] grid-cols-2 gap-3">
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Generate</h3>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Create</h3>
<div className="space-y-0.5">
{generateApps.map((app) => (
<button
@@ -9,6 +9,7 @@ import {
faGear,
faGem,
faGrid2,
faHouse,
faImages,
faCalculator,
faExclamation,
@@ -17,29 +18,12 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import {
FilterMediaClasses,
downloadUrlToPath,
promptDownloadLocationIfNeeded,
} from "@storyteller/api";
import {
getCreatorIcon,
ModelCreator,
IMAGE_MODELS_BY_ID,
VIDEO_MODELS_BY_ID,
CommonAspectRatio,
CommonResolution,
} from "@storyteller/model-list";
import {
useClassyModelSelectorStore,
ModelPage,
} from "@storyteller/ui-model-selector";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
import { gtagEvent } from "@storyteller/google-analytics";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
import { useSubscriptionState } from "@storyteller/subscription";
import { DownloadUrl } from "@storyteller/tauri-api";
import {
useCreditsBalanceChangedEvent,
useSubscriptionPlanChangedEvent,
@@ -67,19 +51,16 @@ import {
useCostBreakdownModalStore,
CreditsModal,
} from "@storyteller/ui-pricing-modal";
import {
RefImage,
RefVideo,
RefAudio,
usePromptImageStore,
usePromptVideoStore,
} from "@storyteller/ui-promptbox";
import type { Prompts } from "@storyteller/api";
import { GalleryViewToggle } from "@storyteller/ui-generation-list";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import {
applyMakeVideoFromImage,
applyRecreateFromPromptData,
downloadMediaFileToDisk,
} from "~/components/generation-feed/desktopMediaActions";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
@@ -117,8 +98,19 @@ type SettingsSection =
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
const CREDITS_POLL_INTERVAL = 60_000; // milliseconds
// NB: See `TabState` for the default tab.
// NB: See `TabState` for the default tab. The Apps ("More") entry is first so
// it's the landing tab and leftmost in the switcher.
const appMenuTabs: MenuIconItem[] = [
{
id: "APPS",
label: "Home",
icon: <FontAwesomeIcon icon={faHouse} />,
description: "Explore all apps and miniapps",
large: true,
tooltipContent: <AppsQuickMenu />,
tooltipInteractive: true,
tooltipPosition: "bottom",
},
...APP_DESCRIPTORS.map((d) => ({
id: d.id,
label: d.label,
@@ -127,16 +119,6 @@ const appMenuTabs: MenuIconItem[] = [
description: d.description,
large: d.large,
})),
{
id: "APPS",
label: "More",
icon: <FontAwesomeIcon icon={faGrid2} />,
description: "Explore more apps and miniapps",
large: true,
tooltipContent: <AppsQuickMenu />,
tooltipInteractive: true,
tooltipPosition: "bottom",
},
];
export const topNavMediaId = signal<string>("");
@@ -286,29 +268,7 @@ export const TopBar = ({ pageName }: Props) => {
);
};
const downloadFile = async (url: string, mediaClass?: string) => {
try {
const chosenPath = await promptDownloadLocationIfNeeded(url);
if (chosenPath === null) {
// User dismissed the picker.
return;
}
if (typeof chosenPath === "string") {
await downloadUrlToPath(url, chosenPath);
} else {
await DownloadUrl(url);
}
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
toast.success(`Downloaded 3D model`);
} else {
toast.success(`Downloaded ${mediaClass}`);
}
} catch (error) {
console.error(">>> Failed to download file:", error);
// NB: Rust/Tauri should now flash a toast instead.
//toast.error("Failed to download file");
}
};
const downloadFile = downloadMediaFileToDisk;
const handleEditFromGallery = async (url: string, mediaId?: string) => {
try {
@@ -337,174 +297,9 @@ export const TopBar = ({ pageName }: Props) => {
}
};
const handleTurnIntoVideoFromGallery = async (
url: string,
mediaId?: string,
) => {
try {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url,
file: new File([], "library-image"),
mediaToken: mediaId || "",
};
// Update zustand store for Video directly
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
useTabStore.getState().setActiveTab("VIDEO");
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
};
const handleTurnIntoVideoFromGallery = applyMakeVideoFromImage;
const handleRecreateFromGallery = (data: {
promptData: Prompts;
mediaClass: string | undefined;
}) => {
try {
const { promptData, mediaClass: recreateMediaClass } = data;
const contextImages = promptData.maybe_context_images || [];
// Partition context images by semantic type
const imgRefs: RefImage[] = [];
let endFrameImage: RefImage | undefined;
const vidRefs: RefVideo[] = [];
const audioRefs: RefAudio[] = [];
for (const ci of contextImages) {
const base = {
id: Math.random().toString(36).substring(7),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
switch (ci.semantic) {
case "vid_end_frame":
endFrameImage = { ...base, file: new File([], "recreate-ref") };
break;
case "vid_ref":
vidRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
case "audioref":
audioRefs.push({
...base,
file: new File([], "recreate-ref"),
duration: 0,
});
break;
default:
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
break;
}
}
// Determine input mode from generation_mode or context image semantics
const hasKeyframeSemantics = contextImages.some(
(ci) =>
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
);
const inputMode: "keyframe" | "reference" =
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
? "keyframe"
: promptData.maybe_generation_mode === "reference"
? "reference"
: imgRefs.length > 0
? "reference"
: "keyframe";
const modelStore = useClassyModelSelectorStore.getState();
if (recreateMediaClass === "video") {
const videoStore = usePromptVideoStore.getState();
// Set model first so the UI syncs sizeOptions / durationOptions
const videoModel = promptData.maybe_model_type
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
: undefined;
if (videoModel) {
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
}
if (promptData.maybe_positive_prompt) {
videoStore.setPrompt(promptData.maybe_positive_prompt);
}
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
if (promptData.maybe_generate_audio !== null) {
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
}
if (promptData.maybe_duration_seconds !== null) {
videoStore.setDuration(promptData.maybe_duration_seconds);
}
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
const match = videoModel.sizeOptions.find(
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
);
if (match) {
videoStore.setAspectRatio(match.textLabel);
}
}
// Map API resolution (like "one_k") → video store format (like "1080p")
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
const resolutionMap: Record<string, string> = {
one_k: "1080p",
two_k: "2k",
three_k: "3k",
four_k: "4k",
};
const mapped = resolutionMap[promptData.maybe_resolution];
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
videoStore.setResolution(mapped);
}
}
videoStore.setInputMode(inputMode);
useTabStore.getState().setActiveTab("VIDEO");
} else {
// Default to image
const imageStore = usePromptImageStore.getState();
if (promptData.maybe_positive_prompt) {
imageStore.setPrompt(promptData.maybe_positive_prompt);
}
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
if (promptData.maybe_aspect_ratio) {
imageStore.setCommonAspectRatio(
promptData.maybe_aspect_ratio as CommonAspectRatio,
);
}
if (promptData.maybe_resolution) {
imageStore.setCommonResolution(
promptData.maybe_resolution as CommonResolution,
);
}
if (promptData.maybe_model_type) {
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
}
useTabStore.getState().setActiveTab("IMAGE");
}
galleryModalVisibleViewMode.value = false;
galleryModalVisibleDuringDrag.value = false;
galleryModalLightboxVisible.value = false;
} catch (e) {
// no-op
}
};
const handleRecreateFromGallery = applyRecreateFromPromptData;
const handleRemoveBackgroundFromGallery = async (url: string) => {
try {
@@ -559,9 +354,9 @@ export const TopBar = ({ pageName }: Props) => {
case "3D":
return "3D Editor";
case "IMAGE":
return "Text to Image";
return "Create Image";
case "VIDEO":
return "Generate Video";
return "Create Video";
case "EDIT":
return "Edit Image";
case "VIDEO_FRAME_EXTRACTOR":
@@ -575,7 +370,7 @@ export const TopBar = ({ pageName }: Props) => {
case "IMAGE_TO_3D_WORLD":
return "Image to 3D World";
case "APPS":
return "ArtCraft Apps";
return "ArtCraft";
case "BACKGROUND_CHANGE":
return "Background Change";
default:
@@ -703,6 +498,8 @@ export const TopBar = ({ pageName }: Props) => {
<div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5">
{(tabStore.activeTabId === "IMAGE" ||
tabStore.activeTabId === "VIDEO") && <GalleryViewToggle />}
<PopoverMenu
position="bottom"
align="center"
@@ -786,7 +583,7 @@ export const TopBar = ({ pageName }: Props) => {
onClick={toggleSubscriptionModal}
className="transition-all duration-300"
>
Support
Upgrade
</Button>
)}
@@ -19,6 +19,8 @@ export interface EnqueueMeta {
refImageUrls?: string[];
modelType?: string;
timestamp: number; // Date.now() at enqueue time
// Requested generation count when one task produces a batch (image page).
batchCount?: number;
}
interface StoredEntry {
@@ -27,12 +29,14 @@ interface StoredEntry {
refImageUrls?: string[]; // base64 data-URLs after conversion
modelType?: string;
timestamp: number;
batchCount?: number;
matchedTaskId?: string; // set once matched to a specific task
}
interface TaskMatch {
prompt?: string;
refImageUrls?: string[];
batchCount?: number;
}
// In-memory cache of task_id → matched metadata
@@ -113,6 +117,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
refImageUrls: meta.refImageUrls,
modelType: meta.modelType,
timestamp: meta.timestamp,
batchCount: meta.batchCount,
};
const entries = readEntries();
entries.push(entry);
@@ -153,6 +158,7 @@ export function getMetaForTask(
const result: TaskMatch = {
prompt: previousMatch.prompt,
refImageUrls: previousMatch.refImageUrls,
batchCount: previousMatch.batchCount,
};
matchedTasks.set(taskId, result);
return result;
@@ -187,6 +193,7 @@ export function getMetaForTask(
const result: TaskMatch = {
prompt: matched.prompt,
refImageUrls: matched.refImageUrls,
batchCount: matched.batchCount,
};
matchedTasks.set(taskId, result);
return result;
@@ -48,12 +48,12 @@ export interface AppDescriptor {
export const APP_DESCRIPTORS: AppDescriptor[] = [
{
id: "IMAGE",
label: "Text to Image",
label: "Create Image",
icon: faImage,
},
{
id: "VIDEO",
label: "Generate Video",
label: "Create Video",
icon: faFilm,
},
{
@@ -82,13 +82,115 @@ export interface FullAppItem {
category: "generate" | "edit";
badge?: "NEW" | "BEST" | "SOON" | "BETA";
action?: AppId;
/** Legacy single-tone icon-square background (e.g. "bg-blue-600/40"). Still
* used as the fallback for the apps-page card styling. */
color?: string;
}
// Per-app card palette for the Apps page (webapp-home-style cards). Derived
// from each app's Tailwind color family so the icon box, its border, and the
// hover gradient all share a hue. Keyed by app id; falls back to a neutral
// slate when an id is missing.
interface AppCardPalette {
/** Hover gradient overlay, e.g. "from-blue-500/20 to-blue-500/0". */
accent: string;
/** Icon box background + border, e.g. "bg-blue-500/20 border-blue-400/30". */
iconBg: string;
/** Icon glyph color, e.g. "text-blue-300". */
iconColor: string;
}
const APP_CARD_PALETTES: Record<string, AppCardPalette> = {
"text-to-image": {
accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/20 border-blue-400/30",
iconColor: "text-blue-300",
},
"image-to-video": {
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/20 border-amber-400/30",
iconColor: "text-amber-300",
},
"image-to-3d-object": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30",
iconColor: "text-emerald-300",
},
"image-to-3d-world": {
accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/30",
iconColor: "text-sky-300",
},
angles: {
accent: "from-lime-500/20 to-lime-500/0",
iconBg: "bg-lime-500/20 border-lime-400/30",
iconColor: "text-lime-300",
},
storyboard: {
accent: "from-fuchsia-500/20 to-fuchsia-500/0",
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/30",
iconColor: "text-fuchsia-300",
},
"2d-canvas": {
accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/30",
iconColor: "text-sky-300",
},
"3d-editor": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30",
iconColor: "text-emerald-300",
},
"edit-image": {
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/20 border-purple-400/30",
iconColor: "text-purple-300",
},
"remove-background": {
accent: "from-violet-500/20 to-violet-500/0",
iconBg: "bg-violet-500/20 border-violet-400/30",
iconColor: "text-violet-300",
},
"video-frame-extractor": {
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/20 border-rose-400/30",
iconColor: "text-rose-300",
},
"background-change": {
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/20 border-orange-400/30",
iconColor: "text-orange-300",
},
"video-editor": {
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/20 border-teal-400/30",
iconColor: "text-teal-300",
},
"video-watermark-removal": {
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/20 border-cyan-400/30",
iconColor: "text-cyan-300",
},
"image-watermark-removal": {
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/20 border-indigo-400/30",
iconColor: "text-indigo-300",
},
};
const FALLBACK_APP_CARD_PALETTE: AppCardPalette = {
accent: "from-white/10 to-white/0",
iconBg: "bg-ui-controls border-ui-controls-border",
iconColor: "text-base-fg",
};
export const getAppCardPalette = (id: string): AppCardPalette =>
APP_CARD_PALETTES[id] ?? FALLBACK_APP_CARD_PALETTE;
export const ALL_APPS: FullAppItem[] = [
{
id: "text-to-image",
label: "Text to Image",
label: "Create Image",
description: "Generate AI images",
icon: faImage,
category: "generate",
@@ -97,7 +199,7 @@ export const ALL_APPS: FullAppItem[] = [
},
{
id: "image-to-video",
label: "Generate Video",
label: "Create Video",
description: "Create video from images",
icon: faFilm,
category: "generate",
@@ -213,7 +315,7 @@ export const ALL_APPS: FullAppItem[] = [
label: "Image Editor",
description: "Easy edits. Great for graphic design.",
icon: faPenNib,
category: "generate",
category: "edit",
action: "2D",
color: "bg-sky-500/40",
},
@@ -11,7 +11,11 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { Button, GenerateButton } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { MediaUploadApi, MediaFilesApi, downloadFileFromUrl } from "@storyteller/api";
import {
MediaUploadApi,
MediaFilesApi,
downloadFileFromUrl,
} from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
@@ -43,7 +47,11 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
import {
HistoryStack,
type ImageBundle,
type BaseSelectorImage,
} from "@storyteller/ui-pagedraw";
const ANGLES_MODEL_PAGE = ModelPage.Angles;
@@ -76,7 +84,9 @@ export const Angles = () => {
// State selectors (only re-render when specific values change)
const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
const sourceThumbnailUrlTemplate = useAnglesStore((s) => s.sourceThumbnailUrlTemplate);
const sourceThumbnailUrlTemplate = useAnglesStore(
(s) => s.sourceThumbnailUrlTemplate,
);
const imageDimensions = useAnglesStore((s) => s.imageDimensions);
const angleConfig = useAnglesStore((s) => s.angleConfig);
// const generateFromBestAngles = useAnglesStore(
@@ -194,11 +204,23 @@ export const Angles = () => {
let thumbnailUrlTemplate: string | undefined;
try {
const api = new MediaFilesApi();
const mediaFile = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
const mediaFile = await api.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (mediaFile.success && mediaFile.data) {
const links = mediaFile.data.media_links as { cdn_url?: string; thumbnail_template?: string; maybe_thumbnail_template?: string } | undefined;
cdnUrl = links?.cdn_url || (mediaFile.data as any).public_bucket_url || objectUrl;
thumbnailUrlTemplate = links?.thumbnail_template || links?.maybe_thumbnail_template;
const links = mediaFile.data.media_links as
| {
cdn_url?: string;
thumbnail_template?: string;
maybe_thumbnail_template?: string;
}
| undefined;
cdnUrl =
links?.cdn_url ||
(mediaFile.data as any).public_bucket_url ||
objectUrl;
thumbnailUrlTemplate =
links?.thumbnail_template || links?.maybe_thumbnail_template;
}
} catch {
// Fall back to blob URL if fetching CDN info fails
@@ -288,7 +310,6 @@ export const Angles = () => {
adjust_zoom: state.angleConfig.zoom,
frontend_subscriber_id: subscriberId,
});
} catch (error) {
console.error("Error generating angle:", error);
toast.error("Failed to generate angle");
@@ -435,7 +456,12 @@ export const Angles = () => {
],
};
return [sourceBundle, ...historyBundles];
}, [sourceImageUrl, sourceMediaToken, sourceThumbnailUrlTemplate, historyBundles]);
}, [
sourceImageUrl,
sourceMediaToken,
sourceThumbnailUrlTemplate,
historyBundles,
]);
const handleHistoryImageSelect = useCallback(
(image: BaseSelectorImage) => {
@@ -705,6 +731,11 @@ export const Angles = () => {
</div>
</Switch.Group> */}
<ClassyModelSelector
variant="embedded"
items={ANGLES_PAGE_MODEL_LIST}
page={ANGLES_MODEL_PAGE}
/>
<GenerateButton
variant="primary"
onClick={handleGenerate}
@@ -718,20 +749,8 @@ export const Angles = () => {
</div>
</div>
{/* ── Model selector (bottom-left) ── */}
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={ANGLES_PAGE_MODEL_LIST}
page={ANGLES_MODEL_PAGE}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
showIconsInList
/>
</div>
{/* ── Cost calculator + Help (bottom-right) ── */}
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ANGLES_MODEL_PAGE} />
<HelpMenuButton />
</div>
@@ -1,81 +1,45 @@
import { faGrid2 } from "@fortawesome/pro-solid-svg-icons";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
useGenerateApps,
useEditApps,
getAppCardPalette,
getBadgeStyles,
goToApp,
type FullAppItem,
} from "~/config/appMenu";
export const AppsIndexPage = () => {
const generateApps = useGenerateApps();
const editApps = useEditApps();
const categories = [
{ title: "Generate", apps: generateApps },
{ title: "Create", apps: generateApps },
{ title: "Edit", apps: editApps },
];
return (
<div className="fixed inset-0 overflow-hidden bg-ui-background pt-[56px] text-base-fg">
<main className="mx-auto h-full max-w-6xl overflow-y-auto px-5 pb-12 pt-16">
<div className="flex w-full flex-col items-start gap-2 pb-4">
<div className="mb-6 flex items-center gap-4">
<FontAwesomeIcon icon={faGrid2} className="text-2xl opacity-70" />
<h1 className="text-4xl font-bold">
Explore <span className="text-primary-400">ArtCraft Apps</span>
</h1>
</div>
<hr className="mb-3.5 w-full border-ui-panel-border" />
</div>
<div className="grid grid-cols-2 gap-8">
// The scroll container spans the full window width so its scrollbar sits
// flush against the right edge; horizontal padding lives on the inner
// wrapper instead.
<div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg">
<main className="mx-auto max-w-6xl px-6 pb-20 pt-12 sm:pt-20">
<h1 className="mx-auto text-center text-4xl font-semibold tracking-tight sm:text-6xl">
What will you <span className="text-primary">craft</span> today?
</h1>
<p className="mx-auto mt-3 max-w-xl text-center text-lg text-base-fg/55">
Every tool in your toolbox - pick one and start creating.
</p>
<div className="mt-12 space-y-10">
{categories.map((category) => (
<section key={category.title}>
<h2 className="mb-3 text-sm font-semibold opacity-50">
<h2 className="mb-4 text-sm font-semibold text-base-fg/85">
{category.title}
</h2>
<div className="space-y-1">
<div className="grid auto-rows-min gap-3 sm:grid-cols-2 lg:grid-cols-3">
{category.apps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-4 rounded-lg px-3 py-3 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-ui-controls/60"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md transition-colors",
app.color || "bg-ui-controls",
)}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-[15px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="text-[13px] opacity-60">
{app.description}
</div>
</div>
</button>
<AppCard key={app.id} app={app} />
))}
</div>
</section>
@@ -85,3 +49,72 @@ export const AppsIndexPage = () => {
</div>
);
};
function AppCard({ app }: { app: FullAppItem }) {
const palette = getAppCardPalette(app.id);
const enabled = !!app.action;
return (
<button
onClick={() => goToApp(app.action)}
disabled={!enabled}
className={twMerge(
"group relative block overflow-hidden rounded-2xl bg-ui-controls/50 p-5 text-left transition-colors",
enabled
? "cursor-pointer hover:bg-ui-controls"
: "cursor-default opacity-60",
)}
>
{/* Hover accent — gradient wash matching the app's hue. */}
{enabled && (
<div
aria-hidden
className={twMerge(
"pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
palette.accent,
)}
/>
)}
<div className="relative flex items-start gap-4">
<div
className={twMerge(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border",
palette.iconBg,
palette.iconColor,
)}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-base font-semibold text-base-fg">
{app.label}
</h3>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
{enabled && (
<FontAwesomeIcon
icon={faArrowRight}
className="text-sm text-base-fg/40 transition-all group-hover:translate-x-0.5 group-hover:text-base-fg/70"
/>
)}
</div>
<p className="mt-1 text-sm leading-snug text-base-fg/55">
{app.description}
</p>
</div>
</div>
</button>
);
}
@@ -1,37 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
JobContextType,
getThumbnailUrl,
THUMBNAIL_SIZES,
} from "@storyteller/common";
import { JobContextType } from "@storyteller/common";
import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { UploadImageMedia } from "@storyteller/api";
import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import {
TEXT_TO_IMAGE_PAGE_MODEL_LIST,
ModelPage,
ClassyModelSelector,
useSelectedImageModel,
useSelectedProviderForModel,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { ImageModel, getCreatorIcon } from "@storyteller/model-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faCircleExclamation,
faCopy,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
interface TextToImageProps {
imageMediaId?: string;
imageUrl?: string;
}
//import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
import { ImageModel } from "@storyteller/model-list";
import { useTextToImageStore } from "./TextToImageStore";
import { animated, useSpring } from "@react-spring/web";
import {
galleryModalLightboxImage,
galleryModalLightboxMediaId,
@@ -39,28 +19,32 @@ import {
galleryModalLightboxNavPrev,
galleryModalLightboxNavNext,
} from "@storyteller/ui-gallery-modal";
import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import Button from "node_modules/@storyteller/ui-button/src/lib/button";
import {
useGalleryData,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsername";
import { DesktopCreatePageShell } from "~/components/generation-feed/DesktopCreatePageShell";
import { DesktopGenerationGallery } from "~/components/generation-feed/DesktopGenerationGallery";
const PAGE_ID: ModelPage = ModelPage.TextToImage;
const IMAGE_FILTER = [FilterMediaClasses.IMAGE];
interface TextToImageProps {
imageMediaId?: string;
imageUrl?: string;
}
const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const batches = useTextToImageStore((s) => s.batches);
const startBatch = useTextToImageStore((s) => s.startBatch);
//const completeBatch = useTextToImageStore((s) => s.completeBatch);
const failBatch = useTextToImageStore((s) => s.failBatch);
const dismissBatch = useTextToImageStore((s) => s.dismissBatch);
const resetBatches = useTextToImageStore((s) => s.reset);
const [imageRowVisible, setImageRowVisible] = useState(false);
const [copiedBatchId, setCopiedBatchId] = useState<string | null>(null);
const promptContentRef = useRef<HTMLDivElement>(null);
const [promptHeight, setPromptHeight] = useState<number>(138);
@@ -76,23 +60,12 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => { },
removeJobToken: () => { },
clearJobTokens: () => { },
addJobToken: () => {},
removeJobToken: () => {},
clearJobTokens: () => {},
};
const hasAnyBatches = batches.length > 0;
const showPromptAtBottom = useMemo(() => hasAnyBatches, [hasAnyBatches]);
const [vh, setVh] = useState<number>(
typeof window !== "undefined" ? window.innerHeight : 800,
);
useEffect(() => {
const onResize = () => setVh(window.innerHeight);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Track the promptbox height so the feed can pad past it.
useEffect(() => {
const el = promptContentRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
@@ -103,323 +76,145 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
return () => ro.disconnect();
}, []);
const bottomMarginPx = 24;
const bottomOffsetPx = promptHeight + bottomMarginPx;
const targetTop = showPromptAtBottom
? Math.max(0, vh - bottomOffsetPx)
: Math.floor(vh / 2);
const promptAnim = useSpring({
top: targetTop,
config: { tension: 200, friction: 28, mass: 1.1 },
// The merged generation feed: in-progress / failed from the Tauri task
// queue, completed history from the library (like the webapp list view).
const username = useDesktopUsername();
const feed = useDesktopGenerationFeed({ mediaType: "image" });
const gallery = useGalleryData({
username,
filterMediaClasses: IMAGE_FILTER,
excludeUploads: true,
});
// Listen for generation-failed-event and mark the oldest pending image batch as failed
useEffect(() => {
let unlisten: Promise<UnlistenFn> | null = null;
unlisten = listen<{ data: { action: string; reason?: string } }>(
"generation-failed-event",
(event) => {
const { action, reason } = event.payload.data;
if (action === "generate_image") {
failBatch(reason || "Generation failed");
}
},
);
return () => {
if (unlisten) unlisten.then((f) => f());
};
}, [failBatch]);
// Content only — while the gallery is still loading the shell keeps the
// hero + background up as a splash and fades them out when items land.
const hasContent =
feed.inProgress.length > 0 ||
feed.failed.length > 0 ||
feed.newlyCompleted.length > 0 ||
gallery.items.length > 0;
// Show the batches in reverse order, with the newest items at top.
// Like Midjourney instead of a "chat history" style.
const inverseBatch = [...batches].reverse();
// Keep a ref so nav callbacks always see the latest batch list without
// needing to be recreated on every render.
const inverseBatchRef = useRef(inverseBatch);
inverseBatchRef.current = inverseBatch;
const openBatchInLightbox = useCallback(
(targetBatch: (typeof inverseBatch)[0], startIndex = 0) => {
const images = targetBatch.images;
if (!images.length) return;
const imageUrls = images.map(
(img) =>
getThumbnailUrl(img.maybe_thumbnail_template, { width: 3200 }) ??
img.cdn_url,
);
const mediaTokens = images.map((img) => img.media_token);
const actionUrls = images.map((img) => img.cdn_url);
const clickedImg = images[startIndex];
const batchIndex = inverseBatchRef.current.findIndex(
(b) => b.id === targetBatch.id,
);
galleryModalLightboxNavPrev.value =
batchIndex > 0
? () => {
const prevBatch = inverseBatchRef.current[batchIndex - 1];
openBatchInLightbox(
prevBatch,
Math.max(0, prevBatch.images.length - 1),
);
}
: null;
galleryModalLightboxNavNext.value =
batchIndex < inverseBatchRef.current.length - 1
? () => openBatchInLightbox(inverseBatchRef.current[batchIndex + 1])
: null;
galleryModalLightboxMediaId.value = clickedImg.media_token;
galleryModalLightboxImage.value = {
id: clickedImg.media_token,
label: targetBatch.prompt || "Generated Image",
thumbnail:
getThumbnailUrl(clickedImg.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}) ?? clickedImg.cdn_url,
fullImage:
getThumbnailUrl(clickedImg.maybe_thumbnail_template, {
width: 2048,
}) ?? clickedImg.cdn_url,
createdAt: new Date(targetBatch.createdAt).toISOString(),
mediaClass: "image" as const,
mediaTokens,
imageUrls,
actionUrls,
initialIndex: startIndex,
thumbnailUrlTemplate: clickedImg.maybe_thumbnail_template,
};
galleryModalLightboxVisible.value = true;
},
[],
const newlyCompletedTokens = useMemo(
() => new Set(feed.newlyCompleted.map((item) => item.id)),
[feed.newlyCompleted],
);
// Flat, time-sorted completed list driving lightbox prev/next navigation.
const flatCompleted = useMemo(() => {
const seen = new Set<string>();
const merged: GalleryItem[] = [];
for (const item of feed.newlyCompleted) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
for (const item of gallery.items) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
merged.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return merged;
}, [feed.newlyCompleted, gallery.items]);
const flatCompletedRef = useRef(flatCompleted);
flatCompletedRef.current = flatCompleted;
// Open a completed row in the global lightbox (rendered by TopBar's
// gallery modal); prev/next walk the merged feed order.
const openInLightbox = useCallback((item: GalleryItem) => {
const list = flatCompletedRef.current;
const index = list.findIndex((i) => i.id === item.id);
galleryModalLightboxNavPrev.value =
index > 0 ? () => openInLightbox(list[index - 1]) : null;
galleryModalLightboxNavNext.value =
index >= 0 && index < list.length - 1
? () => openInLightbox(list[index + 1])
: null;
galleryModalLightboxMediaId.value = item.id;
galleryModalLightboxImage.value = {
id: item.id,
label: item.label,
thumbnail: item.thumbnail,
fullImage: item.fullImage,
createdAt: item.createdAt,
mediaClass: item.mediaClass,
};
galleryModalLightboxVisible.value = true;
}, []);
return (
<div
ref={containerRef}
className="flex h-[calc(100vh-56px)] w-full bg-ui-background"
>
<div className="relative h-full w-full p-16">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
{!showPromptAtBottom && (
<div
className={twMerge(
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
imageRowVisible && "mb-80",
)}
>
<h1 className="text-7xl font-bold text-base-fg">
Generate Image
</h1>
<span className="pt-2 text-xl text-base-fg opacity-80">
Add a prompt, then generate
</span>
</div>
)}
{hasAnyBatches && (
<div
className="h-full w-full overflow-y-auto"
style={{ paddingBottom: bottomOffsetPx + 24 }}
>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
{inverseBatch.map((batch) => (
<div
key={batch.id}
className="relative flex items-stretch gap-4"
>
<div className="grid flex-1 grid-cols-4 gap-4">
{batch.status === "failed" ? (
<>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-3 rounded-lg bg-red-500/10 text-red-400">
<FontAwesomeIcon
icon={faCircleExclamation}
size="2x"
/>
<span className="px-4 text-center text-sm font-medium">
{batch.failureReason || "Generation failed"}
</span>
<button
onClick={() => dismissBatch(batch.id)}
className="mt-1 flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70"
>
<FontAwesomeIcon icon={faXmark} />
Dismiss
</button>
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={`empty-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))}
</>
) : batch.status === "pending" &&
batch.images.length === 0 ? (
<>
{Array.from({
length: Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
),
}).map((_, i) => (
<div
key={`sk-${batch.id}-${i}`}
className="aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]"
>
<div className="h-full w-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent bg-[length:200%_100%]" />
</div>
))}
{Array.from({
length: Math.max(
0,
4 -
Math.max(
1,
Math.min(4, batch.requestedCount ?? 4),
),
),
}).map((_, i) => (
<div
key={`filler-sk-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))}
</>
) : (
<>
{batch.images.slice(0, 4).map((img) => (
<button
key={img.media_token}
onClick={() => {
const startIndex = batch.images.indexOf(img);
openBatchInLightbox(batch, startIndex);
}}
className="aspect-square w-full overflow-hidden rounded-lg transition-opacity duration-200 hover:cursor-pointer hover:opacity-75"
>
<img
src={
getThumbnailUrl(
img.maybe_thumbnail_template,
{ width: THUMBNAIL_SIZES.LARGE },
) ?? img.cdn_url
}
alt="Generated"
loading="lazy"
className="h-full w-full bg-black/10 object-cover"
/>
</button>
))}
{Array.from({
length: Math.max(
0,
4 - batch.images.slice(0, 4).length,
),
}).map((_, i) => (
<div
key={`filler-${batch.id}-${i}`}
className="aspect-square w-full rounded-lg bg-white/[0.02]"
/>
))}
</>
)}
</div>
<div
className="flex w-[320px] shrink-0"
aria-hidden="true"
/>
<div className="absolute bottom-0 right-0 top-0 flex w-[320px] flex-col">
<div className="glass min-h-0 overflow-y-auto rounded-xl px-4 py-3 text-left text-sm text-base-fg/90">
<div>{batch.prompt}</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
onClick={() => {
navigator.clipboard.writeText(batch.prompt || "");
setCopiedBatchId(batch.id);
setTimeout(() => setCopiedBatchId(null), 2000);
}}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-white/50 transition-colors bg-transparent hover:bg-white/10 hover:text-white/70"
>
<FontAwesomeIcon
icon={copiedBatchId === batch.id ? faCheck : faCopy}
className="h-3 w-3"
/>
{copiedBatchId === batch.id ? "Copied" : "Copy"}
</Button>
<Badge
label={batch.modelLabel}
icon={batch.modelCreator ? getCreatorIcon(batch.modelCreator, "h-3.5 w-3.5 icon-auto-contrast") : undefined}
className="px-2 py-1 text-xs opacity-70"
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
<animated.div
className="fixed left-1/2 z-20 -translate-x-1/2"
style={promptAnim}
>
{showPromptAtBottom && batches.length > 0 && (
<div
className={`absolute ${imageRowVisible ? "-top-[108px]" : "-top-9"} flex w-full justify-end`}
>
<button
onClick={() => resetBatches()}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
>
Clear session
</button>
</div>
)}
<div ref={promptContentRef}>
<PromptBoxImage
useJobContext={() => {
return jobContext;
}}
uploadImage={UploadImageMedia}
selectedModel={selectedImageModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onImageRowVisibilityChange={setImageRowVisible}
credits={imageCredits}
onEnqueuePressed={async (prompt, count, subscriberId) => {
const modelLabel = selectedImageModel?.fullName ?? "";
startBatch(prompt, count, modelLabel, subscriberId, selectedImageModel?.creator);
}}
/>
</div>
</animated.div>
{!showPromptAtBottom && <BackgroundGallery />}
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
page={PAGE_ID}
mode="hoverSelect"
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
<DesktopCreatePageShell
hasContent={hasContent}
emptyStateTitle="Create Image"
emptyStateSubtitle="Add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40}
listContent={
<DesktopGenerationGallery
inProgressJobs={feed.inProgress}
failedJobs={feed.failed}
onDismissFailed={feed.dismissFailed}
newlyCompletedItems={feed.newlyCompleted}
galleryItems={gallery.items}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={gallery.hasMore}
isLoading={gallery.isLoading}
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={openInLightbox}
enableMakeVideo
/>
}
promptBox={
<div className="fixed bottom-4 left-1/2 z-20 w-full max-w-5xl -translate-x-1/2 px-2 sm:px-4">
<div ref={promptContentRef}>
<PromptBoxImage
useJobContext={() => {
return jobContext;
}}
uploadImage={UploadImageMedia}
selectedModel={selectedImageModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
credits={imageCredits}
modelSelector={
<ClassyModelSelector
variant="embedded"
items={TEXT_TO_IMAGE_PAGE_MODEL_LIST}
page={PAGE_ID}
/>
}
onEnqueuePressed={async (prompt, count, subscriberId) => {
const modelLabel = selectedImageModel?.fullName ?? "";
startBatch(
prompt,
count,
modelLabel,
subscriberId,
selectedImageModel?.creator,
);
// Nudge the feed (and the TopBar task queue) to refetch so the
// pending row appears immediately.
window.dispatchEvent(new Event("task-queue-update"));
}}
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</div>
</div>
</div>
</div>
}
bottomRight={
<>
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</>
}
/>
);
};
@@ -1,7 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { JobContextType } from "@storyteller/common";
import { PromptBoxVideo, usePromptVideoStore, PromptBoxErrorBoundary } from "@storyteller/ui-promptbox";
import { UploadImageMedia, UploadVideoMedia, UploadAudioMedia } from "@storyteller/api";
import {
PromptBoxVideo,
PromptBoxErrorBoundary,
} from "@storyteller/ui-promptbox";
import {
UploadImageMedia,
UploadVideoMedia,
UploadAudioMedia,
FilterMediaClasses,
} from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import {
ClassyModelSelector,
@@ -9,42 +17,49 @@ import {
ModelPage,
useSelectedVideoModel,
useSelectedProviderForModel,
//ProviderSelector,
//PROVIDER_LOOKUP_BY_PAGE,
} from "@storyteller/ui-model-selector";
import { VideoModel } from "@storyteller/model-list";
import { animated, useSpring } from "@react-spring/web";
import { useImageToVideoStore } from "./ImageToVideoStore";
import {
useVideoGenerationCompleteEvent,
VideoGenerationCompleteEvent,
} from "@storyteller/tauri-events";
// import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import {
galleryModalLightboxImage,
galleryModalLightboxMediaId,
galleryModalLightboxVisible,
galleryModalLightboxNavPrev,
galleryModalLightboxNavNext,
} from "@storyteller/ui-gallery-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import {
useGalleryData,
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsername";
import { DesktopCreatePageShell } from "~/components/generation-feed/DesktopCreatePageShell";
import { DesktopGenerationGallery } from "~/components/generation-feed/DesktopGenerationGallery";
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
const VIDEO_FILTER = [FilterMediaClasses.VIDEO];
interface ImageToVideoProps {
imageMediaId?: string;
imageUrl?: string;
}
const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const batches = useImageToVideoStore((s) => s.batches);
const startBatch = useImageToVideoStore((s) => s.startBatch);
const completeBatch = useImageToVideoStore((s) => s.completeBatch);
// const resetBatches = useImageToVideoStore((s) => s.reset);
const [imageRowVisible, setImageRowVisible] = useState(true);
const inputMode = usePromptVideoStore((s) => s.inputMode);
const promptContentRef = useRef<HTMLDivElement>(null);
const [_promptHeight, setPromptHeight] = useState<number>(138);
const [promptHeight, setPromptHeight] = useState<number>(138);
const selectedVideoModel: VideoModel | undefined =
useSelectedVideoModel(PAGE_ID);
@@ -58,11 +73,13 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
const jobContext: JobContextType = {
jobTokens: [],
addJobToken: () => { },
removeJobToken: () => { },
clearJobTokens: () => { },
addJobToken: () => {},
removeJobToken: () => {},
clearJobTokens: () => {},
};
// Keep the local batch store in sync (other listeners may rely on it); the
// visible feed below is driven by the task queue + library instead.
useVideoGenerationCompleteEvent(
async (event: VideoGenerationCompleteEvent) => {
if (!event.generated_video) return;
@@ -76,18 +93,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
},
);
const hasAnyBatches = batches.length > 0;
const showPromptAtBottom = useMemo(() => hasAnyBatches, [hasAnyBatches]);
const [vh, setVh] = useState<number>(
typeof window !== "undefined" ? window.innerHeight : 800,
);
useEffect(() => {
const onResize = () => setVh(window.innerHeight);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Track the promptbox height so the feed can pad past it.
useEffect(() => {
const el = promptContentRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
@@ -98,153 +104,142 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
return () => ro.disconnect();
}, []);
// TODO: Uncomment when backend supports history frontend id
// const bottomMarginPx = 24;
// const bottomOffsetPx = promptHeight + bottomMarginPx;
const targetTop = showPromptAtBottom
? Math.floor(vh / 2) //change to Math.max(0, vh - bottomOffsetPx) when backend supports history frontend id
: Math.floor(vh / 2);
const promptAnim = useSpring({
top: targetTop,
config: { tension: 200, friction: 28, mass: 1.1 },
// The merged generation feed: in-progress / failed from the Tauri task
// queue, completed history from the library (like the webapp list view).
const username = useDesktopUsername();
const feed = useDesktopGenerationFeed({ mediaType: "video" });
const gallery = useGalleryData({
username,
filterMediaClasses: VIDEO_FILTER,
excludeUploads: true,
});
// const inverseBatch = [...batches].reverse();
// Content only — while the gallery is still loading the shell keeps the
// hero + background up as a splash and fades them out when items land.
const hasContent =
feed.inProgress.length > 0 ||
feed.failed.length > 0 ||
feed.newlyCompleted.length > 0 ||
gallery.items.length > 0;
const newlyCompletedTokens = useMemo(
() => new Set(feed.newlyCompleted.map((item) => item.id)),
[feed.newlyCompleted],
);
// Flat, time-sorted completed list driving lightbox prev/next navigation.
const flatCompleted = useMemo(() => {
const seen = new Set<string>();
const merged: GalleryItem[] = [];
for (const item of feed.newlyCompleted) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
for (const item of gallery.items) {
if (!seen.has(item.id)) {
seen.add(item.id);
merged.push(item);
}
}
merged.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return merged;
}, [feed.newlyCompleted, gallery.items]);
const flatCompletedRef = useRef(flatCompleted);
flatCompletedRef.current = flatCompleted;
// Open a completed row in the global lightbox (rendered by TopBar's
// gallery modal); prev/next walk the merged feed order.
const openInLightbox = useCallback((item: GalleryItem) => {
const list = flatCompletedRef.current;
const index = list.findIndex((i) => i.id === item.id);
galleryModalLightboxNavPrev.value =
index > 0 ? () => openInLightbox(list[index - 1]) : null;
galleryModalLightboxNavNext.value =
index >= 0 && index < list.length - 1
? () => openInLightbox(list[index + 1])
: null;
galleryModalLightboxMediaId.value = item.id;
galleryModalLightboxImage.value = {
id: item.id,
label: item.label,
thumbnail: item.thumbnail,
fullImage: item.fullImage,
createdAt: item.createdAt,
mediaClass: item.mediaClass,
};
galleryModalLightboxVisible.value = true;
}, []);
return (
<div
ref={containerRef}
className="flex h-[calc(100vh-56px)] w-full bg-ui-background"
>
<div className="relative h-full w-full p-16">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md pb-12">
{/* TODO: Uncomment when backend supports history frontend id */}
{/* {!showPromptAtBottom && ( */}
<div
className={twMerge(
"relative z-20 mb-52 flex flex-col items-center justify-center text-center drop-shadow-xl",
imageRowVisible &&
(inputMode === "reference" &&
selectedVideoModel?.supportsReferenceMode
? "mb-[30rem]"
: "mb-80"),
)}
>
<h1 className="text-7xl font-bold text-base-fg">Generate Video</h1>
<span className="pt-2 text-xl text-base-fg opacity-80">
Choose an image, add a prompt, then generate
</span>
</div>
{/* )} */}
{/* TODO: Uncomment when backend supports history frontend id */}
{/* {hasAnyBatches && (
<div
className="h-full w-full overflow-y-auto"
style={{ paddingBottom: bottomOffsetPx + 24 }}
>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 pr-2">
{inverseBatch.map((batch) => (
<div key={batch.id} className="flex items-start gap-4">
<div className="grid flex-1 grid-cols-1 gap-4">
{batch.status === "pending" && !batch.video ? (
<div className="aspect-video w-full animate-pulse rounded-lg bg-white/5" />
) : batch.video ? (
<button
className="aspect-video w-full overflow-hidden rounded-lg"
onClick={() => {
// No-op for now (could open lightbox if desired)
}}
>
<video
className="h-full w-full object-cover"
src={batch.video.cdn_url}
controls
/>
</button>
) : null}
</div>
<div>
<div className="glass inline-block w-[320px] shrink-0 rounded-xl px-4 py-3 text-left text-sm text-white/90">
<div>{batch.prompt}</div>
</div>
<div className="mt-2 flex justify-end">
<Badge
label={batch.modelLabel}
className="px-2 py-1 text-xs opacity-70"
/>
</div>
</div>
</div>
))}
</div>
</div>
)} */}
<animated.div
className="fixed left-1/2 z-20 w-[860px] -translate-x-1/2"
style={promptAnim}
>
{/* TODO: Uncomment when backend supports history frontend id */}
{/* {showPromptAtBottom && batches.length > 0 && (
<div
className={`absolute ${imageRowVisible ? "-top-[108px]" : "-top-9"} flex w-full justify-end`}
>
<button
onClick={() => resetBatches()}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
>
Clear session
</button>
</div>
)} */}
<div ref={promptContentRef}>
<PromptBoxErrorBoundary>
<PromptBoxVideo
useJobContext={() => jobContext}
selectedModel={selectedVideoModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
onImageRowVisibilityChange={setImageRowVisible}
uploadImage={UploadImageMedia}
uploadVideo={UploadVideoMedia}
uploadAudio={UploadAudioMedia}
credits={videoCredits}
onEnqueuePressed={async (prompt, subscriberIds) => {
const modelLabel = selectedVideoModel?.fullName ?? "";
for (const subscriberId of subscriberIds) {
startBatch(prompt, modelLabel, subscriberId);
}
}}
/>
</PromptBoxErrorBoundary>
</div>
</animated.div>
{/* TODO: Uncomment when backend supports history frontend id */}
{/* {!showPromptAtBottom && */}
<BackgroundGallery />
{/* } */}
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
<ClassyModelSelector
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
<DesktopCreatePageShell
hasContent={hasContent}
emptyStateTitle="Create Video"
emptyStateSubtitle="Choose an image, add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40}
listContent={
<DesktopGenerationGallery
inProgressJobs={feed.inProgress}
failedJobs={feed.failed}
onDismissFailed={feed.dismissFailed}
newlyCompletedItems={feed.newlyCompleted}
galleryItems={gallery.items}
newlyCompletedTokens={newlyCompletedTokens}
hasMore={gallery.hasMore}
isLoading={gallery.isLoading}
isInitialLoading={gallery.isInitialLoading}
onLoadMore={gallery.loadMore}
onGalleryItemClick={openInLightbox}
/>
}
promptBox={
<div className="fixed bottom-4 left-1/2 z-20 w-full max-w-5xl -translate-x-1/2 px-2 sm:px-4">
<div ref={promptContentRef}>
<PromptBoxErrorBoundary>
<PromptBoxVideo
useJobContext={() => jobContext}
selectedModel={selectedVideoModel}
selectedProvider={selectedProvider}
imageMediaId={imageMediaId}
url={imageUrl ?? undefined}
uploadImage={UploadImageMedia}
uploadVideo={UploadVideoMedia}
uploadAudio={UploadAudioMedia}
credits={videoCredits}
modelSelector={
<ClassyModelSelector
variant="embedded"
items={IMAGE_TO_VIDEO_PAGE_MODEL_LIST}
page={PAGE_ID}
/>
}
onEnqueuePressed={async (prompt, subscriberIds) => {
const modelLabel = selectedVideoModel?.fullName ?? "";
for (const subscriberId of subscriberIds) {
startBatch(prompt, modelLabel, subscriberId);
}
// Nudge the feed (and the TopBar task queue) to refetch so
// the pending row appears immediately.
window.dispatchEvent(new Event("task-queue-update"));
}}
/>
</PromptBoxErrorBoundary>
</div>
</div>
</div>
</div>
}
bottomRight={
<>
<CostCalculatorButton modelPage={PAGE_ID} />
<HelpMenuButton />
</>
}
/>
);
};
@@ -19,7 +19,7 @@ export type TabId =
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR";
const DEFAULT_TAB: TabId = "IMAGE";
const DEFAULT_TAB: TabId = "APPS";
interface TabState {
// Current active tab
@@ -18,6 +18,20 @@
border: 2px solid #171717;
}
/* Light theme: the scrollbar track/thumb must be light, not the dark defaults. */
.theme-light {
scrollbar-color: #c4c4c8 #ececef;
}
.theme-light *::-webkit-scrollbar-track {
background: #ececef;
}
.theme-light *::-webkit-scrollbar-thumb {
background-color: #c4c4c8;
border: 2px solid #ececef;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
@@ -324,6 +338,19 @@ img {
background-color: rgba(255, 255, 255, 0.35);
}
/* Light theme: white thumb is invisible on the light glass — use a dark tint. */
.theme-light .promptbox-scrollbar {
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
}
.theme-light .promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25);
}
.theme-light .promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.4);
}
.promptbox-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
@@ -647,3 +674,26 @@ img {
transform: translateX(-100%);
}
}
/* Shimmer skeleton used by the shared generation-list rows (matches the
webapp's .animate-shimmer — gradient sweep over a translucent base). */
@keyframes generation-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: generation-shimmer 1.5s ease-in-out infinite;
}
@@ -17,6 +17,13 @@
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
@apply bg-ui-controls/50 !important;
@apply bg-[#303032]/90 !important;
}
/* Light theme: the glass surface must be a light panel, not the dark
hard-coded fill above. Tint + border swapped to read on a light bg. */
.theme-light .glass {
@apply border-black/10 !important;
background-color: rgb(248 248 248 / 0.92) !important;
}
}
+16 -16
View File
@@ -29,7 +29,7 @@
},
"references": [
{
"path": "../../libs/components/badge"
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/vfx"
@@ -40,12 +40,6 @@
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/components/login-modal"
},
@@ -58,15 +52,9 @@
{
"path": "../../libs/components/settings-modal"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/menu-icon-selector"
},
{
"path": "../../libs/tauri-events"
},
{
"path": "../../libs/state/subscription"
},
@@ -82,9 +70,6 @@
{
"path": "../../libs/state/credits"
},
{
"path": "../../libs/common"
},
{
"path": "../../libs/api-enums"
},
@@ -118,6 +103,21 @@
{
"path": "../../libs/components/checkbox"
},
{
"path": "../../libs/common"
},
{
"path": "../../libs/tauri-events"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/generation-list"
},
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/components/help-menu"
},
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,7 +16,7 @@
}
},
"dependencies": {
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-upload": "2.2.1"
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-upload": "2.4.0"
}
}
+1
View File
@@ -17,6 +17,7 @@ export * from "./lib/PromptsApi.js";
export * from "./lib/TtsApi.js";
export * from "./lib/UserBookmarksApi.js";
export * from "./lib/PasswordResetApi.js";
export * from "./lib/UserApiKeysApi.js";
export * from "./lib/UserReferralCodesApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/VideoApi.js";
+197
View File
@@ -0,0 +1,197 @@
import { ApiManager, ApiResponse, buildSessionHeaders } from "./ApiManager.js";
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
// Canonical wire shape for an API key (list rows + single-key GET). Mirrors the
// backend `ApiKeyInfo`. The full secret is NEVER here — only
// `truncated_api_key` (first 20 chars). The full value is returned exactly once
// at creation time (see CreatedApiKey).
export interface ApiKeyInfo {
token: string;
truncated_api_key: string;
name: string;
maybe_description: string | null;
owner_user_token: string;
ip_address_creation: string;
ip_address_update: string;
created_at: string;
updated_at: string;
// Soft-delete timestamp; null means live. The list includes deleted keys, so
// the UI filters these out.
maybe_deleted_at: string | null;
}
// Create response: the `api_key_token` (used for all subsequent management) plus
// the full secret `api_key`, returned exactly once.
export interface CreatedApiKey {
api_key_token: string;
api_key: string;
}
interface ErrorBody {
success: boolean;
error_code?: number;
error_code_str?: string;
message?: string;
}
export class UserApiKeysApi extends ApiManager {
public ListApiKeys({
limit,
offset,
}: {
limit?: number;
offset?: number;
} = {}): Promise<ApiResponse<{ api_keys: ApiKeyInfo[] }>> {
const query = new URLSearchParams();
if (limit !== undefined) query.set("limit", String(limit));
if (offset !== undefined) query.set("offset", String(offset));
const suffix = query.toString() ? `?${query.toString()}` : "";
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/list${suffix}`;
return this.jsonFetch<{
success: boolean;
api_keys?: ApiKeyInfo[];
} & ErrorBody>(endpoint, { method: "GET" })
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return {
success: true,
data: { api_keys: response.api_keys ?? [] },
};
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Single-key lookup. Not currently used by the settings UI (the list already
// carries everything), but provided for parity with the backend.
public GetApiKey({
token,
}: {
token: string;
}): Promise<ApiResponse<ApiKeyInfo>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<
{ success: boolean; api_key?: ApiKeyInfo } & ErrorBody
>(endpoint, { method: "GET" })
.then((response) => {
if (!response.success || !response.api_key) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true, data: response.api_key };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public CreateApiKey({
name,
maybeDescription,
}: {
name: string;
maybeDescription?: string;
}): Promise<ApiResponse<CreatedApiKey>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/create`;
return this.jsonFetch<
{
success: boolean;
api_key_token?: string;
api_key?: string;
} & ErrorBody
>(endpoint, {
method: "POST",
body: { name, maybe_description: maybeDescription ?? null },
})
.then((response) => {
if (!response.success || !response.api_key_token || !response.api_key) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return {
success: true,
data: {
api_key_token: response.api_key_token,
api_key: response.api_key,
},
};
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public UpdateApiKey({
token,
maybeDescription,
}: {
token: string;
maybeDescription: string | null;
}): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
method: "PUT",
body: { maybe_description: maybeDescription },
})
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public DeleteApiKey({ token }: { token: string }): Promise<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/api_keys/${encodeURIComponent(token)}`;
return this.jsonFetch<{ success: boolean } & ErrorBody>(endpoint, {
method: "DELETE",
})
.then((response) => {
if (!response.success) {
return {
success: false,
errorMessage: response.message ?? this.statusFallback(response),
};
}
return { success: true };
})
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Parses JSON for both 2xx and 4xx so `BadInputWithSimpleMessage` text
// reaches the caller. The base ApiManager.fetch throws on non-2xx.
private async jsonFetch<T>(
endpoint: string,
{ method, body }: { method: string; body?: unknown },
): Promise<T> {
const response = await fetch(endpoint, {
method,
headers: buildSessionHeaders({
Accept: "application/json",
"Content-Type": "application/json",
}),
credentials: "include",
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
throw new Error(text || `Request failed with status ${response.status}`);
}
}
private statusFallback(response: ErrorBody): string {
if (response.error_code_str) return response.error_code_str;
if (response.error_code) return `Request failed (${response.error_code})`;
return "Request failed";
}
}
@@ -1,4 +1,5 @@
export enum USER_FEATURE_FLAGS {
API_KEY = "api_key",
EXPLORE_MEDIA = "explore_media",
REFERRALS = "referrals",
STUDIO = "studio",
@@ -52,7 +52,7 @@ import {
galleryModalLightboxNavPrev,
galleryModalLightboxNavNext,
} from "./galleryModalSignals";
import { FOLDER_DROP_EVENT } from "./galleryDnd";
import galleryDnd, { FOLDER_DROP_EVENT } from "./galleryDnd";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBorderAll,
@@ -496,6 +496,11 @@ export const GalleryModal = React.memo(
const lightboxVisibleSignal = galleryModalLightboxVisible;
const failedImageUrls = useRef<Set<string>>(new Set());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Marquee (drag-rectangle) selection — view mode only. The rectangle is
// positioned imperatively (no React state per frame) and tile rects are
// cached at drag start; see handleMarqueePointerDown below.
const marqueeRef = useRef<HTMLDivElement>(null);
const marqueeRaf = useRef(0);
const [username, setUsername] = useState<string>("");
const [usernameError, setUsernameError] = useState(false);
const [usernameRetryCount, setUsernameRetryCount] = useState(0);
@@ -608,6 +613,27 @@ export const GalleryModal = React.memo(
[folders, activeFolderId],
);
// Root-level folders for the filter sidebar — always roots, regardless of
// which folder is open in the browser tab (mirrors the webapp sidebar).
const rootFolders = useMemo(
() => folders.filter((f) => !f.parentId).sort(compareFolders),
[folders],
);
// Root ancestor of the open folder — drives the sidebar highlight (the
// sidebar lists only root folders), guarded against parent-chain cycles.
const activeRootFolderId = useMemo(() => {
if (galleryTab !== "folders" || !activeFolderId) return null;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cursor = byId.get(activeFolderId);
while (cursor && cursor.parentId && !seen.has(cursor.id)) {
seen.add(cursor.id);
cursor = byId.get(cursor.parentId);
}
return cursor?.id ?? null;
}, [galleryTab, folders, activeFolderId]);
// Breadcrumb trail from root → active folder (inclusive), guarded against
// cycles so a corrupted parent chain can never loop forever.
const folderPath = useMemo(() => {
@@ -1027,6 +1053,214 @@ export const GalleryModal = React.memo(
});
}, []);
// ── Marquee (drag) selection ──────────────────────────────────────────
// Dragging from blank background draws a selection rectangle; tiles it
// covers are added to the bulk selection (additive to whatever was
// selected on start). Ported from the webapp library page, scoped to the
// modal's scroll container. Perf-sensitive: the rectangle is positioned
// imperatively, tile rects are cached at drag start, and selection commits
// only when the covered set actually changes.
const handleMarqueePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || lightboxVisibleSignal.value) return;
// A per-tile drag (to a folder / the canvas) is already underway —
// never start a marquee on top of it.
if (galleryDnd.getDragState().isDragging) return;
const target = e.target as HTMLElement;
// Start only on blank background — never from tiles, folder cards,
// controls, or opted-out chrome.
if (
target.closest(
"[data-media-id], [data-folder-id], button, a, input, [data-no-marquee]",
)
) {
return;
}
const scroller = scrollContainerRef.current;
if (!scroller) return;
// Blank-background press: suppress the browser's native text-selection
// default so it can't hijack the marquee drag.
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
// No marquee on touch — a finger drag should scroll. A still tap on
// blank space still clears the selection below.
const isTouch = e.pointerType !== "mouse";
const base = new Set(bulkSelectedIds);
let applied = base;
let active = false;
const readScrollTop = () => scroller.scrollTop;
const readScrollLeft = () => scroller.scrollLeft;
const startScrollTop = readScrollTop();
const startScrollLeft = readScrollLeft();
let lastCx = startX;
let lastCy = startY;
let edgeRaf = 0;
let tiles: {
id: string;
left: number;
top: number;
right: number;
bottom: number;
}[] = [];
const cacheTiles = () => {
tiles = [];
scroller
.querySelectorAll<HTMLElement>("[data-media-id]")
.forEach((el) => {
const id = el.dataset.mediaId;
if (!id) return;
const r = el.getBoundingClientRect();
tiles.push({
id,
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
});
});
};
if (!isTouch) cacheTiles();
const applyMarquee = (cx: number, cy: number) => {
// Compensate the anchor for scrolling since drag start so the
// rectangle keeps growing in content space, not viewport space.
const ax = startX - (readScrollLeft() - startScrollLeft);
const ay = startY - (readScrollTop() - startScrollTop);
const left = Math.min(ax, cx);
const top = Math.min(ay, cy);
const width = Math.abs(cx - ax);
const height = Math.abs(cy - ay);
const right = left + width;
const bottom = top + height;
const box = marqueeRef.current;
if (box) {
// Clamp the *visible* rectangle to the scroll viewport so it never
// paints over the modal header / sidebar. Selection math below
// uses the unclamped rect, so tiles scrolled out of view stay
// selected.
const vr = scroller.getBoundingClientRect();
const dispLeft = Math.max(left, vr.left);
const dispRight = Math.min(right, vr.right);
const dispTop = Math.max(top, vr.top);
const dispBottom = Math.min(bottom, vr.bottom);
box.style.display = "block";
box.style.left = `${dispLeft}px`;
box.style.top = `${dispTop}px`;
box.style.width = `${Math.max(0, dispRight - dispLeft)}px`;
box.style.height = `${Math.max(0, dispBottom - dispTop)}px`;
}
const mounted = new Set(tiles.map((t) => t.id));
const next = new Set(base);
// Tiles swept over earlier may have unmounted (virtualized list)
// once scrolled far away — keep them selected.
for (const id of applied) {
if (!base.has(id) && !mounted.has(id)) next.add(id);
}
for (const t of tiles) {
if (
t.left < right &&
t.right > left &&
t.top < bottom &&
t.bottom > top
) {
next.add(t.id);
}
}
// Commit only when coverage changed — most frames it hasn't.
let changed = next.size !== applied.size;
if (!changed) {
for (const id of next) {
if (!applied.has(id)) {
changed = true;
break;
}
}
}
if (changed) {
applied = next;
setBulkSelectedIds(next);
}
};
// Auto-scroll while the cursor sits within EDGE px of the scroller's
// top or bottom; speed ramps up nearer the edge. Self-perpetuating
// via rAF until the cursor leaves the zone or the drag ends.
const EDGE = 64;
const MAX_SPEED = 24;
const autoScrollTick = () => {
edgeRaf = 0;
if (!active || isTouch) return;
const r = scroller.getBoundingClientRect();
let dy = 0;
if (lastCy < r.top + EDGE) {
dy = -Math.min(MAX_SPEED, Math.ceil((r.top + EDGE - lastCy) / 3));
} else if (lastCy > r.bottom - EDGE) {
dy = Math.min(MAX_SPEED, Math.ceil((lastCy - (r.bottom - EDGE)) / 3));
}
if (dy === 0) return;
scroller.scrollTop += dy;
cacheTiles();
applyMarquee(lastCx, lastCy);
edgeRaf = requestAnimationFrame(autoScrollTick);
};
const ensureAutoScroll = () => {
if (!edgeRaf) edgeRaf = requestAnimationFrame(autoScrollTick);
};
const handleMove = (ev: PointerEvent) => {
if (isTouch) return;
if (!active) {
// Small threshold so plain background clicks don't flash a marquee.
if (
Math.abs(ev.clientX - startX) < 5 &&
Math.abs(ev.clientY - startY) < 5
) {
return;
}
active = true;
document.body.style.userSelect = "none";
}
lastCx = ev.clientX;
lastCy = ev.clientY;
ensureAutoScroll();
cancelAnimationFrame(marqueeRaf.current);
marqueeRaf.current = requestAnimationFrame(() =>
applyMarquee(ev.clientX, ev.clientY),
);
};
const handleScroll = () => {
if (active && !isTouch) {
cacheTiles();
applyMarquee(lastCx, lastCy);
}
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("scroll", handleScroll, true);
cancelAnimationFrame(marqueeRaf.current);
cancelAnimationFrame(edgeRaf);
document.body.style.userSelect = "";
if (marqueeRef.current) marqueeRef.current.style.display = "none";
// Plain click on blank background (no marquee drawn) clears the
// selection, like clicking the desktop on an OS.
if (!active) {
setBulkSelectedIds(new Set());
}
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
window.addEventListener("scroll", handleScroll, true);
},
[bulkSelectedIds, lightboxVisibleSignal],
);
const handleItemClick = useCallback(
(item: GalleryItem) => {
if (mode === "select" && onSelectItem) {
@@ -1280,6 +1514,16 @@ export const GalleryModal = React.memo(
setContextMenu(null);
}, []);
// Open a folder from the filter sidebar: jump to the folder browser tab
// with that folder active.
const handleOpenFolderFromSidebar = useCallback(
(folderId: string) => {
setGalleryTab("folders");
handleOpenFolder(folderId);
},
[handleOpenFolder],
);
// Close any open folder menus
const closeFolderMenus = useCallback(() => {
setFolderMenuOpen(false);
@@ -2167,49 +2411,118 @@ export const GalleryModal = React.memo(
</div>
<div className="flex flex-1 overflow-hidden" data-gallery-modal>
{/* ── Filter sidebar ── (Unsorted tab / picker only; the Folders
tab is a full-width browser) */}
{!hideFilter && galleryTab === "unsorted" && (
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-background flex flex-col overflow-y-auto">
{/* Filter items — hidden when the gallery is used as a constrained picker */}
{!hideFilter && (
<>
<div className="flex flex-col px-1.5 pt-2 pb-1">
{SIDEBAR_FILTERS.map((f) => {
const isActive = activeFilter === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => {
if (!forceFilter) {
setActiveFilter(f.id);
setActiveFolderId(null);
}
}}
className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive && !activeFolderId
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
forceFilter &&
f.id !== activeFilter &&
"opacity-50 pointer-events-none",
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon
icon={f.icon}
className="text-xs w-4"
/>
<span>{f.label}</span>
</div>
</button>
);
})}
{/* ── Filter sidebar ── shown on both tabs; clicking a filter
jumps back to the library, clicking a folder into the browser */}
{!hideFilter && (
<div className="w-52 min-w-[13rem] border-r border-ui-panel-border bg-ui-panel flex flex-col overflow-hidden">
{/* Filter items — pinned; they never scroll out of view */}
<div className="flex flex-col px-1.5 pt-2 pb-1 flex-shrink-0">
{SIDEBAR_FILTERS.map((f) => {
const isActive =
galleryTab === "unsorted" && activeFilter === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => {
if (!forceFilter) {
setActiveFilter(f.id);
switchGalleryTab("unsorted");
}
}}
className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive && !activeFolderId
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
forceFilter &&
f.id !== activeFilter &&
"opacity-50 pointer-events-none",
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon
icon={f.icon}
className="text-xs w-4"
/>
<span>{f.label}</span>
</div>
</button>
);
})}
</div>
{/* Folders header — pinned alongside the filters */}
<div className="flex items-center justify-between px-3 pt-2 pb-1 flex-shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40">
Folders
</span>
{mode === "view" && (
<button
type="button"
onClick={() => handleOpenNewFolderModal(null)}
aria-label="New folder"
className="flex h-5 w-5 items-center justify-center rounded text-base-fg/50 hover:bg-ui-controls/60 hover:text-base-fg transition-colors"
>
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
</button>
)}
</div>
{/* Folder list — the only scrolling region; fills the rest of the column */}
<div className="flex flex-1 min-h-0 flex-col overflow-y-auto px-1.5 pb-2">
{rootFolders.length === 0 ? (
<div className="px-2.5 py-1.5 text-xs text-base-fg/30 italic">
No folders yet
</div>
</>
)}
) : (
rootFolders.map((folder) => (
<button
key={folder.id}
type="button"
data-folder-id={folder.id}
onClick={() => handleOpenFolderFromSidebar(folder.id)}
onContextMenu={
// Folder management is view-mode only; the select picker just browses.
mode === "view"
? (e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}
: undefined
}
className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-base-fg",
activeRootFolderId === folder.id
? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
)}
>
<FontAwesomeIcon
icon={faFolder}
className={twMerge(
"text-xs w-4",
!folder.colorCode && "text-primary",
)}
style={
folder.colorCode
? { color: folder.colorCode }
: undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</button>
))
)}
</div>
</div>
)}
@@ -2218,6 +2531,9 @@ export const GalleryModal = React.memo(
ref={scrollContainerRef}
className="flex-1 overflow-y-auto bg-ui-panel"
onScroll={handleScroll}
onPointerDown={
mode === "view" ? handleMarqueePointerDown : undefined
}
>
{/* Subfolder tiles for the current location (root or open folder) */}
{folderChipsSection}
@@ -2767,6 +3083,22 @@ export const GalleryModal = React.memo(
</>,
document.body,
)}
{/* Marquee selection rectangle — fixed-positioned, painted imperatively
by handleMarqueePointerDown. View mode only. Portaled to <body> so
its `position: fixed` resolves against the viewport (the draggable
modal applies a transform, which would otherwise make a fixed child
position relative to the modal — and the rectangle, set with
viewport clientX/Y, would render off-screen). */}
{mode === "view" &&
createPortal(
<div
ref={marqueeRef}
className="pointer-events-none fixed z-[9999] rounded-sm border border-primary/60 bg-primary/10"
style={{ display: "none" }}
/>,
document.body,
)}
</>
);
},
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-generation-list",
"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,19 @@
export * from "./lib/types";
export * from "./lib/pending-status";
export * from "./lib/format-time-ago";
export * from "./lib/prompts-cache";
export * from "./lib/media-prompt-token-cache";
export * from "./lib/gallery-view-store";
export * from "./lib/GalleryViewToggle";
export * from "./lib/useGalleryData";
export * from "./lib/useGalleryEntries";
export * from "./lib/GalleryThumbnail";
export * from "./lib/CopyPromptButton";
export * from "./lib/PendingRow";
export * from "./lib/FailedRow";
export * from "./lib/GalleryRow";
export * from "./lib/PendingCard";
export * from "./lib/FailedCard";
export * from "./lib/GalleryCard";
export * from "./lib/GenerationListView";
export * from "./lib/GenerationGridView";
@@ -2,12 +2,18 @@ import { useCallback, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faCopy } from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { toast } from "../toast/toast";
// Copies a prompt to the clipboard, with copy→check feedback. Sits at the
// right of a row's prompt line. Shared by the completed / pending / failed
// rows. stopPropagation keeps a tap from also triggering the row's onClick.
export function CopyPromptButton({ text }: { text: string }) {
// Hosts surface success/failure feedback (e.g. a toast) via `onCopyResult`.
export function CopyPromptButton({
text,
onCopyResult,
}: {
text: string;
onCopyResult?: (success: boolean) => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(
@@ -15,14 +21,14 @@ export function CopyPromptButton({ text }: { text: string }) {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
toast.success("Prompt copied");
onCopyResult?.(true);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Unable to copy prompt");
onCopyResult?.(false);
}
},
[text],
[text, onCopyResult],
);
return (
@@ -1,13 +1,7 @@
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRotateRight,
faCircleExclamation,
faSpinnerThird,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
export interface FailedCardProps {
id: string;
@@ -20,10 +14,8 @@ export interface FailedCardProps {
// at low opacity so failed cards still hint at what the user was trying to
// generate.
refImageUrl?: string;
// Prompt token + media class enable the "Recreate" action. Without a prompt
// token there's nothing to seed the create page with, so the button hides.
promptToken?: string;
recreateMediaClass: "image" | "video";
/** Inline action (e.g. a labeled Recreate button) next to Dismiss. */
recreateSlot?: ReactNode;
onDismiss: (id: string) => void;
}
@@ -35,15 +27,10 @@ export const FailedCard = memo(function FailedCard({
modelId,
modelLabel,
refImageUrl,
promptToken,
recreateMediaClass,
recreateSlot,
onDismiss,
}: FailedCardProps) {
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
@@ -69,20 +56,7 @@ export const FailedCard = memo(function FailedCard({
</span>
)}
<div className="mt-1 flex items-center gap-1.5">
{promptToken && (
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={isRecreating ? "animate-spin" : ""}
/>
Recreate
</button>
)}
{recreateSlot}
<button
type="button"
onClick={() => onDismiss(id)}
@@ -1,16 +1,25 @@
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRotateRight,
faCircleExclamation,
faSpinnerThird,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
import { CopyPromptButton } from "./CopyPromptButton";
import type { FailedCardProps } from "./FailedCard";
export interface FailedRowProps {
id: string;
failureReason?: string;
failureMessage?: string;
prompt: string;
modelId: string;
modelLabel: string;
// First user-supplied still image, if any. Rendered behind the error overlay
// at low opacity so failed rows still hint at what the user was trying to
// generate.
refImageUrl?: string;
/** Inline action (e.g. a Recreate button) after the failure reason. */
recreateSlot?: ReactNode;
onDismiss: (id: string) => void;
onCopyPromptResult?: (success: boolean) => void;
}
export const FailedRow = memo(function FailedRow({
id,
@@ -20,15 +29,11 @@ export const FailedRow = memo(function FailedRow({
modelId,
modelLabel,
refImageUrl,
promptToken,
recreateMediaClass,
recreateSlot,
onDismiss,
}: FailedCardProps) {
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
onCopyPromptResult,
}: FailedRowProps) {
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
return (
<div className="flex items-center gap-3 rounded-lg px-2.5 py-2">
@@ -54,23 +59,10 @@ export const FailedRow = memo(function FailedRow({
<p className="min-w-0 flex-1 truncate text-sm font-medium text-red-400">
{failureReason || "Generation failed"}
</p>
{prompt && <CopyPromptButton text={prompt} />}
{promptToken && (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
{prompt && (
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
)}
{recreateSlot}
</div>
{prompt && (
<p className="mt-0.5 truncate text-xs text-white/45">{prompt}</p>
@@ -1,17 +1,12 @@
import { memo, useCallback, useState } from "react";
import { memo, useCallback, useState, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
getCreatorIconPathForModelId,
getModelDisplayName,
} from "@storyteller/model-list";
import { GalleryThumbnail } from "./GalleryThumbnail";
import { useGalleryItemActions } from "./useGalleryItemActions";
import type { GalleryItem } from "./useGalleryData";
import type { GalleryItem } from "./types";
// ── Persistent aspect ratio cache ─────────────────────────────────────────
@@ -58,43 +53,36 @@ export const aspectRatioCache = loadCache();
// ── Component ──────────────────────────────────────────────────────────────
interface GalleryCardProps {
export interface GalleryCardProps {
item: GalleryItem;
onClick: (item: GalleryItem) => void;
// "auto" = dynamic aspect ratio from the loaded image (masonry layouts).
// "square" = fixed 1:1; skips the ratio measurement path (uniform grids).
shape?: "auto" | "square";
// When true, image cards show a "Make Video" quick action that seeds the
// create-video page with the image as a reference. Enabled on the create
// image page only.
enableMakeVideo?: boolean;
/** Hover-revealed quick-action cluster (recreate / share / download …). */
actionsSlot?: ReactNode;
}
export const GalleryCard = memo(function GalleryCard({
item,
onClick,
shape = "auto",
enableMakeVideo = false,
actionsSlot,
}: GalleryCardProps) {
const isSquare = shape === "square";
const cached = aspectRatioCache.get(item.id);
const [ratio, setRatio] = useState<number | undefined>(cached);
const {
mediaIcon,
mediaLabel,
modelDisplayName,
modelIconPath,
recreateMediaClass,
canMakeVideo,
isRecreating,
isDownloading,
shareCopied,
handleRecreate,
handleMakeVideo,
handleShare,
handleDownload,
} = useGalleryItemActions(item, { enableMakeVideo });
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
const modelDisplayName = item.modelId
? getModelDisplayName(item.modelId)
: null;
const modelIconPath = item.modelId
? getCreatorIconPathForModelId(item.modelId)
: null;
const displayRatio = ratio ? Math.min(ratio, MAX_RATIO) : 1;
@@ -151,7 +139,7 @@ export const GalleryCard = memo(function GalleryCard({
<GalleryThumbnail
thumbnail={item.thumbnail}
alt={item.label}
isVideo={item.mediaClass === "video"}
isVideo={isVideo}
fallbackIcon={mediaIcon}
onLoad={measureRatio}
/>
@@ -176,65 +164,11 @@ export const GalleryCard = memo(function GalleryCard({
)}
</div>
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
{recreateMediaClass && (
<Tooltip content="Recreate" position="top">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{canMakeVideo && (
<Tooltip content="Make Video" position="top">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
onClick={handleMakeVideo}
aria-label="Make Video"
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
</button>
</Tooltip>
)}
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
onClick={handleShare}
aria-label="Share"
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
className="text-sm"
/>
</button>
</Tooltip>
{item.fullImage && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
aria-label="Download"
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</div>
{actionsSlot && (
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
{actionsSlot}
</div>
)}
</div>
</div>
);
@@ -1,47 +1,50 @@
import { memo, useCallback } from "react";
import { memo, useCallback, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube, faImage, faPlay, faVideo } from "@fortawesome/pro-solid-svg-icons";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faLink,
faPlay,
faSpinnerThird,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
getCreatorIconPathForModelId,
getModelDisplayName,
} from "@storyteller/model-list";
import { GalleryThumbnail } from "./GalleryThumbnail";
import { CopyPromptButton } from "./CopyPromptButton";
import {
useGalleryItemActions,
type GalleryItemActions,
} from "./useGalleryItemActions";
import { formatTimeAgo } from "../../lib/format-time-ago";
import type { GalleryItem } from "./useGalleryData";
import { formatTimeAgo } from "./format-time-ago";
import type { GalleryItem } from "./types";
interface GalleryRowProps {
export interface GalleryRowProps {
item: GalleryItem;
onClick: (item: GalleryItem) => void;
enableMakeVideo?: boolean;
// Prompt + model resolved from the prompt record by the list. Fall back to
// the item's own fields when the prompt hasn't loaded (or has no text).
title?: string;
modelId?: string;
// The prompt record is still resolving — show a skeleton in place of the title.
loading?: boolean;
/** Hover-revealed quick-action cluster (recreate / share / download …). */
actionsSlot?: ReactNode;
onCopyPromptResult?: (success: boolean) => void;
}
export const GalleryRow = memo(function GalleryRow({
item,
onClick,
enableMakeVideo = false,
title,
modelId,
loading = false,
actionsSlot,
onCopyPromptResult,
}: GalleryRowProps) {
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
const { isVideo, mediaIcon, mediaLabel, modelDisplayName, modelIconPath } =
actions;
const isVideo = item.mediaClass === "video";
const is3D = item.mediaClass === "dimensional";
const mediaIcon = isVideo ? faVideo : is3D ? faCube : faImage;
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : "Image";
const effectiveModelId = modelId ?? item.modelId;
const modelDisplayName = effectiveModelId
? getModelDisplayName(effectiveModelId)
: null;
const modelIconPath = effectiveModelId
? getCreatorIconPathForModelId(effectiveModelId)
: null;
const handleRowClick = useCallback(() => onClick(item), [item, onClick]);
const handleRowKeyDown = useCallback(
@@ -97,7 +100,12 @@ export const GalleryRow = memo(function GalleryRow({
<p className="line-clamp-3 min-w-0 flex-1 text-sm leading-snug text-white/90">
{title || item.label}
</p>
{title && <CopyPromptButton text={title} />}
{title && (
<CopyPromptButton
text={title}
onCopyResult={onCopyPromptResult}
/>
)}
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-white/45">
@@ -121,12 +129,11 @@ export const GalleryRow = memo(function GalleryRow({
{/* Quick actions, right after the media type. Always visible on
mobile (no hover); hover-revealed on desktop. The prompt above
spans the full row width either way. */}
<div className="flex shrink-0 items-center gap-0.5 sm:ms-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
<GalleryRowActions
actions={actions}
hasDownload={!!item.fullImage}
/>
</div>
{actionsSlot && (
<div className="flex shrink-0 items-center gap-0.5 sm:ms-1.5 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
{actionsSlot}
</div>
)}
<span className="ml-auto shrink-0 whitespace-nowrap text-white/40">
{timeAgo}
</span>
@@ -135,91 +142,3 @@ export const GalleryRow = memo(function GalleryRow({
</div>
);
});
// Recreate / make-video / share / download buttons, shared by the desktop
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
// don't also open the lightbox.
function GalleryRowActions({
actions,
hasDownload,
}: {
actions: GalleryItemActions;
hasDownload: boolean;
}) {
const {
recreateMediaClass,
canMakeVideo,
isRecreating,
isDownloading,
shareCopied,
handleRecreate,
handleMakeVideo,
handleShare,
handleDownload,
} = actions;
const buttonClass =
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
return (
<>
{recreateMediaClass && (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className={buttonClass}
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{canMakeVideo && (
<Tooltip content="Make Video" position="top">
<button
type="button"
onClick={handleMakeVideo}
aria-label="Make Video"
className={buttonClass}
>
<FontAwesomeIcon icon={faVideo} className="text-sm" />
</button>
</Tooltip>
)}
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
<button
type="button"
onClick={handleShare}
aria-label="Share"
className={buttonClass}
>
<FontAwesomeIcon
icon={shareCopied ? faCheck : faLink}
className="text-sm"
/>
</button>
</Tooltip>
{hasDownload && (
<Tooltip content="Download" position="top">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
aria-label="Download"
className={buttonClass}
>
<FontAwesomeIcon
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
</>
);
}
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
// video thumbnails 404 until the render job finishes (so we retry on a timer
// and on tab refocus), and broken image URLs fall back to a placeholder.
// ── Shared visibility listener for video thumbnail retries ────────────────
const visibilityCallbacks = new Set<() => void>();
function onTabVisible(cb: () => void) {
visibilityCallbacks.add(cb);
if (visibilityCallbacks.size === 1) {
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
}
return () => {
visibilityCallbacks.delete(cb);
if (visibilityCallbacks.size === 0) {
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
}
};
}
function fireVisibilityCallbacks() {
if (document.hidden) return;
visibilityCallbacks.forEach((cb) => cb());
}
// ── Retry constants ────────────────────────────────────────────────────────
const MAX_RETRIES = 20;
const RETRY_INTERVAL = 5000;
// ── Component ──────────────────────────────────────────────────────────────
interface GalleryThumbnailProps {
thumbnail: string | null;
alt: string;
isVideo: boolean;
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
fallbackIcon: IconDefinition;
// Classes for the <img> element (sizing / object-fit).
imgClassName?: string;
fallbackIconClassName?: string;
// The "Loading thumbnail…" caption is too large for small list rows.
showRetryLabel?: boolean;
// Called on successful load — the card uses it to measure aspect ratio.
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
}
export function GalleryThumbnail({
thumbnail,
alt,
isVideo,
fallbackIcon,
imgClassName = "block h-full w-full object-cover",
fallbackIconClassName = "text-xl text-white/20",
showRetryLabel = true,
onLoad,
}: GalleryThumbnailProps) {
// "retrying" flips to true only after the first error, so videos with ready
// thumbnails render the normal <img> path with zero overhead. While retrying,
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
const [retrying, setRetrying] = useState(false);
const retryImgRef = useRef<HTMLImageElement>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const retryCountRef = useRef(0);
const kickRetry = useCallback(() => {
if (!retryImgRef.current || !thumbnail) return;
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
}, [thumbnail]);
const scheduleRetry = useCallback(() => {
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
if (document.hidden) return;
retryTimerRef.current = setTimeout(() => {
retryCountRef.current++;
kickRetry();
}, RETRY_INTERVAL);
}, [thumbnail, kickRetry]);
// Subscribe to the shared visibility listener while a video card is retrying.
useEffect(() => {
if (!isVideo || !thumbnail || !retrying) return;
const unsubscribe = onTabVisible(() => {
retryCountRef.current = 0;
kickRetry();
});
return () => {
unsubscribe();
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [isVideo, thumbnail, retrying, kickRetry]);
// Reset retry state when the thumbnail URL changes.
useEffect(() => {
if (!isVideo) return;
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}, [isVideo, thumbnail]);
const handleLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo && retrying) {
setRetrying(false);
retryCountRef.current = 0;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
}
onLoad?.(e);
},
[isVideo, retrying, onLoad],
);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
if (isVideo) {
setRetrying(true);
scheduleRetry();
} else {
const target = e.currentTarget;
if (target.dataset.fallback) return;
target.dataset.fallback = "1";
target.src = PLACEHOLDER_IMAGES.DEFAULT;
target.style.opacity = "0.3";
}
},
[isVideo, scheduleRetry],
);
if (retrying) {
return (
<>
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg text-white/30"
/>
{showRetryLabel && (
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
)}
</div>
{/* Hidden img retries in the background via ref — zero re-renders */}
<img
ref={retryImgRef}
src={thumbnail!}
alt=""
className="absolute h-0 w-0 opacity-0"
aria-hidden
onLoad={handleLoad}
onError={handleError}
/>
</>
);
}
if (thumbnail) {
return (
<img
src={thumbnail}
alt={alt}
loading="lazy"
decoding="async"
className={imgClassName}
onLoad={handleLoad}
onError={handleError}
/>
);
}
return (
<div className="flex h-full w-full items-center justify-center">
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
</div>
);
}
@@ -0,0 +1,53 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
useGalleryViewStore,
type GalleryViewMode,
} from "./gallery-view-store";
const OPTIONS: {
mode: GalleryViewMode;
icon: IconDefinition;
label: string;
}[] = [
{ mode: "grid", icon: faGrid2, label: "Grid view" },
{ mode: "list", icon: faListUl, label: "List view" },
];
// Segmented grid/list switch for the create-page galleries. Lives in the
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
export function GalleryViewToggle() {
const viewMode = useGalleryViewStore((s) => s.viewMode);
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
return (
<div
role="group"
aria-label="Gallery layout"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
>
{OPTIONS.map(({ mode, icon, label }) => {
const active = viewMode === mode;
return (
<Tooltip key={mode} content={label} position="bottom" delay={300}>
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={() => setViewMode(mode)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
active
? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80"
}`}
>
<FontAwesomeIcon icon={icon} />
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -0,0 +1,155 @@
import type { ReactNode } from "react";
import Masonry from "react-masonry-css";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingCard } from "./PendingCard";
import { FailedCard } from "./FailedCard";
import { GalleryCard } from "./GalleryCard";
import {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
import type { RecreateSlotContext } from "./GenerationListView";
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
// ── Grid layout constants ─────────────────────────────────────────────────
const BREAKPOINT_COLS = {
default: 4,
1280: 4,
900: 3,
640: 2,
};
// 12px gap on both axes (≈ Tailwind gap-3).
// ml-[-12px] on container offsets the first column's pl-[8px].
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
const COLUMN_CLASS = "pl-[8px]";
// ── Component ──────────────────────────────────────────────────────────────
export interface GenerationGridViewProps {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
onDismissFailed: (jobToken: string) => void;
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
hasMore: boolean;
isLoading?: boolean;
isInitialLoading: boolean;
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
/** Per-card "Recreate" affordance for pending/failed cards. */
renderRecreate?: (ctx: RecreateSlotContext) => ReactNode;
/** Per-card hover quick-action cluster for completed cards. */
renderGalleryActions?: (
item: GalleryItem,
ctx: { modelId?: string; promptToken?: string },
) => ReactNode;
}
export function GenerationGridView({
inProgressJobs,
failedJobs,
onDismissFailed,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
hasMore,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
renderRecreate,
renderGalleryActions,
}: GenerationGridViewProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
if (isInitialLoading) {
return (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
);
}
return (
<>
<Masonry
breakpointCols={BREAKPOINT_COLS}
className={MASONRY_CLASS}
columnClassName={COLUMN_CLASS}
>
{mergedEntries.map((entry) => (
<div key={entry.key} className="mb-[8px]">
{entry.kind === "pending" && (
<PendingCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
mediaClass={entry.job.mediaClass}
recreateSlot={
entry.job.promptToken && renderRecreate
? renderRecreate({
promptToken: entry.job.promptToken,
mediaClass: entry.job.mediaClass,
kind: "pending",
})
: undefined
}
/>
)}
{entry.kind === "failed" && (
<FailedCard
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
recreateSlot={
entry.job.promptToken && renderRecreate
? renderRecreate({
promptToken: entry.job.promptToken,
mediaClass: entry.job.mediaClass,
kind: "failed",
})
: undefined
}
onDismiss={onDismissFailed}
/>
)}
{entry.kind === "gallery" && (
<GalleryCard
item={entry.item}
onClick={onGalleryItemClick}
actionsSlot={renderGalleryActions?.(entry.item, {
modelId: entry.item.modelId,
promptToken: entry.item.promptToken,
})}
/>
)}
</div>
))}
</Masonry>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
)}
</>
);
}
@@ -0,0 +1,210 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { PendingRow } from "./PendingRow";
import { FailedRow } from "./FailedRow";
import { GalleryRow } from "./GalleryRow";
import {
useMergedGalleryEntries,
useInfiniteScrollSentinel,
} from "./useGalleryEntries";
import { usePrompts } from "./prompts-cache";
import { useMediaPromptTokens } from "./media-prompt-token-cache";
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
// per generation: a single time-sorted merge of the in-progress / failed /
// completed streams. Host-specific affordances (recreate, share/download,
// toasts) are injected through the render-prop seams so the webapp and the
// desktop app can share this view verbatim.
export interface RecreateSlotContext {
promptToken: string;
mediaClass: "image" | "video";
kind: "pending" | "failed";
}
export interface GenerationListViewProps {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
onDismissFailed: (jobToken: string) => void;
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
hasMore: boolean;
isLoading?: boolean;
isInitialLoading: boolean;
onLoadMore: () => void;
onGalleryItemClick: (item: GalleryItem) => void;
/** Per-row "Recreate" affordance for pending/failed rows. */
renderRecreate?: (ctx: RecreateSlotContext) => ReactNode;
/** Per-row hover quick-action cluster for completed rows. `modelId` and
* `promptToken` come from the prompt record resolved by the list (richer
* than the media file's own fields). */
renderGalleryActions?: (
item: GalleryItem,
ctx: { modelId?: string; promptToken?: string },
) => ReactNode;
/** Copy-prompt feedback (e.g. host toast). */
onCopyPromptResult?: (success: boolean) => void;
}
export function GenerationListView({
inProgressJobs,
failedJobs,
onDismissFailed,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
hasMore,
isInitialLoading,
onLoadMore,
onGalleryItemClick,
renderRecreate,
renderGalleryActions,
onCopyPromptResult,
}: GenerationListViewProps) {
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
const mergedEntries = useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
});
// Show the real prompt text + model on completed rows instead of the
// "Image Generation" placeholder. The profile media-list endpoint doesn't
// return prompt tokens yet, so for items missing one we resolve it through
// the batch media-files endpoint (media token → prompt token), then feed the
// prompt token into the prompts cache (prompt token → text + model). Both
// steps are batched + cached. Pending/failed entries already carry a prompt.
const mediaTokensNeedingPrompt = useMemo(
() =>
mergedEntries.flatMap((entry) =>
entry.kind === "gallery" && !entry.item.promptToken
? [entry.item.id]
: [],
),
[mergedEntries],
);
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
// For an item: its (non-empty) prompt token, and whether the media→prompt
// lookup has finished. `resolved` lets rows show a skeleton while loading and
// fall back to the label once we know there's no prompt.
const promptStateFor = useCallback(
(item: { id: string; promptToken?: string }) => {
const direct = item.promptToken;
const resolved = direct != null || resolvedPromptTokens.has(item.id);
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
return { promptToken, resolved };
},
[resolvedPromptTokens],
);
const promptTokens = useMemo(() => {
const tokens: string[] = [];
for (const entry of mergedEntries) {
if (entry.kind !== "gallery") continue;
const { promptToken } = promptStateFor(entry.item);
if (promptToken) tokens.push(promptToken);
}
return tokens;
}, [mergedEntries, promptStateFor]);
const promptsMap = usePrompts(promptTokens);
if (isInitialLoading) {
return (
<div className="flex justify-center py-20">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl sm:px-4">
<div className="flex flex-col divide-y divide-white/[0.04]">
{mergedEntries.map((entry) => {
if (entry.kind === "pending") {
return (
<PendingRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
progress={entry.job.progress}
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
batchCount={entry.job.batchCount}
mediaClass={entry.job.mediaClass}
recreateSlot={
entry.job.promptToken && renderRecreate
? renderRecreate({
promptToken: entry.job.promptToken,
mediaClass: entry.job.mediaClass,
kind: "pending",
})
: undefined
}
onCopyPromptResult={onCopyPromptResult}
/>
);
}
if (entry.kind === "failed") {
return (
<FailedRow
key={entry.key}
id={entry.job.id}
prompt={entry.job.prompt}
modelId={entry.job.modelId}
modelLabel={entry.job.modelLabel}
failureReason={entry.job.failureReason}
failureMessage={entry.job.failureMessage}
refImageUrl={entry.job.refImageUrl}
recreateSlot={
entry.job.promptToken && renderRecreate
? renderRecreate({
promptToken: entry.job.promptToken,
mediaClass: entry.job.mediaClass,
kind: "failed",
})
: undefined
}
onDismiss={onDismissFailed}
onCopyPromptResult={onCopyPromptResult}
/>
);
}
const { promptToken, resolved } = promptStateFor(entry.item);
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
// Loading while the media→prompt lookup is pending, or it found a
// prompt token whose text hasn't arrived yet.
const loading = !resolved || (!!promptToken && !prompt);
const modelId = prompt?.maybe_model_type || undefined;
return (
<GalleryRow
key={entry.key}
item={entry.item}
onClick={onGalleryItemClick}
title={prompt?.maybe_positive_prompt?.trim() || undefined}
modelId={modelId}
loading={loading}
actionsSlot={renderGalleryActions?.(entry.item, {
modelId,
promptToken: promptToken || undefined,
})}
onCopyPromptResult={onCopyPromptResult}
/>
);
})}
</div>
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-8">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
)}
</div>
);
}
@@ -1,12 +1,7 @@
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRotateRight,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
import { derivePendingStatus } from "./pending-status";
export interface PendingCardProps {
@@ -17,9 +12,9 @@ export interface PendingCardProps {
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
// Prompt token + media class enable the "Recreate" action mid-generation.
promptToken?: string;
recreateMediaClass: "image" | "video";
mediaClass: "image" | "video";
/** Hover-revealed action (e.g. a Recreate button) next to the prompt. */
recreateSlot?: ReactNode;
}
export const PendingCard = memo(function PendingCard({
@@ -29,26 +24,24 @@ export const PendingCard = memo(function PendingCard({
progress,
estimatedTimeLeftMs,
batchCount,
promptToken,
recreateMediaClass,
mediaClass,
recreateSlot,
}: PendingCardProps) {
const { progressPercent, timeLabel } = derivePendingStatus(
progress,
estimatedTimeLeftMs,
);
const iconPath = getModelCreatorIconPath(modelId);
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
const iconPath = getCreatorIconPathForModelId(modelId);
return (
<div className="group relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" />
{batchCount != null && batchCount > 1 && (
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
Generating {batchCount} videos · Results may appear one at a time
Generating {batchCount}{" "}
{mediaClass === "image" ? "images" : "videos"} · Results may appear
one at a time
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
@@ -70,22 +63,7 @@ export const PendingCard = memo(function PendingCard({
<p className="line-clamp-3 min-w-0 flex-1 text-xs leading-relaxed text-white/80">
{prompt}
</p>
{promptToken && (
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
)}
{recreateSlot}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<img
@@ -1,15 +1,23 @@
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowRotateRight,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
import { useRecreateFromPromptToken } from "../../lib/recreate";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
import { CopyPromptButton } from "./CopyPromptButton";
import { derivePendingStatus } from "./pending-status";
import type { PendingCardProps } from "./PendingCard";
export interface PendingRowProps {
id: string;
modelId: string;
modelLabel: string;
prompt: string;
progress?: number;
estimatedTimeLeftMs?: number;
batchCount?: number;
mediaClass: "image" | "video";
/** Hover-revealed action (e.g. a Recreate button) after the model label. */
recreateSlot?: ReactNode;
onCopyPromptResult?: (success: boolean) => void;
}
export const PendingRow = memo(function PendingRow({
modelId,
@@ -18,18 +26,15 @@ export const PendingRow = memo(function PendingRow({
progress,
estimatedTimeLeftMs,
batchCount,
promptToken,
recreateMediaClass,
}: PendingCardProps) {
mediaClass,
recreateSlot,
onCopyPromptResult,
}: PendingRowProps) {
const { progressPercent, timeLabel } = derivePendingStatus(
progress,
estimatedTimeLeftMs,
);
const iconPath = getModelCreatorIconPath(modelId);
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
promptToken,
recreateMediaClass,
);
const iconPath = getCreatorIconPathForModelId(modelId);
return (
<div className="group flex items-center gap-3 rounded-lg px-2.5 py-2">
@@ -58,7 +63,9 @@ export const PendingRow = memo(function PendingRow({
Generating…
</p>
)}
{prompt.trim() && <CopyPromptButton text={prompt} />}
{prompt.trim() && (
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
)}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-white/45">
<img
@@ -70,27 +77,16 @@ export const PendingRow = memo(function PendingRow({
{batchCount != null && batchCount > 1 && (
<>
<span className="text-white/25">·</span>
<span className="shrink-0">{batchCount} videos</span>
<span className="shrink-0">
{batchCount} {mediaClass === "image" ? "images" : "videos"}
</span>
</>
)}
{/* Recreate is hover-revealed on desktop (always visible on mobile),
mirroring the completed GalleryRow's quick actions. */}
{promptToken && (
{recreateSlot && (
<div className="flex shrink-0 items-center sm:ms-1 sm:opacity-0 transition-opacity sm:group-hover:opacity-100 sm:focus-within:opacity-100">
<Tooltip content="Recreate" position="top">
<button
type="button"
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<FontAwesomeIcon
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
/>
</button>
</Tooltip>
{recreateSlot}
</div>
)}
</div>
@@ -0,0 +1,27 @@
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
// ago") for gallery list rows. Kept dependency-free — the apps have no date
// library, and this avoids pulling one in for a single label.
const MINUTE = 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
const WEEK = DAY * 7;
const MONTH = DAY * 30;
const YEAR = DAY * 365;
const pluralize = (value: number, unit: string): string =>
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
export function formatTimeAgo(input: string | number | Date): string {
const date = input instanceof Date ? input : new Date(input);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
if (seconds < 45) return "just now";
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
return pluralize(Math.round(seconds / YEAR), "year");
}
@@ -0,0 +1,23 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// View mode for the create-page generation galleries. Shared between the
// create pages and the TopBar toggle, and persisted so the user's preference
// survives reloads and carries across pages.
export type GalleryViewMode = "grid" | "list";
interface GalleryViewState {
viewMode: GalleryViewMode;
setViewMode: (mode: GalleryViewMode) => void;
}
export const useGalleryViewStore = create<GalleryViewState>()(
persist(
(set) => ({
viewMode: "grid",
setViewMode: (viewMode) => set({ viewMode }),
}),
{ name: "artcraft-gallery-view" },
),
);
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { MediaFilesApi } from "@storyteller/api";
// Workaround until the profile media-list endpoint returns prompt tokens:
// resolve media token → prompt token via the batch media-files endpoint
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
// in-flight dedupe, and a subscribe-on-load hook.
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
const tokenCache = new Map<string, string>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: MediaFilesApi | null = null;
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
const CHUNK_SIZE = 50;
function getApi(): MediaFilesApi {
if (!api) api = new MediaFilesApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
const missing = mediaTokens.filter(
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const chunks: string[][] = [];
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
chunks.push(missing.slice(i, i + CHUNK_SIZE));
}
const responses = await Promise.all(
chunks.map((chunk) =>
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
),
);
for (const response of responses) {
if (!response.success || !response.data) continue;
for (const file of response.data) {
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
}
}
} catch {
// Swallow — the finally block marks the tokens resolved (empty) so the
// loading skeleton clears and we don't refetch in a loop.
} finally {
// Mark every requested token resolved. Tokens that errored or weren't
// returned get "" (no prompt) so the row falls back to its label instead
// of showing a skeleton forever. Always notify so skeletons clear.
for (const t of missing) {
if (!tokenCache.has(t)) tokenCache.set(t, "");
inFlight.delete(t);
}
notifySubscribers();
}
}
// Returns mediaToken → promptToken for every token whose media→prompt lookup
// has completed; the value is "" when the media has no prompt. A token that is
// absent from the map is still resolving (use `.has()` to distinguish that
// from a resolved-but-promptless item). Triggers a batched fetch for any not
// yet resolved.
export function useMediaPromptTokens(
mediaTokens: readonly (string | null | undefined)[],
): Map<string, string> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of mediaTokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, string>();
for (const t of cleaned) {
const promptToken = tokenCache.get(t);
// Include "" entries so callers can tell "resolved, no prompt" (present)
// apart from "still loading" (absent).
if (promptToken !== undefined) result.set(t, promptToken);
}
return result;
}
@@ -0,0 +1,32 @@
// Progress + time-left derivation shared by the pending card and pending row.
export interface PendingStatus {
progressPercent: number | null;
timeLabel: string | null;
}
export function derivePendingStatus(
progress?: number,
estimatedTimeLeftMs?: number,
): PendingStatus {
const progressPercent =
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
const isAlmostDone = progress != null && progress >= 95;
const timeLabel = isAlmostDone
? "Almost done..."
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
? formatTimeLeft(estimatedTimeLeftMs)
: null;
return { progressPercent, timeLabel };
}
function formatTimeLeft(ms: number): string {
const totalSeconds = Math.ceil(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
if (hours > 0) return `~${hours}h`;
if (minutes > 0) return `~${minutes}m`;
return `~${seconds}s`;
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { PromptsApi } from "@storyteller/api";
import type { Prompts } from "@storyteller/api";
const promptCache = new Map<string, Prompts>();
const inFlight = new Set<string>();
const subscribers = new Set<() => void>();
let api: PromptsApi | null = null;
function getApi(): PromptsApi {
if (!api) api = new PromptsApi();
return api;
}
function notifySubscribers() {
for (const cb of subscribers) cb();
}
async function fetchMissing(tokens: readonly string[]): Promise<void> {
const missing = tokens.filter(
(t) => t && !promptCache.has(t) && !inFlight.has(t),
);
if (missing.length === 0) return;
for (const t of missing) inFlight.add(t);
try {
const response = await getApi().BatchGetPrompts({ tokens: missing });
if (response.success && response.data) {
for (const p of response.data) {
promptCache.set(p.token, p);
}
notifySubscribers();
}
} catch {
// Failures will be retried when usePrompts re-runs with the same tokens.
} finally {
for (const t of missing) inFlight.delete(t);
}
}
export function getCachedPrompt(token: string): Prompts | undefined {
return promptCache.get(token);
}
export function usePrompts(
tokens: readonly (string | null | undefined)[],
): Map<string, Prompts> {
const [, forceUpdate] = useState(0);
useEffect(() => {
const cb = () => forceUpdate((n) => n + 1);
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
const cleaned: string[] = [];
for (const t of tokens) {
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort().join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, Prompts>();
for (const t of cleaned) {
const p = promptCache.get(t);
if (p) result.set(t, p);
}
return result;
}
@@ -0,0 +1,45 @@
// Canonical data shapes for the merged generation feed (in-progress / failed /
// completed). Hosts (webapp, desktop) map their own job sources into these.
export interface GalleryItem {
id: string;
label: string;
thumbnail: string | null;
fullImage: string | null;
createdAt: string;
mediaClass: string;
modelId?: string;
batchImageToken?: string;
// Token for the generation's prompt record. The list view resolves it
// (via the shared prompts cache) to show the real prompt + model.
promptToken?: string;
}
export interface InProgressJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
progress: number;
estimatedTimeLeftMs?: number;
createdAt: string;
batchCount?: number;
// Prompt token + media class enable the "Recreate" action while the job is
// still running, mirroring the failed/completed cards.
promptToken?: string;
mediaClass: "image" | "video";
}
export interface FailedJob {
id: string;
prompt: string;
modelId: string;
modelLabel: string;
failureReason?: string;
failureMessage?: string;
status: string;
createdAt: string;
promptToken?: string;
refImageUrl?: string;
mediaClass: "image" | "video";
}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
GalleryModalApi,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/api";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import type { GalleryItem } from "./types";
const PAGE_SIZE = 40;
const getLabel = (item: any) => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
return "3D Mesh";
default:
return "Generation";
}
};
// ── Hook ───────────────────────────────────────────────────────────────────
export function useGalleryData(options: {
username: string | null;
filterMediaClasses: FilterMediaClasses[];
excludeUploads?: boolean;
}) {
const { username, filterMediaClasses, excludeUploads } = options;
const [items, setItems] = useState<GalleryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const isLoadingRef = useRef(false);
const api = useMemo(() => new GalleryModalApi(), []);
const mapApiItem = useCallback((item: any): GalleryItem => {
const isDimensional = item.media_class === "dimensional";
const thumbnail = isDimensional
? null
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
fullImage: item.media_links?.cdn_url || null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
modelId: item.maybe_model_type || undefined,
batchImageToken: item.maybe_batch_token,
promptToken: item.maybe_prompt_token || undefined,
};
}, []);
const loadItems = useCallback(
async (reset = false) => {
if (!username) return;
if (isLoadingRef.current) return;
isLoadingRef.current = true;
setIsLoading(true);
try {
const response = await api.listUserMediaFiles({
username,
filter_media_classes: filterMediaClasses,
include_user_uploads: !excludeUploads,
page_index: reset ? 0 : pageIndex,
page_size: PAGE_SIZE,
});
if (response.success && response.data) {
const newItems = response.data
.filter(
(item: any) =>
item.media_type !== FilterMediaType.SCENE_JSON &&
!(excludeUploads && item.origin_category === "upload"),
)
.map(mapApiItem);
if (reset) {
setItems(newItems);
} else {
setItems((prev) => [...prev, ...newItems]);
}
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
} catch {
// ignore
}
setIsLoading(false);
setIsInitialLoading(false);
isLoadingRef.current = false;
},
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
);
// Initial load + filter change. When logged out (no username), clear the
// loading flag so the shell renders the empty state instead of a spinner.
useEffect(() => {
setItems([]);
setPageIndex(0);
isLoadingRef.current = false;
if (!username) {
setHasMore(false);
setIsInitialLoading(false);
return;
}
setHasMore(true);
setIsInitialLoading(true);
loadItems(true);
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = useCallback(() => {
if (hasMore && !isLoadingRef.current) {
loadItems();
}
}, [hasMore, loadItems]);
const refresh = useCallback(() => {
setItems([]);
setPageIndex(0);
setHasMore(true);
isLoadingRef.current = false;
loadItems(true);
}, [loadItems]);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
return {
items,
isLoading,
isInitialLoading,
hasMore,
loadMore,
refresh,
removeItem,
};
}
@@ -0,0 +1,97 @@
import { useEffect, useMemo, useRef } from "react";
import type { FailedJob, GalleryItem, InProgressJob } from "./types";
// Shared between the grid and list views: both merge the in-progress / failed /
// completed streams into a single time-sorted feed and lazy-load more on scroll.
export type GalleryEntry =
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
export interface MergedEntriesInput {
inProgressJobs: InProgressJob[];
failedJobs: FailedJob[];
newlyCompletedItems: GalleryItem[];
galleryItems: GalleryItem[];
newlyCompletedTokens: Set<string>;
}
export function useMergedGalleryEntries({
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
}: MergedEntriesInput): GalleryEntry[] {
return useMemo(() => {
const entries: GalleryEntry[] = [];
for (const job of inProgressJobs) {
entries.push({
kind: "pending",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const job of failedJobs) {
entries.push({
kind: "failed",
key: job.id,
createdAt: new Date(job.createdAt).getTime(),
job,
});
}
for (const item of newlyCompletedItems) {
entries.push({
kind: "gallery",
key: `new-${item.id}`,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
for (const item of galleryItems) {
if (newlyCompletedTokens.has(item.id)) continue;
entries.push({
kind: "gallery",
key: item.id,
createdAt: new Date(item.createdAt).getTime(),
item,
});
}
entries.sort((a, b) => b.createdAt - a.createdAt);
return entries;
}, [
inProgressJobs,
failedJobs,
newlyCompletedItems,
galleryItems,
newlyCompletedTokens,
]);
}
// Returns a ref to attach to a sentinel element near the end of the feed;
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
export function useInfiniteScrollSentinel(
hasMore: boolean,
onLoadMore: () => void,
) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, onLoadMore]);
return sentinelRef;
}
@@ -0,0 +1,28 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../tooltip"
},
{
"path": "../loading-spinner"
},
{
"path": "../../common"
},
{
"path": "../../api"
},
{
"path": "../../model-list"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,62 @@
{
"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": "../tooltip/tsconfig.lib.json"
},
{
"path": "../loading-spinner/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../../model-list/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,41 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { isExternal } from '../../shared-vite-config';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/generation-list',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
name: '@storyteller/ui-generation-list',
fileName: 'index',
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: isExternal
},
},
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,
}
},
}));
@@ -2,10 +2,10 @@ import React, { useState, useEffect } from "react";
import { Input } from "@storyteller/ui-input";
import { Button } from "@storyteller/ui-button";
import {
faUser,
faKey,
faEnvelope,
faEye,
faEyeSlash,
faExclamationTriangle,
faSpinnerThird,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,27 +14,35 @@ interface ArtCraftSignUpProps {
username: string,
email: string,
password: string,
passwordConfirmation: string
passwordConfirmation: string,
) => void;
isSignUp: boolean;
onToggleMode: () => void;
formRef?: React.RefObject<HTMLFormElement | null>;
errorMessage?: string;
isLoading?: boolean;
}
const FIELD_LABEL = "text-xs font-semibold text-white/70 ml-1";
const FIELD_INPUT =
"w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors";
export const ArtCraftSignUp = ({
onSubmit,
isSignUp,
onToggleMode,
formRef,
errorMessage,
isLoading = false,
}: ArtCraftSignUpProps) => {
const [localError, setLocalError] = useState<string | undefined>(undefined);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
if (errorMessage) {
setLocalError(
errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1)
errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1),
);
} else {
setLocalError(undefined);
@@ -69,111 +77,128 @@ export const ArtCraftSignUp = ({
}
};
const handleInputFocus = () => {
};
const handleInputBlur = () => {
};
return (
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
<h2 className="text-3xl font-bold mb-3 text-center">
{isSignUp ? "Sign up for ArtCraft" : "Log in to ArtCraft"}
</h2>
<form
className="flex flex-col gap-3 w-full max-w-md"
onSubmit={handleSubmit}
ref={formRef}
>
{isSignUp ? (
<>
<form
className="flex w-full flex-col gap-2"
onSubmit={handleSubmit}
ref={formRef}
>
{localError && (
<div className="flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
<FontAwesomeIcon icon={faExclamationTriangle} />
{localError}
</div>
)}
{isSignUp ? (
<>
<div className="space-y-1">
<label className={FIELD_LABEL}>Username</label>
<Input
label="Username"
icon={faUser}
name="username"
placeholder="Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
inputClassName={FIELD_INPUT}
/>
</div>
<div className="space-y-1">
<label className={FIELD_LABEL}>Email</label>
<Input
label="Email"
icon={faEnvelope}
name="email"
type="email"
placeholder="Email"
required={isSignUp}
style={isSignUp ? {} : { display: "none" }}
placeholder="you@example.com"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
inputClassName={FIELD_INPUT}
/>
</>
) : (
<Input
label="Email or Username"
icon={faUser}
name="usernameOrEmail"
placeholder="Email or Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
<Input
label="Password"
icon={faKey}
type="password"
name="password"
placeholder="Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{isSignUp && (
<Input
label="Confirm Password"
icon={faKey}
type="password"
name="confirmPassword"
placeholder="Confirm Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
<button type="submit" className="hidden" />
<div className="relative mt-3 flex flex-col items-center justify-center">
<div className="flex gap-2 items-center justify-center p-2 px-3 bg-white/5 rounded-lg">
<p className="text-sm opacity-80">
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
</p>
<Button
variant="secondary"
className="p-0 bg-transparent hover:bg-transparent border-none shadow-none"
type="button"
onClick={onToggleMode}
>
<span className="text-sm text-primary-400 underline hover:text-primary-300 transition-colors duration-200">
{isSignUp ? "Log in" : "Sign up"}
</span>
</Button>
</div>
{localError && (
<div className="absolute w-fit -bottom-12 left-1/2 -translate-x-1/2 text-red bg-red/10 border border-red/20 rounded-lg py-1 px-2 justify-center text-sm text-center mb-2 font-semibold flex items-center gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} />
{localError}
</div>
)}
</>
) : (
<div className="space-y-1">
<label className={FIELD_LABEL}>Email or Username</label>
<Input
name="usernameOrEmail"
placeholder="you@example.com or username"
required
autoComplete="off"
inputClassName={FIELD_INPUT}
/>
</div>
</form>
</div>
)}
<div className="space-y-1">
<label className={FIELD_LABEL}>Password</label>
<div className="relative">
<Input
name="password"
type={showPassword ? "text" : "password"}
placeholder="Min. 8 characters"
required
autoComplete="off"
inputClassName={`${FIELD_INPUT} pr-12`}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 transition-colors hover:text-white/60"
tabIndex={-1}
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
{isSignUp && (
<div className="space-y-1">
<label className={FIELD_LABEL}>Confirm Password</label>
<div className="relative">
<Input
name="confirmPassword"
type={showConfirm ? "text" : "password"}
placeholder="Re-enter password"
required
autoComplete="off"
inputClassName={`${FIELD_INPUT} pr-12`}
/>
<button
type="button"
onClick={() => setShowConfirm((v) => !v)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 transition-colors hover:text-white/60"
tabIndex={-1}
>
<FontAwesomeIcon icon={showConfirm ? faEyeSlash : faEye} />
</button>
</div>
</div>
)}
<div className="pt-4">
<Button
type="submit"
disabled={isLoading}
className="h-10 w-full justify-center rounded-full border-none bg-primary font-bold text-white hover:bg-primary-600"
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : isSignUp ? (
"Sign up"
) : (
"Log in"
)}
</Button>
</div>
<div className="mt-2 text-center text-sm text-white/60">
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
<button
type="button"
onClick={onToggleMode}
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
{isSignUp ? "Log in" : "Sign up"}
</button>
</div>
</form>
);
};
@@ -1,22 +1,29 @@
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import {
useSoraLoginListener,
} from "@storyteller/tauri-api";
import { ArtCraftSignUp } from "./artcraft-signup";
import { UsersApi } from "@storyteller/api";
import { useLoginModalStore } from "./useLoginModalStore";
const SIGNUP_SOURCE_ARTCRAFT = "artcraft";
// Webapp auth-showcase video (swap by passing `videoUrl`).
const DEFAULT_SHOWCASE_VIDEO =
"https://player.vimeo.com/video/1169289718?background=1&autoplay=1&loop=1&muted=1";
interface LoginModalProps {
onClose?: () => void;
onOpenChange?: (isOpen: boolean) => void;
onArtCraftAuthSuccess?: (userInfo: any) => void;
isSignUp?: boolean;
/** Optional override for the right-pane showcase video. */
videoUrl?: string;
// Accepted for backwards compatibility with existing call sites (MainApp
// passes these); no longer used now that the showcase is a single video.
videoSrc2D?: string;
videoSrc3D?: string;
}
export function LoginModal({
@@ -24,21 +31,16 @@ export function LoginModal({
onOpenChange,
onArtCraftAuthSuccess,
isSignUp: initialIsSignUp = true,
videoUrl = DEFAULT_SHOWCASE_VIDEO,
}: LoginModalProps) {
const { isOpen, recheckTrigger, closeModal } = useLoginModalStore();
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [_isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false);
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
const [errorMessage, setErrorMessage] = useState("");
const artCraftFormRef = useRef<HTMLFormElement>(null);
const [showDiscord, setShowDiscord] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Now we have 3 steps: Welcome, Sign up/Login, and Success
const uiTotalSteps = 3;
const uiCurrentStep = showSuccess ? 3 : showDiscord ? 2 : step;
const checkArtCraftLogin = async () => {
const usersApi = new UsersApi();
const session = await usersApi.GetSession();
@@ -46,20 +48,14 @@ export function LoginModal({
return loggedIn;
};
useSoraLoginListener((payload: any) => {
console.log("Login success!", payload);
setStep(4); // Always go to the final step after Sora login
});
// Check session on component mount and when recheckTrigger changes
// Check session on mount and when recheckTrigger changes.
useEffect(() => {
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
setIsLoggedInArtCraft(true);
closeModal();
} else {
// Reset all modal state to initial values
setStep(1);
// Reset modal state to initial values.
setIsLoading(false);
setIsSignUp(initialIsSignUp);
setErrorMessage("");
@@ -82,55 +78,81 @@ export function LoginModal({
onClose?.();
};
const handleNext = async () => {
if (step === 2) {
// Trigger the form submit in ArtCraftSignUp
if (artCraftFormRef.current) {
if (artCraftFormRef.current.requestSubmit) {
artCraftFormRef.current.requestSubmit();
} else {
artCraftFormRef.current.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
);
}
}
} else {
setStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) setStep(step - 1);
};
// Progress bar rendering
const renderProgress = () => (
<div className="flex items-center justify-center gap-2 mb-2">
{[...Array(uiTotalSteps)].map((_, idx) => (
<div
key={idx}
className={`h-1.5 rounded transition-all duration-300 w-16 ${idx < uiCurrentStep ? "bg-primary" : "bg-white/30"
}`}
/>
))}
</div>
);
const handleDiscordJoin = () => {
window.open("https://discord.gg/75svZP2Vje", "_blank");
setShowDiscord(false);
setShowSuccess(true);
};
// Step content rendering
const renderStepContent = () => {
const handleAuthSubmit = async (
username: string,
email: string,
password: string,
passwordConfirmation: string
) => {
setIsLoading(true);
const usersApi = new UsersApi();
try {
let signupResponse, loginResponse;
if (isSignUp) {
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
signupSource: SIGNUP_SOURCE_ARTCRAFT,
});
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage || "Signup failed, please try again."
);
setIsLoading(false);
return;
}
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
} else {
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage || "Login failed, please try again."
);
setIsLoading(false);
return;
}
setIsLoggedInArtCraft(true);
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setShowDiscord(true); // Onboarding step after successful auth.
} catch (e) {
console.error(e);
setErrorMessage("An unexpected error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
// ── Post-auth onboarding screens (full-width inside the same card) ────────
const renderOnboarding = () => {
if (showSuccess) {
return (
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
<h2 className="text-3xl font-bold mb-2 text-center">
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
<h2 className="mb-2 text-3xl font-bold text-white">
Thank you for signing in!
</h2>
<p className="text-white/70 mb-6 text-center">
<p className="mb-6 text-white/70">
You're all set to start creating amazing content.
</p>
<Button
@@ -146,183 +168,41 @@ export function LoginModal({
);
}
if (showDiscord) {
return (
<div className="flex flex-col items-center justify-center flex-1 min-h-0">
<h2 className="text-3xl font-bold mb-2 text-center">
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.
</p>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => {
setShowDiscord(false);
setShowSuccess(true);
}}
>
Skip for now
</Button>
<Button
variant="primary"
onClick={handleDiscordJoin}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
>
Join Discord
</Button>
</div>
</div>
);
}
switch (step) {
case 1:
return (
<div className="flex flex-col items-center justify-end flex-1">
<p className="text-xl text-white max-w-lg text-center leading-relaxed drop-shadow-[0_2px_10px_rgba(0,0,0,0.8)]">
It's in your toolbox forever now.
</p>
</div>
);
case 2:
return (
<ArtCraftSignUp
onSubmit={async (
username,
email,
password,
passwordConfirmation
) => {
setIsLoading(true);
const usersApi = new UsersApi();
try {
let signupResponse, loginResponse;
if (isSignUp) {
console.log("Sign up!");
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
signupSource: SIGNUP_SOURCE_ARTCRAFT,
});
console.log(signupResponse);
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage ||
"Signup failed, please try again."
);
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 ||
"Login failed, please try again."
);
setIsLoading(false);
return;
}
setIsLoggedInArtCraft(true);
console.log("ARTCRAFT LOGIN SUCCeSS");
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
console.log("USERINFO");
console.log(userInfo);
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
setShowDiscord(true); // Show Discord step after successful login
} catch (e) {
console.error(e);
setErrorMessage(
"An unexpected error occurred. Please try again."
);
} finally {
setIsLoading(false);
}
}}
isSignUp={isSignUp}
onToggleMode={() => setIsSignUp((prev) => !prev)}
formRef={artCraftFormRef}
errorMessage={errorMessage}
/>
);
default:
return null;
}
};
const renderFooterButtons = () => {
if (showSuccess || showDiscord) {
return null; // No footer buttons needed in success or discord states
}
// Discord
return (
<div className="flex items-center justify-center gap-2.5 mt-6">
{step === 1 ? (
<>
<Button
variant="secondary"
onClick={() => {
setIsSignUp(false);
setStep(2);
}}
>
Login
</Button>
<Button
icon={faArrowRight}
iconFlip={true}
onClick={() => {
setIsSignUp(true);
setStep(2);
}}
>
Sign up
</Button>
</>
) : (
<>
<Button variant="secondary" onClick={handleBack} disabled={isLoading}>
Back
</Button>
<Button
icon={faArrowRight}
iconFlip={true}
onClick={handleNext}
loading={isLoading}
disabled={isLoading}
>
{isSignUp ? "Sign up" : "Login"}
</Button>
</>
)}
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
<h2 className="mb-2 text-3xl font-bold text-white">
Join Our Community
</h2>
<p className="mb-6 max-w-md text-white/70">
Connect with other creators, share your work, and get the latest
updates in our Discord community.
</p>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => {
setShowDiscord(false);
setShowSuccess(true);
}}
>
Skip for now
</Button>
<Button
variant="primary"
onClick={handleDiscordJoin}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
>
Join Discord
</Button>
</div>
</div>
);
};
const inOnboarding = showDiscord || showSuccess;
return (
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
@@ -346,41 +226,56 @@ export function LoginModal({
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[660px] max-w-4xl w-full rounded-xl overflow-hidden bg-[#2C2C2C] text-white shadow-lg border border-[#222222]/10"
className="relative flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] text-white shadow-2xl lg:min-h-[640px]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-4 p-8 h-full relative overflow-hidden">
{step === 1 && !showSuccess && !showDiscord && (
<div className="absolute inset-0 z-0">
<video
className="w-full h-full object-cover opacity-90"
autoPlay
muted
loop
playsInline
>
<source
src="/resources/videos/artcraft-intro.mp4"
type="video/mp4"
/>
</video>
<div className="absolute inset-0 bg-gradient-to-t from-[#090909] via-transparent to-[#111111]/80" />
</div>
)}
{inOnboarding ? (
renderOnboarding()
) : (
<>
{/* ── Form pane ── (no dismiss control — login is required) */}
<div className="relative flex w-full flex-col lg:w-1/2">
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
<div className="mx-auto w-full max-w-sm">
<div className="mb-8 text-center">
<img
src="/resources/logo/artcraft-icon.png"
alt="ArtCraft"
className="pointer-events-none mx-auto mb-6 h-12 w-auto select-none"
draggable={false}
/>
<h1 className="mb-2 text-2xl font-semibold">
{isSignUp ? "Create your account" : "Welcome back"}
</h1>
<p className="text-sm text-white/60">
{isSignUp
? "Sign up to start creating with ArtCraft"
: "Log in to your account"}
</p>
</div>
<div className="relative z-10 flex flex-col h-full">
{!showSuccess && (
<>
<span className="text-sm text-center opacity-60 font-medium">
Step {uiCurrentStep} of {uiTotalSteps}
</span>
{renderProgress()}
</>
)}
{renderStepContent()}
{renderFooterButtons()}
</div>
</div>
<ArtCraftSignUp
onSubmit={handleAuthSubmit}
isSignUp={isSignUp}
onToggleMode={() => setIsSignUp((prev) => !prev)}
errorMessage={errorMessage}
isLoading={isLoading}
/>
</div>
</div>
<div className="px-8 pb-8 text-center text-xs text-white/20">
&copy; {new Date().getFullYear()} ArtCraft. All rights
reserved.
</div>
</div>
{/* ── Showcase pane (desktop only) ── */}
<div className="relative hidden lg:block lg:w-1/2">
<LoginShowcase videoUrl={videoUrl} />
</div>
</>
)}
</div>
</TransitionChild>
</div>
@@ -389,4 +284,44 @@ export function LoginModal({
);
}
// Right-pane video showcase — Vimeo background embed scaled to cover the pane,
// with a legibility gradient + caption. Mirrors the webapp auth-showcase.
function LoginShowcase({ videoUrl }: { videoUrl: string }) {
return (
<div
className="absolute inset-2 overflow-hidden rounded-2xl bg-black"
style={{ containerType: "size" }}
>
<iframe
src={videoUrl}
title="ArtCraft"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{
width: "max(100cqw, calc(100cqh * 16 / 9))",
height: "max(100cqh, calc(100cqw * 9 / 16))",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/10 to-black/25"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-300">
One of the cheapest
</p>
<h2 className="text-2xl font-bold leading-tight">
Seedance 2.0 Video Generation
</h2>
<p className="mt-1 max-w-sm text-sm text-white/70">
Generate jaw-dropping AI videos with Seedance 2.0.
</p>
</div>
</div>
);
}
export default LoginModal;
@@ -11,6 +11,7 @@ import { Model } from "@storyteller/model-list";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleCheck, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import { GenerationProvider } from "@storyteller/api-enums";
import { Tooltip } from "@storyteller/ui-tooltip";
import { defaultModelForPage } from "./defaultModelForPage";
interface ClassyModelSelectorProps {
@@ -25,6 +26,14 @@ interface ClassyModelSelectorProps {
providersByModel?: Partial<Record<string, Provider[]>>;
providerTooltipDelayMs?: number;
maxListHeight?: number | string;
/**
* "floating": the original standalone selector (large two-line trigger with
* model name + provider, hover-to-open list). "embedded": a compact pill
* (creator icon + model name) for use inside a promptbox option row; opens
* a webapp-style rich list on click. Both share the same store wiring and
* provider-selection rows.
*/
variant?: "floating" | "embedded";
}
const DEFAULT_PROVIDER_OPTIONS: GenerationProvider[] = [GenerationProvider.Artcraft];
@@ -103,6 +112,7 @@ export function ClassyModelSelector({
providersByModel,
providerTooltipDelayMs = 300,
maxListHeight = "60vh",
variant = "floating",
...popoverProps
}: ClassyModelSelectorProps) {
const { selectedModels, setSelectedModel, setSelectedProvider } =
@@ -206,6 +216,31 @@ export function ClassyModelSelector({
],
);
if (variant === "embedded") {
const selectedIcon = modelList.find((i) => i.selected)?.icon;
return (
<Tooltip content="Model" position="top" className="z-50" closeOnClick>
<PopoverMenu
items={modelList}
onSelect={handleModelSelect}
mode="toggle"
richList
panelTitle="Select Model"
panelClassName="w-[360px]"
maxListHeight={maxListHeight}
buttonClassName="max-w-48"
triggerIcon={
selectedIcon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{selectedIcon}
</span>
) : undefined
}
/>
</Tooltip>
);
}
return (
<div className="flex items-center gap-3">
<span className="text-base-fg/90 text-base font-semibold">Model</span>

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