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