Compare commits

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

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

### Shared generation-list lib

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

### Desktop create pages

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

### Embedded model selector

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

### Gallery modal

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

### Restyled modals (desktop)

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

### Apps page / navigation

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

### Fixes

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

### Misc

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

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