mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 15:55:41 -05:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede5c1e6a6 | ||
|
|
e6b59a7a7e | ||
|
|
9a6e613d91 |
+6
-7
@@ -1,7 +1,8 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use sqlx::{Executor, MySql};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
pub struct ActiveUserSubscription {
|
||||
pub user_token: String,
|
||||
@@ -18,12 +19,10 @@ pub struct ActiveUserSubscription {
|
||||
pub subscription_expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn list_active_user_subscriptions<'e, 'c : 'e, E>(
|
||||
mysql_executor: E,
|
||||
pub async fn list_active_user_subscriptions(
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
user_token: &str
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>> {
|
||||
// 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.
|
||||
//
|
||||
@@ -46,7 +45,7 @@ WHERE
|
||||
"#,
|
||||
user_token,
|
||||
)
|
||||
.fetch_all(mysql_executor)
|
||||
.fetch_all(&mut **mysql_connection)
|
||||
.await?;
|
||||
|
||||
let records = records.into_iter()
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
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();
|
||||
|
||||
// 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)?;
|
||||
|
||||
// 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));
|
||||
.max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 5)?);
|
||||
|
||||
// TODO(bt,2024-09-17): Configure logging, etc. after we've upgraded sqlx
|
||||
|
||||
let pool = pool_options
|
||||
.connect(&db_connection_string)
|
||||
|
||||
+27
-1
@@ -2,6 +2,7 @@ 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::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;
|
||||
@@ -18,7 +19,7 @@ use serde::Serialize;
|
||||
/// ```ignore
|
||||
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, CommonWebError> {
|
||||
/// // sqlx errors, anyhow errors, session errors — all convert via ?
|
||||
/// let user = require_user_session(&req, ...)?;
|
||||
/// let user = require_user_session_using_connection(&req, ...)?;
|
||||
/// let data = some_db_query(&pool).await?;
|
||||
///
|
||||
/// if data.is_none() {
|
||||
@@ -316,6 +317,15 @@ 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() {
|
||||
@@ -535,6 +545,22 @@ 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{{{";
|
||||
|
||||
+9
-3
@@ -28,7 +28,8 @@ 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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -113,10 +114,15 @@ 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(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+9
-3
@@ -29,7 +29,8 @@ 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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -121,10 +122,15 @@ 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(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+7
-2
@@ -14,7 +14,7 @@ use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAXIMUM_KEYS : u32 = 100;
|
||||
@@ -60,7 +60,12 @@ pub async fn create_beta_keys_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireModeratorError::ServerError => CommonWebError::from_error(err),
|
||||
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let mut maybe_referrer_user_token = None;
|
||||
|
||||
|
||||
+7
-2
@@ -14,7 +14,7 @@ use tokens::tokens::w2l_results::W2lResultToken;
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -58,7 +58,12 @@ pub async fn edit_beta_key_distributed_flag_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireModeratorError::ServerError => CommonWebError::from_error(err),
|
||||
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
edit_beta_key_distributed_flag(&path.token, request.is_distributed, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+7
-2
@@ -13,7 +13,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
|
||||
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -57,7 +57,12 @@ pub async fn edit_beta_key_note_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireModeratorError::ServerError => CommonWebError::from_error(err),
|
||||
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let maybe_note = request.note.as_deref()
|
||||
.map(|note| note.trim())
|
||||
|
||||
+7
-3
@@ -17,7 +17,7 @@ use tokens::tokens::beta_keys::BetaKeyToken;
|
||||
use crate::http_server::common_responses::pagination_page::PaginationPage;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::{require_user_session, RequireUserSessionError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Copy, Clone, Deserialize, ToSchema)]
|
||||
@@ -108,8 +108,12 @@ pub async fn list_beta_keys_handler(
|
||||
query: Query<ListBetaKeysQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let mut is_mod = user_session.can_ban_users;
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ pub async fn redeem_beta_key_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<HttpResponse, CommonWebError>
|
||||
{
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_user_session(&http_request, &server_state).await?;
|
||||
|
||||
let rate_limiter = &server_state.redis_rate_limiters.logged_out;
|
||||
|
||||
|
||||
+6
-2
@@ -10,7 +10,7 @@ use mysql_queries::queries::characters::delete_character::delete_character;
|
||||
use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Delete a character.
|
||||
@@ -40,7 +40,11 @@ pub async fn delete_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Edit a character's name or description.
|
||||
@@ -38,7 +38,11 @@ pub async fn edit_character_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -16,7 +16,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// List characters for the current session.
|
||||
@@ -39,7 +39,11 @@ pub async fn list_characters_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection,
|
||||
).await?;
|
||||
|
||||
let user_token = &user_session.user_token;
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_color_code::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_COLOR_CODE_LEN: usize = 16;
|
||||
@@ -47,7 +47,9 @@ pub async fn color_code_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Normalize: trim, treat empty after trim as `None`. This lets clients
|
||||
// clear by passing `""` or by omitting the field entirely.
|
||||
|
||||
+4
-2
@@ -23,7 +23,7 @@ use tokens::tokens::folders::FolderToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Set or clear the custom cover image on a folder.
|
||||
@@ -64,7 +64,9 @@ pub async fn cover_image_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Confirm the folder exists + is owned. Authoritative 404 source.
|
||||
get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
@@ -45,7 +45,9 @@ pub async fn create_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ use mysql_queries::queries::folders::folder::soft_delete_folder::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Soft-delete a folder. Children retain their parent pointer and become
|
||||
@@ -39,7 +39,9 @@ pub async fn delete_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
|
||||
let rows_affected = soft_delete_folder(SoftDeleteFolderArgs {
|
||||
|
||||
+4
-2
@@ -16,7 +16,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Fetch a single folder owned by the logged-in user.
|
||||
@@ -42,7 +42,9 @@ pub async fn get_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let row = get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
folder_token: &path.folder_token,
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folders_all";
|
||||
@@ -48,7 +48,9 @@ pub async fn list_folders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_name::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
@@ -47,7 +47,9 @@ pub async fn rename_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let new_name = request.new_name.trim().to_string();
|
||||
|
||||
|
||||
+4
-2
@@ -17,7 +17,7 @@ use mysql_queries::queries::folders::folder::update_folder_has_star::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -44,7 +44,9 @@ pub async fn star_folder_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
// Confirm the folder exists + is owned before issuing the update.
|
||||
// A 404 here is the authoritative "not found" signal — the update
|
||||
|
||||
+4
-2
@@ -27,7 +27,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -61,7 +61,9 @@ pub async fn bulk_add_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -30,7 +30,7 @@ use mysql_queries::queries::folders::media_files::recompute_folder_last_media_fi
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -72,7 +72,9 @@ pub async fn bulk_move_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -24,7 +24,7 @@ use tokens::tokens::folders::FolderToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -56,7 +56,9 @@ pub async fn bulk_remove_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.media_file_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -32,7 +32,7 @@ use crate::http_server::common_responses::media::media_domain::MediaDomain;
|
||||
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "folder_mf";
|
||||
@@ -127,7 +127,9 @@ pub async fn list_folder_media_files_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let folder = get_folder_for_owner(GetFolderForOwnerArgs {
|
||||
folder_token: &path.folder_token,
|
||||
|
||||
+4
-2
@@ -25,7 +25,7 @@ use mysql_queries::queries::folders::subfolder::filter_existing_owned_folder_tok
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -58,7 +58,9 @@ pub async fn bulk_add_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.subfolder_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ use mysql_queries::queries::folders::subfolder::bulk_clear_parent_folder::{
|
||||
use tokens::tokens::folders::FolderToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_BULK: usize = 500;
|
||||
@@ -45,7 +45,9 @@ pub async fn bulk_remove_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
if request.subfolder_tokens.len() > MAX_BULK {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
|
||||
+4
-2
@@ -21,7 +21,7 @@ use crate::http_server::endpoints::folders::folder::folder_info_conversion::{
|
||||
build_folder_thumbnails_lookup, folder_row_to_info,
|
||||
};
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const CURSOR_NAME: &str = "subfolders";
|
||||
@@ -55,7 +55,9 @@ pub async fn list_subfolders_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut conn,
|
||||
).await.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
|
||||
// Confirm the parent exists + is owned by the caller before listing.
|
||||
|
||||
+3
-2
@@ -8,7 +8,7 @@ use utoipa::ToSchema;
|
||||
use mysql_queries::queries::generic_inference::web::dismiss_finished_jobs_for_user::dismiss_finished_jobs_for_user;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -40,7 +40,8 @@ pub async fn dismiss_finished_session_jobs_handler(
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
let user_session = require_user_session_using_connection(
|
||||
&http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await?;
|
||||
|
||||
dismiss_finished_jobs_for_user(&mut mysql_connection, &user_session.user_token)
|
||||
|
||||
+6
-2
@@ -56,8 +56,12 @@ pub async fn change_media_file_animation_type_handler(
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SimpleResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Not authorized: {:?}", e);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let media_file_token = path.token.clone();
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+6
-2
@@ -53,8 +53,12 @@ pub async fn change_media_file_engine_category_handler(
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<Json<SimpleResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &server_state.mysql_pool)
|
||||
.await?;
|
||||
let user_session = require_user_session(&http_request, &server_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Not authorized: {:?}", e);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let media_file_token = path.token.clone();
|
||||
let is_mod = user_session.is_mod();
|
||||
|
||||
+4
-5
@@ -23,8 +23,7 @@ use tokens::tokens::media_files::MediaFileToken;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_pmx::extract_and_upload_pmx_files::{extract_and_upload_pmx_files, PmxError};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Form-multipart request fields.
|
||||
@@ -116,11 +115,11 @@ pub async fn upload_pmx_media_file_handler(
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
// NB: We require a moderator to upload PMX files.
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
let user_session = require_moderator(&http_request, &server_state, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
RequireModeratorError::ServerError => MediaFileUploadError::ServerError,
|
||||
RequireModeratorError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
|
||||
+4
-7
@@ -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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_FPS : u8 = 12;
|
||||
@@ -111,14 +111,11 @@ 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(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
let user_session = require_user_session_extended_using_connection(&http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("User session error: {:?}", e);
|
||||
match e {
|
||||
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
MediaFileUploadError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
|
||||
+9
-7
@@ -28,7 +28,8 @@ 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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -81,14 +82,15 @@ 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(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&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);
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+6
-1
@@ -35,7 +35,12 @@ pub async fn moderation_send_alert_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModerationSendAlertResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
|
||||
+4
-1
@@ -72,7 +72,10 @@ pub async fn moderation_list_debug_logs_for_token_handler(
|
||||
query: web::Query<ListDebugLogsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListDebugLogsSuccessResponse>, CommonWebError> {
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
|
||||
+7
-2
@@ -25,7 +25,7 @@ use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::web_utils::serialize_as_json_error::serialize_as_json_error;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::require_moderator;
|
||||
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, RequireModeratorError};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// For the URL PathInfo
|
||||
@@ -60,7 +60,12 @@ pub async fn moderator_get_token_info_handler(
|
||||
server_state: web::Data<Arc<ServerState>>
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireModeratorError::ServerError => CommonWebError::from_error(err),
|
||||
RequireModeratorError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
|
||||
let token = path.token.trim();
|
||||
let maybe_result = get_entity_from_token(&server_state.mysql_pool, &path.token)
|
||||
|
||||
+4
-1
@@ -90,7 +90,10 @@ pub async fn moderation_get_job_by_token_handler(
|
||||
path: web::Path<GetJobByTokenPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<GetJobByTokenSuccessResponse>, CommonWebError> {
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn list_user_jobs_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUserJobsResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let results = list_user_jobs_for_moderation(&path.user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+4
-1
@@ -74,7 +74,10 @@ pub async fn moderator_list_staff_audit_logs_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListStaffAuditLogsSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let limit = query.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn moderator_list_subscribing_users_by_signup_date_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListSubscribingUsersBySignupDateResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let result = list_subscribing_users_by_signup_date_for_moderation(
|
||||
ListSubscribingUsersBySignupDateArgs {
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn moderator_list_users_by_signup_date_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListUsersBySignupDateResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let result = list_users_by_signup_date_for_moderation(
|
||||
ListUsersBySignupDateArgs {
|
||||
|
||||
+3
-1
@@ -33,7 +33,9 @@ pub async fn moderator_user_lookup_by_stripe_customer_id_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorUserLookupByStripeCustomerIdResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let stripe_customer_id = request.stripe_customer_id.trim();
|
||||
|
||||
|
||||
+3
-1
@@ -40,7 +40,9 @@ pub async fn moderator_user_lookup_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorUserLookupSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let search = request.search.trim();
|
||||
|
||||
|
||||
+4
-1
@@ -59,7 +59,10 @@ pub async fn moderation_ban_user_handler(
|
||||
) -> Result<Json<ModerationBanUserSuccessResponse>, CommonWebError> {
|
||||
|
||||
// 1. Require moderator with ban permissions.
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if !user_session.can_ban_users {
|
||||
warn!("User {} is not allowed to ban users", user_session.user_token.as_str());
|
||||
|
||||
+4
-1
@@ -69,7 +69,10 @@ pub async fn moderator_change_user_email_handler(
|
||||
) -> Result<Json<ModeratorChangeUserEmailSuccessResponse>, CommonWebError> {
|
||||
|
||||
// 1. Require moderator.
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
// 2. Normalize + validate the new email.
|
||||
let new_email = request.new_email.trim().to_lowercase();
|
||||
|
||||
+4
-1
@@ -101,7 +101,10 @@ pub async fn moderator_list_email_address_changes_for_user_handler(
|
||||
) -> Result<Json<ModeratorListUserEmailChangesSuccessResponse>, CommonWebError> {
|
||||
|
||||
// 1. Require moderator.
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
// 2. Resolve username → user_token. Not-found → 404; everything else → 500.
|
||||
let username = path.username.trim().to_lowercase();
|
||||
|
||||
+4
-1
@@ -87,7 +87,10 @@ pub async fn moderator_edit_user_feature_flags_handler(
|
||||
redis_pool: Data<r2d2::Pool<Client>>,
|
||||
) -> Result<HttpResponse, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let username_or_token = path.username_or_token.trim();
|
||||
|
||||
|
||||
+4
-1
@@ -47,7 +47,10 @@ pub async fn moderator_list_all_available_user_feature_flags_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, CommonWebError> {
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let feature_flags: Vec<FeatureFlagDescriptor> = UserFeatureFlag::all_variants()
|
||||
.into_iter()
|
||||
|
||||
+4
-1
@@ -54,7 +54,10 @@ pub async fn moderator_list_user_feature_flags_handler(
|
||||
path: Path<ListUserFeatureFlagsPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorListUserFeatureFlagsResponse>, CommonWebError> {
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let username_or_token = path.username_or_token.trim();
|
||||
|
||||
|
||||
+4
-1
@@ -40,7 +40,10 @@ pub async fn moderator_list_global_user_referrals_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListGlobalUserReferralsSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let limit = query.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
|
||||
+4
-1
@@ -45,7 +45,10 @@ pub async fn moderator_list_user_referrals_for_user_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUserReferralsForUserSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let limit = query.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
|
||||
+4
-1
@@ -79,7 +79,10 @@ pub async fn moderator_list_user_session_impersonation_requests_for_user_handler
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUserImpersonationRequestsSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let limit = query.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
|
||||
+4
-1
@@ -73,7 +73,10 @@ pub async fn moderator_list_user_session_impersonation_requests_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListAllImpersonationRequestsSuccessResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let limit = query.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
|
||||
+4
-1
@@ -69,7 +69,10 @@ pub async fn moderator_user_session_impersonation_request_handler(
|
||||
) -> Result<Json<ModerationImpersonateSuccessResponse>, CommonWebError> {
|
||||
|
||||
// 1. Require moderator session.
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool).await.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("Banned moderator tried to impersonate: {}", user_session.user_token.as_str());
|
||||
|
||||
+3
-1
@@ -43,7 +43,9 @@ pub async fn moderator_get_user_stripe_customer_ids_handler(
|
||||
.acquire()
|
||||
.await?;
|
||||
|
||||
let _moderator_session = require_moderator(&http_request, &server_state.session_checker, &mut *mysql_connection).await?;
|
||||
let _moderator_session = require_moderator(&http_request, &server_state, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let customer_links = list_user_stripe_customer_links_for_user(&path.user_token, &mut mysql_connection)
|
||||
.await
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn list_wallet_ledger_entries_by_wallet_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListWalletLedgerEntriesByWalletResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let results = list_wallet_ledger_entries_by_wallet(&path.wallet_token, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn moderator_get_wallet_ledger_entry_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorGetWalletLedgerEntryResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let maybe_entry = get_wallet_ledger_entry_for_moderation(&path.wallet_ledger_entry_token, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn list_user_wallets_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListUserWalletsResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let results = list_user_wallets_for_moderation(&path.user_token, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+6
-1
@@ -46,7 +46,12 @@ pub async fn moderator_add_banked_balance_to_wallet_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorAddBankedBalanceToWalletResponse>, CommonWebError> {
|
||||
|
||||
let user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Moderator check failed: {:?}", err);
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ pub async fn moderator_create_wallet_for_user_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorCreateWalletForUserResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let user_token = request.user_token.as_ref()
|
||||
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("user_token is required".to_string()))?;
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub async fn moderator_get_wallet_handler(
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ModeratorGetWalletResponse>, CommonWebError> {
|
||||
|
||||
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
let _user_session = require_moderator(&http_request, &server_state, &server_state.mysql_pool)
|
||||
.await
|
||||
.map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
|
||||
let maybe_wallet = get_wallet_for_moderation(&path.wallet_token, &server_state.mysql_pool)
|
||||
.await
|
||||
|
||||
+1
-7
@@ -143,20 +143,14 @@ 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 {
|
||||
|
||||
+3
-12
@@ -25,20 +25,18 @@ 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;
|
||||
@@ -57,24 +55,17 @@ 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,
|
||||
&mut billing_connection,
|
||||
mysql_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 {
|
||||
|
||||
+1
-7
@@ -200,22 +200,16 @@ 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 {
|
||||
|
||||
+9
-23
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -27,20 +28,18 @@ 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,
|
||||
@@ -138,19 +137,11 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
|
||||
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
|
||||
|
||||
// 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?
|
||||
};
|
||||
// 3. Bill wallet
|
||||
let billing = bill_wallet(user_token, cost, mysql_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,
|
||||
@@ -165,15 +156,10 @@ 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);
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -192,10 +192,6 @@ 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!
|
||||
|
||||
+7
-3
@@ -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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
@@ -94,10 +94,14 @@ pub async fn set_tags_for_entity_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
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,10 +54,14 @@ pub async fn change_password_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
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,10 +53,14 @@ pub async fn edit_email_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+7
-3
@@ -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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
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,10 +63,14 @@ pub async fn edit_username_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+12
-3
@@ -11,7 +11,8 @@ 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::require_user_session_extended;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
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;
|
||||
@@ -94,10 +95,18 @@ pub async fn update_weight_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended(
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut *mysql_connection).await?;
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
_ => {
|
||||
warn!("get user session error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
},
|
||||
})?;
|
||||
|
||||
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, SessionUserRecord};
|
||||
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_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, MySqlConnection, MySqlPool};
|
||||
use sqlx::{Executor, MySql, 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,18 +209,6 @@ 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),
|
||||
@@ -229,7 +217,7 @@ impl SessionChecker {
|
||||
|
||||
// TODO: Fire both requests off simultaneously.
|
||||
let user_session = {
|
||||
match get_user_session_by_token(&mut *mysql_executor, &session_payload.session_token).await? {
|
||||
match get_user_session_by_token_pooled_connection(mysql_connection, &session_payload.session_token).await? {
|
||||
None => return Ok(None),
|
||||
Some(u) => u,
|
||||
}
|
||||
@@ -238,7 +226,7 @@ impl SessionChecker {
|
||||
// TODO: Cache this so we don't hit the database twice.
|
||||
let subscriptions =
|
||||
list_active_user_subscriptions(
|
||||
&mut *mysql_executor,
|
||||
mysql_connection,
|
||||
user_session.user_token.as_str()
|
||||
).await
|
||||
.map_err(SessionCheckerError::OtherError)?;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod require_moderator;
|
||||
pub mod require_user_session;
|
||||
pub mod require_user_session_extended;
|
||||
pub mod require_user_session_extended_using_connection;
|
||||
pub mod require_user_session_using_connection;
|
||||
|
||||
+28
-8
@@ -1,41 +1,61 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RequireModeratorError {
|
||||
ServerError,
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
impl Display for RequireModeratorError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ServerError => write!(f, "ServerError"),
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RequireModeratorError {}
|
||||
|
||||
/// `mysql_executor` can be any sqlx executor — pass `&server_state.mysql_pool` to grab a
|
||||
/// fresh connection, or an in-flight connection/transaction (`&mut *connection`) to reuse
|
||||
/// one the handler already holds.
|
||||
pub async fn require_moderator<'e, 'c : 'e, E>(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
server_state: &ServerState,
|
||||
mysql_executor: E,
|
||||
) -> Result<SessionUserRecord, CommonWebError>
|
||||
) -> Result<SessionUserRecord, RequireModeratorError>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_user_session = session_checker
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_executor(http_request, mysql_executor)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
RequireModeratorError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
return Err(RequireModeratorError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if !user_session.is_mod() {
|
||||
warn!("user is not a moderator: {:?}", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
return Err(RequireModeratorError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
|
||||
+35
-31
@@ -1,42 +1,46 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// `mysql_executor` can be any sqlx executor — pass `&server_state.mysql_pool` to grab a
|
||||
/// fresh connection, or an in-flight connection/transaction (`&mut *connection`) to reuse
|
||||
/// one the handler already holds.
|
||||
pub async fn require_user_session<'e, 'c : 'e, E>(
|
||||
#[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 {}
|
||||
|
||||
#[deprecated(note = "Use require_user_session_using_connection instead, which reuses an existing connection")]
|
||||
pub async fn require_user_session(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_executor: E,
|
||||
) -> Result<SessionUserRecord, CommonWebError>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_from_executor(http_request, mysql_executor)
|
||||
server_state: &ServerState,
|
||||
) -> Result<SessionUserRecord, RequireUserSessionError> {
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
.map_err(|err| {
|
||||
warn!("MySql pool error: {:?}", err);
|
||||
RequireUserSessionError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
require_user_session_using_connection(
|
||||
http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
.await
|
||||
}
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
use std::error::Error;
|
||||
|
||||
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;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::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)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
|
||||
pub async fn require_user_session_using_connection(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<SessionUserRecord, RequireUserSessionError> {
|
||||
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_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.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
+5
-11
@@ -2,18 +2,12 @@ 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.
|
||||
// Hosts surface success/failure feedback (e.g. a toast) via `onCopyResult`.
|
||||
export function CopyPromptButton({
|
||||
text,
|
||||
onCopyResult,
|
||||
}: {
|
||||
text: string;
|
||||
onCopyResult?: (success: boolean) => void;
|
||||
}) {
|
||||
export function CopyPromptButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
@@ -21,14 +15,14 @@ export function CopyPromptButton({
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onCopyResult?.(true);
|
||||
toast.success("Prompt copied");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
onCopyResult?.(false);
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
},
|
||||
[text, onCopyResult],
|
||||
[text],
|
||||
);
|
||||
|
||||
return (
|
||||
-10
@@ -196,16 +196,6 @@ 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>
|
||||
|
||||
+34
-8
@@ -1,7 +1,13 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faCircleExclamation,
|
||||
faSpinnerThird,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getModelCreatorIconPath } from "../../lib/omni-gen-hooks";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
|
||||
export interface FailedCardProps {
|
||||
id: string;
|
||||
@@ -14,8 +20,10 @@ export interface FailedCardProps {
|
||||
// at low opacity so failed cards still hint at what the user was trying to
|
||||
// generate.
|
||||
refImageUrl?: string;
|
||||
/** Inline action (e.g. a labeled Recreate button) next to Dismiss. */
|
||||
recreateSlot?: ReactNode;
|
||||
// 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";
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -27,10 +35,15 @@ export const FailedCard = memo(function FailedCard({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
recreateSlot,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
onDismiss,
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10">
|
||||
@@ -56,7 +69,20 @@ export const FailedCard = memo(function FailedCard({
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
{recreateSlot}
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(id)}
|
||||
+35
-27
@@ -1,25 +1,16 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleExclamation, faXmark } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
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 { CopyPromptButton } from "./CopyPromptButton";
|
||||
|
||||
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;
|
||||
}
|
||||
import type { FailedCardProps } from "./FailedCard";
|
||||
|
||||
export const FailedRow = memo(function FailedRow({
|
||||
id,
|
||||
@@ -29,11 +20,15 @@ export const FailedRow = memo(function FailedRow({
|
||||
modelId,
|
||||
modelLabel,
|
||||
refImageUrl,
|
||||
recreateSlot,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
onDismiss,
|
||||
onCopyPromptResult,
|
||||
}: FailedRowProps) {
|
||||
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
|
||||
}: FailedCardProps) {
|
||||
const iconPath = modelId ? getModelCreatorIconPath(modelId) : null;
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-2.5 py-2">
|
||||
@@ -59,10 +54,23 @@ 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} onCopyResult={onCopyPromptResult} />
|
||||
{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>
|
||||
)}
|
||||
{recreateSlot}
|
||||
</div>
|
||||
{prompt && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/45">{prompt}</p>
|
||||
+92
-26
@@ -1,12 +1,17 @@
|
||||
import { memo, useCallback, useState, type ReactNode } from "react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import type { GalleryItem } from "./types";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// ── Persistent aspect ratio cache ─────────────────────────────────────────
|
||||
|
||||
@@ -53,36 +58,43 @@ export const aspectRatioCache = loadCache();
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GalleryCardProps {
|
||||
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";
|
||||
/** Hover-revealed quick-action cluster (recreate / share / download …). */
|
||||
actionsSlot?: ReactNode;
|
||||
// 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;
|
||||
}
|
||||
|
||||
export const GalleryCard = memo(function GalleryCard({
|
||||
item,
|
||||
onClick,
|
||||
shape = "auto",
|
||||
actionsSlot,
|
||||
enableMakeVideo = false,
|
||||
}: GalleryCardProps) {
|
||||
const isSquare = shape === "square";
|
||||
const cached = aspectRatioCache.get(item.id);
|
||||
const [ratio, setRatio] = useState<number | undefined>(cached);
|
||||
|
||||
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 {
|
||||
mediaIcon,
|
||||
mediaLabel,
|
||||
modelDisplayName,
|
||||
modelIconPath,
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
|
||||
const displayRatio = ratio ? Math.min(ratio, MAX_RATIO) : 1;
|
||||
|
||||
@@ -139,7 +151,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.label}
|
||||
isVideo={isVideo}
|
||||
isVideo={item.mediaClass === "video"}
|
||||
fallbackIcon={mediaIcon}
|
||||
onLoad={measureRatio}
|
||||
/>
|
||||
@@ -164,11 +176,65 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
)}
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
+117
-36
@@ -1,50 +1,47 @@
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCube, faImage, faPlay, faVideo } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faPlay,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
import { CopyPromptButton } from "./CopyPromptButton";
|
||||
import { formatTimeAgo } from "./format-time-ago";
|
||||
import type { GalleryItem } from "./types";
|
||||
import {
|
||||
useGalleryItemActions,
|
||||
type GalleryItemActions,
|
||||
} from "./useGalleryItemActions";
|
||||
import { formatTimeAgo } from "../../lib/format-time-ago";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
export interface GalleryRowProps {
|
||||
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 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 actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
|
||||
const { isVideo, mediaIcon, mediaLabel, modelDisplayName, modelIconPath } =
|
||||
actions;
|
||||
|
||||
const handleRowClick = useCallback(() => onClick(item), [item, onClick]);
|
||||
const handleRowKeyDown = useCallback(
|
||||
@@ -100,12 +97,7 @@ 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}
|
||||
onCopyResult={onCopyPromptResult}
|
||||
/>
|
||||
)}
|
||||
{title && <CopyPromptButton text={title} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-white/45">
|
||||
@@ -129,11 +121,12 @@ 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. */}
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-white/40">
|
||||
{timeAgo}
|
||||
</span>
|
||||
@@ -142,3 +135,91 @@ 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+182
-2
@@ -1,2 +1,182 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryThumbnail } from "@storyteller/ui-generation-list";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+53
-2
@@ -1,2 +1,53 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+84
-164
@@ -1,26 +1,29 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 {
|
||||
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";
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// 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.
|
||||
// ── 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 function GenerationGalleryGrid({
|
||||
inProgressJobs,
|
||||
@@ -30,163 +33,80 @@ export function GenerationGalleryGrid({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
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 sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
// 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,
|
||||
);
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
|
||||
if (kind === "failed") {
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<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>
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
<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}
|
||||
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>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+128
-184
@@ -1,30 +1,19 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
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 {
|
||||
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";
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import { usePrompts } from "../../lib/prompts-cache";
|
||||
import { useMediaPromptTokens } from "../../lib/media-prompt-token-cache";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
export function GenerationGalleryList({
|
||||
inProgressJobs,
|
||||
@@ -34,176 +23,131 @@ export function GenerationGalleryList({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
onCopyPromptResult={handleCopyResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
function handleCopyResult(success: boolean) {
|
||||
if (success) {
|
||||
toast.success("Prompt copied");
|
||||
} else {
|
||||
toast.error("Unable to copy prompt");
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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" : ""}`}
|
||||
<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}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-13
@@ -1,7 +1,12 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
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 { derivePendingStatus } from "./pending-status";
|
||||
|
||||
export interface PendingCardProps {
|
||||
@@ -12,9 +17,9 @@ export interface PendingCardProps {
|
||||
progress?: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
batchCount?: number;
|
||||
mediaClass: "image" | "video";
|
||||
/** Hover-revealed action (e.g. a Recreate button) next to the prompt. */
|
||||
recreateSlot?: ReactNode;
|
||||
// Prompt token + media class enable the "Recreate" action mid-generation.
|
||||
promptToken?: string;
|
||||
recreateMediaClass: "image" | "video";
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({
|
||||
@@ -24,24 +29,26 @@ export const PendingCard = memo(function PendingCard({
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
batchCount,
|
||||
mediaClass,
|
||||
recreateSlot,
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
}: PendingCardProps) {
|
||||
const { progressPercent, timeLabel } = derivePendingStatus(
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
);
|
||||
|
||||
const iconPath = getCreatorIconPathForModelId(modelId);
|
||||
const iconPath = getModelCreatorIconPath(modelId);
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
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}{" "}
|
||||
{mediaClass === "image" ? "images" : "videos"} · Results may appear
|
||||
one at a time
|
||||
Generating {batchCount} videos · Results may appear one at a time
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
@@ -63,7 +70,22 @@ 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>
|
||||
{recreateSlot}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<img
|
||||
+34
-30
@@ -1,23 +1,15 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { getCreatorIconPathForModelId } from "@storyteller/model-list";
|
||||
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 { CopyPromptButton } from "./CopyPromptButton";
|
||||
import { derivePendingStatus } from "./pending-status";
|
||||
|
||||
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;
|
||||
}
|
||||
import type { PendingCardProps } from "./PendingCard";
|
||||
|
||||
export const PendingRow = memo(function PendingRow({
|
||||
modelId,
|
||||
@@ -26,15 +18,18 @@ export const PendingRow = memo(function PendingRow({
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
batchCount,
|
||||
mediaClass,
|
||||
recreateSlot,
|
||||
onCopyPromptResult,
|
||||
}: PendingRowProps) {
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
}: PendingCardProps) {
|
||||
const { progressPercent, timeLabel } = derivePendingStatus(
|
||||
progress,
|
||||
estimatedTimeLeftMs,
|
||||
);
|
||||
const iconPath = getCreatorIconPathForModelId(modelId);
|
||||
const iconPath = getModelCreatorIconPath(modelId);
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
recreateMediaClass,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-lg px-2.5 py-2">
|
||||
@@ -63,9 +58,7 @@ export const PendingRow = memo(function PendingRow({
|
||||
Generating…
|
||||
</p>
|
||||
)}
|
||||
{prompt.trim() && (
|
||||
<CopyPromptButton text={prompt} onCopyResult={onCopyPromptResult} />
|
||||
)}
|
||||
{prompt.trim() && <CopyPromptButton text={prompt} />}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-white/45">
|
||||
<img
|
||||
@@ -77,16 +70,27 @@ export const PendingRow = memo(function PendingRow({
|
||||
{batchCount != null && batchCount > 1 && (
|
||||
<>
|
||||
<span className="text-white/25">·</span>
|
||||
<span className="shrink-0">
|
||||
{batchCount} {mediaClass === "image" ? "images" : "videos"}
|
||||
</span>
|
||||
<span className="shrink-0">{batchCount} videos</span>
|
||||
</>
|
||||
)}
|
||||
{/* Recreate is hover-revealed on desktop (always visible on mobile),
|
||||
mirroring the completed GalleryRow's quick actions. */}
|
||||
{recreateSlot && (
|
||||
{promptToken && (
|
||||
<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">
|
||||
{recreateSlot}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2,6 +2,12 @@ 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,5 +1,32 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export {
|
||||
derivePendingStatus,
|
||||
type PendingStatus,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
// 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`;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,170 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { useGalleryData, type GalleryItem } from "@storyteller/ui-generation-list";
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+98
-7
@@ -1,7 +1,98 @@
|
||||
// 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";
|
||||
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;
|
||||
}
|
||||
|
||||
+28
-4
@@ -11,11 +11,35 @@ 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 type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
import type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
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";
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,2 +1,27 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { formatTimeAgo } from "@storyteller/ui-generation-list";
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
// 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";
|
||||
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" },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,104 @@
|
||||
// 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";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,73 @@
|
||||
// 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";
|
||||
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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user