Compare commits

...
Author SHA1 Message Date
bflatastic 85819578ff restore signal 2025-04-09 01:43:27 +07:00
bflatastic 2516534b3f modal backdrop darker 2025-04-09 01:37:37 +07:00
bflatastic b221aa5df6 jobs activity with dummy polling for now 2025-04-09 01:37:37 +07:00
Brandon Thomas ea783c14de forgotten file 2025-04-08 13:59:49 -04:00
BFlat 01093918da library modal (#486)
- for selecting existing images from library (+ in prompt box)
- for viewing media in library (my library button on top right)
2025-04-08 22:42:56 +07:00
Brandon Thomas 2ea9464be2 rewire prompting 2025-04-08 02:21:17 -04:00
Brandon Thomas b41b4f8e17 read/write same sora cookie, avoid cloudflare heuristics 2025-04-07 21:17:26 -04:00
ArEnScandMichael Chung 09cf4529c0 Feature/reshape UI (#482)
Co-authored-by: Michael Chung <michaelchung@michaels-mbp-2.home>
2025-04-07 18:40:17 -04:00
Brandon Thomas bf035f9048 login flow 2025-04-07 17:50:48 -04:00
Brandon Thomas 448c760339 fix login 2025-04-07 16:35:14 -04:00
143 changed files with 7623 additions and 665 deletions
@@ -6,39 +6,27 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{DynamicImage, ImageReader};
use std::io::Cursor;
use log::{error, info};
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Manager, State};
use openai_sora_client::credentials::SoraCredentials;
use openai_sora_client::image_gen::common::{ImageSize, NumImages};
use openai_sora_client::image_gen::sora_image_gen_remix::{sora_image_gen_remix, SoraImageGenRemixRequest};
use openai_sora_client::upload::upload_media_from_bytes::sora_media_upload_from_bytes;
use openai_sora_client::upload::upload_media_from_file::SoraMediaUploadRequest;
use crate::state::app_dir::AppDataRoot;
#[tauri::command]
pub async fn image_generation_command(
image: &str,
prompt: &str,
app: AppHandle,
app_data_root: State<'_, AppDataRoot>,
) -> Result<String, String> {
info!("image_generation_command called; processing image...");
for (w, webview) in app.webviews() {
info!("sending webview: {}", w);
//let result = webview.eval("window.location.replace('https://google.com');"); // works
//let result = webview.eval("document.body.innerHTML = 'hello'"); // works
let result = webview.eval("document.body.innerHTML = document.cookie");
if let Err(err) = result {
error!("Error with webview: {:?}", err)
}
}
if true {
return Ok("true".to_string());
}
let bytes = BASE64_STANDARD.decode(image)
.map_err(|err| format!("Base64 decode error: {}", err))?;
generate_image(bytes, prompt)
generate_image(bytes, prompt, &app_data_root)
.await
.map_err(|err| {
error!("error: {:?}", err);
@@ -48,8 +36,8 @@ pub async fn image_generation_command(
Ok("success".to_string())
}
pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str) -> AnyhowResult<()> {
let sora_credentials = get_credentials()?;
pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let sora_credentials = get_credentials(app_data_root)?;
let filename = "image.png".to_string();
@@ -79,16 +67,21 @@ pub async fn generate_image(file_bytes: Vec<u8>, prompt: &str) -> AnyhowResult<(
Ok(())
}
fn get_credentials() -> AnyhowResult<SoraCredentials> {
fn get_credentials(app_data_root: &AppDataRoot) -> AnyhowResult<SoraCredentials> {
let cookie_file = app_data_root.get_sora_cookie_file_path();
let bearer = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/bearer.txt")?
.trim()
.to_string();
let cookie= read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/cookie.txt")?
let cookie= read_to_string(cookie_file)?
.trim()
.to_string();
let sentinel = read_to_string("/Users/bt/dev/storyteller/storyteller-rust/test_data/temp/sentinel.txt")?
.trim()
.to_string();
Ok(SoraCredentials {
bearer_token: bearer,
cookie: cookie,
@@ -1,5 +1,23 @@
use std::time::Duration;
use log::{error, info};
use tauri::{AppHandle, WindowBuilder};
use once_cell::sync::Lazy;
use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder, WindowBuilder};
use tokio::time::sleep_until;
use errors::AnyhowResult;
use crate::threads::login_thread::{LOGIN_WINDOW_NAME, SORA_LOGIN_URL};
use crate::utils::clear_all_webview_cookies::clear_all_webview_cookies;
pub const START_URL_STR: &str = "https://storyteller.ai/";
pub static START_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(START_URL_STR).expect("URL should parse")
});
pub const SORA_HOMEPAGE_URL_STR: &str = "https://sora.com/";
pub static SORA_HOMEPAGE_URL : Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_HOMEPAGE_URL_STR).expect("URL should parse")
});
#[tauri::command]
pub async fn open_login_command(
@@ -7,35 +25,48 @@ pub async fn open_login_command(
) -> Result<String, String> {
info!("open_login_command called");
let _window_builder = WindowBuilder::new(&app, "login")
.always_on_top(true)
.title("Login")
.resizable(true)
.visible(true)
.build()
.map_err(|err| {
error!("Failed to create window: {}", err);
format!("Failed to create window: {}", err)
})?;
/*app.create_tao_window(move || {
let window = tauri::window::WindowBuilder::from_config(&app, &app.config().app.windows.get(0).unwrap().clone())
("WGPU Target".to_string(), window_builder)
});*/
/* "login",
tauri::WindowUrl::App("https://google.com".into()),
tauri::WindowBuilder::new(
&app,
"login",
tauri::WindowUrl::App("https://google.com".into()),
)
.title("Login")
.inner_size(800.0, 600.0)
.decorations(true)
.resizable(true)
).map_err(|err| format!("Failed to create window: {}", err))?;*/
do_open_login(&app)
.await
.map_err(|err| {
error!("Error opening login: {:?}", err);
format!("Error opening login: {:?}", err)
})?;
Ok("result".to_string())
}
}
async fn do_open_login(app: &AppHandle) -> AnyhowResult<()> {
info!("Building login window...");
let url = WebviewUrl::External(START_URL.clone());
let window = WebviewWindowBuilder::new(app, LOGIN_WINDOW_NAME, url)
//.user_agent(openai_sora_client::credentials::USER_AGENT)
.always_on_top(true)
.title("Login to OpenAI")
.center()
.resizable(true)
.visible(true)
.closable(true)
.focused(true)
.devtools(true)
.build()?;
for (name, webview) in window.webviews() {
if name != LOGIN_WINDOW_NAME {
continue;
}
clear_all_webview_cookies(&webview)?;
// NB: We're starting to get Cloudflare protection screens. Let's try to avoid.
webview.navigate(SORA_HOMEPAGE_URL.clone())?;
tokio::time::sleep(Duration::from_millis(100)).await;
webview.navigate(SORA_LOGIN_URL.clone())?;
break;
}
info!("Done.");
Ok(())
}
+1 -2
View File
@@ -23,7 +23,6 @@ pub fn run() {
let config = AppConfig::init()
.expect("config should load");
let app_data_root = config.app_data_root.clone();
let app_data_root2 = config.app_data_root.clone();
@@ -50,7 +49,7 @@ pub fn run() {
//}
let app = app.handle().clone();
tauri::async_runtime::spawn(login_thread(app));
tauri::async_runtime::spawn(login_thread(app, app_data_root2));
Ok(())
})
@@ -114,6 +114,10 @@ impl AppDataRoot {
pub fn log_file_name_str(&self) -> &str {
&self.log_file_name_string
}
pub fn get_sora_cookie_file_path(&self) -> PathBuf {
self.path.join("sora_cookies.txt")
}
}
impl AppAssetsDir {
@@ -1,42 +1,103 @@
use std::fs::OpenOptions;
use std::io::Write;
use anyhow::anyhow;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::{AppHandle, Manager, Webview};
use errors::AnyhowResult;
use crate::state::app_dir::AppDataRoot;
use crate::utils::sora_webview_cookies::get_all_sora_cookies_as_string;
pub async fn login_thread(app: AppHandle) -> ! {
pub const LOGIN_WINDOW_NAME: &str = "login_window";
pub const SORA_LOGIN_URL_STR: &str = "https://sora.com/auth/login?callback_path=/";
pub static SORA_LOGIN_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_LOGIN_URL_STR).expect("URL should parse")
});
pub const SORA_ROOT_URL_STR: &str = "https://sora.com/";
pub static SORA_ROOT_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_URL_STR).expect("URL should parse")
});
pub async fn login_thread(app: AppHandle, app_data_root: AppDataRoot) -> ! {
loop {
/*for (w, webview) in app.webviews() {
info!("Sending webview {}", w);
if let Err(err) = random_behavior(&webview) {
error!("Error in webview : {:?}", err);
for (window_name, webview) in app.webviews() {
if window_name == LOGIN_WINDOW_NAME {
let result = check_login_window(&webview, &app_data_root).await;
if let Err(err) = result {
error!("Error checking login window: {:?}", err);
}
break;
}
}*/
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
}
tokio::time::sleep(std::time::Duration::from_millis(2_000)).await;
}
}
pub fn random_behavior(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url();
//webview.navigate(url)?;
//webview.cookies_for_url(url)?;
async fn check_login_window(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
clear_browsing_data_on_test_domain(webview)?;
//keep_on_task(webview)?;
extract_cookies_to_file(webview, app_data_root)?;
// TODO: Other maintenance tasks ...
Ok(())
}
let rand = rand::random_range(0..5u8);
match rand {
0..3 => {
redirect_google(&webview)?;
}
fn keep_on_task(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"auth.openai.com" => {},
"openai.com" => {},
"sora.com" => {},
// Third party SSO
"accounts.google.com" => {},
"accounts.youtube.com" => {},
"login.live.com" => {},
"appleid.apple.com" => {},
_ => {
write_cookies_to_body(&webview)?;
info!("Non login hostname: {}", hostname);
webview.navigate(SORA_LOGIN_URL.clone())?;
}
}
Ok(())
}
pub fn redirect_google(webview: &Webview) -> AnyhowResult<()> {
webview.eval("window.location.replace('https://google.com');")?;
/// This is just so we have a way to clear browsing data.
fn clear_browsing_data_on_test_domain(webview: &Webview) -> AnyhowResult<()> {
let url = webview.url()?;
let hostname= url.host()
.ok_or(anyhow!("no host in url"))?
.to_string();
match hostname.as_str() {
"storyteller.ai" => {
info!("Clearing all browsing data...");
webview.clear_all_browsing_data()?;
}
_ => {}
}
Ok(())
}
pub fn write_cookies_to_body(webview: &Webview) -> AnyhowResult<()> {
webview.eval("document.body.innerHTML = document.cookie")?;
// TODO(bt,2025-04-07): Heuristic to detect when logged in. Only write when logged in.
fn extract_cookies_to_file(webview: &Webview, app_data_root: &AppDataRoot) -> AnyhowResult<()> {
let cookies = get_all_sora_cookies_as_string(webview)?;
let cookie_file = app_data_root.get_sora_cookie_file_path();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(cookie_file)?;
file.write_all(cookies.as_bytes())?;
file.flush()?;
Ok(())
}
@@ -0,0 +1,7 @@
use tauri::Webview;
use errors::AnyhowResult;
pub fn clear_all_webview_cookies(webview: &Webview) -> AnyhowResult<()> {
webview.clear_all_browsing_data()?;
Ok(())
}
+3 -1
View File
@@ -1 +1,3 @@
pub mod image;
pub mod clear_all_webview_cookies;
pub mod image;
pub mod sora_webview_cookies;
@@ -0,0 +1,26 @@
use once_cell::sync::Lazy;
use reqwest::Url;
use tauri::Webview;
use tauri::webview::Cookie;
use errors::AnyhowResult;
const SORA_ROOT_COOKIE_URL_STR: &str = "https://sora.com/";
static SORA_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(SORA_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
pub fn get_all_sora_cookies(webview: &Webview) -> AnyhowResult<Vec<Cookie>> {
let cookies = webview.cookies_for_url(SORA_ROOT_COOKIE_URL.clone())?;
Ok(cookies)
}
pub fn get_all_sora_cookies_as_string(webview: &Webview) -> AnyhowResult<String> {
let cookies = get_all_sora_cookies(webview)?;
let cookie_string = cookies
.iter()
.map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
.collect::<Vec<String>>()
.join("; ");
Ok(cookie_string)
}
@@ -4,7 +4,7 @@ use serde_derive::Deserialize;
use errors::AnyhowResult;
// NB: It appears that the sentinel may require a matching user agent.
pub (crate) const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
pub const USER_AGENT : &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
#[derive(Clone, Deserialize)]
pub struct SoraCredentials {
+56 -40
View File
@@ -9,12 +9,18 @@
"version": "0.0.0",
"dependencies": {
"@devnomic/marquee": "^1.0.3",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@headlessui/react": "^2.1.3",
"@preact/signals-react": "^2.2.0",
"@tauri-apps/api": "^2.2.0",
"@techstark/opencv-js": "^4.10.0-release.1",
"@zip.js/zip.js": "^2.7.52",
"dayjs": "^1.11.13",
"fortawesome": "^0.0.1-security",
"konva": "^9.3.14",
"onnxruntime-web": "^1.19.2",
"posthog-js": "^1.174.3",
@@ -26,14 +32,10 @@
"react-router-dom": "^6.26.1",
"sonner": "^2.0.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
"yarn": "^1.22.22"
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/pro-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
@@ -588,45 +590,54 @@
"integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA=="
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.6.0/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"license": "MIT",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.6.0/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"dev": true,
"license": "MIT",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-svg-core/-/6.7.2/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.6.0/free-brands-svg-icons-6.6.0.tgz",
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/6.7.2/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-regular-svg-icons/-/6.7.2/pro-regular-svg-icons-6.7.2.tgz",
"integrity": "sha512-cJolK4K/s56mHAqOyno24SmjviHqSqG9ZfckBKCpWjJlgDexi61EfET5wWbmmEot6/BqasFjw9S6tudFQUL/PQ==",
"license": "UNLICENSED",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/pro-solid-svg-icons": {
"version": "6.6.0",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.6.0/pro-solid-svg-icons-6.6.0.tgz",
"integrity": "sha512-IwYuyO+i681arIxOjPUoqRwoHeTqeg6i2s5rY2WbZ1R7PnwavOd1+aPHCJlfU2k8l5LZ+u3hAKlVqu+emQFlZg==",
"dev": true,
"version": "6.7.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.7.2/pro-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-SY2aP8d7uBlg1rNYznA9w9SVnnQhu3x/C7CCKQYcOGOC1YtPPHgIJhxKGbSzm6RkeJs1HVEfAkORX3J0XB5K9Q==",
"license": "UNLICENSED",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
@@ -636,8 +647,6 @@
"version": "0.2.2",
"resolved": "https://npm.fontawesome.com/@fortawesome/react-fontawesome/-/0.2.2/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
@@ -2436,6 +2445,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@@ -3227,6 +3241,11 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fortawesome": {
"version": "0.0.1-security",
"resolved": "https://registry.npmjs.org/fortawesome/-/fortawesome-0.0.1-security.tgz",
"integrity": "sha512-o/pwn9ZSnveFLYP0d5IejSwz0rpGMDQsBaYVvR8WXSvnxILIwh7IUJK0P90QMF4cnIyouVF2BGrz0LEVSyzimA=="
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -4544,6 +4563,8 @@
},
"node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -5284,6 +5305,8 @@
},
"node_modules/npm/node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"inBundle": true,
"license": "ISC",
@@ -5332,6 +5355,8 @@
},
"node_modules/npm/node_modules/debug/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true,
"inBundle": true,
"license": "MIT"
@@ -7158,6 +7183,8 @@
},
"node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -7195,6 +7222,8 @@
},
"node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -9309,19 +9338,6 @@
"node": ">= 14"
}
},
"node_modules/yarn": {
"version": "1.22.22",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz",
"integrity": "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==",
"hasInstallScript": true,
"bin": {
"yarn": "bin/yarn.js",
"yarnpkg": "bin/yarn.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+8 -6
View File
@@ -12,12 +12,18 @@
},
"dependencies": {
"@devnomic/marquee": "^1.0.3",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@headlessui/react": "^2.1.3",
"@preact/signals-react": "^2.2.0",
"@tauri-apps/api": "^2.2.0",
"@techstark/opencv-js": "^4.10.0-release.1",
"@zip.js/zip.js": "^2.7.52",
"dayjs": "^1.11.13",
"fortawesome": "^0.0.1-security",
"konva": "^9.3.14",
"onnxruntime-web": "^1.19.2",
"posthog-js": "^1.174.3",
@@ -29,14 +35,10 @@
"react-router-dom": "^6.26.1",
"sonner": "^2.0.1",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
"yarn": "^1.22.22"
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/pro-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@preact/signals-react-transform": "^0.4.0",
"@tauri-apps/cli": "^2.4.0",
"@types/deep-equal": "^1.0.4",
+2 -2
View File
@@ -67,14 +67,14 @@ export class EditEngine {
width: window.innerWidth,
height: window.innerHeight,
});
this.bgLayer = new Konva.Layer();
//this.bgLayer = new Konva.Layer();
this.mediaLayer = new Konva.Layer();
this.nodeIsolationLayer = new Konva.Layer();
this.uiLayer = new Konva.Layer();
this.stage.add(this.bgLayer);
//this.stage.add(this.bgLayer);
this.stage.add(this.mediaLayer);
this.stage.add(this.nodeIsolationLayer);
this.stage.add(this.uiLayer);
+43 -40
View File
@@ -1,11 +1,11 @@
import Konva from "konva";
import {uiAccess, uiEvents} from "~/signals";
import {ImageNode, ShapeNode, ShapeType, TextNode, VideoNode} from "./Nodes";
import {PaintNode} from "./Nodes/PaintNode";
import {PreviewCopyNode} from "./Nodes/PreviewCopy";
import {UndoStackManager} from "./UndoRedo";
import {CommandManager, MatteBox, SceneManager} from "./EngineUtitlities";
import { uiAccess, uiEvents } from "~/signals";
import { ImageNode, ShapeNode, ShapeType, TextNode, VideoNode } from "./Nodes";
import { PaintNode } from "./Nodes/PaintNode";
import { PreviewCopyNode } from "./Nodes/PreviewCopy";
import { UndoStackManager } from "./UndoRedo";
import { CommandManager, MatteBox, SceneManager } from "./EngineUtitlities";
import {
NodeIsolator,
NodesManager,
@@ -16,22 +16,21 @@ import {
SelectionManagerEvents,
SelectorSquare,
} from "./NodesManagers";
import {EngineOptions, TextNodeData, VideoNodeData} from "./types";
import { EngineOptions, TextNodeData, VideoNodeData } from "./types";
import {AppModes, VideoResolutions} from "./constants";
import {ToolbarMainButtonNames} from "~/components/features/ToolbarMain/enum";
import { AppModes, VideoResolutions } from "./constants";
import { ToolbarMainButtonNames } from "~/components/features/ToolbarMain/enum";
import {ToolbarNodeButtonNames} from "~/components/features/ToolbarNode/enums";
import {NavigateFunction} from "react-router-dom";
import {LoadingVideosProvider} from "./EngineUtitlities/LoadingVideosProvider";
import {VideoExtractionHandler} from "./EngineUtitlities/VideoExtractionHandler/VideoExtractionHandler";
import {RealTimeDrawEngine} from "./RenderingPrimitives/RealTimeDrawEngine";
import {NodeColor} from "~/signals/uiEvents/toolbarNode";
import {invoke} from "@tauri-apps/api/core";
import {DecodeBase64ToImage} from "~/utilities/DecodeBase64ToImage.ts";
import {EncodeImageBitmapToBase64} from "~/utilities/EncodeImageBitmapToBase64.ts";
import { ToolbarNodeButtonNames } from "~/components/features/ToolbarNode/enums";
import { NavigateFunction } from "react-router-dom";
import { LoadingVideosProvider } from "./EngineUtitlities/LoadingVideosProvider";
import { VideoExtractionHandler } from "./EngineUtitlities/VideoExtractionHandler/VideoExtractionHandler";
import { RealTimeDrawEngine } from "./RenderingPrimitives/RealTimeDrawEngine";
import { NodeColor } from "~/signals/uiEvents/toolbarNode";
import { invoke } from "@tauri-apps/api/core";
import { DecodeBase64ToImage } from "~/utilities/DecodeBase64ToImage.ts";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64.ts";
export interface RenderingOptions {
artstyle: string;
@@ -315,7 +314,7 @@ export class Engine {
});
}
});
uiEvents.toolbarNode.DELETE.onClick(() =>
this.commandManager.deleteNodes(),
);
@@ -339,14 +338,12 @@ export class Engine {
});
uiEvents.toolbarMain.SAVE.onClick(async (/*event*/) => {
//document.location.href = "https://auth.openai.com/log-in";
//console.log('save clicked');
//await this.realTimeDrawEngine.saveOutput(); // This works.
await invoke("open_login_command");
});
uiEvents.toolbarMain.GENERATE.onClick(async (/*event*/) => {
console.log('generate clicked');
console.log("generate clicked");
await this.realTimeDrawEngine.generateImage();
});
@@ -396,7 +393,6 @@ export class Engine {
this.addText(textdata);
});
let renderTimeout: NodeJS.Timeout;
uiEvents.promptEvents.onPromptStrengthChanged(async (strength) => {
@@ -429,8 +425,7 @@ export class Engine {
}
});
uiEvents.toolbarMain.onBgColorChanged((data)=>{
uiEvents.toolbarMain.onBgColorChanged((data) => {
this.realTimeDrawEngine.updateBackground(data);
});
@@ -462,29 +457,38 @@ export class Engine {
return; // Can only remove background from a single image at a time.
}
const [ selectedNode ] = selectedNodeSet;
const [selectedNode] = selectedNodeSet;
const removeBgInvocation
= async (selectedNode: ImageNode, callback: (existingImageNode: ImageNode, createdImage: ImageBitmap) => void) => {
const image : HTMLImageElement = selectedNode.kNode.image();
const removeBgInvocation = async (
selectedNode: ImageNode,
callback: (
existingImageNode: ImageNode,
createdImage: ImageBitmap,
) => void,
) => {
const image: HTMLImageElement = selectedNode.kNode.image();
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const base64BitmapResponse = await invoke("remove_background", {
image: base64Bitmap,
});
const createdImage = await DecodeBase64ToImage(base64BitmapResponse as string);
const createdImage = await DecodeBase64ToImage(
base64BitmapResponse as string,
);
callback(selectedNode, createdImage);
};
const _promise = removeBgInvocation(selectedNode, (selectedNode: ImageNode, createdImage: ImageBitmap) => {
if (!this.nodesManager.hasNode(selectedNode)) {
return;
}
this.commandManager.deleteSpecificNodes([selectedNode]);
this.addImageFromImageBitmap(createdImage);
})
const _promise = removeBgInvocation(
selectedNode,
(selectedNode: ImageNode, createdImage: ImageBitmap) => {
if (!this.nodesManager.hasNode(selectedNode)) {
return;
}
this.commandManager.deleteSpecificNodes([selectedNode]);
this.addImageFromImageBitmap(createdImage);
},
);
}
disableAllButtons() {
@@ -618,7 +622,6 @@ export class Engine {
}
public addShape(type: ShapeType, size: number, color?: string) {
const shapeNode = new ShapeNode({
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
@@ -13,7 +13,6 @@ import { MediaNode } from "../types";
import { RenderTask } from "./RenderTask";
import { OffScreenSceneCanvas } from "./OffScreenSceneCanvas";
import { PaintNode } from "../Nodes/PaintNode";
import { WebSocketClient } from "../../PyServer/DrawServer";
@@ -27,7 +26,8 @@ import {
} from "~/signals/uiEvents/loadingIndicator";
import { ensureBase64Prefix } from "../EngineUtitlities/Base64Helpers";
import { DecodeBase64ToImage } from "~/utilities/DecodeBase64ToImage.ts";
import {EncodeImageBitmapToBase64} from "~/utilities/EncodeImageBitmapToBase64.ts";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64.ts";
import { setCanvasRenderBitmap } from "~/signal/canvasRenderBitmap";
interface ServerSettings {
model_path: string;
@@ -50,7 +50,6 @@ export class RealTimeDrawEngine {
private outputBitmap: ImageBitmap | undefined;
// private frames: ImageBitmap[];
// capturing composite within window
private mediaLayerRef: Konva.Layer;
@@ -67,8 +66,6 @@ export class RealTimeDrawEngine {
public captureCanvas: Konva.Rect;
public backgroundRasterRect: Konva.Image;
public previewCanvas: Konva.Image;
public videoLoadingCanvas: VideoNode | undefined;
public fps: number = 24;
@@ -100,9 +97,7 @@ export class RealTimeDrawEngine {
private client: WebSocketClient | null = null;
private isConnected: boolean = false;
public backgroundColor: string = "#d2d2d2";
public backgroundColor: string = "#d2d2d2"; // off white
constructor({
width,
height,
@@ -131,17 +126,15 @@ export class RealTimeDrawEngine {
this.imageNodes = [];
this.onDrawCallback = onDraw;
this.onPreviewCopyCallback = onPreviewCopy; // Assign Callback
// TODO: Make this dynamic and update this on change of canvas.
this.width = width * 1.5;
this.height = height * 1;
this.width = width;
this.height = height;
this.positionX = window.innerWidth - this.width;
this.positionY = window.innerHeight - this.height;
this.positionX = window.innerWidth / 2 - this.width / 2 - this.width;
this.positionY = window.innerHeight / 2 - this.height / 2;
this.positionPreviewX = window.innerWidth / 2 - this.width / 2 + this.width;
this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
// this.positionPreviewX = window.innerWidth / 2 - this.width / 2 + this.width;
// this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
this.offScreenCanvas = offScreenCanvas;
this.offScreenCanvas.width = this.width;
@@ -165,6 +158,7 @@ export class RealTimeDrawEngine {
this.currentPrompt = "";
this.currentStrength = 1;
// This is captures a subset of the medialayer ref
this.captureCanvas = new Konva.Rect({
name: "CaptureCanvas",
@@ -172,49 +166,36 @@ export class RealTimeDrawEngine {
y: this.positionY,
width: this.width,
height: this.height,
fill: this.backgroundColor,
stroke: "black",
stroke: "blue",
strokeWidth: 1,
cornerRadius: 10,
draggable: false,
});
this.previewCanvas = new Konva.Image({
name: "PreviewCanvas",
x: this.positionX,
y: this.positionY,
width: this.width,
height: this.height,
image: undefined,
stroke: "black",
strokeWidth: 1,
draggable: false,
fill: "white",
});
this.offscreenRenderDiv = document.createElement("div");
this.backgroundRasterRect = new Konva.Image({
name: "backgroundRasterRect",
x: this.positionX,
y: this.positionY,
width: this.width,
height: this.height,
fill: this.backgroundColor,
image: undefined,
stroke: "black",
strokeWidth: 1,
draggable: false,
});
// this.backgroundRasterRect = new Konva.Image({
// name: "backgroundRasterRect",
// x: this.positionX,
// y: this.positionY,
// width: this.width,
// height: this.height,
// fill: "red",
// image: undefined,
// stroke: "black",
// strokeWidth: 1,
// draggable: false,
// });
this.mediaLayerRef.add(this.captureCanvas);
this.mediaLayerRef.add(this.backgroundRasterRect);
this.mediaLayerRef.add(this.previewCanvas);
//this.mediaLayerRef.add(this.backgroundRasterRect);
//this.mediaLayerRef.add(this.previewCanvas);
// send back
this.captureCanvas.setZIndex(0);
this.backgroundRasterRect.setZIndex(0);
this.previewCanvas.setZIndex(1);
//this.backgroundRasterRect.setZIndex(0);
//this.previewCanvas.setZIndex(1);
// Add mouse events for preview canvas copying
this.previewCopyListener();
//this.previewCopyListener();
this.listenToServerEvents();
}
@@ -283,20 +264,6 @@ export class RealTimeDrawEngine {
}
});
this.client.onResult(async (response) => {
try {
// Convert the base64 image to ImageBitmap and update the preview
const bitmap = await DecodeBase64ToImage(response.image);
this.outputBitmap = bitmap;
this.previewCanvas.image(bitmap);
this.mediaLayerRef.batchDraw();
} catch (error) {
console.error("Error processing result image:", error);
} finally {
this.isProcessing = false;
}
});
// Wait for connection to be established
await new Promise<void>((resolve, reject) => {
if (!this.client) return reject("Client not initialized");
@@ -535,86 +502,6 @@ export class RealTimeDrawEngine {
}
}
public previewCopyListener() {
// Start of Selection
this.previewCanvas.on("mousedown touchstart", () => {
if (!this.outputBitmap) {
console.log("No preview image to copy");
return;
}
// Create draggable preview copy
const previewCopy = new Konva.Image({
x: this.previewCanvas.x(),
y: this.previewCanvas.y(),
width: this.width,
height: this.height,
image: this.outputBitmap,
draggable: true,
listening: true,
});
if (this.onPreviewCopyCallback) {
this.onPreviewCopyCallback(previewCopy);
}
previewCopy.startDrag();
previewCopy.moveToTop();
previewCopy.on("dragend", (e: Konva.KonvaEventObject<DragEvent>) => {
const previewBox = previewCopy.getClientRect();
const captureBox = this.captureCanvas.getClientRect();
if (Konva.Util.haveIntersection(previewBox, captureBox)) {
// Snap to capture canvas position
previewCopy.position({
x: this.captureCanvas.x(),
y: this.captureCanvas.y(),
});
e.target.off("dragend");
} else {
// Start Generation Here
previewCopy.destroy();
this.mediaLayerRef.batchDraw();
}
});
// Start Generation Here
const stage = this.mediaLayerRef.getStage();
const layer = this.mediaLayerRef; // Use the existing media layer
// Create centered text
// Start of Selection
const centeredText = new Konva.Text({
text: "Hold and Drag Over, To Copy",
fontSize: 24,
fontFamily: "Arial",
fill: "black",
x: this.captureCanvas.x() + this.captureCanvas.width() / 2,
y: this.captureCanvas.y() + this.captureCanvas.height() / 2,
id: "copyText",
listening: false,
});
// Calculate and set offsets based on text size to center it
centeredText.offsetX(centeredText.width() / 2);
centeredText.offsetY(centeredText.height() / 2);
layer.add(centeredText);
centeredText.moveToTop();
layer.batchDraw();
// Remove text on mouseup/touchend
const removeText = () => {
const text = layer.findOne("#copyText");
if (text) {
text.destroy();
layer.batchDraw();
}
stage.off("mouseup touchend", removeText);
};
stage.on("mouseup touchend", removeText);
});
}
public findImageNodeById(
id: string,
): ImageNode | TextNode | ShapeNode | PaintNode | undefined {
@@ -629,7 +516,7 @@ export class RealTimeDrawEngine {
width: number | undefined,
height: number | undefined,
) {
if (!this.captureCanvas || !this.previewCanvas) {
if (!this.captureCanvas) {
return;
}
if (width) {
@@ -648,33 +535,13 @@ export class RealTimeDrawEngine {
const oldPositionY = this.positionY;
// recompute the position
const padBetweenCaptureAndPreview = 2;
this.positionX =
window.innerWidth / 2 -
this.width / 2 -
this.width / 2 -
padBetweenCaptureAndPreview;
this.positionY = window.innerHeight / 2 - this.height / 2;
this.positionPreviewX =
window.innerWidth / 2 -
this.width / 2 +
this.width / 2 +
padBetweenCaptureAndPreview;
this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
this.captureCanvas.setPosition({
x: this.positionX,
y: this.positionY,
x: this.positionX / 2,
y: this.positionY / 2,
});
this.captureCanvas.size({ width: this.width, height: this.height });
this.previewCanvas.setPosition({
x: this.positionPreviewX,
y: this.positionPreviewY,
});
this.previewCanvas.size({ width: this.width, height: this.height });
// this is the change in positions
const deltaX = this.positionX - oldPositionX;
const deltaY = this.positionY - oldPositionY;
@@ -684,7 +551,7 @@ export class RealTimeDrawEngine {
let node = children[i];
// skip the capture canvas and preview canvas update.
if (node.name() === "CaptureCanvas" || node.name() === "PreviewCanvas") {
if (node.name() === "CaptureCanvas") {
continue;
}
const pos = node.getPosition();
@@ -781,8 +648,7 @@ export class RealTimeDrawEngine {
if (this.isEnabled) {
this.disableDragging();
}
// ensure this goes to the top.
this.previewCanvas.moveToTop();
await this.render();
}
@@ -808,7 +674,10 @@ export class RealTimeDrawEngine {
}
}
private cloneStageForRender(stage: Konva.Stage, layerOfInterest: Konva.Layer): Konva.Stage {
private cloneStageForRender(
stage: Konva.Stage,
layerOfInterest: Konva.Layer,
): Konva.Stage {
const stageClone = new Konva.Stage({
width: stage.width(),
height: stage.height(),
@@ -852,14 +721,17 @@ export class RealTimeDrawEngine {
// Clone the required layer from the stage
// Set the right details (like removing highlight stroke)
// Then render the cloned stage to a bitmap
const stageClone = this.cloneStageForRender(stage, config.layerOfInterest);
const stageBlob = await stageClone.toBlob({
const stageClone = this.cloneStageForRender(
stage,
config.layerOfInterest,
);
const stageBlob = (await stageClone.toBlob({
x: x,
y: y,
width: config.width || Math.ceil(box.width),
height: config.height || Math.ceil(box.height),
pixelRatio: pixelRatio,
}) as Blob;
})) as Blob;
// if config.test is true, the result is downloaded to the local files
// config.test = true;
@@ -914,10 +786,10 @@ export class RealTimeDrawEngine {
console.error("No rendered bitmap available to generate from");
return;
}
const base64Bitmap = await this.imageBitmapToBase64(this.lastRenderedBitmap);
//const base64Bitmap = await EncodeImageBitmapToBase64(this.outputBitmap);
const base64Bitmap = await this.imageBitmapToBase64(
this.lastRenderedBitmap,
);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
@@ -956,21 +828,9 @@ export class RealTimeDrawEngine {
this.lastRenderedBitmap = bitmap;
setCanvasRenderBitmap(bitmap);
try {
//const base64Bitmap = await this.imageBitmapToBase64(bitmap);
//const base64BitmapResponse = await invoke("image_generation_command", {
// image: base64Bitmap,
// prompt: this.currentPrompt,
//});
////console.log(base64BitmapResponse);
//const decoded = await DecodeBase64ToImage(
// base64BitmapResponse as string,
//);
//this.outputBitmap = decoded;
//this.previewCanvas.image(decoded);
} catch (error) {
console.error("Error during image processing:", error);
} finally {
@@ -1014,12 +874,10 @@ export class RealTimeDrawEngine {
imageSource.onload = () => {
this.backgroundRasterRect.fill(color);
this.backgroundRasterRect.image(imageSource)
this.backgroundRasterRect.image(imageSource);
this.mediaLayerRef.batchDraw();
this.render();
};
}
}
@@ -101,6 +101,7 @@ export class RenderEngine {
this.port = undefined;
this.fps = 24;
this.captureCanvas = new Konva.Rect({
name: "CaptureCanvas",
x: this.positionX,
@@ -9,7 +9,7 @@ import { SignaledCanvasDragDropFiles } from "./SignaledCanvasDragDropFiles";
import { SignaledDialogs } from "./SignaledDialogs";
import { SignaledToolbarMain } from "./SignaledToolbarMain";
import { SignaledToolbarVideoExtraction } from "./SignaledToolbarVideoExtraction";
import { PromptBox } from "~/components/PromptBox";
// The KonvaApp is the root of the Konva stage
// and only entry point for anything in Konva JS
import { EngineType } from "~/KonvaApp";
@@ -56,14 +56,14 @@ export const KonvaRootComponent = ({
<KonvaCanvasContainer
ref={konvaContainerCallbackRef}
className={className}
// retreive the classNames from the parent for sizing/styling
// retreive the classNames from the parent for sizing/styling
/>
{/* <SignaledMagicBox /> */}
<SignaledCanvasDragDropFiles
openAddImage={appUiContext.openAddImage}
openAddVideo={appUiContext.openAddVideo}
/>
<SignaledPromptText />
<PromptBox />
<SignaledToolbarMain
layoutSignal={layoutContext.signal}
appUiContext={appUiContext}
@@ -25,13 +25,13 @@ export const SignaledModelButtons = () => {
return (
<div className={twMerge("z-20 flex h-fit w-fit items-center gap-2")}>
<ModelButton
{/* <ModelButton
iconSrc={modelIconSrc}
iconAlt={selectedModelData?.name || "Model"}
label={selectedModelData?.name || "Realistic"}
subtitle="Model"
type="model"
/>
/> */}
{/* <ModelButton
iconSrc={loraIconSrc}
@@ -0,0 +1,406 @@
import { useState, useRef, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useSignals } from "@preact/signals-react/runtime";
import {
UploaderStates,
// UploaderState,
uploadImage,
} from "~/components/UploadImage";
import { PopoverMenu } from "~/components/reusable/Popover";
import { Tooltip } from "~/components/reusable/Tooltip";
import { Button } from "~/components/reusable/Button";
import { ToggleButton } from "~/components/reusable/ToggleButton";
import {
faMessageXmark,
faMessageCheck,
faSparkles,
faSpinnerThird,
faTimes,
faPlus,
faUpload,
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleWide,
faRectangleVertical,
faSquare,
faRectangle,
} from "@fortawesome/pro-regular-svg-icons";
import { getCanvasRenderBitmap } from "~/signal/canvasRenderBitmap";
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverItem } from "~/components/reusable/Popover";
interface ReferenceImage {
id: string;
url: string;
file: File;
mediaToken: string;
}
export const PromptBox = () => {
useSignals();
//const { lastRenderedBitmap } = useCanvasSignal();
const [prompt, setPrompt] = useState("");
const [isEnqueueing, setisEnqueueing] = useState(false);
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
>([]);
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
{
label: "3:2",
selected: true,
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
},
{
label: "2:3",
selected: false,
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
},
{
label: "1:1",
selected: false,
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
},
{
label: "16:9",
selected: false,
icon: <FontAwesomeIcon icon={faRectangleWide} className="h-4 w-4" />,
},
]);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [prompt]);
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
setAspectRatioList((prev) =>
prev.map((item) => ({
...item,
selected: item.label === selectedItem.label,
})),
);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files) {
Array.from(files).forEach((file) => {
const uploadId = Math.random().toString(36).substring(7);
setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
assetFile: file,
progressCallback: (newState) => {
console.debug("Upload progress:", newState.data);
if (newState.status === UploaderStates.success && newState.data) {
const referenceImage: ReferenceImage = {
id: Math.random().toString(36).substring(7),
url: reader.result as string,
file,
mediaToken: newState.data || "",
};
setReferenceImages((prev) => [...prev, referenceImage]);
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
console.log("Reference image added:", referenceImage);
} else if (
newState.status === UploaderStates.assetError ||
newState.status === UploaderStates.imageCreateError
) {
setUploadingImages((prev) =>
prev.filter((img) => img.id !== uploadId),
);
console.error("Upload failed");
}
},
});
};
reader.readAsDataURL(file);
});
}
};
const handleRemoveReference = (id: string) => {
setReferenceImages((prev) => prev.filter((img) => img.id !== id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleLibrarySelect = () => {
console.log("Opening library selector");
};
const handleAction = (action: string) => {
switch (action) {
case "upload":
handleUploadClick();
break;
case "library":
handleLibrarySelect();
break;
default:
console.log("Unknown action:", action);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text").trim();
setPrompt(pastedText);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
};
const handleEnqueue = async () => {
if (!prompt.trim()) return;
setisEnqueueing(true);
try {
console.log(
"Enqueuing with prompt:",
prompt,
"and reference images:",
referenceImages,
);
let image = getCanvasRenderBitmap();
if (image === undefined) {
return;
}
const base64Bitmap = await EncodeImageBitmapToBase64(image);
const generateResponse = await invoke("image_generation_command", {
image: base64Bitmap,
prompt: prompt,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
} finally {
setisEnqueueing(false);
}
};
const getCurrentAspectRatioIcon = () => {
const selected = aspectRatioList.find((item) => item.selected);
if (!selected || !selected.icon) return faRectangle;
const iconElement = selected.icon as React.ReactElement;
return iconElement.props.icon;
};
// const handleSaveFrame = async () => {};
// const handleDownloadFrame = () => {};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleEnqueue();
}
};
return (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
);
})}
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
disabled: true,
},
]}
onPanelAction={handleAction}
showIconsInList
buttonClassName="backdrop-blur-none bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {}}
onBlur={() => {}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{/* <Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip> */}
{/* <Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button> */}
<Button
className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,76 @@
import { v4 as uuidv4 } from "uuid";
import { MediaUploadApi } from "~/Classes/ApiManager";
import { getFileName } from "~/utilities";
export enum UploaderStates {
ready,
uploadingAsset,
uploadingImage,
uploadingCover,
settingCover,
success,
assetError,
coverCreateError,
coverSetError,
imageCreateError,
}
export interface UploaderState {
status: UploaderStates;
errorMessage?: string;
data?: string;
}
export const initialUploaderState = {
status: UploaderStates.ready,
};
export const uploadImage = async ({
title,
assetFile,
progressCallback,
}: {
title: string;
assetFile: File;
progressCallback: (newState: UploaderState) => void;
}) => {
const mediaUploadApi = new MediaUploadApi();
console.log("Uploading image:", assetFile);
progressCallback({ status: UploaderStates.uploadingImage });
const imageResponse = await mediaUploadApi.UploadImage({
uuid: uuidv4(),
blob: assetFile,
fileName: getFileName(assetFile),
maybe_title: "char_frame_" + title,
});
console.log("Image response:", imageResponse);
if (imageResponse == undefined) {
console.log("Error: Could not upload image!");
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: "Could not upload image!",
});
return;
}
if (!imageResponse.success || !imageResponse.data) {
console.log("Error:", imageResponse.errorMessage);
progressCallback({
status: UploaderStates.imageCreateError,
errorMessage: imageResponse.errorMessage,
});
return;
}
console.log("Upload successful:", imageResponse.data);
progressCallback({
status: UploaderStates.success,
data: imageResponse.data,
});
};
@@ -63,11 +63,11 @@ export const ToolbarMain = ({
buttonProps={buttonProps.SELECT}
tooltip="Select"
/>
<ToolbarButton
{/* <ToolbarButton
icon={faText}
buttonProps={buttonProps.ADD_TEXT}
tooltip="Add Text"
/>
/> */}
<Popover className="relative">
{({ close }) => (
@@ -239,7 +239,7 @@ export const ToolbarMain = ({
<div className="flex flex-col items-center gap-2">
<div className="relative">
<Tooltip tip="Background Color" position="right">
{/* <Tooltip tip="Background Color" position="right">
<div>
<BackgroundMenu
color={bgColor.value}
@@ -259,7 +259,7 @@ export const ToolbarMain = ({
style={{ backgroundColor: bgColor.value }}
/>
</div>
</Tooltip>
</Tooltip> */}
</div>
</div>
</div>
@@ -1,12 +1,15 @@
import {twMerge} from "tailwind-merge";
import { twMerge } from "tailwind-merge";
import {faPalette,} from "@fortawesome/pro-solid-svg-icons";
import {ToolbarButton, ToolbarButtonProps,} from "~/components/features/ToolbarButton";
import {paperWrapperStyles} from "~/components/styles";
import {ToolbarNodeButtonNames} from "./enums";
import {ToolbarNodeButtonData} from "./data";
import {ColorPicker} from "~/components/ui/TextEditor/ColorPicker";
import {DEFAULT_PAINT_COLOR} from "~/signals/uiEvents/toolbarMain/paintMode";
import { faPalette } from "@fortawesome/pro-solid-svg-icons";
import {
ToolbarButton,
ToolbarButtonProps,
} from "~/components/features/ToolbarButton";
import { paperWrapperStyles } from "~/components/styles";
import { ToolbarNodeButtonNames } from "./enums";
import { ToolbarNodeButtonData } from "./data";
import { ColorPicker } from "~/components/ui/TextEditor/ColorPicker";
import { DEFAULT_PAINT_COLOR } from "~/signals/uiEvents/toolbarMain/paintMode";
export interface ToolbarNodeProps {
disabled?: boolean;
@@ -29,7 +32,7 @@ export const ToolbarNode = ({
onLockClicked,
buttonsProps,
color = DEFAULT_PAINT_COLOR,
onColorChange = () => { },
onColorChange = () => {},
}: ToolbarNodeProps) => {
return (
<div
@@ -41,7 +44,7 @@ export const ToolbarNode = ({
>
{ToolbarNodeButtonData.map((buttonDatum, idx) => {
const buttonProps = buttonsProps?.[buttonDatum.name];
console.log(buttonDatum, idx);
if (!buttonProps || buttonProps.hidden) {
return;
}
@@ -55,14 +58,20 @@ export const ToolbarNode = ({
>
<span className="text-[16px]">{buttonDatum.tooltip}</span>
</ToolbarButton>
);
} else {
return (
<ColorPicker color={color} onChange={onColorChange} faIcon={faPalette} borderStyle="w-full justify-start h-auto gap-2.5 text-white/80" showBar={false} key={idx}>
<ColorPicker
color={color}
onChange={onColorChange}
faIcon={faPalette}
borderStyle="w-full justify-start h-auto gap-2.5 text-white/80"
showBar={false}
key={idx}
>
<span className="text-[16px]">{buttonDatum.tooltip}</span>
</ColorPicker>
)
);
}
})}
</div>
@@ -18,14 +18,16 @@ export const ToolbarTopLeft = () => {
const tabs: TabItem[] = [
{ id: "realtime", label: "Sketch" },
//{ id: "edit", label: "Edit" },
{ id: "gallery", label: "Gallery" },
//{ id: "generate", label: "Generate" },
// { id: "video", label: "Video" },
];
const handleTabChange = (tabId: string) => {
// Update the global signal
dispatchUiEvents.changeAppMode(tabId as "realtime" | "edit" | "generate");
dispatchUiEvents.changeAppMode(
tabId as "realtime" | "edit" | "generate" | "gallery",
);
};
const generationState = generationSignal.value;
@@ -1,50 +1,15 @@
import {
faRectangle,
faRectanglePortrait,
faSave,
faSquare,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { Button } from "~/components/ui/Button";
import { toolbarMain } from "~/signals/uiAccess/toolbarMain";
import { dispatchers } from "~/signals/uiEvents/toolbarMain";
import { ToolbarMainButtonNames } from "../ToolbarMain/enum";
import { ToggleButton } from "~/components/ui/ToggleButton";
import { useState } from "react";
import { Activity } from "~/components/ui/Activity/Activity";
// import { paperWrapperStyles } from "~/components/styles";
// import { faPlus, faQuestion } from "@fortawesome/pro-solid-svg-icons";
// import { ToolbarButton } from "~/components/features/ToolbarButton";
export const ToolbarTopRight = () => {
const saveButtonState =
toolbarMain.signal.value.buttonStates[ToolbarMainButtonNames.SAVE];
const [aspectRatio, setAspectRatio] = useState("1:1");
return (
<div
className={twMerge("relative z-50 flex h-fit w-fit items-center gap-2")}
>
{import.meta.env.DEV && ( // only show in dev mode for now
<ToggleButton
label="Ratio"
value={aspectRatio}
onChange={setAspectRatio}
options={["1:1", "3:2", "2:3"]}
icons={{
"1:1": faSquare,
"3:2": faRectangle,
"2:3": faRectanglePortrait,
}}
/>
)}
<Button
icon={faSave}
onClick={dispatchers[ToolbarMainButtonNames.SAVE]}
disabled={saveButtonState?.disabled}
>
Save Image
</Button>
<Activity />
</div>
);
};
@@ -0,0 +1,20 @@
import { twMerge } from "tailwind-merge";
interface BadgeProps {
label: string;
color?: string;
className?: string;
}
export const Badge = ({ label, className }: BadgeProps) => {
return (
<div
className={twMerge(
"rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
className,
)}
>
{label}
</div>
);
};
@@ -0,0 +1 @@
export { Badge } from "./Badge";
@@ -0,0 +1,20 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
# Button
A button denotes a possible action to be taken on behalf of the user.
There are three types of Button: primary, secondary, action.
<Canvas of={ButtonStories.Primary} />
<Canvas of={ButtonStories.Secondary} />
<Canvas of={ButtonStories.Action} />
It can be shown with an optional icon. If "iconFlip" is true, the icon shows after the "children", if false it shows before.
<Canvas of={ButtonStories.WithIconFlip} />
<Canvas of={ButtonStories.WithIconNoFlip} />
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof Button> = {
component: Button,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
render: () => <Button variant="primary">Primary</Button>,
};
export const Secondary: Story = {
render: () => <Button variant="secondary">Secondary</Button>,
};
export const Action: Story = {
render: () => <Button variant="action">Action</Button>,
};
export const WithIconFlip: Story = {
render: () => (
<Button variant="secondary" iconFlip icon={faAngleLeft}>
Icon Flip
</Button>
),
};
export const WithIconNoFlip: Story = {
render: () => (
<Button variant="secondary" icon={faAngleLeft}>
Icon
</Button>
),
};
export default meta;
@@ -0,0 +1,107 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ButtonHTMLAttributes } from "react";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon?: IconDefinition;
iconClassName?: string;
iconFlip?: boolean;
htmlFor?: string;
variant?: "primary" | "secondary" | "action";
loading?: boolean;
}
export const Button = ({
icon,
iconClassName,
children,
className: propsClassName,
htmlFor,
variant: propsVariant = "primary",
disabled,
iconFlip = false,
loading,
...rest
}: ButtonProps) => {
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
return "bg-brand-secondary hover:bg-brand-secondary-700 text-white focus-visible:outline-brand-secondary";
}
case "action": {
return "bg-action hover:bg-action-900 text-white focus-visible:outline-action";
}
case "primary":
default: {
return "bg-brand-primary hover:bg-brand-primary-400 text-white focus-visible:outline-brand-primary-600";
}
}
}
const disabledClass = twMerge(
disabled || loading ? "opacity-50 pointer-events-none" : "",
);
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 border border-[#363636] shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150 flex gap-2 items-center justify-center",
getVariantClassNames(propsVariant),
propsClassName,
disabledClass,
);
// const ButtonType = htmlFor ? "label" : "button";
if (htmlFor) {
return (
<label className={className} htmlFor={htmlFor} style={rest.style}>
{loading && !iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && !iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
{children}
{loading && iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
</label>
);
}
return (
<button
className={className}
disabled={disabled || loading}
{...{ ...rest, htmlFor }}
>
{loading && !iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && !iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
{children}
{loading && iconFlip ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
<>
{icon && iconFlip ? (
<FontAwesomeIcon icon={icon} className={iconClassName} />
) : null}
</>
)}
</button>
);
};
@@ -0,0 +1 @@
export * from "./Button";
@@ -0,0 +1,81 @@
import { useState } from "react";
import { TransitionDialogue, Button, ButtonProps } from "~/components";
type UnionedButtonProps = { label?: string } & ButtonProps;
interface ButtonDialoguePropsI {
buttonProps?: UnionedButtonProps;
confirmButtonProps?: UnionedButtonProps;
closeButtonProps?: UnionedButtonProps;
dialogProps?: {
className?: string;
};
title?: React.ReactNode;
children: React.ReactNode;
showClose?: boolean;
}
export const ButtonDialogue = ({
dialogProps = {},
buttonProps: unionedButtonProps,
confirmButtonProps,
closeButtonProps: unionedCloseButtonProps,
title,
children,
showClose = true,
}: ButtonDialoguePropsI) => {
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => setIsOpen(false);
const openModal = () => setIsOpen(true);
const { label: buttonLabel, ...buttonProps } = unionedButtonProps || {
label: "Open",
};
const { label: closeButtonLabel, ...closeButtonProps } =
unionedCloseButtonProps || { label: "Close" };
return (
<>
<Button type="button" onClick={openModal} {...buttonProps}>
{buttonLabel}
</Button>
<TransitionDialogue
title={title}
isOpen={isOpen}
onClose={closeModal}
className={dialogProps.className}
>
<div className="mt-2 h-full">{children}</div>
<div className="mt-6 flex justify-end gap-2">
{showClose && (
<Button
type="button"
onClick={closeModal}
{...closeButtonProps}
variant="secondary"
>
{closeButtonLabel}
</Button>
)}
{confirmButtonProps && (
<Button
type="button"
{...confirmButtonProps}
onClick={(e) => {
if (confirmButtonProps.onClick) {
confirmButtonProps.onClick(e);
}
closeModal();
}}
>
{confirmButtonProps.label ? confirmButtonProps.label : "Confirm"}
</Button>
)}
</div>
</TransitionDialogue>
</>
);
};
@@ -0,0 +1 @@
export { ButtonDialogue } from './ButtonDialogue';
@@ -0,0 +1,15 @@
import { Canvas, Meta } from "@storybook/blocks";
import * as ButtonDropdownStories from "./ButtonDropdown.stories";
<Meta of={ButtonDropdownStories} />
# Button for dropdown actions
Dropdown options that open up a dialog
<Canvas of={ButtonDropdownStories.OpenDialog} />
Dropdown options with onClick event
<Canvas of={ButtonDropdownStories.ClickEvent} />
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonDropdown } from "./ButtonDropdown";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft, faFile } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof ButtonDropdown> = {
component: ButtonDropdown,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof ButtonDropdown>;
export const OpenDialog: Story = {
render: () => (
<ButtonDropdown
label="Dialog Options"
icon={faFile}
options={[
{
label: "Open Dialog",
description: "Desc 1",
onDialogOpen: () => {
//console.log("Opened Dialog");
},
dialogProps: {
title: "Dialog Title",
content: <div className="text-white">Dialog content here...</div>,
confirmButtonProps: {
label: "Create",
disabled: false,
onClick: () => {
//console.log("Action on Dialog");
},
},
closeButtonProps: {
label: "Cancel",
},
showClose: true,
onClose: () => {
//console.log("Closed Dialog");
},
},
},
{
label: "Disabled",
description: "Desc 2",
disabled: true,
},
]}
/>
),
};
export const ClickEvent: Story = {
render: () => (
<ButtonDropdown
label="Event Options"
icon={faFile}
options={[
{
label: "Click Event",
description: "Desc",
onClick: () => {
//console.log("Clicked");
},
},
]}
/>
),
};
export default meta;
@@ -0,0 +1,212 @@
import { ButtonHTMLAttributes, Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react";
import {
IconDefinition,
faChevronDown,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonProps, TransitionDialogue } from "~/components";
import { twMerge } from "tailwind-merge";
type UnionedButtonProps = { label?: string } & ButtonProps;
interface ButtonDropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon?: IconDefinition;
align?: "left" | "right";
showSelected?: boolean;
options: Array<{
label: string;
className?: string;
icon?: IconDefinition;
selected?: boolean;
description?: string;
onClick?: () => void;
disabled?: boolean;
divider?: boolean;
onDialogOpen?: () => void;
dialogProps?: {
title: string;
content: React.ReactNode;
className?: string;
confirmButtonProps?: UnionedButtonProps;
closeButtonProps?: UnionedButtonProps;
showClose?: boolean;
onClose?: () => void;
};
}>;
}
export const ButtonDropdown = ({
className,
label,
options,
icon,
align = "left",
showSelected,
}: ButtonDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
null,
);
const closeModal = () => {
setIsOpen(false);
options[selectedOptionIndex!].dialogProps?.onClose?.();
};
const handleOptionClick = (index: number) => {
const option = options[index];
if (option.onClick) {
option.onClick();
}
if (option.onDialogOpen) {
option.onDialogOpen();
}
if (option.dialogProps) {
setSelectedOptionIndex(index);
setIsOpen(true);
}
};
const currentDialogProps =
selectedOptionIndex !== null
? options[selectedOptionIndex].dialogProps
: null;
return (
<div className="relative">
<Menu as="div" className="inline-block text-left">
<Menu.Button as="div">
<Button
className={className}
icon={faChevronDown}
iconFlip={true}
variant="secondary"
>
{icon ? <FontAwesomeIcon icon={icon} /> : null}
{label}
</Button>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className={twMerge(
"absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none",
align === "left" ? "left-0" : "right-0",
)}
>
<div>
{options.map((option, index) => (
<Fragment key={index}>
{option.divider && (
<div className="my-1.5 border-t border-ui-divider" />
)}
<Menu.Item>
{({ active }) => (
<button
disabled={option.disabled}
className={twMerge(
"duration-50 bg-brand-secondary font-medium text-white transition-all",
active ? "bg-brand-secondary-800" : "",
option.disabled
? "pointer-events-none opacity-40"
: "",
"group flex w-full items-center py-1.5 pl-7 pr-4 text-sm",
option.className,
)}
onClick={() => handleOptionClick(index)}
>
<div className="flex w-full items-center">
{option.icon && (
<FontAwesomeIcon
icon={option.icon}
className="mr-2"
/>
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-10 font-normal text-white/75">
{option.description && option.description}
</div>
{showSelected && (
<>
{option.selected ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className="ml-3 flex h-5"
>
<path
opacity="1"
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
fill="#FC6B68"
/>
<path
d="M369 175c-9.4 9.4-9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0z"
fill="#FFFFFF"
/>
</svg>
) : (
<div className="w-8" />
)}
</>
)}
</div>
</button>
)}
</Menu.Item>
</Fragment>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
{currentDialogProps && (
<TransitionDialogue
title={currentDialogProps.title}
isOpen={isOpen}
onClose={closeModal}
className={currentDialogProps.className}
>
{currentDialogProps.content}
<div className="mt-6 flex justify-end gap-2">
{currentDialogProps.showClose !== false &&
currentDialogProps.closeButtonProps && (
<Button
variant="secondary"
{...currentDialogProps.closeButtonProps}
onClick={closeModal}
>
{currentDialogProps.closeButtonProps.label}
</Button>
)}
{currentDialogProps.confirmButtonProps && (
<Button
{...currentDialogProps.confirmButtonProps}
onClick={(e) => {
if (currentDialogProps.confirmButtonProps?.onClick) {
currentDialogProps.confirmButtonProps?.onClick(e);
}
closeModal();
}}
>
{currentDialogProps.confirmButtonProps.label || "Confirm"}
</Button>
)}
</div>
</TransitionDialogue>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from "./ButtonDropdown";
@@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonIconStories from './ButtonIcon.stories';
<Meta of={ButtonIconStories} />
# ButtonIcon
A button denotes a possible action to be taken on behalf of the user.
This button shows only a single icon.
<Canvas of={ButtonIconStories.Default} />
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonIcon } from "./ButtonIcon";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof ButtonIcon> = {
component: ButtonIcon,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof ButtonIcon>;
export const Default: Story = {
render: () => <ButtonIcon icon={faAngleLeft} onClick={() => {}} />,
};
export default meta;
@@ -0,0 +1,39 @@
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
interface ButtonIconProps extends FontAwesomeIconProps {
icon: IconDefinition;
onClick: () => void;
className?: string;
bgFill?: boolean;
disabled?: boolean;
}
export const ButtonIcon = ({
icon,
size,
onClick,
className: propsClassName,
bgFill = false,
disabled,
...rest
}: ButtonIconProps) => {
const className = twMerge(
"box-content flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
bgFill
? "bg-ui-controls-button hover:bg-ui-controls-button/[0.75]"
: "bg-transparent hover:bg-ui-panel/[0.4]",
disabled && "opacity-50 hover:bg-transparent",
propsClassName,
);
return (
<button className={className} onClick={onClick} disabled={disabled}>
<FontAwesomeIcon icon={icon} size={size} {...rest} />
</button>
);
};
@@ -0,0 +1 @@
export * from "./ButtonIcon";
@@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonIconSelectStories from './ButtonIconSelect.stories';
<Meta of={ButtonIconSelectStories} />
# ButtonIconSelectStories
A button denotes a possible action to be taken on behalf of the user.
This button shows only a single icon.
<Canvas of={ButtonIconSelectStories.Default} />
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonIconSelect } from "./ButtonIconSelect";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft, faAngleRight } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof ButtonIconSelect> = {
component: ButtonIconSelect,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof ButtonIconSelect>;
export const Default: Story = {
render: () => (
<ButtonIconSelect
onOptionChange={() => {}}
options={[
{
value: "V1",
icon: faAngleLeft,
text: "Select 1",
},
{
value: "V2",
icon: faAngleRight,
text: "Select 2",
},
]}
/>
),
};
export default meta;
@@ -0,0 +1,54 @@
import { useState } from "react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
interface Option {
value: string;
icon: IconDefinition;
text?: string;
}
interface ButtonIconSelectProps {
options: Option[];
onOptionChange?: (value: string) => void;
}
export function ButtonIconSelect({
options,
onOptionChange,
}: ButtonIconSelectProps) {
const [selectedOption, setSelectedOption] = useState<string>(
options[0].value,
);
const handleOptionChange = (value: string) => {
setSelectedOption(value);
if (onOptionChange) {
onOptionChange(value);
}
};
return (
<div className="flex space-x-1">
{options.map(({ value, icon, text }) => (
<button
key={value}
className={twMerge(
`flex h-8 items-center justify-center rounded-lg border text-sm transition-all duration-150`,
text ? "h-auto w-auto gap-2 px-2.5 py-1" : "w-8",
selectedOption === value
? "border-brand-primary bg-ui-panel/[0.3]"
: "border-transparent hover:bg-ui-panel/[0.4]",
)}
onClick={() => handleOptionChange(value)}
>
<FontAwesomeIcon icon={icon} />
{text && (
<span className="text-nowrap text-sm font-medium">{text}</span>
)}
</button>
))}
</div>
);
}
@@ -0,0 +1 @@
export * from "./ButtonIconSelect";
@@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonIconStackStories from './ButtonIconStack.stories';
<Meta of={ButtonIconStackStories} />
# ButtonIconStackStories
A button denotes a possible action to be taken on behalf of the user.
This button shows only a single icon.
<Canvas of={ButtonIconStackStories.Default} />
@@ -0,0 +1,28 @@
import { faImage } from "@fortawesome/pro-solid-svg-icons";
import { withActions } from "@storybook/addon-actions/decorator";
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonIconStack } from "./ButtonIconStack";
const meta: Meta<typeof ButtonIconStack> = {
component: ButtonIconStack,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof ButtonIconStack>;
export const Default: Story = {
render: () => (
<ButtonIconStack
onClick={() => { }}
icon={faImage}
text={"Start Frame"}
/>
),
};
export default meta;
@@ -0,0 +1,39 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
interface ButtonIconStackProps {
icon: IconDefinition;
text: string;
onClick?: () => void;
style?: string;
additionalStyle?: string;
}
export function ButtonIconStack({
icon,
text = "",
onClick,
style,
additionalStyle,
}: ButtonIconStackProps) {
const defaultStyle = twMerge(
"flex flex-col items-center justify-center text-xs transition-all duration-150",
"h-full w-full gap-1 p-2",
additionalStyle
)
return (
<button
className={style ?? defaultStyle}
onClick={onClick}
>
<FontAwesomeIcon icon={icon} size="xl" />
{text && (
<span className="text-nowrap text-xs font-medium">{text}</span>
)}
</button>
);
}
@@ -0,0 +1 @@
export * from "./ButtonIconStack";
@@ -0,0 +1,20 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as ButtonLinkStories from './ButtonLink.stories';
<Meta of={ButtonLinkStories} />
# ButtonLink
A button denotes a possible action to be taken on behalf of the user.
The button label comes via "children".
There are three types of Button: primary, secondary, action.
<Canvas of={ButtonLinkStories.Primary} />
<Canvas of={ButtonLinkStories.Secondary} />
It can be shown with an optional icon.
<Canvas of={ButtonLinkStories.WithIcon} />
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonLink } from "./ButtonLink";
import { withActions } from "@storybook/addon-actions/decorator";
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof ButtonLink> = {
component: ButtonLink,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof ButtonLink>;
export const Primary: Story = {
render: () => (
<ButtonLink to="google.com" variant="primary">
Primary
</ButtonLink>
),
};
export const Secondary: Story = {
render: () => (
<ButtonLink to="google.com" variant="secondary">
Secondary
</ButtonLink>
),
};
export const WithIcon: Story = {
render: () => (
<ButtonLink to="google.com" variant="secondary" icon={faAngleLeft}>
Icon
</ButtonLink>
),
};
export default meta;
@@ -0,0 +1,44 @@
import { Link, LinkProps } from "@remix-run/react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
interface LinkButtonProps extends LinkProps {
icon?: IconDefinition;
variant?: "primary" | "secondary";
}
export const ButtonLink = ({
icon,
children,
className: propsClassName,
variant = "primary",
...rest
}: LinkButtonProps) => {
//TODO: Duplicated from Button.tsx
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
return "bg-brand-secondary hover:bg-brand-secondary-900 text-white focus-visible:outline-brand-secondary";
}
case "primary":
default: {
return "bg-brand-primary hover:bg-brand-primary-400 text-white focus-visible:outline-brand-primary-600";
}
}
}
const baseClassName =
"text-sm font-medium whitespace-nowrap rounded-lg px-3 py-2 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150";
const variantClassNames = getVariantClassNames(variant);
const className = twMerge(baseClassName, variantClassNames, propsClassName);
// END TODO
return (
<Link {...rest}>
<button className={className}>
{icon && <FontAwesomeIcon className="mr-2" icon={icon} size="sm" />}
{children}
</button>
</Link>
);
};
@@ -0,0 +1 @@
export * from "./ButtonLink";
@@ -0,0 +1,111 @@
import { Fragment, useCallback } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { DoNotShow } from "~/constants";
import { Button } from "~/components";
interface Props {
text: string;
title: string;
open: boolean;
onClose: () => void;
onOk?: () => void;
okText?: string;
okColor?: string;
onCancel?: () => void;
cancelText?: string;
canHide?: boolean;
}
export const ConfirmationModal = ({
text,
title,
open,
onClose,
onOk,
okText = "OK",
okColor = "bg-brand-success",
onCancel,
cancelText = "Cancel",
canHide,
}: Props) => {
const handleOk = useCallback(() => {
const element = document.getElementById("hide-dialog");
if (element) {
if ((element as HTMLInputElement).checked) {
localStorage.setItem(title.replace(" ", "-"), DoNotShow);
}
}
onOk!();
}, [onOk, title]);
return (
<Transition appear show={open} as={Fragment}>
<Dialog open={open} onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40" />
</Transition.Child>
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-5 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h4"
className="mb-4 text-xl font-bold text-white"
>
{title}
</Dialog.Title>
<div className="mt-2">{text}</div>
{canHide && (
<div className="mt-2">
<label>
<input id="hide-dialog" type="checkbox" />
&nbsp;&nbsp;Do not show this again
</label>
</div>
)}
<div className="mt-6 flex justify-end gap-2">
{!!onCancel && (
<Button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-2"
variant="secondary"
>
{cancelText}
</Button>
)}
{!!onOk && (
<Button
type="button"
onClick={handleOk}
className={[okColor, "rounded-lg px-3 py-2"].join(" ")}
>
{okText}
</Button>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
);
};
@@ -0,0 +1,52 @@
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { P } from "~/components";
export const DragAndDropZone = ({
file,
fileTypes,
}: {
file?: File;
fileTypes: string[];
}) => {
if (!file) {
return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
<div className="flex flex-col gap-0">
<P className="font-medium">
<u>Upload a file</u> or drop it here
</P>
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
{fileTypes.join(", ").toString()} supported
</P>
</div>
</div>
);
}
const fileName = file.name.split(".")[0].toUpperCase();
const fileSize =
file.size >= 1024 * 1024
? (file.size / 1024 / 1024).toFixed(2) + " MB"
: `${Math.floor(file.size / 1024)} KB`;
return (
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
<div className="flex grow flex-col gap-0">
<P className="font-medium">
{file.name.slice(0, file.name.lastIndexOf("."))}
</P>
<P className="flex items-center gap-2 text-sm font-normal">
<span className="opacity-50">
{`${fileName} file size: ${fileSize} `}
</span>
<u className="transition-all hover:text-white/80">Change File</u>
</P>
</div>
</div>
);
};
@@ -0,0 +1,23 @@
import { FileUploader as DragDropFileUploader } from "react-drag-drop-files";
// Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files
import { DragAndDropZone } from "./DragAndDropZone";
export const FileUploader = ({
file,
fileTypes,
handleChange,
}: {
file: File | undefined;
handleChange: (file: File) => void;
fileTypes: string[];
}) => (
<DragDropFileUploader
handleChange={handleChange}
name="file"
maxSize={50}
types={fileTypes}
>
<DragAndDropZone file={file} fileTypes={fileTypes} />
</DragDropFileUploader>
);
@@ -0,0 +1 @@
export * from "./FileUploader";
@@ -0,0 +1,14 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as FilterButtonsStories from './FilterButtons.stories';
<Meta of={FilterButtonsStories} />
# Three buttons which appear at the top of some of the media tabs.
The value can be set to Featured
<Canvas of={FilterButtonsStories.Featured} />
Mine
<Canvas of={FilterButtonsStories.Mine} />
or Bookmarked
<Canvas of={FilterButtonsStories.Bookmarked} />
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FilterButtons } from "./FilterButtons";
import { withActions } from "@storybook/addon-actions/decorator";
import { AssetFilterOption } from "~/enums";
const meta: Meta<typeof FilterButtons> = {
component: FilterButtons,
parameters: {
actions: {
handles: ["click"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof FilterButtons>;
export const Featured: Story = {
render: () => (
<FilterButtons onClick={() => null} value={AssetFilterOption.FEATURED} />
),
};
export const Mine: Story = {
render: () => (
<FilterButtons onClick={() => null} value={AssetFilterOption.MINE} />
),
};
export const Bookmarked: Story = {
render: () => (
<FilterButtons onClick={() => null} value={AssetFilterOption.BOOKMARKED} />
),
};
export default meta;
@@ -0,0 +1,43 @@
import { AssetFilterOption } from "~/enums";
interface Props {
onClick: (button: AssetFilterOption) => void;
value: AssetFilterOption;
}
const FILTERS = [
{
text: "Featured",
option: AssetFilterOption.FEATURED,
},
{
text: "Mine",
option: AssetFilterOption.MINE,
},
{
text: "Bookmarked",
option: AssetFilterOption.BOOKMARKED,
},
];
export function FilterButtons({ onClick, value }: Props) {
return (
<div>
<div className="flex gap-2 overflow-x-auto overflow-y-hidden px-4">
{FILTERS.map((filter) => {
const isBookmarks = filter.option === AssetFilterOption.BOOKMARKED;
return (
<button
key={filter.option}
className={`filter-tab${value === filter.option ? " active" : ""}`}
disabled={isBookmarks}
onClick={() => onClick(filter.option)}
>
{filter.text}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1 @@
export * from "./FilterButtons";
@@ -0,0 +1,107 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
interface Props {
size: number;
email_hash?: string;
username?: string;
avatarIndex?: number;
backgroundIndex?: number;
className?: string;
}
function Gravatar(props: Props) {
//url to the local fallback image
const localDefaultUrl =
props.avatarIndex === undefined
? "/resources/avatars/0.webp"
: `/resources/avatars/${props.avatarIndex}.webp`;
// submitted fallback need to be a url served from our server for gravatar to grab it
const defaultImageUrl =
props.avatarIndex === undefined
? "https://storyteller.ai/images/avatars/2000x2000/0.webp"
: `https://storyteller.ai/images/avatars/2000x2000/${props.avatarIndex}.webp`;
// NB: Gravatar suggests URI encoding these:
// https://en.gravatar.com/site/implement/images/
const encondedDefaultImage = encodeURIComponent(defaultImageUrl);
const gravatarUrl = `${EnvironmentVariables.values.GRAVATAR_API}/avatar/${props.email_hash}?s=${props.size}&d=${encondedDefaultImage}`;
const [{ imgUrl, showLoader }, setState] = useState<{
imgUrl: string;
showLoader: boolean;
}>({
imgUrl: gravatarUrl,
showLoader: true,
});
const altText = props.username ? `${props.username}'s gravatar` : "gravatar";
function getBackgroundColor(backgroundIndex?: number): string {
switch (backgroundIndex) {
case 0:
return "#E66462";
case 1:
return "#FD881B";
case 2:
return "#E7C13C";
case 3:
return "#4BA905";
case 4:
return "#25B8A0";
case 5:
return "#0078D1";
case 6:
return "#7F52C1";
case 7:
return "#FF66AC";
case 8:
return "#259FEC";
default:
return `#1a1a27`;
}
}
return (
<div
className={twMerge(
"relative aspect-square overflow-hidden rounded-full border border-white",
props.className,
)}
>
{showLoader && (
<div className="absolute flex h-full w-full items-center justify-center bg-brand-secondary">
<FontAwesomeIcon icon={faSpinnerThird} spin size={"lg"} />
</div>
)}
<img
crossOrigin="anonymous"
alt={altText}
src={imgUrl}
height={props.size}
width={props.size}
style={{ backgroundColor: getBackgroundColor(props.backgroundIndex) }}
onLoad={() => {
setState((curr) => ({
imgUrl: curr.imgUrl,
showLoader: false,
}));
}}
onError={() => {
setState((curr) => ({
imgUrl:
curr.imgUrl !== localDefaultUrl ? localDefaultUrl : curr.imgUrl,
showLoader: false,
}));
}}
/>
</div>
);
}
export { Gravatar };
@@ -0,0 +1 @@
export * from "./Gravatar";
@@ -0,0 +1,19 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as InputStories from './Input.stories';
<Meta of={InputStories} />
# Input control
Here is the input with placeholder
<Canvas of={InputStories.Default} />
Here is the input with icon
<Canvas of={InputStories.WithIcon} />
Here is the input with label
<Canvas of={InputStories.WithLabel} />
Here is the input with error message
<Canvas of={InputStories.WithError} />
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./Input";
import { withActions } from "@storybook/addon-actions/decorator";
import { faUser } from "@fortawesome/pro-solid-svg-icons";
const meta: Meta<typeof Input> = {
component: Input,
parameters: {
actions: {
handles: ["change", "blur", "focus"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof Input>;
export const Default: Story = {
render: () => (
<div className="bg-action p-8">
<Input placeholder="field placeholder" />
</div>
),
};
export const WithIcon: Story = {
render: () => (
<div className="bg-action p-8">
<Input icon={faUser} />
</div>
),
};
export const WithLabel: Story = {
render: () => (
<div className="bg-action p-8">
<Input label="Input label" />
</div>
),
};
export const WithError: Story = {
render: () => (
<div className="bg-action p-8">
<Input errorMessage="Error message" />
</div>
),
};
export default meta;
@@ -0,0 +1,83 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import { IconDefinition } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Label, H6 } from "~/components";
import { kebabCase } from "~/utilities";
import {
disableHotkeyInput,
enableHotkeyInput,
DomLevels,
} from "~/pages/PageEnigma/signals";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputClassName?: string;
iconClassName?: string;
label?: string;
icon?: IconDefinition;
isError?: boolean;
errorMessage?: string;
}
export const Input = React.forwardRef(
(
{
label,
icon,
inputClassName,
iconClassName,
className,
id,
isError,
onBlur,
onFocus,
errorMessage,
...rest
}: InputProps,
ref: React.ForwardedRef<HTMLInputElement>,
) => {
return (
<div className={twMerge("flex flex-col", className)}>
{label && <Label htmlFor={id ? id : kebabCase(label)}>{label}</Label>}
<div className="relative w-full">
{icon && (
<FontAwesomeIcon
icon={icon}
className={twMerge("text-md absolute pl-3 pt-3", iconClassName)}
/>
)}
<input
ref={ref}
id={id ? id : label ? kebabCase(label) : undefined}
className={twMerge(
"h-10 w-full rounded-lg bg-[#242424] px-3 py-2.5 text-white placeholder-white/50 outline-none",
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:!outline-none",
icon && "pl-10",
isError && "outline-red focus:outline-red",
inputClassName,
)}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
if (onFocus) {
onFocus(e);
}
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) {
onBlur(e);
}
enableHotkeyInput(DomLevels.INPUT);
}}
{...rest}
/>
{errorMessage && (
<H6 className="absolute z-10 text-red">{errorMessage}</H6>
)}
</div>
</div>
);
},
);
Input.displayName = "Input";
@@ -0,0 +1 @@
export * from "./Input";
@@ -0,0 +1,10 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as InputVectorStories from './InputVector.stories';
<Meta of={InputVectorStories} />
# 3 Input controls: one for x, y, and z
It allows entry os plus or minus numbers with decimals. It can also be adjusted via dragging the mouse left or right.
<Canvas of={InputVectorStories.Default} />
@@ -0,0 +1,44 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { InputVector } from "./InputVector";
import { withActions } from "@storybook/addon-actions/decorator";
const meta: Meta<typeof InputVector> = {
component: InputVector,
parameters: {
actions: {
handles: ["change", "blur", "focus"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof InputVector>;
const InputVectorRender = () => {
// Sets the hooks for both the label and primary props
const [xyz, setXyz] = useState<Record<string, string>>({
x: "0",
y: "0",
z: "0",
});
return (
<InputVector
x={xyz.x}
y={xyz.y}
z={xyz.z}
onChange={(value) => setXyz(value)}
/>
);
};
export const Default: Story = {
render: () => (
<div className="bg-action p-8">
<InputVectorRender />
</div>
),
};
export default meta;
@@ -0,0 +1,232 @@
import React, { ChangeEvent, RefObject, useRef } from "react";
import { twMerge } from "tailwind-merge";
import {
disableHotkeyInput,
DomLevels,
enableHotkeyInput,
} from "~/pages/PageEnigma/signals";
interface InputVectorProps {
x: string;
y: string;
z: string;
onChange: (v: Record<string, string>) => void;
increment?: number;
disabled?: boolean;
}
export const InputVector = ({
x,
y,
z,
onChange,
increment = 0.1,
disabled,
}: InputVectorProps) => {
const xRef = useRef<HTMLInputElement>(null);
const yRef = useRef<HTMLInputElement>(null);
const zRef = useRef<HTMLInputElement>(null);
const isDragging = useRef(false);
const previousMousePosition = useRef(0);
const oldX = useRef("");
const oldY = useRef("");
const oldZ = useRef("");
oldX.current = x;
oldY.current = y;
oldZ.current = z;
const inputCommonClasses =
"relative h-6 rounded-r-lg bg-brand-secondary-900/35 p-2 text-sm text-white transition-all duration-100 ease-in-out outline-none -outline-offset-2 text-end w-full hover:cursor-e-resize hover:bg-brand-secondary-900";
const wrapperCommonClasses =
"relative flex items-center before:inline-block before:h-6 before:bg-brand-primary before:text-white before:rounded-l-lg before:w-1.5 before:text-center before:justify-center before:items-center before:font-semibold before:flex before:align-middle before:leading-8 text-xs";
const lockedCommonClasses =
"hover:cursor-not-allowed hover:bg-brand-secondary";
function validateInput(newValue: string, oldValue: string) {
if (newValue === "" || newValue === "-" || newValue === ".") {
return newValue;
}
return /^-?\d*(\.\d{0,2})?$/.test(newValue) ? newValue : oldValue;
}
function handleOnChange(event: ChangeEvent<HTMLInputElement>) {
const newVector: Record<string, string> = {
x: oldX.current,
y: oldY.current,
z: oldZ.current,
};
newVector[event.target.name] = validateInput(
event.target.value,
newVector[event.target.name],
);
onChange(newVector);
}
const blurAllInputs = () => {
xRef.current?.blur();
yRef.current?.blur();
zRef.current?.blur();
};
// For dragging the input value to increment/decrement
const handleMouseDown = (
e: React.MouseEvent,
ref: RefObject<HTMLInputElement>,
) => {
e.stopPropagation();
blurAllInputs();
previousMousePosition.current = e.clientX;
isDragging.current = true;
const mouseMoveHandler = (e: MouseEvent) => {
e.stopPropagation();
const currentMousePosition = e.clientX;
const direction =
currentMousePosition === previousMousePosition.current
? 0
: currentMousePosition > previousMousePosition.current
? 1
: -1;
previousMousePosition.current = currentMousePosition;
const newVector = {
x: oldX.current,
y: oldY.current,
z: oldZ.current,
} as Record<string, string>;
const newValue =
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
newVector[ref.current?.name ?? ""] = (
Math.round(newValue * 1000) / 1000
).toString();
onChange(newVector);
};
const mouseUpHandler = () => {
document.removeEventListener("mousemove", mouseMoveHandler);
document.removeEventListener("mouseup", mouseUpHandler);
isDragging.current = false;
};
document.addEventListener("mousemove", mouseMoveHandler);
document.addEventListener("mouseup", mouseUpHandler);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
(e.currentTarget as HTMLInputElement).blur(); // Unfocus the input field on pressing Enter
}
};
return (
<div className="flex w-full flex-col justify-between gap-1.5">
<span
className={twMerge(
wrapperCommonClasses,
"before:bg-axis-x",
disabled && "opacity-60",
)}
>
<div className="absolute left-3.5 z-10 font-semibold">X</div>
<input
className={twMerge(
inputCommonClasses,
"focus:outline-axis-x",
disabled && lockedCommonClasses,
)}
type="text"
name="x"
onChange={handleOnChange}
ref={xRef}
value={x}
disabled={disabled}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
onMouseDown={(e) => {
handleMouseDown(e, xRef);
}}
onKeyDown={handleKeyDown}
/>
</span>
<span
className={twMerge(
wrapperCommonClasses,
"before:bg-axis-y",
disabled && "opacity-60",
)}
>
<div className="absolute left-3.5 z-10 font-semibold">Y</div>
<input
className={twMerge(
inputCommonClasses,
"focus:outline-axis-y",
disabled && lockedCommonClasses,
)}
type="text"
name="y"
onChange={handleOnChange}
ref={yRef}
value={y}
disabled={disabled}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
onMouseDown={(e) => {
handleMouseDown(e, yRef);
}}
onKeyDown={handleKeyDown}
/>
</span>
<span
className={twMerge(
wrapperCommonClasses,
"before:bg-axis-z",
disabled && "opacity-60",
)}
>
<div className="absolute left-3.5 z-10 font-semibold">Z</div>
<input
className={twMerge(
inputCommonClasses,
"focus:outline-axis-z",
disabled && lockedCommonClasses,
)}
type="text"
name="z"
onChange={handleOnChange}
ref={zRef}
value={z}
disabled={disabled}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
onMouseDown={(e) => {
handleMouseDown(e, zRef);
}}
onKeyDown={handleKeyDown}
/>
</span>
</div>
);
};
@@ -0,0 +1 @@
export * from "./InputVector";
@@ -0,0 +1,66 @@
import { Fragment, useEffect, useState } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface ListDropdownProps {
list: { [key: string]: string }[];
onSelect: (val: string) => void;
}
export const ListDropdown = ({ list, onSelect }: ListDropdownProps) => {
const [selected, setSelected] = useState(list[0]);
useEffect(() => {
onSelect(Object.values(selected)[0]);
}, [selected]);
return (
<Listbox value={selected} onChange={setSelected}>
<div className="relative mt-1">
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
<span className="block truncate">{Object.keys(selected)[0]}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{list.map((item, itemIdx) => (
<Listbox.Option
key={itemIdx}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
active ? "text-white" : "text-gray-400"
}`
}
value={item}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{Object.values(item)[0]}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
};
@@ -0,0 +1,100 @@
import { Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "~/components";
interface LoadingBarProps {
id?: string;
wrapperClassName?: string;
innerWrapperClassName?: string;
progressBackgroundClassName?: string;
progressClassName?: string;
variant?: string;
show?: boolean;
hasSpinner?: boolean;
progressData: {
progress: number;
label?: string;
message?: string;
};
}
export const LoadingBar = ({
wrapperClassName: propsWrapperClassName,
innerWrapperClassName: propsInnerWrapperClassName,
progressBackgroundClassName: propsProgressBackgroundClassName,
progressClassName: propsProgressClassName,
progressData,
hasSpinner,
variant = "primary",
show = true,
...rest
}: LoadingBarProps) => {
function getVariantClassNames(variant: string) {
switch (variant) {
case "secondary": {
return " bg-brand-secondary text-white ";
}
case "primary":
default: {
return " bg-brand-primary text-white ";
}
}
}
const wrapperClassName = twMerge(
"w-full h-full relative bg-ui-background",
propsWrapperClassName,
);
const innerWrapperClassName = twMerge(
"w-full h-full p-4 gap-6 m-auto flex flex-col justify-center items-center",
propsInnerWrapperClassName,
);
const progressBackgroundClassName = twMerge(
"w-full bg-brand-secondary-800 rounded-full h-2.5",
propsProgressBackgroundClassName,
);
const progressClassName = twMerge(
"h-2.5 rounded-full transition-all duration-1000",
getVariantClassNames(variant),
propsProgressClassName,
);
return (
<Transition
className={wrapperClassName}
show={show}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-1000"
leaveFrom="opacity-100"
leaveTo="opacity-0"
{...rest}
>
<div className={innerWrapperClassName}>
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-2.5">
{hasSpinner && <LoadingSpinner className="h-5" />}
{progressData.label && (
<label className="text-lg font-medium">
{progressData.label}
</label>
)}
</div>
</div>
<div className={progressBackgroundClassName}>
<div
className={progressClassName}
style={{ width: progressData.progress + "%" }}
/>
</div>
{progressData.message && (
<p className="text-center text-sm opacity-75">
{progressData.message}
</p>
)}
</div>
</Transition>
);
};
@@ -0,0 +1,61 @@
import { Transition } from '@headlessui/react';
import { twMerge } from 'tailwind-merge';
import { H4 } from '.';
interface LoadingDotsProps {
className?: string;
isShowing?: boolean;
};
interface LoadingDotsInnerProps {
className?: string;
isShowing?: boolean;
type?: 'typing'|'bricks';
message?: string;
};
export const LoadingDotsTyping = (props: LoadingDotsProps)=>{
return (
<LoadingDots {...props} />
)
}
export const LoadingDotsBricks = (props: LoadingDotsProps)=>{
return (
<LoadingDots {...props} type="bricks"/>
)
}
export function LoadingDots({
className,
isShowing=true,
type = 'typing',
message,
}: LoadingDotsInnerProps){
const classNames = twMerge("w-full h-full flex flex-col justify-center items-center bg-ui-background gap-6", className);
return(
<Transition
className={classNames}
show={isShowing}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-1000"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{ type==='typing' &&
<div className="dot-typing"></div>
}
{ type==='bricks' &&
<div className="dot-bricks"></div>
}
{
message &&
<H4>{message}</H4>
}
</Transition>
);
};
@@ -0,0 +1,27 @@
import { twMerge } from "tailwind-merge";
interface LoadingSpinnerProps {
className?: string;
}
export const LoadingSpinner = ({ className }: LoadingSpinnerProps) => {
return (
<svg
className={twMerge("h-6 w-6 animate-spin", className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
);
};
@@ -0,0 +1,86 @@
import React, { useEffect, useState } from "react";
import { Input } from "../Input";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
}
export const NumberInput: React.FC<NumberInputProps> = ({
value,
onChange,
}) => {
const [inputValue, setInputValue] = useState<string>(value.toString());
useEffect(() => {
setInputValue(Math.round(value * 100) / 100 + "");
}, [value]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
if (
newValue === "" ||
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
) {
setInputValue(newValue);
const numericValue = parseFloat(newValue);
if (newValue !== "") {
onChange(Math.round(numericValue * 100) / 100);
}
}
};
const handleBlur = () => {
if (inputValue === "") {
setInputValue(Math.round(value * 100) / 100 + "");
}
};
const handleIncrement = () => {
const newValue = Math.min(Number(inputValue) + 1, 100);
setInputValue(Math.round(newValue * 100) / 100 + "");
onChange(Math.round(newValue * 100) / 100);
};
const handleDecrement = () => {
const newValue = Math.max(Number(inputValue) - 1, 0);
setInputValue(Math.round(newValue * 100) / 100 + "");
onChange(Math.round(newValue * 100) / 100);
};
const incrementButtonStyle =
"flex h-[15px] w-6 items-center justify-center text-[10px] text-white/80 bg-ui-controls hover:bg-ui-controls-button outline-none focus:outline-none transition-all active:outline-none";
return (
<div className="relative">
<Input
type="text"
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
inputClassName="w-[70px] h-[30px] rounded-md text-sm"
/>
<div className="absolute right-0 top-0 flex h-[30px] flex-col rounded-r-md border-l border-white/15 bg-gray-800">
<button
className={twMerge(
incrementButtonStyle,
"rounded-tr-md border-b border-white/10",
)}
onClick={handleIncrement}
>
<FontAwesomeIcon icon={faChevronUp} />
</button>
<button
className={twMerge(incrementButtonStyle, "rounded-br-md")}
onClick={handleDecrement}
>
<FontAwesomeIcon icon={faChevronDown} />
</button>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./NumberInput";
@@ -0,0 +1,16 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as PaginationStories from './Pagination.stories';
<Meta of={PaginationStories} />
# Controls for pagination.
Here is what it looks like when empty
<Canvas of={PaginationStories.Empty} />
First Page
<Canvas of={PaginationStories.FirstPage} />
Middle
<Canvas of={PaginationStories.Middle} />
Last Page
<Canvas of={PaginationStories.LastPage} />
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Pagination } from "./Pagination";
import { withActions } from "@storybook/addon-actions/decorator";
const meta: Meta<typeof Pagination> = {
component: Pagination,
parameters: {
actions: {
handles: ["pageChange"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof Pagination>;
export const Empty: Story = {
render: () => (
<div className="w-[300px] bg-brand-secondary-200">
<Pagination
className=""
currentPage={0}
totalPages={0}
onPageChange={() => {}}
/>
</div>
),
};
export const FirstPage: Story = {
render: () => (
<div className="w-[300px] bg-brand-secondary-200">
<Pagination
className=""
currentPage={0}
totalPages={5}
onPageChange={() => {}}
/>
</div>
),
};
export const Middle: Story = {
render: () => (
<div className="w-[300px] bg-brand-secondary-200">
<Pagination
className=""
currentPage={2}
totalPages={5}
onPageChange={() => {}}
/>
</div>
),
};
export const LastPage: Story = {
render: () => (
<div className="w-[300px] bg-brand-secondary-200">
<Pagination
className=""
currentPage={4}
totalPages={5}
onPageChange={() => {}}
/>
</div>
),
};
export default meta;
@@ -0,0 +1,99 @@
import {
faChevronRight,
faChevronLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { ButtonIcon } from "~/components";
export const Pagination = ({
className,
currentPage,
totalPages,
onPageChange,
onFetchMorePages,
}: {
className: string;
currentPage: number; // index starts from 0
totalPages: number;
onPageChange: (newPage: number) => void;
onFetchMorePages?: () => void;
}) => {
const pageGroupSize = 5;
const pageGroup = Math.floor(currentPage / pageGroupSize);
const totalPagesInPageGroup =
(pageGroup + 1) * pageGroupSize > totalPages
? totalPages % pageGroupSize
: pageGroupSize;
const pageGroupOffset = pageGroup * pageGroupSize;
const handleSetPreviousPage = () => {
onPageChange(currentPage - 1);
};
const handleSetNextPage = () => {
onPageChange(currentPage + 1);
};
return (
<nav
className={twMerge(
"flex items-center justify-between border-gray-200 px-4",
className,
)}
>
<ButtonIcon
className="-ml-2 pt-2 text-gray-400"
icon={faChevronLeft}
onClick={handleSetPreviousPage}
disabled={currentPage === 0}
aria-hidden
/>
<div className="hidden md:flex">
{[...Array(totalPagesInPageGroup)].map((e, i) => {
const pageIndex = i + pageGroupOffset;
if (pageIndex === currentPage) {
return (
<button
key={pageIndex}
className="border-indigo-500 text-indigo-600 inline-flex items-center border-t-2 px-4 pt-2 text-sm font-medium"
aria-current="page"
onClick={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</button>
);
} else {
return (
<button
key={pageIndex}
className="inline-flex items-center border-t-2 border-transparent px-4 pt-2 text-sm font-medium text-gray-500 hover:border-brand-primary hover:text-brand-primary"
onClick={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</button>
);
}
})}
</div>
<span>
{currentPage + 1 !== totalPages ? (
<ButtonIcon
className="-mr-2 pt-2 text-gray-500 hover:text-brand-primary"
icon={faChevronRight}
onClick={handleSetNextPage}
aria-hidden
/>
) : (
onFetchMorePages && (
<button
className="inline-flex items-center border-t-2 border-transparent pl-2 pr-4 pt-2 text-sm font-medium text-gray-500 hover:text-brand-primary"
key={totalPages}
onClick={onFetchMorePages}
>
More...
</button>
)
)}
</span>
</nav>
);
};
@@ -0,0 +1 @@
export * from "./Pagination";
@@ -0,0 +1,11 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as PillStories from './Pill.stories';
<Meta of={PillStories} />
# Pill
A pill is a container around the children.
<Canvas of={PillStories.Default} />
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Pill } from "./Pill";
const meta: Meta<typeof Pill> = {
component: Pill,
parameters: {},
};
type Story = StoryObj<typeof Pill>;
export const Default: Story = {
render: () => <Pill>A Pill</Pill>,
};
export default meta;
@@ -0,0 +1,21 @@
import { twMerge } from "tailwind-merge";
import { ReactNode } from "react";
export const Pill = ({
className,
children,
}: {
className?: string;
children: ReactNode;
}) => {
return (
<div
className={twMerge(
"w-fit rounded-md bg-brand-primary px-1.5 py-0.5 text-xs font-bold",
className,
)}
>
{children}
</div>
);
};
@@ -0,0 +1 @@
export * from "./Pill";
@@ -0,0 +1,204 @@
import { ReactNode } from "react";
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
export interface PopoverItem {
label: string;
selected: boolean;
icon?: ReactNode;
action?: string;
disabled?: boolean;
}
interface PopoverMenuProps {
items?: PopoverItem[];
onSelect?: (item: PopoverItem) => void;
onAdd?: () => void;
triggerIcon?: ReactNode;
showAddButton?: boolean;
showIconsInList?: boolean;
mode?: "default" | "toggle" | "button";
triggerLabel?: string;
children?: ReactNode;
buttonClassName?: string;
panelClassName?: string;
onPanelAction?: (action: string) => void;
panelTitle?: string;
position?: "top" | "bottom";
align?: "start" | "center" | "end";
panelActionLabel?: string;
}
export const PopoverMenu = ({
items = [],
onSelect,
onAdd,
triggerIcon,
showAddButton = false,
showIconsInList = false,
mode = "default",
triggerLabel,
children,
buttonClassName,
panelClassName,
onPanelAction,
panelTitle,
position = "top",
align = "start",
panelActionLabel,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
const handleItemClick = (item: PopoverItem, close: () => void) => {
if (mode === "button" && item.action && onPanelAction) {
onPanelAction(item.action);
close();
} else {
onSelect?.(item);
close();
}
};
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-150",
"bg-[#5F5F68]/60 px-3 text-white backdrop-blur-lg hover:bg-[#5F5F68]/90",
buttonClassName,
);
const positionClasses = {
top: "bottom-full",
bottom: "top-full",
};
const alignClasses = {
start: "left-0",
center: "left-1/2 -translate-x-1/2",
end: "right-0",
};
return (
<Popover className="relative">
{({ close }) => (
<>
<PopoverButton className={className}>
{triggerIcon}
{mode === "toggle" && selectedItem ? selectedItem.label : null}
{mode === "default" && triggerLabel ? triggerLabel : null}
</PopoverButton>
<div
className={twMerge(
"absolute transform-gpu",
positionClasses[position],
alignClasses[align],
)}
>
<Transition
enter="transition duration-100 ease-out"
enterFrom={
position === "bottom"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
}
enterTo="translate-y-0 opacity-100"
leave="transition duration-100 ease-in"
leaveFrom="translate-y-0 opacity-100"
leaveTo={
position === "bottom"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
}
>
<PopoverPanel
className={twMerge(
position === "bottom" ? "origin-top" : "origin-bottom",
)}
>
<div
className={twMerge(
"z-10 min-w-48 rounded-lg bg-[#46464B] p-1.5 shadow-lg",
position === "top" && "mb-2",
position === "bottom" && "mt-2",
panelClassName,
)}
>
{panelTitle && (
<div className="mb-2 mt-0.5 flex justify-between px-1.5 text-sm font-normal text-white opacity-70">
{panelTitle}
{panelActionLabel && (
<button
onClick={() => {
onPanelAction?.(panelActionLabel);
close();
}}
className="text-end text-sm text-white/85 hover:underline"
>
{panelActionLabel}
</button>
)}
</div>
)}
{mode === "default" && children ? (
<div className="text-sm text-white">{children}</div>
) : (
<div className="flex flex-col gap-0 text-sm text-white">
{items.map((item, index) => (
<Button
key={index}
className={twMerge(
"flex items-center justify-between border-transparent bg-transparent px-1.5",
"hover:bg-[#63636B]/60",
mode === "toggle" && item.selected
? "hover:bg-[#63636B]"
: "bg-transparent",
item.disabled
? "!cursor-not-allowed opacity-50"
: "",
)}
onClick={() =>
!item.disabled && handleItemClick(item, close)
}
variant="secondary"
disabled={item.disabled}
>
<div className="flex items-center gap-2">
{showIconsInList && item.icon}
{item.label}
</div>
{mode === "toggle" && (
<input
type="radio"
checked={item.selected}
readOnly
/>
)}
</Button>
))}
{showAddButton && onAdd && (
<Button
variant="secondary"
className="mb-0.5 mt-2 border-none bg-[#7B7B84] py-1 hover:bg-[#8c8c96]"
onClick={onAdd}
>
+ Add
</Button>
)}
</div>
)}
</div>
</PopoverPanel>
</Transition>
</div>
</>
)}
</Popover>
);
};
@@ -0,0 +1 @@
export * from "./Popover";
@@ -0,0 +1,88 @@
import {
faCircleXmark,
faFilterList,
faSearch,
} from "@fortawesome/pro-solid-svg-icons";
import { Button, Input, Tooltip } from "~/components";
import debounce from "lodash/debounce";
import { useRef, useState, useEffect, useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface SearchFilterProps {
onSearchChange: (value: string) => void;
placeholder?: string;
searchTerm?: string;
showFilters?: boolean;
}
export const SearchFilter = ({
onSearchChange,
placeholder = "Search...",
searchTerm = "",
showFilters = false,
}: SearchFilterProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(searchTerm);
const [hasText, setHasText] = useState(searchTerm.length > 0);
useEffect(() => {
setInputValue(searchTerm);
setHasText(searchTerm.length > 0);
}, [searchTerm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedChangeHandler = useCallback(
debounce((value: string) => {
onSearchChange(value);
}, 500),
[],
);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setInputValue(value);
setHasText(value.length > 0);
debouncedChangeHandler(value);
};
const clearSearch = () => {
setInputValue("");
setHasText(false);
onSearchChange("");
};
return (
<div className="flex gap-2">
<div className="relative grow">
<Input
ref={inputRef}
icon={faSearch}
iconClassName="opacity-50 pt-[11px] text-sm"
inputClassName="h-9 rounded-lg text-sm pr-8 pl-9"
placeholder={placeholder}
className="grow"
value={inputValue}
onChange={handleInputChange}
/>
{hasText && (
<FontAwesomeIcon
icon={faCircleXmark}
className="absolute right-2.5 top-1/2 -translate-y-1/2 transform cursor-pointer opacity-50 transition-all duration-100 hover:opacity-100"
onClick={clearSearch}
/>
)}
</div>
{showFilters && (
<Tooltip position="top" content="Filters">
<Button
icon={faFilterList}
variant="secondary"
className="h-9 w-9"
iconClassName="text-[16px]"
></Button>
</Tooltip>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from "./SearchFilter";
@@ -0,0 +1,76 @@
import { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MediaFileAnimationType } from "~/enums";
export type SelectValue = string | number;
export type SelectOption = { label: string; value: SelectValue };
interface ListDropdownProps {
options: SelectOption[];
onChange: (val: MediaFileAnimationType) => void;
placeholder?: string;
value?: SelectValue;
}
export const Select = ({
onChange,
options,
placeholder,
value,
}: ListDropdownProps) => {
const selectedOption = options.find(
(option: SelectOption) => option.value === value,
) || { label: placeholder || "", value: "" };
return (
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
<span className={`block truncate${value ? "" : " opacity-50"}`}>
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{options.map((option, itemIdx) => (
<Listbox.Option
key={itemIdx}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
active ? "text-white" : "text-gray-400"
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
};
@@ -0,0 +1,130 @@
import { Transition } from "@headlessui/react";
import React, { useState, useRef, useEffect, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface SliderProps {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
step: number;
className?: string;
showTooltip?: boolean;
tooltipContent?: ReactNode;
suffix?: string;
}
export const Slider: React.FC<SliderProps> = ({
min,
max,
value,
onChange,
step,
className,
showTooltip,
tooltipContent,
suffix,
}) => {
const [localValue, setLocalValue] = useState(value);
const sliderRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const updatePosition = (x: number) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const fraction = (x - rect.left) / rect.width;
const newValue = fraction * (max - min) + min;
const roundedValue = Math.round(newValue / step) * step;
const clampedValue = Math.min(Math.max(roundedValue, min), max);
setLocalValue(clampedValue);
onChange(clampedValue);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
setShowHandleTooltip(true);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
updatePosition(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
updatePosition(e.clientX);
};
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(false);
setShowHandleTooltip(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseEnter = () => {
if (!isDragging) {
setShowHandleTooltip(true);
}
};
const handleMouseLeave = () => {
if (!isDragging) {
setShowHandleTooltip(false);
}
};
const percentage = ((localValue - min) / (max - min)) * 100;
const displayValue =
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
(suffix || "");
return (
<div
ref={sliderRef}
className={twMerge(
"relative h-2.5 w-full cursor-pointer rounded-full bg-brand-secondary-900",
className,
)}
onMouseDown={handleMouseDown}
>
<div
className="absolute h-2.5 rounded-full bg-brand-primary"
style={{ width: `${percentage}%` }}
></div>
<div
className={twMerge(
"absolute -top-1 h-[18px] w-[18px] -translate-x-1/2 transform rounded-full bg-gray-200 transition-colors",
isDragging ? "bg-white" : "bg-gray-300 hover:bg-white",
)}
style={{ left: `${percentage}%` }}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
<Transition
show={showHandleTooltip}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0"
>
{showTooltip && showHandleTooltip && (
<div
className="absolute -top-8 z-10 rounded-md bg-ui-controls px-2 py-1 text-xs text-white shadow-lg"
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
>
{tooltipContent ? tooltipContent : displayValue}
</div>
)}
</Transition>
</div>
);
};
@@ -0,0 +1 @@
export * from "./Slider";
@@ -0,0 +1,184 @@
import { Transition } from "@headlessui/react";
import React, { useState, useRef, useEffect, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
interface SliderProps {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
step: number;
className?: string;
showTooltip?: boolean;
tooltipContent?: ReactNode;
suffix?: string;
innerLabel?: string;
showDecrement?: boolean;
showIncrement?: boolean;
}
export const SliderV2 = ({
min,
max,
value,
onChange,
step,
className,
showTooltip,
tooltipContent,
suffix,
innerLabel,
showDecrement,
showIncrement,
}: SliderProps) => {
const [localValue, setLocalValue] = useState(value);
const sliderRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const setClampedValue = (value: number) => {
const roundedValue = Math.round(value / step) * step;
const clampedValue = Math.min(Math.max(roundedValue, min), max);
setLocalValue(clampedValue);
onChange(clampedValue);
};
const updatePosition = (x: number) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const fraction = (x - rect.left) / rect.width;
const newValue = fraction * (max - min) + min;
setClampedValue(newValue);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
setShowHandleTooltip(true);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
updatePosition(e.clientX);
};
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
updatePosition(e.clientX);
};
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
setIsDragging(false);
setShowHandleTooltip(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseEnter = () => {
if (!isDragging) {
setShowHandleTooltip(true);
}
};
const handleMouseLeave = () => {
if (!isDragging) {
setShowHandleTooltip(false);
}
};
const handleDecrement = () => {
setClampedValue(localValue - step);
};
const handleIncrement = () => {
setClampedValue(localValue + step);
};
const percentage = ((localValue - min) / (max - min)) * 100;
const displayValue =
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
(suffix || "");
return (
<div className="flex w-full">
{showDecrement && (
<Button
icon={faMinus}
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
onClick={handleDecrement}
/>
)}
<div
ref={sliderRef}
className={twMerge(
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
isDragging && "!bg-ui-controls/90",
className,
)}
onMouseDown={handleMouseDown}
>
<div
className={twMerge(
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
isDragging && "!bg-brand-primary/50",
)}
style={{ width: `${percentage}%` }}
>
{innerLabel && (
<span
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white",
)}
>
{innerLabel}
</span>
)}
<div
className={twMerge(
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
isDragging ? "bg-white" : "bg-white/50",
)}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</div>
{showIncrement && (
<Button
icon={faPlus}
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
onClick={handleIncrement}
/>
)}
{showTooltip && (
<Transition
as={"div"}
show={showHandleTooltip}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0"
>
<div
className="absolute -top-8 z-10 rounded-md bg-ui-panel px-2 py-1 text-xs text-black shadow-lg"
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
>
{tooltipContent ? tooltipContent : displayValue}
</div>
</Transition>
)}
</div>
);
};
@@ -0,0 +1 @@
export * from "./SliderV2";
@@ -0,0 +1,42 @@
import { useState } from "react";
import { Tab } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
export type TabType = {
header: string;
children: JSX.Element;
};
export const Tabs = ({ tabs: tabProps }: { tabs: TabType[] }) => {
const [tabs] = useState(tabProps);
const tabHeaderClassName = (selected: boolean) =>
twMerge(
"px-4 py-4 text-md font-medium leading-5 focus:outline-none transition duration-150 ease-in-out border-b-[3px] border-white/[.1]",
selected
? "text-white border-brand-primary"
: "text-white/[0.5] hover:text-white hover:border-white/[.3]",
);
return (
<div className="flex h-full w-full flex-col px-2 sm:px-0">
<Tab.Group defaultIndex={3}>
<Tab.List className="flex">
{tabs.map((tab, idx) => (
<Tab
key={idx}
className={({ selected }) => tabHeaderClassName(selected)}
>
{tab.header}
</Tab>
))}
</Tab.List>
<Tab.Panels className="grow overflow-auto p-4">
{tabs.map((tab, idx) => (
<Tab.Panel key={idx}>{tab.children}</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
);
};
@@ -0,0 +1 @@
export { Tabs } from './Tabs';
@@ -0,0 +1,13 @@
import { Canvas, Meta } from '@storybook/blocks';
import * as TextareaStories from './Textarea.stories';
<Meta of={TextareaStories} />
# Textarea control
Here is the Textarea with placeholder
<Canvas of={TextareaStories.Default} />
Here is the Textarea with label
<Canvas of={TextareaStories.WithLabel} />
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Textarea } from "./Textarea";
import { withActions } from "@storybook/addon-actions/decorator";
const meta: Meta<typeof Textarea> = {
component: Textarea,
parameters: {
actions: {
handles: ["change", "blur", "focus"],
},
},
decorators: [withActions],
};
type Story = StoryObj<typeof Textarea>;
export const Default: Story = {
render: () => (
<div className="bg-action p-8">
<Textarea placeholder="field placeholder" />
</div>
),
};
export const WithLabel: Story = {
render: () => (
<div className="bg-action p-8">
<Textarea label="Label" />
</div>
),
};
export default meta;
@@ -0,0 +1,54 @@
import { TextareaHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { Label } from "~/components";
import { kebabCase } from "~/utilities";
import {
disableHotkeyInput,
enableHotkeyInput,
DomLevels,
} from "~/pages/PageEnigma/signals";
type ResizeType =
| "none"
| "both"
| "horizontal"
| "vertical"
| "block"
| "inline"
| undefined;
export const Textarea = ({
className,
label,
resize = "vertical",
id,
...rest
}: TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string;
resize?: ResizeType;
}) => {
return (
<div className="flex flex-col">
{label && <Label htmlFor={id ? id : kebabCase(label)}>{label}</Label>}
<textarea
id={id ? id : label ? kebabCase(label) : undefined}
className={twMerge(
"rounded-lg border border-[#363636] bg-brand-secondary px-3 py-2 placeholder-white/50 outline-none transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:outline-none",
className,
)}
style={{
resize: resize,
}}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
onKeyDown={(event) => event.stopPropagation()}
{...rest}
/>
</div>
);
};
@@ -0,0 +1 @@
export * from "./Textarea";
@@ -0,0 +1,34 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Button } from "~/components/reusable/Button";
import { twMerge } from "tailwind-merge";
interface ToggleButtonProps {
isActive: boolean;
icon?: IconDefinition;
activeIcon?: IconDefinition;
onClick: () => void;
className?: string;
}
export const ToggleButton = ({
isActive,
icon,
activeIcon,
onClick,
className,
}: ToggleButtonProps) => {
return (
<Button
className={twMerge(
"flex h-9 w-9 items-center border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
isActive
? "border-white/20 bg-primary/40 hover:border-white/30 hover:bg-primary/40"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
className,
)}
variant="secondary"
icon={isActive && activeIcon ? activeIcon : icon}
onClick={onClick}
/>
);
};
@@ -0,0 +1 @@
export * from "./ToggleButton";

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