mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-02 07:45:41 -05:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e95be9bdb | ||
|
|
b7667976df | ||
|
|
e07d302df1 | ||
|
|
0de867ad13 | ||
|
|
a1cb1b2629 | ||
|
|
8da68b009c | ||
|
|
e4bc031fbb |
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: ApiKeyToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "api_key_prefix!",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_description",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 2048
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "ip_address_update",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 160
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "created_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "updated_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805"
|
||||
}
|
||||
Generated
+494
-564
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -342,11 +342,11 @@ sha2 = "0.11.0-rc.5"
|
||||
sha3 = "0.10.8"
|
||||
speculoos = "0.11.0"
|
||||
strum = { version = "0.28.0", features = ["derive"] }
|
||||
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.3.3"
|
||||
tauri-plugin-fs = "2.4.2"
|
||||
tauri-plugin-log = "2.6.0"
|
||||
tauri-plugin-upload = "2.3.1"
|
||||
tauri = { version = "2.11.2", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-upload = "2.4.0"
|
||||
tempdir = "0.3.7"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Effective "primary key" (used internally for lookups and as a foreign key).
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- The actual API key value presented by clients to authenticate.
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
|
||||
-- ========== KEY INFO ==========
|
||||
|
||||
-- User-supplied display name for the key.
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Optional user-supplied description.
|
||||
maybe_description VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
-- ========== OWNER ==========
|
||||
|
||||
-- The user that owns this API key.
|
||||
owner_user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the key. Wide enough for IPv4/IPv6.
|
||||
ip_address_update VARCHAR(40) NOT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the key is live.
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (api_key),
|
||||
KEY fk_owner_user_token (owner_user_token),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,42 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// Canonical wire shape for an API key, used by the list rows and the
|
||||
/// single-key GET.
|
||||
///
|
||||
/// NB: the full secret `api_key` is NEVER included here — only
|
||||
/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys
|
||||
/// in a UI. The full value is returned exactly once, at creation time.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ApiKeyInfo {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the API key. The full secret is never returned
|
||||
/// after creation.
|
||||
pub truncated_api_key: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
/// Soft-delete timestamp. `None` for live keys.
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Path info for endpoints addressed by the API key's `token` (NOT the secret
|
||||
/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ApiKeyPathInfo {
|
||||
pub api_key_token: ApiKeyToken,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
// ── POST /v1/api_keys/create ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
/// Display name (title) for the key.
|
||||
pub name: String,
|
||||
|
||||
/// Optional description.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CreateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The key's `token` — used for all subsequent management
|
||||
/// (get / update / delete). Safe to store and display.
|
||||
pub api_key_token: ApiKeyToken,
|
||||
|
||||
/// The full secret API key value. This is the ONLY time it is ever
|
||||
/// returned — the caller must store it now, as it cannot be retrieved again.
|
||||
pub api_key: String,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── DELETE /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DeleteApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct GetApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
pub api_key: ApiKeyInfo,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::api_keys::common::ApiKeyInfo;
|
||||
|
||||
// ── GET /v1/api_keys/list ──
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListApiKeysQueryParams {
|
||||
/// Page size. Defaults server-side if omitted.
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Number of rows to skip. Defaults to 0.
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListApiKeysSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The user's API keys (including soft-deleted ones), newest first. Each
|
||||
/// entry's key value is truncated — the full secret is never listed.
|
||||
pub api_keys: Vec<ApiKeyInfo>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod common;
|
||||
pub mod create_api_key;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key;
|
||||
pub mod list_api_keys;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── PUT /v1/api_keys/{api_key_token} ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateApiKeyRequest {
|
||||
/// New description for the key. `None` clears it.
|
||||
pub maybe_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct UpdateApiKeySuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod characters;
|
||||
pub mod common;
|
||||
pub mod credits;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A materialized `api_keys` row, minus the internal `id` and the full secret
|
||||
/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to
|
||||
/// disambiguate keys in a UI without ever exposing the secret value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyRow {
|
||||
pub token: ApiKeyToken,
|
||||
|
||||
/// First 20 characters of the `api_key`. The full secret is never returned.
|
||||
pub api_key_prefix: String,
|
||||
|
||||
pub name: String,
|
||||
pub maybe_description: Option<String>,
|
||||
|
||||
pub owner_user_token: UserToken,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct DeleteApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the
|
||||
/// number of rows affected (0 if no live key matched the token).
|
||||
pub async fn delete_api_key<'e, 'c: 'e, E>(
|
||||
args: DeleteApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET maybe_deleted_at = NOW()
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetApiKeyByTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up an API key by its `token`. Returns every detail (including
|
||||
/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the
|
||||
/// internal `id` and the full secret `api_key` (only its first 20 characters
|
||||
/// are returned). `Ok(None)` if no row matches the token.
|
||||
pub async fn get_api_key_by_token<'e, 'c: 'e, E>(
|
||||
args: GetApiKeyByTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct GetLiveApiKeyByValueArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The actual API key secret presented by the client.
|
||||
pub api_key: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up a **live** API key by its secret value (for authenticating an
|
||||
/// incoming request). Returns `Ok(None)` if no row matches OR if the matching
|
||||
/// row is soft-deleted (`maybe_deleted_at IS NOT NULL`). The full secret is
|
||||
/// never echoed back — only its first 20 characters — and the internal `id` is
|
||||
/// omitted.
|
||||
pub async fn get_live_api_key_by_value<'e, 'c: 'e, E>(
|
||||
args: GetLiveApiKeyByValueArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE api_key = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.api_key,
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct InsertApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
pub ip_address: &'e str,
|
||||
pub name: &'e str,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
pub api_key: &'e str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Insert a new API key. The `ApiKeyToken` is minted here (in the data-access
|
||||
/// layer, not the handler) and returned to the caller. The provided IP address
|
||||
/// is stamped on both the creation and update IP columns, and `created_at` is
|
||||
/// set to `NOW()`.
|
||||
pub async fn insert_api_key<'e, 'c: 'e, E>(
|
||||
args: InsertApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<ApiKeyToken, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = ApiKeyToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO api_keys
|
||||
SET
|
||||
token = ?,
|
||||
api_key = ?,
|
||||
name = ?,
|
||||
maybe_description = ?,
|
||||
owner_user_token = ?,
|
||||
ip_address_creation = ?,
|
||||
ip_address_update = ?,
|
||||
created_at = NOW()
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.api_key,
|
||||
args.name,
|
||||
args.maybe_description,
|
||||
args.owner_user_token.as_str(),
|
||||
args.ip_address,
|
||||
args.ip_address,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
pub struct ListApiKeysForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub owner_user_token: &'e UserToken,
|
||||
|
||||
/// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset
|
||||
/// so we don't have to expose the internal `id`.
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// List a user's API keys, newest first, **including soft-deleted** keys.
|
||||
/// Never returns the full `api_key` — only its first 20 characters — and never
|
||||
/// returns the internal `id`.
|
||||
pub async fn list_api_keys_for_user<'e, 'c: 'e, E>(
|
||||
args: ListApiKeysForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<ApiKeyRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let limit = args.limit as i64;
|
||||
let offset = args.offset as i64;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: ApiKeyToken`,
|
||||
LEFT(api_key, 20) as `api_key_prefix!`,
|
||||
name,
|
||||
maybe_description,
|
||||
owner_user_token as `owner_user_token: UserToken`,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
maybe_deleted_at
|
||||
FROM api_keys
|
||||
WHERE owner_user_token = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.owner_user_token.as_str(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| ApiKeyRow {
|
||||
token: r.token,
|
||||
api_key_prefix: r.api_key_prefix,
|
||||
name: r.name,
|
||||
maybe_description: r.maybe_description,
|
||||
owner_user_token: r.owner_user_token,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod api_key_row;
|
||||
pub mod delete_api_key;
|
||||
pub mod get_api_key_by_token;
|
||||
pub mod get_live_api_key_by_value;
|
||||
pub mod insert_api_key;
|
||||
pub mod list_api_keys_for_user;
|
||||
pub mod update_api_key;
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
pub struct UpdateApiKeyArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e ApiKeyToken,
|
||||
pub maybe_description: Option<&'e str>,
|
||||
|
||||
/// IP address of the request making the update; stamped on `ip_address_update`.
|
||||
pub ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Update an API key's `maybe_description`, also refreshing `ip_address_update`
|
||||
/// (and, via the column's `ON UPDATE` clause, `updated_at`). Scoped to live keys
|
||||
/// only. Returns the number of rows affected (0 if no live key matched).
|
||||
pub async fn update_api_key<'e, 'c: 'e, E>(
|
||||
args: UpdateApiKeyArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET
|
||||
maybe_description = ?,
|
||||
ip_address_update = ?
|
||||
WHERE token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.maybe_description,
|
||||
args.ip_address,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
pub mod analytics_active_users;
|
||||
pub mod api_keys;
|
||||
pub mod api_tokens;
|
||||
pub mod audit_logs;
|
||||
pub mod batch_generations;
|
||||
|
||||
+7
-6
@@ -1,8 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::common::payments_namespace::PaymentsNamespace;
|
||||
use errors::AnyhowResult;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ActiveUserSubscription {
|
||||
pub user_token: String,
|
||||
@@ -19,10 +18,12 @@ pub struct ActiveUserSubscription {
|
||||
pub subscription_expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn list_active_user_subscriptions(
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
pub async fn list_active_user_subscriptions<'e, 'c : 'e, E>(
|
||||
mysql_executor: E,
|
||||
user_token: &str
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>> {
|
||||
) -> AnyhowResult<Vec<ActiveUserSubscription>>
|
||||
where E: 'e + Executor<'c, Database = MySql>
|
||||
{
|
||||
// NB: "status=incomplete" subscriptions can happen when a user submits credit card info,
|
||||
// but the CRC is wrong. Eg. what happened with ftx on 2022.11.18.
|
||||
//
|
||||
@@ -45,7 +46,7 @@ WHERE
|
||||
"#,
|
||||
user_token,
|
||||
)
|
||||
.fetch_all(&mut **mysql_connection)
|
||||
.fetch_all(mysql_executor)
|
||||
.await?;
|
||||
|
||||
let records = records.into_iter()
|
||||
|
||||
@@ -23,6 +23,7 @@ use strum::EnumIter;
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
pub(crate) enum TokenPrefix {
|
||||
AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables.
|
||||
ApiKey,
|
||||
AppSession, // NB: These are generated client-side (!)
|
||||
AuditLog,
|
||||
BatchGeneration,
|
||||
@@ -112,6 +113,7 @@ impl PrefixGenerator for TokenPrefix {
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::AnonymousVisitorTracking => "avt_",
|
||||
Self::ApiKey => "api_key_",
|
||||
Self::AppSession => "app_session_",
|
||||
Self::AuditLog => "audit_",
|
||||
Self::BatchGeneration => "batch_g_",
|
||||
@@ -348,6 +350,7 @@ mod tests {
|
||||
|| prefix == "INT_API:"
|
||||
|| prefix == "batch_g_"
|
||||
|| prefix == "beta_key_"
|
||||
|| prefix == "api_key_"
|
||||
|| prefix == "email_job_"
|
||||
|| prefix == "pw_reset_"
|
||||
|| prefix == "tts_task_"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for api_keys.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct ApiKeyToken(pub String);
|
||||
|
||||
impl_mysql_token_from_row!(ApiKeyToken);
|
||||
impl_string_token!(ApiKeyToken);
|
||||
impl_crockford_generator!(ApiKeyToken, 32usize, TokenPrefix::ApiKey, CrockfordLower);
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod anonymous_visitor_tracking;
|
||||
pub mod api_keys;
|
||||
pub mod api_tokens_external;
|
||||
pub mod api_tokens_internal;
|
||||
pub mod app_session;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -95,6 +95,19 @@ use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_ha
|
||||
use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::*;
|
||||
|
||||
// API key endpoints
|
||||
use crate::http_server::endpoints::api_keys::create_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::list_api_keys_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::get_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::delete_api_key_handler::*;
|
||||
use crate::http_server::endpoints::api_keys::update_api_key_handler::*;
|
||||
use artcraft_api_defs::api_keys::common::*;
|
||||
use artcraft_api_defs::api_keys::create_api_key::*;
|
||||
use artcraft_api_defs::api_keys::list_api_keys::*;
|
||||
use artcraft_api_defs::api_keys::get_api_key::*;
|
||||
use artcraft_api_defs::api_keys::delete_api_key::*;
|
||||
use artcraft_api_defs::api_keys::update_api_key::*;
|
||||
|
||||
// Folder endpoints
|
||||
use crate::http_server::endpoints::folders::folder::color_code_folder_handler::*;
|
||||
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*;
|
||||
@@ -561,6 +574,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler,
|
||||
crate::http_server::endpoints::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids_handler::moderator_get_user_stripe_customer_ids_handler,
|
||||
|
||||
// API keys
|
||||
crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler,
|
||||
crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler,
|
||||
crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler,
|
||||
|
||||
// Folders
|
||||
crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler,
|
||||
crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler,
|
||||
@@ -1250,6 +1270,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
// User Emails (Moderation)
|
||||
ModeratorListUserEmailChangesQueryParams,
|
||||
|
||||
// API keys
|
||||
ApiKeyInfo,
|
||||
ApiKeyPathInfo,
|
||||
CreateApiKeyRequest,
|
||||
CreateApiKeySuccessResponse,
|
||||
ListApiKeysQueryParams,
|
||||
ListApiKeysSuccessResponse,
|
||||
GetApiKeySuccessResponse,
|
||||
DeleteApiKeySuccessResponse,
|
||||
UpdateApiKeyRequest,
|
||||
UpdateApiKeySuccessResponse,
|
||||
|
||||
// Folders
|
||||
FolderInfo,
|
||||
FolderThumbnail,
|
||||
|
||||
-26
@@ -2,7 +2,6 @@ use std::fmt::{Display, Formatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::http_server::session::session_checker_error::SessionCheckerError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_payload_error::AvtCookiePayloadError;
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_payload_error::HttpUserSessionPayloadError;
|
||||
use actix_http::StatusCode;
|
||||
@@ -317,15 +316,6 @@ impl From<serde_json::Error> for CommonWebError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequireUserSessionError> for CommonWebError {
|
||||
fn from(value: RequireUserSessionError) -> Self {
|
||||
match value {
|
||||
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
|
||||
RequireUserSessionError::ServerError => Self::from_error(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpUserSessionPayloadError> for CommonWebError {
|
||||
fn from(err: HttpUserSessionPayloadError) -> Self {
|
||||
if err.is_server_error() {
|
||||
@@ -545,22 +535,6 @@ mod tests {
|
||||
assert!(source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_not_authorized_maps_to_401() {
|
||||
let error: CommonWebError = RequireUserSessionError::NotAuthorized.into();
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_server_error_wraps_cause() {
|
||||
let error: CommonWebError = RequireUserSessionError::ServerError.into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
assert!(error.cause().is_some());
|
||||
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_json_error_converts_to_500() {
|
||||
let bad_json = "not json at all{{{";
|
||||
|
||||
+3
-9
@@ -28,8 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -114,15 +113,10 @@ pub async fn enqueue_face_fusion_workflow_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+3
-9
@@ -29,8 +29,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::cleaners::empty_media_file_token_to_null::empty_media_file_token_to_null;
|
||||
|
||||
@@ -122,15 +121,10 @@ pub async fn enqueue_live_portrait_workflow_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyInfo;
|
||||
use mysql_queries::queries::api_keys::api_key_row::ApiKeyRow;
|
||||
|
||||
/// Convert a data-access `ApiKeyRow` into the public `ApiKeyInfo` wire shape.
|
||||
/// The row only ever carries the truncated key prefix (never the full secret),
|
||||
/// so this is a straight field map.
|
||||
pub fn api_key_row_to_info(row: ApiKeyRow) -> ApiKeyInfo {
|
||||
ApiKeyInfo {
|
||||
token: row.token,
|
||||
truncated_api_key: row.api_key_prefix,
|
||||
name: row.name,
|
||||
maybe_description: row.maybe_description,
|
||||
owner_user_token: row.owner_user_token,
|
||||
ip_address_creation: row.ip_address_creation,
|
||||
ip_address_update: row.ip_address_update,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
maybe_deleted_at: row.maybe_deleted_at,
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::create_api_key::{CreateApiKeyRequest, CreateApiKeySuccessResponse};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::generate_api_key::generate_api_key;
|
||||
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
|
||||
/// Create a new API key for the logged-in user. The full secret `api_key`
|
||||
/// value is returned exactly once in this response — it can never be retrieved
|
||||
/// again afterward.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/create",
|
||||
request_body = CreateApiKeyRequest,
|
||||
responses(
|
||||
(status = 200, body = CreateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn create_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<CreateApiKeyRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<CreateApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let name = request.name.trim().to_string();
|
||||
|
||||
let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string());
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string()));
|
||||
}
|
||||
if name.len() > MAX_NAME_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("name too long (max {} chars)", MAX_NAME_LEN),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(description) = &maybe_description {
|
||||
if description.len() > MAX_DESCRIPTION_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
// Generate the secret once. It is returned to the caller here and never again.
|
||||
let api_key = generate_api_key();
|
||||
|
||||
let token = insert_api_key(InsertApiKeyArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
ip_address: &ip_address,
|
||||
name: &name,
|
||||
maybe_description: maybe_description.as_deref(),
|
||||
api_key: &api_key,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("insert_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(CreateApiKeySuccessResponse {
|
||||
success: true,
|
||||
api_key_token: token,
|
||||
api_key,
|
||||
}))
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::delete_api_key::DeleteApiKeySuccessResponse;
|
||||
use mysql_queries::queries::api_keys::delete_api_key::{delete_api_key, DeleteApiKeyArgs};
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Soft-delete an API key by `token`, scoped to the logged-in user.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
responses(
|
||||
(status = 200, body = DeleteApiKeySuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn delete_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<DeleteApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
// The by-token delete query isn't owner-scoped, so first look the key up and
|
||||
// confirm ownership. Return NotFound for missing OR other-owned keys, so we
|
||||
// never leak the existence of another user's key.
|
||||
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
// Only issue the delete if the key is still live. If it's already
|
||||
// soft-deleted the desired end state already holds, so treat it as success.
|
||||
if row.maybe_deleted_at.is_none() {
|
||||
delete_api_key(DeleteApiKeyArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Json(DeleteApiKeySuccessResponse { success: true }))
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::get_api_key::GetApiKeySuccessResponse;
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Fetch a single API key by its `token`, scoped to the logged-in user.
|
||||
/// `NotFound` if the key doesn't exist or belongs to a different user (so we
|
||||
/// don't leak the existence of others' keys). Never returns the full secret.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
responses(
|
||||
(status = 200, body = GetApiKeySuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn get_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<GetApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
let row = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
Ok(Json(GetApiKeySuccessResponse {
|
||||
success: true,
|
||||
api_key: api_key_row_to_info(row),
|
||||
}))
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Query};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::list_api_keys::{ListApiKeysQueryParams, ListApiKeysSuccessResponse};
|
||||
use mysql_queries::queries::api_keys::list_api_keys_for_user::{
|
||||
list_api_keys_for_user, ListApiKeysForUserArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
/// List the logged-in user's API keys, newest first, **including
|
||||
/// soft-deleted** keys. Paginated via `limit`/`offset`. Never returns the full
|
||||
/// secret — each entry carries only the truncated key prefix.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/list",
|
||||
params(ListApiKeysQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListApiKeysSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_api_keys_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListApiKeysQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListApiKeysSuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
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);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let rows = list_api_keys_for_user(ListApiKeysForUserArgs {
|
||||
owner_user_token: &user_session.user_token,
|
||||
limit,
|
||||
offset,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_api_keys_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let api_keys = rows.into_iter().map(api_key_row_to_info).collect();
|
||||
|
||||
Ok(Json(ListApiKeysSuccessResponse {
|
||||
success: true,
|
||||
api_keys,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod api_key_info_conversion;
|
||||
pub mod create_api_key_handler;
|
||||
pub mod delete_api_key_handler;
|
||||
pub mod get_api_key_handler;
|
||||
pub mod list_api_keys_handler;
|
||||
pub mod update_api_key_handler;
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::{Json, Path};
|
||||
use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
|
||||
use artcraft_api_defs::api_keys::common::ApiKeyPathInfo;
|
||||
use artcraft_api_defs::api_keys::update_api_key::{UpdateApiKeyRequest, UpdateApiKeySuccessResponse};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::api_keys::get_api_key_by_token::{
|
||||
get_api_key_by_token, GetApiKeyByTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::api_keys::update_api_key::{update_api_key, UpdateApiKeyArgs};
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session::require_user_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_DESCRIPTION_LEN: usize = 512;
|
||||
|
||||
/// Update an API key's description (also refreshing its update IP), scoped to
|
||||
/// the logged-in user. `NotFound` if the key doesn't exist, belongs to another
|
||||
/// user, or has been deleted.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
tag = "API Keys",
|
||||
path = "/v1/api_keys/{api_key_token}",
|
||||
params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")),
|
||||
request_body = UpdateApiKeyRequest,
|
||||
responses(
|
||||
(status = 200, body = UpdateApiKeySuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn update_api_key_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<ApiKeyPathInfo>,
|
||||
request: Json<UpdateApiKeyRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<UpdateApiKeySuccessResponse>, CommonWebError> {
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?;
|
||||
|
||||
if let Some(description) = &request.maybe_description {
|
||||
if description.len() > MAX_DESCRIPTION_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the key exists and is owned by this user before updating (the
|
||||
// update query isn't owner-scoped). NotFound for missing/other-owned keys.
|
||||
let existing = get_api_key_by_token(GetApiKeyByTokenArgs {
|
||||
token: &path.api_key_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_api_key_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.owner_user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
if existing.maybe_deleted_at.is_some() {
|
||||
return Err(CommonWebError::NotFound);
|
||||
}
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
update_api_key(UpdateApiKeyArgs {
|
||||
token: &path.api_key_token,
|
||||
maybe_description: request.maybe_description.as_deref(),
|
||||
ip_address: &ip_address,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("update_api_key failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(UpdateApiKeySuccessResponse { success: true }))
|
||||
}
|
||||
+7
-4
@@ -19,13 +19,13 @@ use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
|
||||
use mysql_queries::queries::media_files::create::insert_media_file_from_studio_scene_render::{insert_media_file_from_studio_scene_render, InsertStudioSceneRenderArgs};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::extract_frames_from_zip::{extract_frames_from_zip, ExtractFramesError};
|
||||
use crate::http_server::endpoints::media_files::upload::upload_studio_shot::ffmpeg_frames_to_mp4::ffmpeg_frames_to_mp4;
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_FPS : u8 = 12;
|
||||
@@ -111,11 +111,14 @@ pub async fn upload_studio_shot_media_file_handler(
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
// NB: We require a moderator to upload PMX files.
|
||||
let user_session = require_user_session_extended_using_connection(&http_request, &server_state.session_checker, &mut mysql_connection)
|
||||
let user_session = require_user_session_extended(&http_request, &server_state.session_checker, &mut *mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("User session error: {:?}", e);
|
||||
MediaFileUploadError::NotAuthorized
|
||||
match e {
|
||||
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
_ => MediaFileUploadError::ServerError,
|
||||
}
|
||||
})?;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod api_keys;
|
||||
pub mod app_state;
|
||||
pub mod beta_keys;
|
||||
pub mod billing_fakeyou;
|
||||
|
||||
+7
-9
@@ -28,8 +28,7 @@ use crate::http_server::requests::request_headers::get_routing_tag_header::get_r
|
||||
use crate::http_server::requests::request_headers::has_debug_header::has_debug_header;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::validations::validate_model_title::validate_model_title;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -82,15 +81,14 @@ pub async fn enqueue_gptsovits_model_download_handler(
|
||||
let maybe_avt_token = server_state.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::ServerError => CommonWebError::from_error(err),
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
|
||||
// ==================== PAID PLAN + PRIORITY ==================== //
|
||||
|
||||
|
||||
+7
-1
@@ -143,14 +143,20 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
// ==================== PIPELINE ==================== //
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
resolved_media: &resolved_media,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateImageResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+12
-3
@@ -25,18 +25,20 @@ use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesA
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateImageRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut sqlx::pool::PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`finalize_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires a
|
||||
// short-lived connection only for the wallet deduction. Holding a pooled connection across the
|
||||
// external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(
|
||||
args: RunPipelineV2Args<'_>,
|
||||
) -> Result<ImagePipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
resolved_media,
|
||||
} = args;
|
||||
@@ -55,17 +57,24 @@ pub async fn run_pipeline_v2(
|
||||
let apriori_job_token = InferenceJobToken::generate();
|
||||
|
||||
let maybe_wallet_ledger_entry_token = if cost > 0 {
|
||||
// NB: Short-lived connection — released before the external call below.
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
warn!("Failed to acquire MySQL connection for image billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
let deduction = attempt_wallet_deduction_else_common_web_error(
|
||||
user_token,
|
||||
Some(apriori_job_token.as_str()),
|
||||
cost,
|
||||
mysql_connection,
|
||||
&mut billing_connection,
|
||||
).await?;
|
||||
Some(deduction.ledger_entry_token)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// NB: No pooled DB connection is held across this external generation call.
|
||||
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
|
||||
|
||||
Ok(ImagePipelineResult {
|
||||
|
||||
+7
-1
@@ -200,16 +200,22 @@ pub async fn omni_gen_video_generate_handler(
|
||||
warn!("Failed to insert HTTP request debug log: {:?}", err);
|
||||
}
|
||||
|
||||
// NB: Release the pooled DB connection before the (slow, external) generation call so we don't
|
||||
// hold a pool slot idle while waiting on the provider — that's what starves the pool and causes
|
||||
// PoolTimedOut on unrelated endpoints. We re-acquire below to write the result.
|
||||
drop(mysql_connection);
|
||||
|
||||
let pipeline_result = run_pipeline_v2(RunPipelineV2Args {
|
||||
router_builder: &router_builder,
|
||||
server_state: &server_state,
|
||||
mysql_connection: &mut mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map: &media_file_to_url_map,
|
||||
kinovi_character_id_map: &kinovi_character_id_map,
|
||||
kinovi_account,
|
||||
}).await?;
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// ==================== DEBUG LOG: FAL REQUEST ==================== //
|
||||
|
||||
if let GenerateVideoResponse::Fal(ref fal_payload) = pipeline_result.response {
|
||||
|
||||
+23
-9
@@ -3,7 +3,6 @@ use std::convert::TryFrom;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use log::{error, info, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use artcraft_router::api::router_video_model::RouterVideoModel;
|
||||
use artcraft_router::api::router_provider::RouterProvider;
|
||||
use artcraft_router::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
|
||||
@@ -28,18 +27,20 @@ use crate::state::server_state::ServerState;
|
||||
pub struct RunPipelineV2Args<'a> {
|
||||
pub router_builder: &'a GenerateVideoRequestBuilder,
|
||||
pub server_state: &'a ServerState,
|
||||
pub mysql_connection: &'a mut PoolConnection<sqlx::MySql>,
|
||||
pub user_token: &'a UserToken,
|
||||
pub media_file_to_url_map: &'a Option<HashMap<MediaFileToken, String>>,
|
||||
pub kinovi_character_id_map: &'a Option<HashMap<CharacterToken, String>>,
|
||||
pub kinovi_account: KinoviAccount,
|
||||
}
|
||||
|
||||
// NB: This pipeline does an external generation call (`upload_and_generate`) that can take many
|
||||
// seconds. It deliberately does NOT hold a pooled DB connection across that call — it acquires
|
||||
// short-lived connections only for the billing and (on failure) refund writes. Holding a pooled
|
||||
// connection across the external call is what starves the pool and causes `PoolTimedOut`.
|
||||
pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResult, CommonWebError> {
|
||||
let RunPipelineV2Args {
|
||||
router_builder,
|
||||
server_state,
|
||||
mysql_connection,
|
||||
user_token,
|
||||
media_file_to_url_map,
|
||||
kinovi_character_id_map,
|
||||
@@ -137,11 +138,19 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
|
||||
info!("v2 estimated cost: {} credits (estimates: {:?})", cost, cost_estimates);
|
||||
|
||||
// 3. Bill wallet
|
||||
let billing = bill_wallet(user_token, cost, mysql_connection).await?;
|
||||
// 3. Bill wallet (short-lived connection — released before the external call below).
|
||||
let billing = {
|
||||
let mut billing_connection = server_state.mysql_pool.acquire().await
|
||||
.map_err(|err| {
|
||||
error!("Failed to acquire MySQL connection for billing: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
bill_wallet(user_token, cost, &mut billing_connection).await?
|
||||
};
|
||||
|
||||
// 4. Upload media (if draft) and generate video.
|
||||
// The entire block is wrapped so Kinovi failures trigger a refund.
|
||||
// NB: No pooled DB connection is held across this call.
|
||||
let result = upload_and_generate(
|
||||
draft_or_request,
|
||||
server_state,
|
||||
@@ -156,10 +165,15 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
|
||||
if let Some(ledger_entry_token) = billing.maybe_wallet_ledger_entry_token.as_ref() {
|
||||
warn!("Kinovi v2 generation failed, issuing refund for {}: {:?}", ledger_entry_token.as_str(), err);
|
||||
|
||||
let result = refund_wallet_after_api_failure(ledger_entry_token, mysql_connection).await;
|
||||
|
||||
if let Err(refund_err) = result {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
match server_state.mysql_pool.acquire().await {
|
||||
Ok(mut refund_connection) => {
|
||||
if let Err(refund_err) = refund_wallet_after_api_failure(ledger_entry_token, &mut refund_connection).await {
|
||||
error!("Failed to refund wallet after Kinovi v2 failure: {:?}", refund_err);
|
||||
}
|
||||
}
|
||||
Err(acquire_err) => {
|
||||
error!("Failed to acquire MySQL connection to refund wallet after Kinovi v2 failure: {:?}", acquire_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -192,6 +192,10 @@ pub async fn enqueue_studio_gen2_handler(
|
||||
CommonWebError::BadInputWithSimpleMessage("invalid idempotency token".to_string())
|
||||
})?;
|
||||
|
||||
// NB: The connection isn't needed past this point (the enqueue below uses the pool directly),
|
||||
// so release it now rather than holding a pool slot for the rest of the handler.
|
||||
drop(mysql_connection);
|
||||
|
||||
// ==================== LOOK UP MODEL INFO ==================== //
|
||||
|
||||
// TODO(bt): CHECK DATABASE FOR TOKENS!
|
||||
|
||||
+3
-7
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use crate::http_server::common_responses::tag_info::TagInfo;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use actix_web::error::ResponseError;
|
||||
use actix_web::http::StatusCode;
|
||||
@@ -94,14 +94,10 @@ pub async fn set_tags_for_entity_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+3
-7
@@ -11,7 +11,7 @@ use log::warn;
|
||||
use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse};
|
||||
use crate::http_server::validations::validate_passwords::validate_passwords;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_password::{update_password, UpdatePasswordArgs};
|
||||
@@ -54,14 +54,10 @@ pub async fn change_password_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+3
-7
@@ -10,7 +10,7 @@ use actix_web::{web, HttpRequest};
|
||||
use log::warn;
|
||||
use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError};
|
||||
@@ -53,14 +53,10 @@ pub async fn edit_email_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+3
-7
@@ -12,7 +12,7 @@ use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameR
|
||||
use crate::http_server::validations::is_reserved_username::is_reserved_username;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::validations::validate_username::validate_username;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::users::user::update::update_username::{update_username, UpdateUsernameArgs, UpdateUsernameError};
|
||||
@@ -63,14 +63,10 @@ pub async fn edit_username_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
if user_session.role.is_banned {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
|
||||
+3
-12
@@ -11,8 +11,7 @@ use utoipa::ToSchema;
|
||||
|
||||
use crate::configs::supported_languages_for_models::{get_canonicalized_language_tag_for_model, get_primary_language_subtag};
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::RequireUserSessionError;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended_using_connection::require_user_session_extended_using_connection;
|
||||
use crate::http_server::web_utils::user_session::require_user_session_extended::require_user_session_extended;
|
||||
use crate::state::server_state::ServerState;
|
||||
use enums::by_table::media_files::media_file_type::MediaFileType;
|
||||
use enums::by_table::model_weights::weights_types::WeightsType;
|
||||
@@ -95,18 +94,10 @@ pub async fn update_weight_handler(
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let user_session = require_user_session_extended_using_connection(
|
||||
let user_session = require_user_session_extended(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&mut mysql_connection)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
RequireUserSessionError::NotAuthorized => CommonWebError::NotAuthorized,
|
||||
_ => {
|
||||
warn!("get user session error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
},
|
||||
})?;
|
||||
&mut *mysql_connection).await?;
|
||||
|
||||
let weight_token = path.weight_token.clone();
|
||||
|
||||
|
||||
+2
@@ -1,4 +1,5 @@
|
||||
use crate::http_server::routes::application_routes::analytics_routes::add_analytics_routes;
|
||||
use crate::http_server::routes::application_routes::api_keys_routes::add_api_keys_routes;
|
||||
use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_billing_fakeyou_routes;
|
||||
use crate::http_server::routes::application_routes::character_routes::add_character_routes;
|
||||
use crate::http_server::routes::application_routes::comments_routes::add_comments_routes;
|
||||
@@ -57,6 +58,7 @@ where
|
||||
// Media files routes
|
||||
app = add_media_file_routes(app); // /v1/media_files/...
|
||||
app = add_folders_routes(app); // /v1/folders/...
|
||||
app = add_api_keys_routes(app); // /v1/api_keys/...
|
||||
app = add_featured_item_routes(app); // /v1/featured_item/...
|
||||
app = add_prompts_routes(app); // /v1/prompts/...
|
||||
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
use actix_http::body::MessageBody;
|
||||
use actix_service::ServiceFactory;
|
||||
use actix_web::dev::{ServiceRequest, ServiceResponse};
|
||||
use actix_web::{web, App, Error, HttpResponse};
|
||||
|
||||
use crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler;
|
||||
use crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler;
|
||||
use crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler;
|
||||
|
||||
pub fn add_api_keys_routes<T, B>(app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(
|
||||
web::scope("/v1/api_keys")
|
||||
// NB: Static routes are registered BEFORE the dynamic `/{api_key_token}`
|
||||
// route so they take precedence. API key tokens are always `api_key_…`,
|
||||
// so they can never collide with these literals anyway.
|
||||
.service(
|
||||
web::resource("/create")
|
||||
.route(web::post().to(create_api_key_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/list")
|
||||
.route(web::get().to(list_api_keys_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{api_key_token}")
|
||||
.route(web::get().to(get_api_key_handler))
|
||||
.route(web::delete().to(delete_api_key_handler))
|
||||
.route(web::put().to(update_api_key_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics_routes;
|
||||
mod api_keys_routes;
|
||||
mod billing_fakeyou_routes;
|
||||
mod character_routes;
|
||||
mod comments_routes;
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, get_user_session_by_token_pooled_connection, SessionUserRecord};
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, SessionUserRecord};
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token_light::{get_user_session_by_token_light, SessionRecord};
|
||||
use mysql_queries::queries::users::user_subscriptions::list_active_user_subscriptions::list_active_user_subscriptions;
|
||||
use redis_caching::redis_ttl_cache::{RedisTtlCache, RedisTtlCacheConnection};
|
||||
use redis_common::redis_cache_keys::RedisCacheKeys;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Executor, MySql, MySqlPool};
|
||||
use sqlx::{Executor, MySql, MySqlConnection, MySqlPool};
|
||||
|
||||
use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails};
|
||||
use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
@@ -209,6 +209,18 @@ impl SessionChecker {
|
||||
request: &HttpRequest,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
|
||||
{
|
||||
self.maybe_get_user_session_extended_from_executor(request, &mut **mysql_connection).await
|
||||
}
|
||||
|
||||
// NB: This takes a concrete `&mut MySqlConnection` rather than a generic `Executor` because
|
||||
// the extended lookup runs two queries; it reborrows the connection for each. (A by-value
|
||||
// `E: Executor` would be consumed by the first query.)
|
||||
pub async fn maybe_get_user_session_extended_from_executor(
|
||||
&self,
|
||||
request: &HttpRequest,
|
||||
mysql_executor: &mut MySqlConnection,
|
||||
) -> Result<Option<UserSessionExtended>, SessionCheckerError>
|
||||
{
|
||||
let session_payload= match self.cookie_manager.decode_session_payload_from_request(request)? {
|
||||
None => return Ok(None),
|
||||
@@ -217,7 +229,7 @@ impl SessionChecker {
|
||||
|
||||
// TODO: Fire both requests off simultaneously.
|
||||
let user_session = {
|
||||
match get_user_session_by_token_pooled_connection(mysql_connection, &session_payload.session_token).await? {
|
||||
match get_user_session_by_token(&mut *mysql_executor, &session_payload.session_token).await? {
|
||||
None => return Ok(None),
|
||||
Some(u) => u,
|
||||
}
|
||||
@@ -226,7 +238,7 @@ impl SessionChecker {
|
||||
// TODO: Cache this so we don't hit the database twice.
|
||||
let subscriptions =
|
||||
list_active_user_subscriptions(
|
||||
mysql_connection,
|
||||
&mut *mysql_executor,
|
||||
user_session.user_token.as_str()
|
||||
).await
|
||||
.map_err(SessionCheckerError::OtherError)?;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod require_moderator;
|
||||
pub mod require_user_session;
|
||||
pub mod require_user_session_extended_using_connection;
|
||||
pub mod require_user_session_extended;
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::MySqlConnection;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
|
||||
/// Pass an in-flight connection (`&mut *connection`) to reuse one the handler already holds.
|
||||
/// (The extended lookup runs two queries, so it takes a concrete `&mut MySqlConnection` — which
|
||||
/// it reborrows for each — rather than a by-value `Executor` that the first query would consume.)
|
||||
pub async fn require_user_session_extended(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_executor: &mut MySqlConnection,
|
||||
) -> Result<UserSessionExtended, CommonWebError>
|
||||
{
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_extended_from_executor(http_request, mysql_executor)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
CommonWebError::from_error(e)
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.role.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
|
||||
use crate::http_server::session::lookup::user_session_extended::UserSessionExtended;
|
||||
use crate::http_server::session::session_checker::SessionChecker;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RequireUserSessionError {
|
||||
ServerError,
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
impl Display for RequireUserSessionError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ServerError => write!(f, "ServerError"),
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for RequireUserSessionError {}
|
||||
|
||||
pub async fn require_user_session_extended_using_connection(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<UserSessionExtended, RequireUserSessionError> {
|
||||
|
||||
let maybe_user_session = session_checker
|
||||
.maybe_get_user_session_extended_from_connection(&http_request, mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Session checker error: {:?}", e);
|
||||
RequireUserSessionError::ServerError
|
||||
})?;
|
||||
|
||||
let user_session = match maybe_user_session {
|
||||
Some(session) => session,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
if user_session.role.is_banned {
|
||||
warn!("user is banned: {:?}", user_session.user_token.as_str());
|
||||
return Err(RequireUserSessionError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(user_session)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use rand::Rng;
|
||||
|
||||
/// All Artcraft API keys begin with this prefix. It counts toward the total key length.
|
||||
///
|
||||
/// The underscores in this literal are the ONLY underscores permitted in a key — the random
|
||||
/// suffix is pure Crockford-lowercase alphanumeric. (Well-known keys do this too: Stripe uses
|
||||
/// `sk_live_<random>`, GitHub `ghp_<random>`, etc. — a readable prefix plus a long random body.)
|
||||
const API_KEY_PREFIX: &str = "artcraft_api_";
|
||||
|
||||
/// Crockford base32 lowercase alphabet — 32 symbols, deliberately excluding the ambiguous
|
||||
/// `i`, `l`, `o`, `u`. Every symbol is alphanumeric, so keys never contain `.`, `+`, `/`, `_`,
|
||||
/// or any other punctuation in the random body. Each symbol carries log2(32) = 5 bits.
|
||||
///
|
||||
/// NB: Unlike Stripe/GitHub keys (which use a full base-36 `0-9a-z` alphabet), ours is Crockford
|
||||
/// so the body is unambiguous to read/transcribe.
|
||||
const CROCKFORD_LOWERCASE_CHARSET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// Number of random Crockford characters in the entropy suffix.
|
||||
///
|
||||
/// 40 symbols * 5 bits = **200 bits** of entropy — far above the ~128-bit cryptographic-security
|
||||
/// floor, and ahead of a typical Stripe secret key (~24 base-36 chars ≈ 124 bits). The total key
|
||||
/// length is therefore `API_KEY_PREFIX.len() + 40` = 53 characters.
|
||||
const ENTROPY_CHAR_COUNT: usize = 40;
|
||||
|
||||
/// Generate a cryptographically-secure Artcraft API key, e.g.
|
||||
/// `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t...` (40 Crockford chars after the prefix).
|
||||
///
|
||||
/// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded
|
||||
/// generator), which never fails.
|
||||
pub fn generate_api_key() -> String {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
|
||||
key.push_str(API_KEY_PREFIX);
|
||||
|
||||
for _ in 0..ENTROPY_CHAR_COUNT {
|
||||
let index = rng.random_range(0..CROCKFORD_LOWERCASE_CHARSET.len());
|
||||
key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char);
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starts_with_prefix() {
|
||||
assert!(generate_api_key().starts_with("artcraft_api_"));
|
||||
assert!(generate_api_key().starts_with(API_KEY_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_expected_total_length() {
|
||||
assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_suffix_is_crockford_lowercase() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
|
||||
assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT);
|
||||
for byte in suffix.bytes() {
|
||||
assert!(
|
||||
CROCKFORD_LOWERCASE_CHARSET.contains(&byte),
|
||||
"unexpected character {:?} in entropy suffix",
|
||||
byte as char,
|
||||
);
|
||||
}
|
||||
// Crockford excludes the ambiguous i, l, o, u.
|
||||
assert!(!suffix.contains(['i', 'l', 'o', 'u']));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_characters_are_absent() {
|
||||
let key = generate_api_key();
|
||||
for forbidden in ['.', '+', '/'] {
|
||||
assert!(!key.contains(forbidden), "key must not contain {:?}", forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscores_only_appear_in_the_prefix() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
assert!(!suffix.contains('_'), "the random body must not contain underscores");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_suffix_is_alphanumeric() {
|
||||
let key = generate_api_key();
|
||||
let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_is_cryptographically_sufficient() {
|
||||
// 32-symbol alphabet => 5 bits per character. Require a comfortable margin over 128 bits.
|
||||
let bits = ENTROPY_CHAR_COUNT * 5;
|
||||
assert!(bits >= 128, "insufficient entropy: {bits} bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_unique() {
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
assert!(seen.insert(generate_api_key()), "generated a duplicate key");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod cleaners;
|
||||
pub mod delete_role_disambiguation;
|
||||
pub mod encrypted_sort_id;
|
||||
pub mod enroll_in_studio;
|
||||
pub mod generate_api_key;
|
||||
pub mod http_download_url_to_bytes;
|
||||
pub mod http_download_url_to_tempfile;
|
||||
pub mod lookup;
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ ios = { level = "full", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
#tauri-plugin = { workspace = true, features = ["build"] }
|
||||
tauri-plugin = { version = "2.2.0", features = ["build"] }
|
||||
tauri-plugin = { version = "2.6.2", features = ["build"] }
|
||||
#schemars = { workspace = true }
|
||||
schemars = { version = "0.8" }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -91,7 +91,7 @@ sqlx-sqlite = { version = "0.7.3", default-features = false, features = ["any",
|
||||
stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time = { version = "0.3.36", features = ["local-offset", "macros", "serde-well-known"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
@@ -171,8 +171,8 @@ stable_deref_trait = { version = "1.2.0" }
|
||||
subtle = { version = "2.5.0", default-features = false, features = ["i128"] }
|
||||
syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit-mut"] }
|
||||
syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.87", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] }
|
||||
tauri-build = { version = "2.1.1" }
|
||||
tauri-utils = { version = "2.4.0", default-features = false, features = ["build", "compression", "resources"] }
|
||||
tauri-build = { version = "2.6.2" }
|
||||
tauri-utils = { version = "2.9.2", default-features = false, features = ["build", "compression", "resources"] }
|
||||
time-macros = { version = "0.2.18", default-features = false, features = ["formatting", "parsing", "serde"] }
|
||||
tokio = { version = "1.45.1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["fs"] }
|
||||
|
||||
+10
@@ -196,6 +196,16 @@ export function CreateMediaPageShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade behind the floating prompt box so it stays legible
|
||||
over the feed. Sits under the prompt box (z-30). */}
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
|
||||
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptBox}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-182
@@ -1,182 +1,2 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
|
||||
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
|
||||
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
|
||||
// video thumbnails 404 until the render job finishes (so we retry on a timer
|
||||
// and on tab refocus), and broken image URLs fall back to a placeholder.
|
||||
|
||||
// ── Shared visibility listener for video thumbnail retries ────────────────
|
||||
|
||||
const visibilityCallbacks = new Set<() => void>();
|
||||
|
||||
function onTabVisible(cb: () => void) {
|
||||
visibilityCallbacks.add(cb);
|
||||
if (visibilityCallbacks.size === 1) {
|
||||
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
return () => {
|
||||
visibilityCallbacks.delete(cb);
|
||||
if (visibilityCallbacks.size === 0) {
|
||||
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fireVisibilityCallbacks() {
|
||||
if (document.hidden) return;
|
||||
visibilityCallbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ── Retry constants ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 20;
|
||||
const RETRY_INTERVAL = 5000;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface GalleryThumbnailProps {
|
||||
thumbnail: string | null;
|
||||
alt: string;
|
||||
isVideo: boolean;
|
||||
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
|
||||
fallbackIcon: IconDefinition;
|
||||
// Classes for the <img> element (sizing / object-fit).
|
||||
imgClassName?: string;
|
||||
fallbackIconClassName?: string;
|
||||
// The "Loading thumbnail…" caption is too large for small list rows.
|
||||
showRetryLabel?: boolean;
|
||||
// Called on successful load — the card uses it to measure aspect ratio.
|
||||
onLoad?: (e: React.SyntheticEvent<HTMLImageElement>) => void;
|
||||
}
|
||||
|
||||
export function GalleryThumbnail({
|
||||
thumbnail,
|
||||
alt,
|
||||
isVideo,
|
||||
fallbackIcon,
|
||||
imgClassName = "block h-full w-full object-cover",
|
||||
fallbackIconClassName = "text-xl text-white/20",
|
||||
showRetryLabel = true,
|
||||
onLoad,
|
||||
}: GalleryThumbnailProps) {
|
||||
// "retrying" flips to true only after the first error, so videos with ready
|
||||
// thumbnails render the normal <img> path with zero overhead. While retrying,
|
||||
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const retryImgRef = useRef<HTMLImageElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
|
||||
const kickRetry = useCallback(() => {
|
||||
if (!retryImgRef.current || !thumbnail) return;
|
||||
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
|
||||
}, [thumbnail]);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
|
||||
if (document.hidden) return;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryCountRef.current++;
|
||||
kickRetry();
|
||||
}, RETRY_INTERVAL);
|
||||
}, [thumbnail, kickRetry]);
|
||||
|
||||
// Subscribe to the shared visibility listener while a video card is retrying.
|
||||
useEffect(() => {
|
||||
if (!isVideo || !thumbnail || !retrying) return;
|
||||
const unsubscribe = onTabVisible(() => {
|
||||
retryCountRef.current = 0;
|
||||
kickRetry();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [isVideo, thumbnail, retrying, kickRetry]);
|
||||
|
||||
// Reset retry state when the thumbnail URL changes.
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}, [isVideo, thumbnail]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo && retrying) {
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
onLoad?.(e);
|
||||
},
|
||||
[isVideo, retrying, onLoad],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo) {
|
||||
setRetrying(true);
|
||||
scheduleRetry();
|
||||
} else {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback) return;
|
||||
target.dataset.fallback = "1";
|
||||
target.src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
target.style.opacity = "0.3";
|
||||
}
|
||||
},
|
||||
[isVideo, scheduleRetry],
|
||||
);
|
||||
|
||||
if (retrying) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/30"
|
||||
/>
|
||||
{showRetryLabel && (
|
||||
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Hidden img retries in the background via ref — zero re-renders */}
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
return (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={imgClassName}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon icon={fallbackIcon} className={fallbackIconClassName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryThumbnail } from "@storyteller/ui-generation-list";
|
||||
|
||||
+2
-53
@@ -1,53 +1,2 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGrid2, faListUl } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
useGalleryViewStore,
|
||||
type GalleryViewMode,
|
||||
} from "../../lib/gallery-view-store";
|
||||
|
||||
const OPTIONS: {
|
||||
mode: GalleryViewMode;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ mode: "grid", icon: faGrid2, label: "Grid view" },
|
||||
{ mode: "list", icon: faListUl, label: "List view" },
|
||||
];
|
||||
|
||||
// Segmented grid/list switch for the create-page galleries. Lives in the
|
||||
// TopBar; the selection is shared with the gallery via useGalleryViewStore.
|
||||
export function GalleryViewToggle() {
|
||||
const viewMode = useGalleryViewStore((s) => s.viewMode);
|
||||
const setViewMode = useGalleryViewStore((s) => s.setViewMode);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Gallery layout"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{OPTIONS.map(({ mode, icon, label }) => {
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Tooltip key={mode} content={label} position="bottom" delay={300}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
|
||||
active
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/45 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
|
||||
+163
-83
@@ -1,29 +1,26 @@
|
||||
import Masonry from "react-masonry-css";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingCard } from "./PendingCard";
|
||||
import { FailedCard } from "./FailedCard";
|
||||
import { GalleryCard } from "./GalleryCard";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationGridView,
|
||||
type GalleryItem,
|
||||
type RecreateSlotContext,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// ── Grid layout constants ─────────────────────────────────────────────────
|
||||
|
||||
const BREAKPOINT_COLS = {
|
||||
default: 4,
|
||||
1280: 4,
|
||||
900: 3,
|
||||
640: 2,
|
||||
};
|
||||
|
||||
// 12px gap on both axes (≈ Tailwind gap-3).
|
||||
// ml-[-12px] on container offsets the first column's pl-[8px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
|
||||
const COLUMN_CLASS = "pl-[8px]";
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
// Thin wrapper over the shared masonry grid: injects the webapp-only
|
||||
// affordances (recreate navigation, share/download actions) through the
|
||||
// view's render-prop seams. Layout and card markup live in
|
||||
// @storyteller/ui-generation-list and are shared with the desktop app.
|
||||
|
||||
export function GenerationGalleryGrid({
|
||||
inProgressJobs,
|
||||
@@ -33,80 +30,163 @@ export function GenerationGalleryGrid({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
return (
|
||||
<GenerationGridView
|
||||
inProgressJobs={inProgressJobs}
|
||||
failedJobs={failedJobs}
|
||||
onDismissFailed={onDismissFailed}
|
||||
newlyCompletedItems={newlyCompletedItems}
|
||||
galleryItems={galleryItems}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
onLoadMore={onLoadMore}
|
||||
onGalleryItemClick={onGalleryItemClick}
|
||||
renderRecreate={(ctx) => <CardRecreateButton {...ctx} />}
|
||||
renderGalleryActions={(item) => (
|
||||
<CardActions item={item} enableMakeVideo={enableMakeVideo} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
// Recreate affordance for pending + failed cards. The pending card shows a
|
||||
// hover-revealed icon button by the prompt; the failed card a labeled button
|
||||
// next to Dismiss.
|
||||
function CardRecreateButton({
|
||||
promptToken,
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
mediaClass,
|
||||
);
|
||||
|
||||
if (isInitialLoading) {
|
||||
if (kind === "failed") {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={isRecreating ? "animate-spin" : ""}
|
||||
/>
|
||||
Recreate
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Masonry
|
||||
breakpointCols={BREAKPOINT_COLS}
|
||||
className={MASONRY_CLASS}
|
||||
columnClassName={COLUMN_CLASS}
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
|
||||
>
|
||||
{mergedEntries.map((entry) => (
|
||||
<div key={entry.key} className="mb-[8px]">
|
||||
{entry.kind === "pending" && (
|
||||
<PendingCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "failed" && (
|
||||
<FailedCard
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
onDismiss={onDismissFailed}
|
||||
/>
|
||||
)}
|
||||
{entry.kind === "gallery" && (
|
||||
<GalleryCard
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
// Hover quick actions for completed cards (recreate / make-video / share /
|
||||
// download), styled for the card's dark action pill.
|
||||
function CardActions({
|
||||
item,
|
||||
enableMakeVideo,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
enableMakeVideo?: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = useGalleryItemActions(item, { enableMakeVideo });
|
||||
|
||||
const buttonClass =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className={buttonClass}
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+188
-132
@@ -1,19 +1,30 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { PendingRow } from "./PendingRow";
|
||||
import { FailedRow } from "./FailedRow";
|
||||
import { GalleryRow } from "./GalleryRow";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
} from "./useGalleryEntries";
|
||||
import { usePrompts } from "../../lib/prompts-cache";
|
||||
import { useMediaPromptTokens } from "../../lib/media-prompt-token-cache";
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationListView,
|
||||
type GalleryItem,
|
||||
type RecreateSlotContext,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { toast } from "../toast/toast";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import {
|
||||
useGalleryItemActions,
|
||||
type GalleryItemActions,
|
||||
} from "./useGalleryItemActions";
|
||||
import type { GenerationGalleryProps } from "./types";
|
||||
|
||||
// Constrains the feed to the promptbox width (max-w-5xl) and stacks one row
|
||||
// per generation, mirroring the masonry grid's merged in-progress / failed /
|
||||
// completed feed.
|
||||
// Thin wrapper over the shared list view: injects the webapp-only affordances
|
||||
// (recreate navigation, share/download actions, toasts) through the view's
|
||||
// render-prop seams. Layout and row markup live in
|
||||
// @storyteller/ui-generation-list and are shared with the desktop app.
|
||||
|
||||
export function GenerationGalleryList({
|
||||
inProgressJobs,
|
||||
@@ -23,131 +34,176 @@ export function GenerationGalleryList({
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
onLoadMore,
|
||||
onGalleryItemClick,
|
||||
enableMakeVideo,
|
||||
}: GenerationGalleryProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
const mergedEntries = useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
});
|
||||
|
||||
// Show the real prompt text + model on completed rows instead of the
|
||||
// "Image Generation" placeholder. The profile media-list endpoint doesn't
|
||||
// return prompt tokens yet, so for items missing one we resolve it through
|
||||
// the batch media-files endpoint (media token → prompt token), then feed the
|
||||
// prompt token into the prompts cache (prompt token → text + model). Both
|
||||
// steps are batched + cached. Pending/failed entries already carry a prompt.
|
||||
const mediaTokensNeedingPrompt = useMemo(
|
||||
() =>
|
||||
mergedEntries.flatMap((entry) =>
|
||||
entry.kind === "gallery" && !entry.item.promptToken
|
||||
? [entry.item.id]
|
||||
: [],
|
||||
),
|
||||
[mergedEntries],
|
||||
);
|
||||
const resolvedPromptTokens = useMediaPromptTokens(mediaTokensNeedingPrompt);
|
||||
|
||||
// For an item: its (non-empty) prompt token, and whether the media→prompt
|
||||
// lookup has finished. `resolved` lets rows show a skeleton while loading and
|
||||
// fall back to the label once we know there's no prompt.
|
||||
const promptStateFor = useCallback(
|
||||
(item: { id: string; promptToken?: string }) => {
|
||||
const direct = item.promptToken;
|
||||
const resolved = direct != null || resolvedPromptTokens.has(item.id);
|
||||
const promptToken = direct || resolvedPromptTokens.get(item.id) || "";
|
||||
return { promptToken, resolved };
|
||||
},
|
||||
[resolvedPromptTokens],
|
||||
);
|
||||
|
||||
const promptTokens = useMemo(() => {
|
||||
const tokens: string[] = [];
|
||||
for (const entry of mergedEntries) {
|
||||
if (entry.kind !== "gallery") continue;
|
||||
const { promptToken } = promptStateFor(entry.item);
|
||||
if (promptToken) tokens.push(promptToken);
|
||||
}
|
||||
return tokens;
|
||||
}, [mergedEntries, promptStateFor]);
|
||||
const promptsMap = usePrompts(promptTokens);
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl sm:px-4">
|
||||
<div className="flex flex-col divide-y divide-white/[0.04]">
|
||||
{mergedEntries.map((entry) => {
|
||||
if (entry.kind === "pending") {
|
||||
return (
|
||||
<PendingRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
progress={entry.job.progress}
|
||||
estimatedTimeLeftMs={entry.job.estimatedTimeLeftMs}
|
||||
batchCount={entry.job.batchCount}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "failed") {
|
||||
return (
|
||||
<FailedRow
|
||||
key={entry.key}
|
||||
id={entry.job.id}
|
||||
prompt={entry.job.prompt}
|
||||
modelId={entry.job.modelId}
|
||||
modelLabel={entry.job.modelLabel}
|
||||
failureReason={entry.job.failureReason}
|
||||
failureMessage={entry.job.failureMessage}
|
||||
refImageUrl={entry.job.refImageUrl}
|
||||
promptToken={entry.job.promptToken}
|
||||
recreateMediaClass={entry.job.mediaClass}
|
||||
onDismiss={onDismissFailed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { promptToken, resolved } = promptStateFor(entry.item);
|
||||
const prompt = promptToken ? promptsMap.get(promptToken) : undefined;
|
||||
// Loading while the media→prompt lookup is pending, or it found a
|
||||
// prompt token whose text hasn't arrived yet.
|
||||
const loading = !resolved || (!!promptToken && !prompt);
|
||||
return (
|
||||
<GalleryRow
|
||||
key={entry.key}
|
||||
item={entry.item}
|
||||
onClick={onGalleryItemClick}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
title={prompt?.maybe_positive_prompt?.trim() || undefined}
|
||||
modelId={prompt?.maybe_model_type || undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||
<LoadingSpinner className="h-6 w-6 text-white/60" />
|
||||
</div>
|
||||
<GenerationListView
|
||||
inProgressJobs={inProgressJobs}
|
||||
failedJobs={failedJobs}
|
||||
onDismissFailed={onDismissFailed}
|
||||
newlyCompletedItems={newlyCompletedItems}
|
||||
galleryItems={galleryItems}
|
||||
newlyCompletedTokens={newlyCompletedTokens}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
onLoadMore={onLoadMore}
|
||||
onGalleryItemClick={onGalleryItemClick}
|
||||
renderRecreate={(ctx) => <RowRecreateButton {...ctx} />}
|
||||
renderGalleryActions={(item, { modelId }) => (
|
||||
<GalleryRowHoverActions
|
||||
item={item}
|
||||
modelId={modelId}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
onCopyPromptResult={handleCopyResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopyResult(success: boolean) {
|
||||
if (success) {
|
||||
toast.success("Prompt copied");
|
||||
} else {
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate button for pending + failed rows. Pending rows render it inside the
|
||||
// lib's hover-revealed cluster; failed rows show it inline (dimmer tint).
|
||||
function RowRecreateButton({
|
||||
promptToken,
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
mediaClass,
|
||||
);
|
||||
return (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={
|
||||
kind === "pending"
|
||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryRowHoverActions({
|
||||
item,
|
||||
modelId,
|
||||
enableMakeVideo,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
modelId?: string;
|
||||
enableMakeVideo?: boolean;
|
||||
}) {
|
||||
const actions = useGalleryItemActions(item, { enableMakeVideo, modelId });
|
||||
return <GalleryRowActions actions={actions} hasDownload={!!item.fullImage} />;
|
||||
}
|
||||
|
||||
// Recreate / make-video / share / download buttons, shared by the desktop
|
||||
// (hover) and mobile (inline) clusters. Each handler stops propagation so taps
|
||||
// don't also open the lightbox.
|
||||
function GalleryRowActions({
|
||||
actions,
|
||||
hasDownload,
|
||||
}: {
|
||||
actions: GalleryItemActions;
|
||||
hasDownload: boolean;
|
||||
}) {
|
||||
const {
|
||||
recreateMediaClass,
|
||||
canMakeVideo,
|
||||
isRecreating,
|
||||
isDownloading,
|
||||
shareCopied,
|
||||
handleRecreate,
|
||||
handleMakeVideo,
|
||||
handleShare,
|
||||
handleDownload,
|
||||
} = actions;
|
||||
|
||||
const buttonClass =
|
||||
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<>
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{hasDownload && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,6 @@ export { GenerationGallery } from "./GenerationGallery";
|
||||
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
|
||||
export { GenerationGalleryList } from "./GenerationGalleryList";
|
||||
export { GalleryViewToggle } from "./GalleryViewToggle";
|
||||
export { GalleryCard } from "./GalleryCard";
|
||||
export { GalleryRow } from "./GalleryRow";
|
||||
export { PendingCard, type PendingCardProps } from "./PendingCard";
|
||||
export { PendingRow } from "./PendingRow";
|
||||
export { FailedCard, type FailedCardProps } from "./FailedCard";
|
||||
export { FailedRow } from "./FailedRow";
|
||||
export { useGalleryData, type GalleryItem } from "./useGalleryData";
|
||||
export {
|
||||
useGenerationJobs,
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
// Progress + time-left derivation shared by the pending card and pending row.
|
||||
|
||||
export interface PendingStatus {
|
||||
progressPercent: number | null;
|
||||
timeLabel: string | null;
|
||||
}
|
||||
|
||||
export function derivePendingStatus(
|
||||
progress?: number,
|
||||
estimatedTimeLeftMs?: number,
|
||||
): PendingStatus {
|
||||
const progressPercent =
|
||||
progress != null ? Math.max(0, Math.min(100, Math.round(progress))) : null;
|
||||
const isAlmostDone = progress != null && progress >= 95;
|
||||
const timeLabel = isAlmostDone
|
||||
? "Almost done..."
|
||||
: estimatedTimeLeftMs != null && estimatedTimeLeftMs > 0
|
||||
? formatTimeLeft(estimatedTimeLeftMs)
|
||||
: null;
|
||||
return { progressPercent, timeLabel };
|
||||
}
|
||||
|
||||
function formatTimeLeft(ms: number): string {
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0 && minutes > 0) return `~${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `~${hours}h`;
|
||||
if (minutes > 0) return `~${minutes}m`;
|
||||
return `~${seconds}s`;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export {
|
||||
derivePendingStatus,
|
||||
type PendingStatus,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,170 +1,2 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
GalleryModalApi,
|
||||
FilterMediaClasses,
|
||||
FilterMediaType,
|
||||
} from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
modelId?: string;
|
||||
batchImageToken?: string;
|
||||
// Token for the generation's prompt record. The list view resolves it
|
||||
// (via the shared prompts cache) to show the real prompt + model.
|
||||
promptToken?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
const getLabel = (item: any) => {
|
||||
if (item.maybe_title) return item.maybe_title;
|
||||
switch (item.media_class) {
|
||||
case "image":
|
||||
return "Image Generation";
|
||||
case "video":
|
||||
return "Video Generation";
|
||||
case "dimensional":
|
||||
return "3D Mesh";
|
||||
default:
|
||||
return "Generation";
|
||||
}
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGalleryData(options: {
|
||||
username: string | null;
|
||||
filterMediaClasses: FilterMediaClasses[];
|
||||
excludeUploads?: boolean;
|
||||
}) {
|
||||
const { username, filterMediaClasses, excludeUploads } = options;
|
||||
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const api = useMemo(() => new GalleryModalApi(), []);
|
||||
|
||||
const mapApiItem = useCallback((item: any): GalleryItem => {
|
||||
const isDimensional = item.media_class === "dimensional";
|
||||
const thumbnail = isDimensional
|
||||
? null
|
||||
: getMediaThumbnail(item.media_links, item.media_class, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.token,
|
||||
label: getLabel(item),
|
||||
thumbnail,
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
modelId: item.maybe_model_type || undefined,
|
||||
batchImageToken: item.maybe_batch_token,
|
||||
promptToken: item.maybe_prompt_token || undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadItems = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!username) return;
|
||||
if (isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.listUserMediaFiles({
|
||||
username,
|
||||
filter_media_classes: filterMediaClasses,
|
||||
include_user_uploads: !excludeUploads,
|
||||
page_index: reset ? 0 : pageIndex,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) =>
|
||||
item.media_type !== FilterMediaType.SCENE_JSON &&
|
||||
!(excludeUploads && item.origin_category === "upload"),
|
||||
)
|
||||
.map(mapApiItem);
|
||||
|
||||
if (reset) {
|
||||
setItems(newItems);
|
||||
} else {
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
}
|
||||
|
||||
const current = response.pagination?.current ?? 0;
|
||||
const total = response.pagination?.total_page_count ?? 1;
|
||||
setPageIndex(current + 1);
|
||||
setHasMore(current + 1 < total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsInitialLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
},
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
|
||||
);
|
||||
|
||||
// Initial load + filter change. When logged out (no username), clear the
|
||||
// loading flag so the shell renders the empty state instead of a spinner.
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
isLoadingRef.current = false;
|
||||
if (!username) {
|
||||
setHasMore(false);
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
setHasMore(true);
|
||||
setIsInitialLoading(true);
|
||||
loadItems(true);
|
||||
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !isLoadingRef.current) {
|
||||
loadItems();
|
||||
}
|
||||
}, [hasMore, loadItems]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
setHasMore(true);
|
||||
isLoadingRef.current = false;
|
||||
loadItems(true);
|
||||
}, [loadItems]);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
isInitialLoading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
removeItem,
|
||||
};
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { useGalleryData, type GalleryItem } from "@storyteller/ui-generation-list";
|
||||
|
||||
+7
-98
@@ -1,98 +1,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { FailedJob, InProgressJob } from "./useGenerationJobs";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// Shared between the grid and list views: both merge the in-progress / failed /
|
||||
// completed streams into a single time-sorted feed and lazy-load more on scroll.
|
||||
|
||||
export type GalleryEntry =
|
||||
| { kind: "pending"; key: string; createdAt: number; job: InProgressJob }
|
||||
| { kind: "failed"; key: string; createdAt: number; job: FailedJob }
|
||||
| { kind: "gallery"; key: string; createdAt: number; item: GalleryItem };
|
||||
|
||||
export interface MergedEntriesInput {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
}
|
||||
|
||||
export function useMergedGalleryEntries({
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
}: MergedEntriesInput): GalleryEntry[] {
|
||||
return useMemo(() => {
|
||||
const entries: GalleryEntry[] = [];
|
||||
|
||||
for (const job of inProgressJobs) {
|
||||
entries.push({
|
||||
kind: "pending",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const job of failedJobs) {
|
||||
entries.push({
|
||||
kind: "failed",
|
||||
key: job.id,
|
||||
createdAt: new Date(job.createdAt).getTime(),
|
||||
job,
|
||||
});
|
||||
}
|
||||
for (const item of newlyCompletedItems) {
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: `new-${item.id}`,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
for (const item of galleryItems) {
|
||||
if (newlyCompletedTokens.has(item.id)) continue;
|
||||
entries.push({
|
||||
kind: "gallery",
|
||||
key: item.id,
|
||||
createdAt: new Date(item.createdAt).getTime(),
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return entries;
|
||||
}, [
|
||||
inProgressJobs,
|
||||
failedJobs,
|
||||
newlyCompletedItems,
|
||||
galleryItems,
|
||||
newlyCompletedTokens,
|
||||
]);
|
||||
}
|
||||
|
||||
// Returns a ref to attach to a sentinel element near the end of the feed;
|
||||
// `onLoadMore` fires when it scrolls into view (with a 400px lookahead).
|
||||
export function useInfiniteScrollSentinel(
|
||||
hasMore: boolean,
|
||||
onLoadMore: () => void,
|
||||
) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !hasMore) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, onLoadMore]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export {
|
||||
useMergedGalleryEntries,
|
||||
useInfiniteScrollSentinel,
|
||||
type GalleryEntry,
|
||||
type MergedEntriesInput,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
+4
-28
@@ -11,35 +11,11 @@ import type { GalleryItem } from "./useGalleryData";
|
||||
import { getCachedPrompt, usePrompts } from "../../lib/prompts-cache";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
// The job shapes moved to the shared generation-list lib; re-exported here so
|
||||
// existing import sites keep working.
|
||||
|
||||
export interface InProgressJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
progress: number;
|
||||
estimatedTimeLeftMs?: number;
|
||||
createdAt: string;
|
||||
batchCount?: number;
|
||||
// Prompt token + media class enable the "Recreate" action while the job is
|
||||
// still running, mirroring the failed/completed cards.
|
||||
promptToken?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
|
||||
export interface FailedJob {
|
||||
id: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
failureReason?: string;
|
||||
failureMessage?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
promptToken?: string;
|
||||
refImageUrl?: string;
|
||||
mediaClass: "image" | "video";
|
||||
}
|
||||
export type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
import type { InProgressJob, FailedJob } from "@storyteller/ui-generation-list";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,27 +1,2 @@
|
||||
// Compact relative-time formatter ("just now", "5 minutes ago", "2 months
|
||||
// ago") for gallery list rows. Kept dependency-free — the app has no date
|
||||
// library, and this avoids pulling one in for a single label.
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = MINUTE * 60;
|
||||
const DAY = HOUR * 24;
|
||||
const WEEK = DAY * 7;
|
||||
const MONTH = DAY * 30;
|
||||
const YEAR = DAY * 365;
|
||||
|
||||
const pluralize = (value: number, unit: string): string =>
|
||||
`${value} ${unit}${value === 1 ? "" : "s"} ago`;
|
||||
|
||||
export function formatTimeAgo(input: string | number | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "just now";
|
||||
if (seconds < 45) return "just now";
|
||||
if (seconds < HOUR) return pluralize(Math.round(seconds / MINUTE), "minute");
|
||||
if (seconds < DAY) return pluralize(Math.round(seconds / HOUR), "hour");
|
||||
if (seconds < WEEK) return pluralize(Math.round(seconds / DAY), "day");
|
||||
if (seconds < MONTH) return pluralize(Math.round(seconds / WEEK), "week");
|
||||
if (seconds < YEAR) return pluralize(Math.round(seconds / MONTH), "month");
|
||||
return pluralize(Math.round(seconds / YEAR), "year");
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { formatTimeAgo } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// View mode for the create-page generation galleries. Shared between the
|
||||
// create-image / create-video pages and the TopBar toggle, and persisted so
|
||||
// the user's preference survives reloads and carries across both pages.
|
||||
|
||||
export type GalleryViewMode = "grid" | "list";
|
||||
|
||||
interface GalleryViewState {
|
||||
viewMode: GalleryViewMode;
|
||||
setViewMode: (mode: GalleryViewMode) => void;
|
||||
}
|
||||
|
||||
export const useGalleryViewStore = create<GalleryViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "grid",
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
}),
|
||||
{ name: "artcraft-gallery-view" },
|
||||
),
|
||||
);
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's store instance.
|
||||
export {
|
||||
useGalleryViewStore,
|
||||
type GalleryViewMode,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,104 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
|
||||
// Workaround until the profile media-list endpoint returns prompt tokens:
|
||||
// resolve media token → prompt token via the batch media-files endpoint
|
||||
// (GET /v1/media_files/batch), which does include `maybe_prompt_token`. The
|
||||
// resolved prompt token is then fed into the prompts cache (usePrompts) to get
|
||||
// the prompt text + model. Mirrors prompts-cache.ts: module-level cache,
|
||||
// in-flight dedupe, and a subscribe-on-load hook.
|
||||
|
||||
// mediaToken → promptToken ("" = resolved, but the media has no prompt token).
|
||||
const tokenCache = new Map<string, string>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: MediaFilesApi | null = null;
|
||||
|
||||
// Keep request URLs (one `tokens=` param per token) comfortably under length limits.
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
function getApi(): MediaFilesApi {
|
||||
if (!api) api = new MediaFilesApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(mediaTokens: readonly string[]): Promise<void> {
|
||||
const missing = mediaTokens.filter(
|
||||
(t) => t && !tokenCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
|
||||
try {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < missing.length; i += CHUNK_SIZE) {
|
||||
chunks.push(missing.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
const responses = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
getApi().ListMediaFilesByTokens({ mediaTokens: chunk }),
|
||||
),
|
||||
);
|
||||
for (const response of responses) {
|
||||
if (!response.success || !response.data) continue;
|
||||
for (const file of response.data) {
|
||||
tokenCache.set(file.token, file.maybe_prompt_token ?? "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Swallow — the finally block marks the tokens resolved (empty) so the
|
||||
// loading skeleton clears and we don't refetch in a loop.
|
||||
} finally {
|
||||
// Mark every requested token resolved. Tokens that errored or weren't
|
||||
// returned get "" (no prompt) so the row falls back to its label instead
|
||||
// of showing a skeleton forever. Always notify so skeletons clear.
|
||||
for (const t of missing) {
|
||||
if (!tokenCache.has(t)) tokenCache.set(t, "");
|
||||
inFlight.delete(t);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns mediaToken → promptToken for every token whose media→prompt lookup
|
||||
// has completed; the value is "" when the media has no prompt. A token that is
|
||||
// absent from the map is still resolving (use `.has()` to distinguish that
|
||||
// from a resolved-but-promptless item). Triggers a batched fetch for any not
|
||||
// yet resolved.
|
||||
export function useMediaPromptTokens(
|
||||
mediaTokens: readonly (string | null | undefined)[],
|
||||
): Map<string, string> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of mediaTokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list, order-independent.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
for (const t of cleaned) {
|
||||
const promptToken = tokenCache.get(t);
|
||||
// Include "" entries so callers can tell "resolved, no prompt" (present)
|
||||
// apart from "still loading" (absent).
|
||||
if (promptToken !== undefined) result.set(t, promptToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's module-level cache.
|
||||
export { useMediaPromptTokens } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,73 +1,3 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
|
||||
const promptCache = new Map<string, Prompts>();
|
||||
const inFlight = new Set<string>();
|
||||
const subscribers = new Set<() => void>();
|
||||
let api: PromptsApi | null = null;
|
||||
|
||||
function getApi(): PromptsApi {
|
||||
if (!api) api = new PromptsApi();
|
||||
return api;
|
||||
}
|
||||
|
||||
function notifySubscribers() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
async function fetchMissing(tokens: readonly string[]): Promise<void> {
|
||||
const missing = tokens.filter(
|
||||
(t) => t && !promptCache.has(t) && !inFlight.has(t),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
for (const t of missing) inFlight.add(t);
|
||||
try {
|
||||
const response = await getApi().BatchGetPrompts({ tokens: missing });
|
||||
if (response.success && response.data) {
|
||||
for (const p of response.data) {
|
||||
promptCache.set(p.token, p);
|
||||
}
|
||||
notifySubscribers();
|
||||
}
|
||||
} catch {
|
||||
// Failures will be retried when usePrompts re-runs with the same tokens.
|
||||
} finally {
|
||||
for (const t of missing) inFlight.delete(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedPrompt(token: string): Prompts | undefined {
|
||||
return promptCache.get(token);
|
||||
}
|
||||
|
||||
export function usePrompts(tokens: readonly (string | null | undefined)[]): Map<string, Prompts> {
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => forceUpdate((n) => n + 1);
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t) cleaned.push(t);
|
||||
}
|
||||
// Stable cache key for the effect's dep list. Sorting keeps it stable when
|
||||
// a poll returns the same set in a different order.
|
||||
const key = [...new Set(cleaned)].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
for (const t of cleaned) {
|
||||
const p = promptCache.get(t);
|
||||
if (p) result.set(t, p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
// Re-exported so every webapp consumer shares the lib's module-level cache.
|
||||
export { getCachedPrompt, usePrompts } from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -208,6 +208,28 @@ export default function CreateImage() {
|
||||
excludeUploads: true,
|
||||
});
|
||||
|
||||
// Map job token → batch count so the pending card/row can show
|
||||
// "Generating N images" — the batch runs as a single job chip.
|
||||
const batches = useCreateImageStore((s) => s.batches);
|
||||
const jobTokenToBatchCount = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const batch of batches) {
|
||||
if (batch.jobToken && batch.requestedCount > 1) {
|
||||
map.set(batch.jobToken, batch.requestedCount);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [batches]);
|
||||
|
||||
const enrichedInProgress = useMemo(
|
||||
() =>
|
||||
jobs.inProgress.map((job) => {
|
||||
const batchCount = jobTokenToBatchCount.get(job.id);
|
||||
return batchCount ? { ...job, batchCount } : job;
|
||||
}),
|
||||
[jobs.inProgress, jobTokenToBatchCount],
|
||||
);
|
||||
|
||||
const newlyCompletedTokens = useMemo(
|
||||
() => new Set(jobs.newlyCompleted.map((i) => i.id)),
|
||||
[jobs.newlyCompleted],
|
||||
@@ -565,7 +587,7 @@ export default function CreateImage() {
|
||||
promptForm={mobileForm}
|
||||
gridContent={
|
||||
<GenerationGallery
|
||||
inProgressJobs={jobs.inProgress}
|
||||
inProgressJobs={enrichedInProgress}
|
||||
failedJobs={jobs.failed}
|
||||
onDismissFailed={jobs.dismissFailed}
|
||||
newlyCompletedItems={jobs.newlyCompleted}
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
|
||||
@apply bg-[#303034]/90 !important;
|
||||
@apply bg-[#303032]/90 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,6 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
|
||||
},
|
||||
@@ -67,6 +64,9 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch/tsconfig.lib.json"
|
||||
},
|
||||
@@ -74,16 +74,22 @@
|
||||
"path": "../../libs/components/viewer-3d/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gravatar/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner/tsconfig.lib.json"
|
||||
"path": "../../libs/components/tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/generation-list/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector/tsconfig.lib.json"
|
||||
@@ -91,9 +97,6 @@
|
||||
{
|
||||
"path": "../../libs/components/popover/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tooltip/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal/tsconfig.lib.json"
|
||||
},
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/pricing-modal"
|
||||
},
|
||||
@@ -32,6 +29,9 @@
|
||||
{
|
||||
"path": "../../libs/components/pagescene"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gallery-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/switch"
|
||||
},
|
||||
@@ -39,16 +39,22 @@
|
||||
"path": "../../libs/components/viewer-3d"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/gravatar"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/action-reminder-modal"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/model-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/loading-spinner"
|
||||
"path": "../../libs/components/tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/generation-list"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tab-selector"
|
||||
@@ -56,9 +62,6 @@
|
||||
{
|
||||
"path": "../../libs/components/popover"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/tooltip"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/modal"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1119,10 +1119,21 @@ export const ImageTo3DExperience = ({
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex justify-center gap-2.5",
|
||||
"flex items-center justify-between gap-2.5",
|
||||
hasResults ? "mt-3" : "mt-3",
|
||||
)}
|
||||
>
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={
|
||||
variant === "world"
|
||||
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
|
||||
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
|
||||
}
|
||||
page={
|
||||
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
|
||||
}
|
||||
/>
|
||||
<GenerateButton
|
||||
variant="primary"
|
||||
icon={undefined}
|
||||
@@ -1150,23 +1161,7 @@ export const ImageTo3DExperience = ({
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={
|
||||
variant === "world"
|
||||
? IMAGE_TO_3D_WORLD_PAGE_MODEL_LIST
|
||||
: IMAGE_TO_3D_OBJECT_PAGE_MODEL_LIST
|
||||
}
|
||||
page={variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton
|
||||
modelPage={
|
||||
variant === "world" ? WORLD_MODEL_PAGE : OBJECT_MODEL_PAGE
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// Shared page chrome for the desktop Generate Image / Generate Video pages,
|
||||
// mirroring the webapp's CreateMediaPageShell: a centered hero over the
|
||||
// decorative background, and a scrollable generation feed (padded past the
|
||||
// fixed promptbox) once there is anything to show.
|
||||
//
|
||||
// The hero + background behave like a splash screen: they stay up while the
|
||||
// feed is still loading (pages remount on tab switches, so this covers the
|
||||
// reload) and fade out once content arrives. With a genuinely empty library
|
||||
// they simply stay — they ARE the empty state.
|
||||
|
||||
const SPLASH_FADE_MS = 700;
|
||||
|
||||
interface DesktopCreatePageShellProps {
|
||||
hasContent: boolean;
|
||||
emptyStateTitle: string;
|
||||
emptyStateSubtitle: string;
|
||||
/** Decorative page background (fixed-position); fades out with the hero. */
|
||||
background?: ReactNode;
|
||||
/** Height of the fixed promptbox area, used to pad the feed's bottom. */
|
||||
bottomOffset: number;
|
||||
listContent: ReactNode;
|
||||
/** The fixed-position promptbox stack (caller controls its positioning). */
|
||||
promptBox: ReactNode;
|
||||
bottomRight?: ReactNode;
|
||||
}
|
||||
|
||||
export function DesktopCreatePageShell({
|
||||
hasContent,
|
||||
emptyStateTitle,
|
||||
emptyStateSubtitle,
|
||||
background,
|
||||
bottomOffset,
|
||||
listContent,
|
||||
promptBox,
|
||||
bottomRight,
|
||||
}: DesktopCreatePageShellProps) {
|
||||
const [splashState, setSplashState] = useState<
|
||||
"visible" | "fading" | "hidden"
|
||||
>("visible");
|
||||
|
||||
useEffect(() => {
|
||||
if (hasContent && splashState === "visible") {
|
||||
setSplashState("fading");
|
||||
const t = setTimeout(() => setSplashState("hidden"), SPLASH_FADE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (!hasContent && splashState === "hidden") {
|
||||
// Feed emptied out again (e.g. everything deleted) — bring it back.
|
||||
setSplashState("visible");
|
||||
}
|
||||
return undefined;
|
||||
}, [hasContent, splashState]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background">
|
||||
<div className="relative h-full w-full">
|
||||
{hasContent && (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto pt-4"
|
||||
style={{ paddingBottom: bottomOffset + 24 }}
|
||||
>
|
||||
{listContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splashState !== "hidden" && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 z-10 transition-opacity duration-700",
|
||||
splashState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center pb-52">
|
||||
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl">
|
||||
<h1 className="text-7xl font-bold text-base-fg">
|
||||
{emptyStateTitle}
|
||||
</h1>
|
||||
<span className="pt-2 text-xl text-base-fg opacity-80">
|
||||
{emptyStateSubtitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{background}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom fade behind the floating prompt box so it stays legible
|
||||
over the feed. Sits under the prompt box (z-20). */}
|
||||
{hasContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptBox}
|
||||
|
||||
{bottomRight && (
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
{bottomRight}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import {
|
||||
GenerationListView,
|
||||
GenerationGridView,
|
||||
useGalleryViewStore,
|
||||
type FailedJob,
|
||||
type GalleryItem,
|
||||
type InProgressJob,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import {
|
||||
applyMakeVideoFromImage,
|
||||
applyRecreateFromPromptToken,
|
||||
copyShareLink,
|
||||
downloadMediaFileToDisk,
|
||||
} from "./desktopMediaActions";
|
||||
|
||||
// Desktop twin of the webapp's GenerationGallery: renders the shared feed in
|
||||
// whichever layout the TopBar toggle picked (grid by default), injecting the
|
||||
// desktop flavors of the quick actions (recreate / make-video / share /
|
||||
// download) through the views' render-prop seams.
|
||||
|
||||
export interface DesktopGenerationGalleryProps {
|
||||
inProgressJobs: InProgressJob[];
|
||||
failedJobs: FailedJob[];
|
||||
onDismissFailed: (jobToken: string) => void;
|
||||
newlyCompletedItems: GalleryItem[];
|
||||
galleryItems: GalleryItem[];
|
||||
newlyCompletedTokens: Set<string>;
|
||||
hasMore: boolean;
|
||||
isLoading?: boolean;
|
||||
isInitialLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
onGalleryItemClick: (item: GalleryItem) => void;
|
||||
/** Image page only: show a "Make Video" quick action on image items. */
|
||||
enableMakeVideo?: boolean;
|
||||
}
|
||||
|
||||
export function DesktopGenerationGallery({
|
||||
enableMakeVideo,
|
||||
...feedProps
|
||||
}: DesktopGenerationGalleryProps) {
|
||||
const viewMode = useGalleryViewStore((s) => s.viewMode);
|
||||
|
||||
const handleCopyResult = useCallback((success: boolean) => {
|
||||
if (success) {
|
||||
toast.success("Prompt copied");
|
||||
} else {
|
||||
toast.error("Unable to copy prompt");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (viewMode === "list") {
|
||||
return (
|
||||
<GenerationListView
|
||||
{...feedProps}
|
||||
renderGalleryActions={(item, ctx) => (
|
||||
<ItemActions
|
||||
item={item}
|
||||
promptToken={ctx.promptToken ?? item.promptToken}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
variant="row"
|
||||
/>
|
||||
)}
|
||||
onCopyPromptResult={handleCopyResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<GenerationGridView
|
||||
{...feedProps}
|
||||
renderGalleryActions={(item, ctx) => (
|
||||
<ItemActions
|
||||
item={item}
|
||||
promptToken={ctx.promptToken ?? item.promptToken}
|
||||
enableMakeVideo={enableMakeVideo}
|
||||
variant="card"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick actions for a completed item — desktop equivalents of the webapp's
|
||||
// useGalleryItemActions: recreate re-seeds the create page from the prompt
|
||||
// record, make-video seeds the video page, share copies the public media
|
||||
// link, download saves via Tauri. stopPropagation keeps taps from also
|
||||
// opening the lightbox.
|
||||
function ItemActions({
|
||||
item,
|
||||
promptToken,
|
||||
enableMakeVideo,
|
||||
variant,
|
||||
}: {
|
||||
item: GalleryItem;
|
||||
promptToken?: string;
|
||||
enableMakeVideo?: boolean;
|
||||
variant: "row" | "card";
|
||||
}) {
|
||||
const [isRecreating, setIsRecreating] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const recreateMediaClass: "image" | "video" | null = isVideo
|
||||
? "video"
|
||||
: is3D
|
||||
? null
|
||||
: "image";
|
||||
const canRecreate = !!promptToken && !!recreateMediaClass;
|
||||
const canMakeVideo = !!enableMakeVideo && !isVideo && !is3D;
|
||||
|
||||
const buttonClass =
|
||||
variant === "card"
|
||||
? "flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
: "flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
|
||||
|
||||
const handleRecreate = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!promptToken || !recreateMediaClass || isRecreating) return;
|
||||
setIsRecreating(true);
|
||||
try {
|
||||
await applyRecreateFromPromptToken(promptToken, recreateMediaClass);
|
||||
} finally {
|
||||
setIsRecreating(false);
|
||||
}
|
||||
},
|
||||
[promptToken, recreateMediaClass, isRecreating],
|
||||
);
|
||||
|
||||
const handleMakeVideo = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const url = item.fullImage || item.thumbnail;
|
||||
if (!url) return;
|
||||
applyMakeVideoFromImage(url, item.id);
|
||||
},
|
||||
[item.fullImage, item.thumbnail, item.id],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const ok = await copyShareLink(item.id);
|
||||
if (ok) {
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 1500);
|
||||
}
|
||||
},
|
||||
[item.id],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!item.fullImage || isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadMediaFileToDisk(item.fullImage, item.mediaClass);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
[item.fullImage, item.mediaClass, isDownloading],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canRecreate && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMakeVideo && (
|
||||
<Tooltip content="Make Video" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMakeVideo}
|
||||
aria-label="Make Video"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon icon={faVideo} className="text-sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{item.fullImage && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
aria-label="Download"
|
||||
className={buttonClass}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isDownloading ? faSpinnerThird : faArrowDownToLine}
|
||||
className={`text-sm ${isDownloading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
PromptsApi,
|
||||
downloadUrlToPath,
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
RefAudio,
|
||||
usePromptImageStore,
|
||||
usePromptVideoStore,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import {
|
||||
useClassyModelSelectorStore,
|
||||
ModelPage,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import {
|
||||
IMAGE_MODELS_BY_ID,
|
||||
VIDEO_MODELS_BY_ID,
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
} from "@storyteller/model-list";
|
||||
import {
|
||||
galleryModalLightboxVisible,
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalVisibleViewMode,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { getCachedPrompt } from "@storyteller/ui-generation-list";
|
||||
import { useTabStore } from "~/pages/Stores/TabState";
|
||||
|
||||
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
|
||||
// the create pages' generation feed (rows + cards). All of them operate on
|
||||
// global stores via getState(), so they're safe to call from anywhere.
|
||||
|
||||
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
|
||||
|
||||
/** Download a media file, prompting for a location when configured to. */
|
||||
export async function downloadMediaFileToDisk(url: string, mediaClass?: string) {
|
||||
try {
|
||||
const chosenPath = await promptDownloadLocationIfNeeded(url);
|
||||
if (chosenPath === null) {
|
||||
// User dismissed the picker.
|
||||
return;
|
||||
}
|
||||
if (typeof chosenPath === "string") {
|
||||
await downloadUrlToPath(url, chosenPath);
|
||||
} else {
|
||||
await DownloadUrl(url);
|
||||
}
|
||||
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
|
||||
toast.success(`Downloaded 3D model`);
|
||||
} else {
|
||||
toast.success(`Downloaded ${mediaClass}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(">>> Failed to download file:", error);
|
||||
// NB: Rust/Tauri should now flash a toast instead.
|
||||
//toast.error("Failed to download file");
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the video page with `url` as the starting image and switch to it. */
|
||||
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaToken || "",
|
||||
};
|
||||
// Update zustand store for Video directly
|
||||
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy a public share link for the media token. Returns success. */
|
||||
export async function copyShareLink(mediaToken: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${SHARE_URL_BASE}${mediaToken}`);
|
||||
toast.success("Share link copied");
|
||||
return true;
|
||||
} catch {
|
||||
toast.error("Unable to copy link");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the image or video create page from a generation's prompt record
|
||||
* (prompt text, reference media, settings, model) and switch to that page.
|
||||
*/
|
||||
export function applyRecreateFromPromptData(data: {
|
||||
promptData: Prompts;
|
||||
mediaClass: string | undefined;
|
||||
}) {
|
||||
try {
|
||||
const { promptData, mediaClass: recreateMediaClass } = data;
|
||||
const contextImages = promptData.maybe_context_images || [];
|
||||
|
||||
// Partition context images by semantic type
|
||||
const imgRefs: RefImage[] = [];
|
||||
let endFrameImage: RefImage | undefined;
|
||||
const vidRefs: RefVideo[] = [];
|
||||
const audioRefs: RefAudio[] = [];
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
switch (ci.semantic) {
|
||||
case "vid_end_frame":
|
||||
endFrameImage = { ...base, file: new File([], "recreate-ref") };
|
||||
break;
|
||||
case "vid_ref":
|
||||
vidRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
case "audioref":
|
||||
audioRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
|
||||
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine input mode from generation_mode or context image semantics
|
||||
const hasKeyframeSemantics = contextImages.some(
|
||||
(ci) =>
|
||||
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
|
||||
);
|
||||
const inputMode: "keyframe" | "reference" =
|
||||
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
|
||||
? "keyframe"
|
||||
: promptData.maybe_generation_mode === "reference"
|
||||
? "reference"
|
||||
: imgRefs.length > 0
|
||||
? "reference"
|
||||
: "keyframe";
|
||||
|
||||
const modelStore = useClassyModelSelectorStore.getState();
|
||||
|
||||
if (recreateMediaClass === "video") {
|
||||
const videoStore = usePromptVideoStore.getState();
|
||||
|
||||
// Set model first so the UI syncs sizeOptions / durationOptions
|
||||
const videoModel = promptData.maybe_model_type
|
||||
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
: undefined;
|
||||
if (videoModel) {
|
||||
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
|
||||
}
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
videoStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
|
||||
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
|
||||
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
|
||||
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
|
||||
if (promptData.maybe_generate_audio !== null) {
|
||||
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
|
||||
}
|
||||
if (promptData.maybe_duration_seconds !== null) {
|
||||
videoStore.setDuration(promptData.maybe_duration_seconds);
|
||||
}
|
||||
|
||||
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
|
||||
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
|
||||
const match = videoModel.sizeOptions.find(
|
||||
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
|
||||
);
|
||||
if (match) {
|
||||
videoStore.setAspectRatio(match.textLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Map API resolution (like "one_k") → video store format (like "1080p")
|
||||
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
|
||||
const resolutionMap: Record<string, string> = {
|
||||
one_k: "1080p",
|
||||
two_k: "2k",
|
||||
three_k: "3k",
|
||||
four_k: "4k",
|
||||
};
|
||||
const mapped = resolutionMap[promptData.maybe_resolution];
|
||||
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
|
||||
videoStore.setResolution(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
videoStore.setInputMode(inputMode);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
} else {
|
||||
// Default to image
|
||||
const imageStore = usePromptImageStore.getState();
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
imageStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
|
||||
if (promptData.maybe_aspect_ratio) {
|
||||
imageStore.setCommonAspectRatio(
|
||||
promptData.maybe_aspect_ratio as CommonAspectRatio,
|
||||
);
|
||||
}
|
||||
if (promptData.maybe_resolution) {
|
||||
imageStore.setCommonResolution(
|
||||
promptData.maybe_resolution as CommonResolution,
|
||||
);
|
||||
}
|
||||
|
||||
if (promptData.maybe_model_type) {
|
||||
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE");
|
||||
}
|
||||
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate from a prompt token: resolve the prompt record (shared cache
|
||||
* first, then the API), then seed the create page from it.
|
||||
*/
|
||||
export async function applyRecreateFromPromptToken(
|
||||
promptToken: string,
|
||||
mediaClass: "image" | "video",
|
||||
) {
|
||||
let promptData: Prompts | undefined = getCachedPrompt(promptToken);
|
||||
if (!promptData) {
|
||||
try {
|
||||
const res = await new PromptsApi().BatchGetPrompts({
|
||||
tokens: [promptToken],
|
||||
});
|
||||
promptData = res.success && res.data?.[0] ? res.data[0] : undefined;
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
if (!promptData) {
|
||||
toast.error("Could not load the original prompt.");
|
||||
return;
|
||||
}
|
||||
applyRecreateFromPromptData({ promptData, mediaClass });
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { GetTaskQueue, MarkTaskAsDismissed } from "@storyteller/tauri-api";
|
||||
import type { TaskQueueItem } from "@storyteller/tauri-api";
|
||||
import {
|
||||
useTextToImageGenerationCompleteEvent,
|
||||
useVideoGenerationCompleteEvent,
|
||||
} from "@storyteller/tauri-events";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import {
|
||||
getMediaThumbnail,
|
||||
getThumbnailUrl,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
ALL_MODELS_LIST,
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
} from "@storyteller/model-list";
|
||||
import type {
|
||||
FailedJob,
|
||||
GalleryItem,
|
||||
InProgressJob,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import {
|
||||
getMetaForTask,
|
||||
cleanupOldEntries,
|
||||
} from "../signaled/TopBar/taskEnqueueMeta";
|
||||
|
||||
// Desktop twin of the webapp's useGenerationJobs: feeds the shared
|
||||
// GenerationListView from the Tauri task queue instead of JobsApi. Polls every
|
||||
// 5s and reloads instantly on Tauri generation events / local enqueues.
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const FAILED_STATUSES = new Set([
|
||||
"complete_failure",
|
||||
"attempt_failed",
|
||||
"dead",
|
||||
"cancelled_by_user",
|
||||
"cancelled_by_provider",
|
||||
"cancelled_by_us",
|
||||
]);
|
||||
|
||||
const FAILURE_REASON_LABEL: Record<string, string> = {
|
||||
rule_bans_user_image: "Image violates content policy",
|
||||
rule_bans_user_image_with_faces: "Images with faces are not allowed",
|
||||
rule_bans_user_text_prompt: "Text prompt violates content policy",
|
||||
rule_bans_user_content: "Content violates content policy",
|
||||
rule_bans_generated_video: "Generated video flagged by content policy",
|
||||
rule_bans_generated_audio: "Generated audio flagged by content policy",
|
||||
rule_bans_generated_content: "Generated content flagged by content policy",
|
||||
generation_failed: "Generation failed",
|
||||
unknown: "An unknown error occurred",
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDesktopGenerationFeed(options: {
|
||||
mediaType: "image" | "video";
|
||||
}) {
|
||||
const { mediaType } = options;
|
||||
const mediaApiRef = useRef(new MediaFilesApi());
|
||||
|
||||
const [inProgress, setInProgress] = useState<InProgressJob[]>([]);
|
||||
const [failed, setFailed] = useState<FailedJob[]>([]);
|
||||
const [newlyCompleted, setNewlyCompleted] = useState<GalleryItem[]>([]);
|
||||
|
||||
// Snapshot per-task durations so the fake progress stays stable.
|
||||
const durationsRef = useRef<Map<string, number>>(new Map());
|
||||
const prevCompletedIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
// Locally dismissed failed tasks — hides them until the server-side
|
||||
// dismissal lands in the next poll.
|
||||
const dismissedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { tasks } = await GetTaskQueue();
|
||||
const filtered = tasks.filter(
|
||||
(t) => getTaskMediaType(t) === mediaType && !dismissedRef.current.has(t.id),
|
||||
);
|
||||
|
||||
const newInProgress = filtered
|
||||
.filter(
|
||||
(t) => t.task_status === "pending" || t.task_status === "started",
|
||||
)
|
||||
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
|
||||
.map((t) => taskToInProgress(t, mediaType, durationsRef.current));
|
||||
|
||||
const newFailed = filtered
|
||||
.filter((t) => FAILED_STATUSES.has(String(t.task_status)))
|
||||
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
|
||||
.map((t) => taskToFailed(t, mediaType));
|
||||
|
||||
const completedTasks = filtered.filter(
|
||||
(t) => String(t.task_status) === "complete_success",
|
||||
);
|
||||
const completedIdSet = new Set(completedTasks.map((t) => t.id));
|
||||
|
||||
// Detect newly completed (skip on first load to avoid flooding —
|
||||
// history comes from the gallery API instead).
|
||||
let expandedNewItems: GalleryItem[] = [];
|
||||
if (initialLoadDoneRef.current) {
|
||||
const newOnes = completedTasks.filter(
|
||||
(t) => !prevCompletedIdsRef.current.has(t.id),
|
||||
);
|
||||
if (newOnes.length > 0) {
|
||||
// Await expansion so the pending row and its completed replacement
|
||||
// commit in the same React render — no "remove then add" gap.
|
||||
const expanded = await Promise.all(
|
||||
newOnes.map((t) =>
|
||||
taskToGalleryItems(t, mediaType, mediaApiRef.current),
|
||||
),
|
||||
);
|
||||
expandedNewItems = expanded.flat();
|
||||
}
|
||||
}
|
||||
initialLoadDoneRef.current = true;
|
||||
prevCompletedIdsRef.current = completedIdSet;
|
||||
|
||||
// Prune duration snapshots for tasks no longer in progress.
|
||||
const activeIds = new Set(newInProgress.map((j) => j.id));
|
||||
for (const id of Array.from(durationsRef.current.keys())) {
|
||||
if (!activeIds.has(id)) durationsRef.current.delete(id);
|
||||
}
|
||||
|
||||
if (expandedNewItems.length > 0) {
|
||||
setNewlyCompleted((prev) => {
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = expandedNewItems.filter((i) => !existingIds.has(i.id));
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}
|
||||
setInProgress(newInProgress);
|
||||
setFailed(newFailed);
|
||||
} catch {
|
||||
// ignore — next poll retries
|
||||
}
|
||||
}, [mediaType]);
|
||||
|
||||
/** Prepend completed items (deduped) ahead of the next poll. */
|
||||
const addCompletedItems = useCallback((items: GalleryItem[]) => {
|
||||
if (items.length === 0) return;
|
||||
setNewlyCompleted((prev) => {
|
||||
const existingIds = new Set(prev.map((i) => i.id));
|
||||
const fresh = items.filter((i) => !existingIds.has(i.id));
|
||||
if (fresh.length === 0) return prev;
|
||||
return [...fresh, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Push-style completion: the Rust backend emits typed completion events
|
||||
// carrying the generated media inline, so results appear instantly instead
|
||||
// of waiting on the next task-queue poll. The follow-up load() reconciles
|
||||
// the pending chip; the poll's own newly-completed detection dedupes by
|
||||
// media token against these. NB: the tauri-events hooks subscribe once on
|
||||
// mount, so the callbacks must only capture stable values (mediaType is a
|
||||
// constant per page; addCompletedItems/load are stable).
|
||||
useTextToImageGenerationCompleteEvent(async (event) => {
|
||||
if (mediaType !== "image" || !event.generated_images?.length) return;
|
||||
const createdAt = new Date().toISOString();
|
||||
addCompletedItems(
|
||||
event.generated_images.map((img) => ({
|
||||
id: img.media_token,
|
||||
label: "Image Generation",
|
||||
thumbnail:
|
||||
getThumbnailUrl(img.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? img.cdn_url,
|
||||
fullImage: img.cdn_url,
|
||||
createdAt,
|
||||
mediaClass: "image",
|
||||
})),
|
||||
);
|
||||
load();
|
||||
});
|
||||
|
||||
useVideoGenerationCompleteEvent(async (event) => {
|
||||
if (mediaType !== "video" || !event.generated_video) return;
|
||||
const video = event.generated_video;
|
||||
addCompletedItems([
|
||||
{
|
||||
id: video.media_token,
|
||||
label: "Video Generation",
|
||||
thumbnail:
|
||||
getThumbnailUrl(video.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? null,
|
||||
fullImage: video.cdn_url,
|
||||
createdAt: new Date().toISOString(),
|
||||
mediaClass: "video",
|
||||
},
|
||||
]);
|
||||
load();
|
||||
});
|
||||
|
||||
// Poll + reload on Tauri generation events and local "task-queue-update"
|
||||
// dispatches (fired by the pages right after enqueueing).
|
||||
useEffect(() => {
|
||||
cleanupOldEntries();
|
||||
load();
|
||||
const intervalId = setInterval(load, POLL_INTERVAL_MS);
|
||||
|
||||
const handleTaskUpdate = () => load();
|
||||
window.addEventListener("task-queue-update", handleTaskUpdate);
|
||||
|
||||
let cancelled = false;
|
||||
const unlistenComplete: Promise<UnlistenFn> = listen(
|
||||
"generation-complete-event",
|
||||
() => {
|
||||
if (!cancelled) load();
|
||||
},
|
||||
);
|
||||
const unlistenFailed: Promise<UnlistenFn> = listen(
|
||||
"generation-failed-event",
|
||||
() => {
|
||||
if (!cancelled) load();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("task-queue-update", handleTaskUpdate);
|
||||
unlistenComplete.then((f) => f());
|
||||
unlistenFailed.then((f) => f());
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const dismissFailed = useCallback(async (taskId: string) => {
|
||||
dismissedRef.current.add(taskId);
|
||||
setFailed((prev) => prev.filter((f) => f.id !== taskId));
|
||||
try {
|
||||
await MarkTaskAsDismissed(taskId);
|
||||
} catch {
|
||||
// ignore — the local filter already hides it
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
inProgress,
|
||||
failed,
|
||||
newlyCompleted,
|
||||
dismissFailed,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Task mapping helpers ────────────────────────────────────────────────────
|
||||
|
||||
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
|
||||
const taskTypeStr = t.task_type ? String(t.task_type).toLowerCase() : "";
|
||||
const modelTypeStr = t.model_type ? String(t.model_type).toLowerCase() : "";
|
||||
const isSplat =
|
||||
taskTypeStr.includes("gaussian") ||
|
||||
modelTypeStr.includes("marble") ||
|
||||
modelTypeStr.includes("worldlabs");
|
||||
const is3D =
|
||||
taskTypeStr.includes("3d") ||
|
||||
taskTypeStr.includes("object") ||
|
||||
taskTypeStr.includes("dimensional") ||
|
||||
isSplat;
|
||||
if (is3D) return "other";
|
||||
if (taskTypeStr.includes("video")) return "video";
|
||||
if (taskTypeStr.includes("image")) return "image";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getTaskModelLabel(t: TaskQueueItem): string {
|
||||
const modelType = t.model_type ? String(t.model_type) : "";
|
||||
const modelDisplay = modelType ? getModelDisplayName(modelType) : undefined;
|
||||
const provider = t.provider
|
||||
? getProviderDisplayName(String(t.provider).toLowerCase())
|
||||
: undefined;
|
||||
if (modelDisplay && provider) return `${modelDisplay} · ${provider}`;
|
||||
return modelDisplay || provider || "Unknown model";
|
||||
}
|
||||
|
||||
function taskToInProgress(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
durations: Map<string, number>,
|
||||
): InProgressJob {
|
||||
const now = Date.now();
|
||||
const createdMs = t.created_at.getTime();
|
||||
|
||||
let duration = durations.get(t.id);
|
||||
if (!duration) {
|
||||
const model = t.model_type
|
||||
? ALL_MODELS_LIST.find(
|
||||
(m) =>
|
||||
m.tauriId === String(t.model_type) || m.id === String(t.model_type),
|
||||
)
|
||||
: undefined;
|
||||
duration =
|
||||
model?.progressBarTime ?? (mediaType === "video" ? 900000 : 30000);
|
||||
durations.set(t.id, duration);
|
||||
}
|
||||
|
||||
const elapsed = now - createdMs;
|
||||
const progress = Math.min(95, Math.max(0, (elapsed / duration) * 100));
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
createdMs,
|
||||
);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
prompt: meta?.prompt ?? "",
|
||||
modelId: t.model_type ? String(t.model_type) : "",
|
||||
modelLabel: getTaskModelLabel(t),
|
||||
progress,
|
||||
estimatedTimeLeftMs: Math.max(0, duration - elapsed),
|
||||
createdAt: t.created_at.toISOString(),
|
||||
// One image task can produce a whole batch — surface the requested count
|
||||
// so the pending chip reads "Generating N images".
|
||||
batchCount: meta?.batchCount,
|
||||
mediaClass: mediaType,
|
||||
};
|
||||
}
|
||||
|
||||
function taskToFailed(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
): FailedJob {
|
||||
const fr = t.failure_reason;
|
||||
const failureReason = fr
|
||||
? FAILURE_REASON_LABEL[fr.failure_type] || fr.failure_message || undefined
|
||||
: undefined;
|
||||
const failureMessage =
|
||||
fr?.failure_message && fr.failure_type !== "unknown"
|
||||
? fr.failure_message
|
||||
: undefined;
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
t.created_at.getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
prompt: meta?.prompt ?? "",
|
||||
modelId: t.model_type ? String(t.model_type) : "",
|
||||
modelLabel: getTaskModelLabel(t),
|
||||
failureReason,
|
||||
failureMessage,
|
||||
status: String(t.task_status),
|
||||
createdAt: t.created_at.toISOString(),
|
||||
refImageUrl: meta?.refImageUrls?.[0],
|
||||
mediaClass: mediaType,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a completed task to its GalleryItem(s), expanding batch siblings. */
|
||||
async function taskToGalleryItems(
|
||||
t: TaskQueueItem,
|
||||
mediaType: "image" | "video",
|
||||
mediaFilesApi: MediaFilesApi,
|
||||
): Promise<GalleryItem[]> {
|
||||
const file = t.completed_item?.primary_media_file;
|
||||
if (!file) return [];
|
||||
|
||||
const meta = getMetaForTask(
|
||||
t.id,
|
||||
t.model_type ? String(t.model_type) : undefined,
|
||||
t.created_at.getTime(),
|
||||
);
|
||||
const label =
|
||||
meta?.prompt ||
|
||||
(mediaType === "video" ? "Video Generation" : "Image Generation");
|
||||
const modelId = t.model_type ? String(t.model_type) : undefined;
|
||||
// Sort by task creation time (not completion time) so the completed row
|
||||
// occupies the same slot the pending row held — no layout shift.
|
||||
const createdAt = t.created_at.toISOString();
|
||||
|
||||
const primary: GalleryItem = {
|
||||
id: file.token,
|
||||
label,
|
||||
thumbnail:
|
||||
getThumbnailUrl(file.maybe_thumbnail_url_template, {
|
||||
width: THUMBNAIL_SIZES.LARGE,
|
||||
}) ?? null,
|
||||
fullImage: file.cdn_url || null,
|
||||
createdAt,
|
||||
mediaClass: mediaType,
|
||||
modelId,
|
||||
batchImageToken: t.completed_item?.maybe_batch_token,
|
||||
};
|
||||
|
||||
const batchToken = t.completed_item?.maybe_batch_token;
|
||||
if (!batchToken) return [primary];
|
||||
|
||||
try {
|
||||
const batchResponse = await mediaFilesApi.GetMediaFilesByBatchToken({
|
||||
batchToken,
|
||||
});
|
||||
if (!batchResponse.success || !batchResponse.data?.length) {
|
||||
return [primary];
|
||||
}
|
||||
return batchResponse.data
|
||||
.map((batchFile: any): GalleryItem | null => {
|
||||
const cdnUrl = batchFile.media_links?.cdn_url;
|
||||
if (!cdnUrl) return null;
|
||||
const thumbnail = getMediaThumbnail(batchFile.media_links, mediaType, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
return {
|
||||
id: batchFile.token,
|
||||
label,
|
||||
thumbnail: thumbnail || cdnUrl,
|
||||
fullImage: cdnUrl,
|
||||
createdAt,
|
||||
mediaClass: mediaType,
|
||||
modelId,
|
||||
batchImageToken: batchToken,
|
||||
};
|
||||
})
|
||||
.filter((i): i is GalleryItem => i !== null);
|
||||
} catch {
|
||||
return [primary];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
|
||||
// Resolve the current session's username (needed by the gallery list API).
|
||||
// Mirrors the gallery modal's session fetch; null while loading / logged out.
|
||||
export function useDesktopUsername(): string | null {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const session = await new UsersApi().GetSession();
|
||||
if (!cancelled && session.success && session.data?.user) {
|
||||
setUsername(session.data.user.username);
|
||||
}
|
||||
} catch {
|
||||
// ignore — stays null and the page shows the empty state
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return username;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export const AppsQuickMenu = () => {
|
||||
return (
|
||||
<div className="grid w-[680px] grid-cols-2 gap-3">
|
||||
<div>
|
||||
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Generate</h3>
|
||||
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Create</h3>
|
||||
<div className="space-y-0.5">
|
||||
{generateApps.map((app) => (
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
faGear,
|
||||
faGem,
|
||||
faGrid2,
|
||||
faHouse,
|
||||
faImages,
|
||||
faCalculator,
|
||||
faExclamation,
|
||||
@@ -17,29 +18,12 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { signal } from "@preact/signals-react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import {
|
||||
FilterMediaClasses,
|
||||
downloadUrlToPath,
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import {
|
||||
getCreatorIcon,
|
||||
ModelCreator,
|
||||
IMAGE_MODELS_BY_ID,
|
||||
VIDEO_MODELS_BY_ID,
|
||||
CommonAspectRatio,
|
||||
CommonResolution,
|
||||
} from "@storyteller/model-list";
|
||||
import {
|
||||
useClassyModelSelectorStore,
|
||||
ModelPage,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
|
||||
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
|
||||
import { gtagEvent } from "@storyteller/google-analytics";
|
||||
import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
|
||||
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import {
|
||||
useCreditsBalanceChangedEvent,
|
||||
useSubscriptionPlanChangedEvent,
|
||||
@@ -67,19 +51,16 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
CreditsModal,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
RefAudio,
|
||||
usePromptImageStore,
|
||||
usePromptVideoStore,
|
||||
} from "@storyteller/ui-promptbox";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
|
||||
import {
|
||||
applyMakeVideoFromImage,
|
||||
applyRecreateFromPromptData,
|
||||
downloadMediaFileToDisk,
|
||||
} from "~/components/generation-feed/desktopMediaActions";
|
||||
import { useStoryboardStore } from "~/pages/PageStoryboard";
|
||||
import { useSceneStore } from "@storyteller/ui-pagedraw";
|
||||
import { usePageSceneStore } from "@storyteller/ui-pagescene";
|
||||
@@ -117,8 +98,19 @@ type SettingsSection =
|
||||
const SWITCHER_THROTTLE_TIME = 500; // milliseconds
|
||||
const CREDITS_POLL_INTERVAL = 60_000; // milliseconds
|
||||
|
||||
// NB: See `TabState` for the default tab.
|
||||
// NB: See `TabState` for the default tab. The Apps ("More") entry is first so
|
||||
// it's the landing tab and leftmost in the switcher.
|
||||
const appMenuTabs: MenuIconItem[] = [
|
||||
{
|
||||
id: "APPS",
|
||||
label: "Home",
|
||||
icon: <FontAwesomeIcon icon={faHouse} />,
|
||||
description: "Explore all apps and miniapps",
|
||||
large: true,
|
||||
tooltipContent: <AppsQuickMenu />,
|
||||
tooltipInteractive: true,
|
||||
tooltipPosition: "bottom",
|
||||
},
|
||||
...APP_DESCRIPTORS.map((d) => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
@@ -127,16 +119,6 @@ const appMenuTabs: MenuIconItem[] = [
|
||||
description: d.description,
|
||||
large: d.large,
|
||||
})),
|
||||
{
|
||||
id: "APPS",
|
||||
label: "More",
|
||||
icon: <FontAwesomeIcon icon={faGrid2} />,
|
||||
description: "Explore more apps and miniapps",
|
||||
large: true,
|
||||
tooltipContent: <AppsQuickMenu />,
|
||||
tooltipInteractive: true,
|
||||
tooltipPosition: "bottom",
|
||||
},
|
||||
];
|
||||
|
||||
export const topNavMediaId = signal<string>("");
|
||||
@@ -286,29 +268,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadFile = async (url: string, mediaClass?: string) => {
|
||||
try {
|
||||
const chosenPath = await promptDownloadLocationIfNeeded(url);
|
||||
if (chosenPath === null) {
|
||||
// User dismissed the picker.
|
||||
return;
|
||||
}
|
||||
if (typeof chosenPath === "string") {
|
||||
await downloadUrlToPath(url, chosenPath);
|
||||
} else {
|
||||
await DownloadUrl(url);
|
||||
}
|
||||
if (mediaClass === FilterMediaClasses.DIMENSIONAL) {
|
||||
toast.success(`Downloaded 3D model`);
|
||||
} else {
|
||||
toast.success(`Downloaded ${mediaClass}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(">>> Failed to download file:", error);
|
||||
// NB: Rust/Tauri should now flash a toast instead.
|
||||
//toast.error("Failed to download file");
|
||||
}
|
||||
};
|
||||
const downloadFile = downloadMediaFileToDisk;
|
||||
|
||||
const handleEditFromGallery = async (url: string, mediaId?: string) => {
|
||||
try {
|
||||
@@ -337,174 +297,9 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTurnIntoVideoFromGallery = async (
|
||||
url: string,
|
||||
mediaId?: string,
|
||||
) => {
|
||||
try {
|
||||
const referenceImage: RefImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: mediaId || "",
|
||||
};
|
||||
// Update zustand store for Video directly
|
||||
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
const handleTurnIntoVideoFromGallery = applyMakeVideoFromImage;
|
||||
|
||||
const handleRecreateFromGallery = (data: {
|
||||
promptData: Prompts;
|
||||
mediaClass: string | undefined;
|
||||
}) => {
|
||||
try {
|
||||
const { promptData, mediaClass: recreateMediaClass } = data;
|
||||
const contextImages = promptData.maybe_context_images || [];
|
||||
|
||||
// Partition context images by semantic type
|
||||
const imgRefs: RefImage[] = [];
|
||||
let endFrameImage: RefImage | undefined;
|
||||
const vidRefs: RefVideo[] = [];
|
||||
const audioRefs: RefAudio[] = [];
|
||||
|
||||
for (const ci of contextImages) {
|
||||
const base = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: ci.media_links.cdn_url,
|
||||
mediaToken: ci.media_token,
|
||||
};
|
||||
|
||||
switch (ci.semantic) {
|
||||
case "vid_end_frame":
|
||||
endFrameImage = { ...base, file: new File([], "recreate-ref") };
|
||||
break;
|
||||
case "vid_ref":
|
||||
vidRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
case "audioref":
|
||||
audioRefs.push({
|
||||
...base,
|
||||
file: new File([], "recreate-ref"),
|
||||
duration: 0,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc, vid_start_frame, imgmask
|
||||
imgRefs.push({ ...base, file: new File([], "recreate-ref") });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine input mode from generation_mode or context image semantics
|
||||
const hasKeyframeSemantics = contextImages.some(
|
||||
(ci) =>
|
||||
ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
|
||||
);
|
||||
const inputMode: "keyframe" | "reference" =
|
||||
promptData.maybe_generation_mode === "keyframe" || hasKeyframeSemantics
|
||||
? "keyframe"
|
||||
: promptData.maybe_generation_mode === "reference"
|
||||
? "reference"
|
||||
: imgRefs.length > 0
|
||||
? "reference"
|
||||
: "keyframe";
|
||||
|
||||
const modelStore = useClassyModelSelectorStore.getState();
|
||||
|
||||
if (recreateMediaClass === "video") {
|
||||
const videoStore = usePromptVideoStore.getState();
|
||||
|
||||
// Set model first so the UI syncs sizeOptions / durationOptions
|
||||
const videoModel = promptData.maybe_model_type
|
||||
? VIDEO_MODELS_BY_ID.get(promptData.maybe_model_type)
|
||||
: undefined;
|
||||
if (videoModel) {
|
||||
modelStore.setSelectedModel(ModelPage.ImageToVideo, videoModel);
|
||||
}
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
videoStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) videoStore.setReferenceImages(imgRefs);
|
||||
if (endFrameImage) videoStore.setEndFrameImage(endFrameImage);
|
||||
if (vidRefs.length > 0) videoStore.setReferenceVideos(vidRefs);
|
||||
if (audioRefs.length > 0) videoStore.setReferenceAudios(audioRefs);
|
||||
if (promptData.maybe_generate_audio !== null) {
|
||||
videoStore.setGenerateWithSound(promptData.maybe_generate_audio);
|
||||
}
|
||||
if (promptData.maybe_duration_seconds !== null) {
|
||||
videoStore.setDuration(promptData.maybe_duration_seconds);
|
||||
}
|
||||
|
||||
// Map API aspect ratio (tauriValue like "wide_sixteen_by_nine") → textLabel (like "16:9")
|
||||
if (promptData.maybe_aspect_ratio && videoModel?.sizeOptions) {
|
||||
const match = videoModel.sizeOptions.find(
|
||||
(opt) => opt.tauriValue === promptData.maybe_aspect_ratio,
|
||||
);
|
||||
if (match) {
|
||||
videoStore.setAspectRatio(match.textLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Map API resolution (like "one_k") → video store format (like "1080p")
|
||||
if (promptData.maybe_resolution && videoModel?.resolutionOptions) {
|
||||
const resolutionMap: Record<string, string> = {
|
||||
one_k: "1080p",
|
||||
two_k: "2k",
|
||||
three_k: "3k",
|
||||
four_k: "4k",
|
||||
};
|
||||
const mapped = resolutionMap[promptData.maybe_resolution];
|
||||
if (mapped && videoModel.resolutionOptions.includes(mapped)) {
|
||||
videoStore.setResolution(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
videoStore.setInputMode(inputMode);
|
||||
useTabStore.getState().setActiveTab("VIDEO");
|
||||
} else {
|
||||
// Default to image
|
||||
const imageStore = usePromptImageStore.getState();
|
||||
|
||||
if (promptData.maybe_positive_prompt) {
|
||||
imageStore.setPrompt(promptData.maybe_positive_prompt);
|
||||
}
|
||||
if (imgRefs.length > 0) imageStore.setReferenceImages(imgRefs);
|
||||
if (promptData.maybe_aspect_ratio) {
|
||||
imageStore.setCommonAspectRatio(
|
||||
promptData.maybe_aspect_ratio as CommonAspectRatio,
|
||||
);
|
||||
}
|
||||
if (promptData.maybe_resolution) {
|
||||
imageStore.setCommonResolution(
|
||||
promptData.maybe_resolution as CommonResolution,
|
||||
);
|
||||
}
|
||||
|
||||
if (promptData.maybe_model_type) {
|
||||
const model = IMAGE_MODELS_BY_ID.get(promptData.maybe_model_type);
|
||||
if (model) modelStore.setSelectedModel(ModelPage.TextToImage, model);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE");
|
||||
}
|
||||
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
galleryModalLightboxVisible.value = false;
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
const handleRecreateFromGallery = applyRecreateFromPromptData;
|
||||
|
||||
const handleRemoveBackgroundFromGallery = async (url: string) => {
|
||||
try {
|
||||
@@ -559,9 +354,9 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
case "3D":
|
||||
return "3D Editor";
|
||||
case "IMAGE":
|
||||
return "Text to Image";
|
||||
return "Create Image";
|
||||
case "VIDEO":
|
||||
return "Generate Video";
|
||||
return "Create Video";
|
||||
case "EDIT":
|
||||
return "Edit Image";
|
||||
case "VIDEO_FRAME_EXTRACTOR":
|
||||
@@ -575,7 +370,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
case "IMAGE_TO_3D_WORLD":
|
||||
return "Image to 3D World";
|
||||
case "APPS":
|
||||
return "ArtCraft Apps";
|
||||
return "ArtCraft";
|
||||
case "BACKGROUND_CHANGE":
|
||||
return "Background Change";
|
||||
default:
|
||||
@@ -703,6 +498,8 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
|
||||
<div className="flex justify-end gap-2" data-tauri-drag-region>
|
||||
<div className="no-drag flex items-center gap-1.5">
|
||||
{(tabStore.activeTabId === "IMAGE" ||
|
||||
tabStore.activeTabId === "VIDEO") && <GalleryViewToggle />}
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="center"
|
||||
@@ -786,7 +583,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
onClick={toggleSubscriptionModal}
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
Support
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface EnqueueMeta {
|
||||
refImageUrls?: string[];
|
||||
modelType?: string;
|
||||
timestamp: number; // Date.now() at enqueue time
|
||||
// Requested generation count when one task produces a batch (image page).
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
interface StoredEntry {
|
||||
@@ -27,12 +29,14 @@ interface StoredEntry {
|
||||
refImageUrls?: string[]; // base64 data-URLs after conversion
|
||||
modelType?: string;
|
||||
timestamp: number;
|
||||
batchCount?: number;
|
||||
matchedTaskId?: string; // set once matched to a specific task
|
||||
}
|
||||
|
||||
interface TaskMatch {
|
||||
prompt?: string;
|
||||
refImageUrls?: string[];
|
||||
batchCount?: number;
|
||||
}
|
||||
|
||||
// In-memory cache of task_id → matched metadata
|
||||
@@ -113,6 +117,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
|
||||
refImageUrls: meta.refImageUrls,
|
||||
modelType: meta.modelType,
|
||||
timestamp: meta.timestamp,
|
||||
batchCount: meta.batchCount,
|
||||
};
|
||||
const entries = readEntries();
|
||||
entries.push(entry);
|
||||
@@ -153,6 +158,7 @@ export function getMetaForTask(
|
||||
const result: TaskMatch = {
|
||||
prompt: previousMatch.prompt,
|
||||
refImageUrls: previousMatch.refImageUrls,
|
||||
batchCount: previousMatch.batchCount,
|
||||
};
|
||||
matchedTasks.set(taskId, result);
|
||||
return result;
|
||||
@@ -187,6 +193,7 @@ export function getMetaForTask(
|
||||
const result: TaskMatch = {
|
||||
prompt: matched.prompt,
|
||||
refImageUrls: matched.refImageUrls,
|
||||
batchCount: matched.batchCount,
|
||||
};
|
||||
matchedTasks.set(taskId, result);
|
||||
return result;
|
||||
|
||||
@@ -48,12 +48,12 @@ export interface AppDescriptor {
|
||||
export const APP_DESCRIPTORS: AppDescriptor[] = [
|
||||
{
|
||||
id: "IMAGE",
|
||||
label: "Text to Image",
|
||||
label: "Create Image",
|
||||
icon: faImage,
|
||||
},
|
||||
{
|
||||
id: "VIDEO",
|
||||
label: "Generate Video",
|
||||
label: "Create Video",
|
||||
icon: faFilm,
|
||||
},
|
||||
{
|
||||
@@ -82,13 +82,115 @@ export interface FullAppItem {
|
||||
category: "generate" | "edit";
|
||||
badge?: "NEW" | "BEST" | "SOON" | "BETA";
|
||||
action?: AppId;
|
||||
/** Legacy single-tone icon-square background (e.g. "bg-blue-600/40"). Still
|
||||
* used as the fallback for the apps-page card styling. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// Per-app card palette for the Apps page (webapp-home-style cards). Derived
|
||||
// from each app's Tailwind color family so the icon box, its border, and the
|
||||
// hover gradient all share a hue. Keyed by app id; falls back to a neutral
|
||||
// slate when an id is missing.
|
||||
interface AppCardPalette {
|
||||
/** Hover gradient overlay, e.g. "from-blue-500/20 to-blue-500/0". */
|
||||
accent: string;
|
||||
/** Icon box background + border, e.g. "bg-blue-500/20 border-blue-400/30". */
|
||||
iconBg: string;
|
||||
/** Icon glyph color, e.g. "text-blue-300". */
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
const APP_CARD_PALETTES: Record<string, AppCardPalette> = {
|
||||
"text-to-image": {
|
||||
accent: "from-blue-500/20 to-blue-500/0",
|
||||
iconBg: "bg-blue-500/20 border-blue-400/30",
|
||||
iconColor: "text-blue-300",
|
||||
},
|
||||
"image-to-video": {
|
||||
accent: "from-amber-500/20 to-amber-500/0",
|
||||
iconBg: "bg-amber-500/20 border-amber-400/30",
|
||||
iconColor: "text-amber-300",
|
||||
},
|
||||
"image-to-3d-object": {
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/20 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
},
|
||||
"image-to-3d-world": {
|
||||
accent: "from-sky-500/20 to-sky-500/0",
|
||||
iconBg: "bg-sky-500/20 border-sky-400/30",
|
||||
iconColor: "text-sky-300",
|
||||
},
|
||||
angles: {
|
||||
accent: "from-lime-500/20 to-lime-500/0",
|
||||
iconBg: "bg-lime-500/20 border-lime-400/30",
|
||||
iconColor: "text-lime-300",
|
||||
},
|
||||
storyboard: {
|
||||
accent: "from-fuchsia-500/20 to-fuchsia-500/0",
|
||||
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/30",
|
||||
iconColor: "text-fuchsia-300",
|
||||
},
|
||||
"2d-canvas": {
|
||||
accent: "from-sky-500/20 to-sky-500/0",
|
||||
iconBg: "bg-sky-500/20 border-sky-400/30",
|
||||
iconColor: "text-sky-300",
|
||||
},
|
||||
"3d-editor": {
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/20 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
},
|
||||
"edit-image": {
|
||||
accent: "from-purple-500/20 to-purple-500/0",
|
||||
iconBg: "bg-purple-500/20 border-purple-400/30",
|
||||
iconColor: "text-purple-300",
|
||||
},
|
||||
"remove-background": {
|
||||
accent: "from-violet-500/20 to-violet-500/0",
|
||||
iconBg: "bg-violet-500/20 border-violet-400/30",
|
||||
iconColor: "text-violet-300",
|
||||
},
|
||||
"video-frame-extractor": {
|
||||
accent: "from-rose-500/20 to-rose-500/0",
|
||||
iconBg: "bg-rose-500/20 border-rose-400/30",
|
||||
iconColor: "text-rose-300",
|
||||
},
|
||||
"background-change": {
|
||||
accent: "from-orange-500/20 to-orange-500/0",
|
||||
iconBg: "bg-orange-500/20 border-orange-400/30",
|
||||
iconColor: "text-orange-300",
|
||||
},
|
||||
"video-editor": {
|
||||
accent: "from-teal-500/20 to-teal-500/0",
|
||||
iconBg: "bg-teal-500/20 border-teal-400/30",
|
||||
iconColor: "text-teal-300",
|
||||
},
|
||||
"video-watermark-removal": {
|
||||
accent: "from-cyan-500/20 to-cyan-500/0",
|
||||
iconBg: "bg-cyan-500/20 border-cyan-400/30",
|
||||
iconColor: "text-cyan-300",
|
||||
},
|
||||
"image-watermark-removal": {
|
||||
accent: "from-indigo-500/20 to-indigo-500/0",
|
||||
iconBg: "bg-indigo-500/20 border-indigo-400/30",
|
||||
iconColor: "text-indigo-300",
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_APP_CARD_PALETTE: AppCardPalette = {
|
||||
accent: "from-white/10 to-white/0",
|
||||
iconBg: "bg-ui-controls border-ui-controls-border",
|
||||
iconColor: "text-base-fg",
|
||||
};
|
||||
|
||||
export const getAppCardPalette = (id: string): AppCardPalette =>
|
||||
APP_CARD_PALETTES[id] ?? FALLBACK_APP_CARD_PALETTE;
|
||||
|
||||
export const ALL_APPS: FullAppItem[] = [
|
||||
{
|
||||
id: "text-to-image",
|
||||
label: "Text to Image",
|
||||
label: "Create Image",
|
||||
description: "Generate AI images",
|
||||
icon: faImage,
|
||||
category: "generate",
|
||||
@@ -97,7 +199,7 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
},
|
||||
{
|
||||
id: "image-to-video",
|
||||
label: "Generate Video",
|
||||
label: "Create Video",
|
||||
description: "Create video from images",
|
||||
icon: faFilm,
|
||||
category: "generate",
|
||||
@@ -213,7 +315,7 @@ export const ALL_APPS: FullAppItem[] = [
|
||||
label: "Image Editor",
|
||||
description: "Easy edits. Great for graphic design.",
|
||||
icon: faPenNib,
|
||||
category: "generate",
|
||||
category: "edit",
|
||||
action: "2D",
|
||||
color: "bg-sky-500/40",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button, GenerateButton } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { MediaUploadApi, MediaFilesApi, downloadFileFromUrl } from "@storyteller/api";
|
||||
import {
|
||||
MediaUploadApi,
|
||||
MediaFilesApi,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -43,7 +47,11 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { HistoryStack, type ImageBundle, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
|
||||
import {
|
||||
HistoryStack,
|
||||
type ImageBundle,
|
||||
type BaseSelectorImage,
|
||||
} from "@storyteller/ui-pagedraw";
|
||||
|
||||
const ANGLES_MODEL_PAGE = ModelPage.Angles;
|
||||
|
||||
@@ -76,7 +84,9 @@ export const Angles = () => {
|
||||
// State selectors (only re-render when specific values change)
|
||||
const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
|
||||
const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
|
||||
const sourceThumbnailUrlTemplate = useAnglesStore((s) => s.sourceThumbnailUrlTemplate);
|
||||
const sourceThumbnailUrlTemplate = useAnglesStore(
|
||||
(s) => s.sourceThumbnailUrlTemplate,
|
||||
);
|
||||
const imageDimensions = useAnglesStore((s) => s.imageDimensions);
|
||||
const angleConfig = useAnglesStore((s) => s.angleConfig);
|
||||
// const generateFromBestAngles = useAnglesStore(
|
||||
@@ -194,11 +204,23 @@ export const Angles = () => {
|
||||
let thumbnailUrlTemplate: string | undefined;
|
||||
try {
|
||||
const api = new MediaFilesApi();
|
||||
const mediaFile = await api.GetMediaFileByToken({ mediaFileToken: mediaToken });
|
||||
const mediaFile = await api.GetMediaFileByToken({
|
||||
mediaFileToken: mediaToken,
|
||||
});
|
||||
if (mediaFile.success && mediaFile.data) {
|
||||
const links = mediaFile.data.media_links as { cdn_url?: string; thumbnail_template?: string; maybe_thumbnail_template?: string } | undefined;
|
||||
cdnUrl = links?.cdn_url || (mediaFile.data as any).public_bucket_url || objectUrl;
|
||||
thumbnailUrlTemplate = links?.thumbnail_template || links?.maybe_thumbnail_template;
|
||||
const links = mediaFile.data.media_links as
|
||||
| {
|
||||
cdn_url?: string;
|
||||
thumbnail_template?: string;
|
||||
maybe_thumbnail_template?: string;
|
||||
}
|
||||
| undefined;
|
||||
cdnUrl =
|
||||
links?.cdn_url ||
|
||||
(mediaFile.data as any).public_bucket_url ||
|
||||
objectUrl;
|
||||
thumbnailUrlTemplate =
|
||||
links?.thumbnail_template || links?.maybe_thumbnail_template;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to blob URL if fetching CDN info fails
|
||||
@@ -288,7 +310,6 @@ export const Angles = () => {
|
||||
adjust_zoom: state.angleConfig.zoom,
|
||||
frontend_subscriber_id: subscriberId,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating angle:", error);
|
||||
toast.error("Failed to generate angle");
|
||||
@@ -435,7 +456,12 @@ export const Angles = () => {
|
||||
],
|
||||
};
|
||||
return [sourceBundle, ...historyBundles];
|
||||
}, [sourceImageUrl, sourceMediaToken, sourceThumbnailUrlTemplate, historyBundles]);
|
||||
}, [
|
||||
sourceImageUrl,
|
||||
sourceMediaToken,
|
||||
sourceThumbnailUrlTemplate,
|
||||
historyBundles,
|
||||
]);
|
||||
|
||||
const handleHistoryImageSelect = useCallback(
|
||||
(image: BaseSelectorImage) => {
|
||||
@@ -705,6 +731,11 @@ export const Angles = () => {
|
||||
</div>
|
||||
</Switch.Group> */}
|
||||
|
||||
<ClassyModelSelector
|
||||
variant="embedded"
|
||||
items={ANGLES_PAGE_MODEL_LIST}
|
||||
page={ANGLES_MODEL_PAGE}
|
||||
/>
|
||||
<GenerateButton
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
@@ -718,20 +749,8 @@ export const Angles = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Model selector (bottom-left) ── */}
|
||||
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-5">
|
||||
<ClassyModelSelector
|
||||
items={ANGLES_PAGE_MODEL_LIST}
|
||||
page={ANGLES_MODEL_PAGE}
|
||||
mode="hoverSelect"
|
||||
panelTitle="Select Model"
|
||||
panelClassName="min-w-[300px]"
|
||||
showIconsInList
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Cost calculator + Help (bottom-right) ── */}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
|
||||
<CostCalculatorButton modelPage={ANGLES_MODEL_PAGE} />
|
||||
<HelpMenuButton />
|
||||
</div>
|
||||
|
||||
@@ -1,81 +1,45 @@
|
||||
import { faGrid2 } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
useGenerateApps,
|
||||
useEditApps,
|
||||
getAppCardPalette,
|
||||
getBadgeStyles,
|
||||
goToApp,
|
||||
type FullAppItem,
|
||||
} from "~/config/appMenu";
|
||||
|
||||
export const AppsIndexPage = () => {
|
||||
const generateApps = useGenerateApps();
|
||||
const editApps = useEditApps();
|
||||
const categories = [
|
||||
{ title: "Generate", apps: generateApps },
|
||||
{ title: "Create", apps: generateApps },
|
||||
{ title: "Edit", apps: editApps },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden bg-ui-background pt-[56px] text-base-fg">
|
||||
<main className="mx-auto h-full max-w-6xl overflow-y-auto px-5 pb-12 pt-16">
|
||||
<div className="flex w-full flex-col items-start gap-2 pb-4">
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<FontAwesomeIcon icon={faGrid2} className="text-2xl opacity-70" />
|
||||
<h1 className="text-4xl font-bold">
|
||||
Explore <span className="text-primary-400">ArtCraft Apps</span>
|
||||
</h1>
|
||||
</div>
|
||||
<hr className="mb-3.5 w-full border-ui-panel-border" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
// The scroll container spans the full window width so its scrollbar sits
|
||||
// flush against the right edge; horizontal padding lives on the inner
|
||||
// wrapper instead.
|
||||
<div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg">
|
||||
<main className="mx-auto max-w-6xl px-6 pb-20 pt-12 sm:pt-20">
|
||||
<h1 className="mx-auto text-center text-4xl font-semibold tracking-tight sm:text-6xl">
|
||||
What will you <span className="text-primary">craft</span> today?
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-xl text-center text-lg text-base-fg/55">
|
||||
Every tool in your toolbox - pick one and start creating.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-10">
|
||||
{categories.map((category) => (
|
||||
<section key={category.title}>
|
||||
<h2 className="mb-3 text-sm font-semibold opacity-50">
|
||||
<h2 className="mb-4 text-sm font-semibold text-base-fg/85">
|
||||
{category.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<div className="grid auto-rows-min gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{category.apps.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => goToApp(app.action)}
|
||||
disabled={!app.action}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center gap-4 rounded-lg px-3 py-3 text-left transition-colors",
|
||||
app.action
|
||||
? "cursor-pointer hover:bg-ui-controls/60"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
app.color || "bg-ui-controls",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-[15px] font-medium">
|
||||
{app.label}
|
||||
</div>
|
||||
{app.badge && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
|
||||
getBadgeStyles(app.badge),
|
||||
)}
|
||||
>
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[13px] opacity-60">
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,3 +49,72 @@ export const AppsIndexPage = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AppCard({ app }: { app: FullAppItem }) {
|
||||
const palette = getAppCardPalette(app.id);
|
||||
const enabled = !!app.action;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => goToApp(app.action)}
|
||||
disabled={!enabled}
|
||||
className={twMerge(
|
||||
"group relative block overflow-hidden rounded-2xl bg-ui-controls/50 p-5 text-left transition-colors",
|
||||
enabled
|
||||
? "cursor-pointer hover:bg-ui-controls"
|
||||
: "cursor-default opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* Hover accent — gradient wash matching the app's hue. */}
|
||||
{enabled && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
|
||||
palette.accent,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border",
|
||||
palette.iconBg,
|
||||
palette.iconColor,
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-base font-semibold text-base-fg">
|
||||
{app.label}
|
||||
</h3>
|
||||
{app.badge && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none tracking-wider",
|
||||
getBadgeStyles(app.badge),
|
||||
)}
|
||||
>
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{enabled && (
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-sm text-base-fg/40 transition-all group-hover:translate-x-0.5 group-hover:text-base-fg/70"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug text-base-fg/55">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user