refactor(frontend): rewrite with Thaw UI components and inline modal
This commit is contained in:
155
Cargo.lock
generated
155
Cargo.lock
generated
@@ -97,6 +97,15 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.37"
|
version = "0.4.37"
|
||||||
@@ -314,6 +323,12 @@ version = "3.19.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "by_address"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -644,6 +659,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fast-srgb8"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
@@ -690,6 +711,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
"tailwind_fuse",
|
"tailwind_fuse",
|
||||||
|
"thaw",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -1035,6 +1057,21 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icondata_ai"
|
||||||
|
version = "0.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9bf3a9c196a6a169f790639ecc8fdd4396660b1d53b905230bf0b364776a56fc"
|
||||||
|
dependencies = [
|
||||||
|
"icondata_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icondata_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c97be924215abd5e630d84e95a47c710138a6559b4c55039f4f33aa897fa859"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1526,6 +1563,30 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56d80efc4b6721e8be2a10a5df21a30fa0b470f1539e53d8b4e6e75faf938b63"
|
checksum = "56d80efc4b6721e8be2a10a5df21a30fa0b470f1539e53d8b4e6e75faf938b63"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"fast-srgb8",
|
||||||
|
"palette_derive",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
|
||||||
|
dependencies = [
|
||||||
|
"by_address",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1567,6 +1628,48 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -2142,6 +2245,12 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
@@ -2235,6 +2344,52 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thaw"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a2ea14de27ddd0ce167c69e78852122c5a9c755b3c083c8249e491c4a8821ce"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"chrono",
|
||||||
|
"icondata_ai",
|
||||||
|
"icondata_core",
|
||||||
|
"leptos",
|
||||||
|
"num-traits",
|
||||||
|
"palette",
|
||||||
|
"thaw_components",
|
||||||
|
"thaw_utils",
|
||||||
|
"uuid",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thaw_components"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4ee9d9598f7a5162845a83ef0ed3ccde52c23d987cf64433056b2fd4542161c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"leptos",
|
||||||
|
"thaw_utils",
|
||||||
|
"uuid",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thaw_utils"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d5fe5c99ba6b6730ec533810dd4d92422b778d4fbaffa9b6d7fb705b28b3978"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"chrono",
|
||||||
|
"leptos",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
web-sys = { version = "0.3", features = ["Window", "Storage"] }
|
||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
tailwind_fuse = "0.3.2"
|
tailwind_fuse = "0.3.2"
|
||||||
|
thaw = "0.3"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
|
use thaw::*;
|
||||||
use shared::{Torrent, AppEvent, TorrentStatus, Theme};
|
use shared::{Torrent, AppEvent, TorrentStatus, Theme};
|
||||||
use crate::components::context_menu::ContextMenu;
|
use crate::components::toolbar::Toolbar;
|
||||||
use crate::components::modal::Modal;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
// use crate::components::context_menu::ContextMenu;
|
||||||
|
// use crate::components::modal::Modal;
|
||||||
|
use crate::components::status_bar::StatusBar;
|
||||||
|
use crate::components::torrent_table::TorrentTable;
|
||||||
use gloo_net::eventsource::futures::EventSource;
|
use gloo_net::eventsource::futures::EventSource;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
@@ -14,7 +18,7 @@ pub fn App() -> impl IntoView {
|
|||||||
let (sort_asc, set_sort_asc) = create_signal(false); // Descending (Newest first)
|
let (sort_asc, set_sort_asc) = create_signal(false); // Descending (Newest first)
|
||||||
let (filter_status, set_filter_status) = create_signal(Option::<TorrentStatus>::None);
|
let (filter_status, set_filter_status) = create_signal(Option::<TorrentStatus>::None);
|
||||||
let (active_tab, set_active_tab) = create_signal("torrents");
|
let (active_tab, set_active_tab) = create_signal("torrents");
|
||||||
let (show_mobile_sidebar, set_show_mobile_sidebar) = create_signal(false);
|
|
||||||
// Theme with Persistence
|
// Theme with Persistence
|
||||||
let (theme, set_theme) = create_signal({
|
let (theme, set_theme) = create_signal({
|
||||||
let storage = window().local_storage().ok().flatten();
|
let storage = window().local_storage().ok().flatten();
|
||||||
@@ -26,8 +30,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist Theme
|
// Persist Theme Logic
|
||||||
// Persist Theme & Apply CSS Variables
|
|
||||||
create_effect(move |_| {
|
create_effect(move |_| {
|
||||||
let val = match theme.get() {
|
let val = match theme.get() {
|
||||||
Theme::Midnight => "Midnight",
|
Theme::Midnight => "Midnight",
|
||||||
@@ -38,15 +41,13 @@ pub fn App() -> impl IntoView {
|
|||||||
if let Some(doc) = window().document() {
|
if let Some(doc) = window().document() {
|
||||||
if let Some(body) = doc.body() {
|
if let Some(body) = doc.body() {
|
||||||
let list = body.class_list();
|
let list = body.class_list();
|
||||||
match theme.get() {
|
// Reset classes
|
||||||
Theme::Light => {
|
|
||||||
let _ = list.remove_1("dark");
|
let _ = list.remove_1("dark");
|
||||||
let _ = list.remove_1("amoled");
|
let _ = list.remove_1("amoled");
|
||||||
},
|
|
||||||
Theme::Midnight => {
|
match theme.get() {
|
||||||
let _ = list.add_1("dark");
|
Theme::Light => {},
|
||||||
let _ = list.remove_1("amoled");
|
Theme::Midnight => { let _ = list.add_1("dark"); },
|
||||||
},
|
|
||||||
Theme::Amoled => {
|
Theme::Amoled => {
|
||||||
let _ = list.add_1("dark");
|
let _ = list.add_1("dark");
|
||||||
let _ = list.add_1("amoled");
|
let _ = list.add_1("amoled");
|
||||||
@@ -60,7 +61,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove Loading Spinner (Fix for spinner hanging)
|
// Remove Loading Spinner
|
||||||
create_effect(move |_| {
|
create_effect(move |_| {
|
||||||
if let Some(doc) = window().document() {
|
if let Some(doc) = window().document() {
|
||||||
if let Some(el) = doc.get_element_by_id("app-loading") {
|
if let Some(el) = doc.get_element_by_id("app-loading") {
|
||||||
@@ -69,29 +70,6 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile Sidebar Scroll Lock
|
|
||||||
create_effect(move |_| {
|
|
||||||
if let Some(doc) = window().document() {
|
|
||||||
if let Some(body) = doc.body() {
|
|
||||||
let style = body.style();
|
|
||||||
if show_mobile_sidebar.get() {
|
|
||||||
let _ = style.set_property("overflow", "hidden");
|
|
||||||
} else {
|
|
||||||
let _ = style.remove_property("overflow");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context Menu Signals
|
|
||||||
let (cm_visible, set_cm_visible) = create_signal(false);
|
|
||||||
let (cm_pos, set_cm_pos) = create_signal((0, 0));
|
|
||||||
let (cm_target_hash, set_cm_target_hash) = create_signal(String::new());
|
|
||||||
|
|
||||||
// Delete Confirmation State
|
|
||||||
let (show_delete_modal, set_show_delete_modal) = create_signal(false);
|
|
||||||
let (pending_action, set_pending_action) = create_signal(Option::<(String, String)>::None); // (Action, Hash)
|
|
||||||
|
|
||||||
// Debug: Last Updated Timestamp
|
// Debug: Last Updated Timestamp
|
||||||
let (last_updated, set_last_updated) = create_signal(0u64);
|
let (last_updated, set_last_updated) = create_signal(0u64);
|
||||||
|
|
||||||
@@ -121,15 +99,6 @@ pub fn App() -> impl IntoView {
|
|||||||
items
|
items
|
||||||
});
|
});
|
||||||
|
|
||||||
let sort = move |key: i32| {
|
|
||||||
if sort_key.get() == key {
|
|
||||||
set_sort_asc.update(|a| *a = !*a);
|
|
||||||
} else {
|
|
||||||
set_sort_key.set(key);
|
|
||||||
set_sort_asc.set(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add Torrent Logic
|
// Add Torrent Logic
|
||||||
let (show_modal, set_show_modal) = create_signal(false);
|
let (show_modal, set_show_modal) = create_signal(false);
|
||||||
let (magnet_link, set_magnet_link) = create_signal(String::new());
|
let (magnet_link, set_magnet_link) = create_signal(String::new());
|
||||||
@@ -152,7 +121,6 @@ pub fn App() -> impl IntoView {
|
|||||||
// Connect SSE
|
// Connect SSE
|
||||||
create_effect(move |_| {
|
create_effect(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
logging::log!("Connecting to SSE...");
|
|
||||||
let mut es = EventSource::new("/api/events").unwrap();
|
let mut es = EventSource::new("/api/events").unwrap();
|
||||||
let mut stream = es.subscribe("message").unwrap();
|
let mut stream = es.subscribe("message").unwrap();
|
||||||
|
|
||||||
@@ -160,8 +128,7 @@ pub fn App() -> impl IntoView {
|
|||||||
match stream.next().await {
|
match stream.next().await {
|
||||||
Some(Ok((_, msg))) => {
|
Some(Ok((_, msg))) => {
|
||||||
let data = msg.data().as_string().unwrap();
|
let data = msg.data().as_string().unwrap();
|
||||||
match serde_json::from_str::<AppEvent>(&data) {
|
if let Ok(event) = serde_json::from_str::<AppEvent>(&data) {
|
||||||
Ok(event) => {
|
|
||||||
match event {
|
match event {
|
||||||
AppEvent::FullList(list, ts) => {
|
AppEvent::FullList(list, ts) => {
|
||||||
set_torrents.set(list);
|
set_torrents.set(list);
|
||||||
@@ -184,515 +151,89 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
logging::error!("Failed to parse SSE JSON: {}", e);
|
None => break,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
logging::error!("SSE Stream Error: {:?}", e);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
logging::warn!("SSE Stream Ended (None received)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logging::warn!("SSE Task Exiting");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Formatting Helpers
|
// Toolbar Callbacks
|
||||||
let format_bytes = |bytes: i64| {
|
let on_add = Callback::new(move |_| set_show_modal.set(true));
|
||||||
if bytes < 1024 { format!("{} B", bytes) }
|
let on_start = Callback::new(move |_| logging::log!("Start all - to be implemented with selection"));
|
||||||
else if bytes < 1048576 { format!("{:.1} KB", bytes as f64 / 1024.0) }
|
let on_pause = Callback::new(move |_| logging::log!("Pause all - to be implemented with selection"));
|
||||||
else if bytes < 1073741824 { format!("{:.1} MB", bytes as f64 / 1048576.0) }
|
let on_delete = Callback::new(move |_| logging::log!("Delete - to be implemented with selection"));
|
||||||
else { format!("{:.1} GB", bytes as f64 / 1073741824.0) }
|
let on_settings = Callback::new(move |_| set_active_tab.set(if active_tab.get() == "settings" { "torrents" } else { "settings" }));
|
||||||
};
|
|
||||||
|
|
||||||
let format_eta = |eta: i64| {
|
|
||||||
if eta <= 0 || eta > 31536000 { return "∞".to_string(); }
|
|
||||||
let h = eta / 3600;
|
|
||||||
let m = (eta % 3600) / 60;
|
|
||||||
format!("{}h {}m", h, m)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Theme Engine
|
|
||||||
|
|
||||||
|
|
||||||
let filter_btn_class = move |status: Option<TorrentStatus>| {
|
|
||||||
crate::utils::cn(format!(
|
|
||||||
"block px-4 py-2 rounded-md transition-all duration-200 text-left w-full flex items-center gap-3 border text-sm font-medium {}",
|
|
||||||
if filter_status.get() == status {
|
|
||||||
"bg-primary/10 text-primary border-primary/20"
|
|
||||||
} else {
|
|
||||||
"border-transparent text-muted-foreground hover:text-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
}
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
let tab_btn_class = move |tab: &str| {
|
|
||||||
crate::utils::cn(format!(
|
|
||||||
"flex flex-col items-center justify-center p-2 flex-1 transition-colors relative {}",
|
|
||||||
if active_tab.get() == tab {
|
|
||||||
"text-primary"
|
|
||||||
} else {
|
|
||||||
"text-muted-foreground hover:text-foreground"
|
|
||||||
}
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sidebar Content Logic
|
|
||||||
let sidebar_content = move || {
|
|
||||||
view! {
|
|
||||||
<div class="mb-10 px-2 flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
|
||||||
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 tracking-tight">
|
|
||||||
"VibeTorrent"
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs font-bold uppercase tracking-widest mb-4 px-2 text-muted-foreground">"Filters"</div>
|
|
||||||
<nav class="space-y-2 flex-1">
|
|
||||||
<button class={move || filter_btn_class(None)} on:click=move |_| { set_filter_status.set(None); set_show_mobile_sidebar.set(false); }>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
|
|
||||||
"All Torrents"
|
|
||||||
</button>
|
|
||||||
<button class={move || filter_btn_class(Some(TorrentStatus::Downloading))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Downloading)); set_show_mobile_sidebar.set(false); }>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
|
||||||
"Downloading"
|
|
||||||
</button>
|
|
||||||
<button class={move || filter_btn_class(Some(TorrentStatus::Seeding))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Seeding)); set_show_mobile_sidebar.set(false); }>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
|
||||||
"Seeding"
|
|
||||||
</button>
|
|
||||||
<button class={move || filter_btn_class(Some(TorrentStatus::Paused))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Paused)); set_show_mobile_sidebar.set(false); }>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-yellow-500"></span>
|
|
||||||
"Paused"
|
|
||||||
</button>
|
|
||||||
<button class={move || filter_btn_class(Some(TorrentStatus::Error))} on:click=move |_| { set_filter_status.set(Some(TorrentStatus::Error)); set_show_mobile_sidebar.set(false); }>
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
|
||||||
"Errors"
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-6 border-t border-border">
|
|
||||||
<div class="rounded-xl p-4 border border-border relative overflow-hidden bg-card">
|
|
||||||
<div class="absolute inset-0 opacity-5 bg-foreground"></div>
|
|
||||||
<div class="text-xs mb-2 z-10 relative text-muted-foreground">"Storage"</div>
|
|
||||||
<div class="w-full bg-secondary/50 rounded-full h-1.5 mb-2 overflow-hidden z-10 relative">
|
|
||||||
<div class="bg-gradient-to-r from-blue-500 to-purple-500 w-[70%] h-full rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs z-10 relative text-muted-foreground">
|
|
||||||
<span>"700 GB used"</span>
|
|
||||||
<span>"1 TB total"</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let theme_option = move |t: Theme, label: &str, color: &str| {
|
|
||||||
let is_active = theme.get() == t;
|
|
||||||
let border_class = if is_active { "border-blue-500 ring-1 ring-blue-500/50" } else { "border-transparent hover:border-gray-500/30" };
|
|
||||||
let label_owned = label.to_string();
|
|
||||||
let color_owned = color.to_string();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<button
|
<div class="flex h-screen overflow-hidden bg-background text-foreground font-sans">
|
||||||
class={format!("flex items-center gap-4 p-4 rounded-xl border bg-black/5 dark:bg-white/5 transition-all w-full text-left {}", border_class)}
|
<Sidebar
|
||||||
on:click=move |_| set_theme.set(t.clone())
|
active_filter=filter_status
|
||||||
>
|
on_filter_change=Callback::new(move |s| set_filter_status.set(s))
|
||||||
<div class={format!("w-12 h-12 rounded-full shadow-lg flex-shrink-0 {}", color_owned)}></div>
|
/>
|
||||||
<div>
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<div class="font-bold">{label_owned}</div>
|
<Toolbar
|
||||||
<div class="text-xs opacity-60">"Select this theme"</div>
|
on_add=on_add
|
||||||
</div>
|
on_start=on_start
|
||||||
{if is_active {
|
on_pause=on_pause
|
||||||
view! {
|
on_delete=on_delete
|
||||||
<div class="ml-auto text-blue-500">
|
on_settings=on_settings
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
/>
|
||||||
</div>
|
|
||||||
}.into_view()
|
|
||||||
} else {
|
|
||||||
view! {}.into_view()
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="min-h-screen font-sans flex flex-col md:flex-row overflow-hidden transition-colors duration-300 bg-background text-foreground">
|
|
||||||
// DESKTOP SIDEBAR
|
|
||||||
<aside class="hidden md:flex flex-col w-72 border-r border-border p-6 z-20 h-screen bg-card/50 backdrop-blur-xl">
|
|
||||||
{sidebar_content}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
// MOBILE SIDEBAR
|
|
||||||
<div
|
|
||||||
class={move || if show_mobile_sidebar.get() { "fixed inset-0 z-50 flex md:hidden" } else { "hidden" }}
|
|
||||||
on:click=move |_| ()
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity cursor-default"
|
|
||||||
on:click=move |_| set_show_mobile_sidebar.set(false)
|
|
||||||
></div>
|
|
||||||
<aside
|
|
||||||
class="relative w-80 max-w-[85vw] h-full shadow-2xl p-6 flex flex-col animate-in slide-in-from-left duration-300 border-r border-border bg-card"
|
|
||||||
on:click=move |e: web_sys::MouseEvent| e.stop_propagation()
|
|
||||||
>
|
|
||||||
<button class="absolute top-4 right-4 p-2 hover:opacity-80 text-muted-foreground" on:click=move |_| set_show_mobile_sidebar.set(false)>
|
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
|
||||||
</button>
|
|
||||||
{sidebar_content}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// MAIN CONTENT
|
|
||||||
<main class="flex-1 h-screen overflow-y-auto overflow-x-hidden relative pb-24 md:pb-0">
|
|
||||||
<header class="fixed top-0 left-0 right-0 md:sticky md:top-0 z-40 border-b border-border px-6 py-4 flex justify-between items-center transition-colors duration-300 bg-background/80 backdrop-blur-xl">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button class="md:hidden p-1 -ml-2 hover:opacity-80 text-muted-foreground" on:click=move |_| set_show_mobile_sidebar.set(true)>
|
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
|
||||||
</button>
|
|
||||||
<h2 class="text-xl font-bold flex items-center gap-2 text-foreground">
|
|
||||||
{move || if active_tab.get() == "settings" { "Settings" } else if active_tab.get() == "dashboard" { "Dashboard" } else {
|
|
||||||
match filter_status.get() {
|
|
||||||
None => "All Torrents",
|
|
||||||
Some(TorrentStatus::Downloading) => "Downloading",
|
|
||||||
Some(TorrentStatus::Seeding) => "Seeding",
|
|
||||||
Some(TorrentStatus::Paused) => "Paused",
|
|
||||||
Some(TorrentStatus::Error) => "Errors",
|
|
||||||
_ => "Torrents"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="hidden md:block text-xs font-mono text-muted-foreground">
|
|
||||||
"Server Time: "
|
|
||||||
{move || {
|
|
||||||
let ts = last_updated.get();
|
|
||||||
if ts == 0 {
|
|
||||||
"Waiting...".to_string()
|
|
||||||
} else {
|
|
||||||
let s = ts % 60;
|
|
||||||
let m = (ts / 60) % 60;
|
|
||||||
let h = (ts / 3600) % 24;
|
|
||||||
format!("{:02}:{:02}:{:02} UTC", h, m, s)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
class="gap-2"
|
|
||||||
on_click=Callback::from(move |_| set_show_modal.set(true))
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
|
||||||
<span class="hidden md:inline">"Add Torrent"</span>
|
|
||||||
<span class="md:hidden">"Add"</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Ghost
|
|
||||||
class={Signal::derive(move || if active_tab.get() == "settings" { "text-primary bg-primary/10 border-primary/20".to_string() } else { "text-muted-foreground".to_string() })}
|
|
||||||
on_click=Callback::from(move |_| set_active_tab.set(if active_tab.get() == "settings" { "torrents" } else { "settings" }))
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="p-6 max-w-7xl mx-auto animate-in fade-in duration-500 pt-[88px] md:pt-6">
|
|
||||||
{move || if active_tab.get() == "settings" {
|
{move || if active_tab.get() == "settings" {
|
||||||
view! {
|
view! {
|
||||||
<div class="space-y-8">
|
<div class="p-6">
|
||||||
<div>
|
<h1 class="text-2xl font-bold mb-4">"Settings"</h1>
|
||||||
<h3 class="text-lg font-bold mb-4 text-foreground">"Appearance"</h3>
|
<div class="flex gap-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<Button on_click=move |_| set_theme.set(Theme::Midnight)>"Midnight"</Button>
|
||||||
{theme_option(Theme::Midnight, "Midnight", "bg-[#0a0a0c] border border-gray-700")}
|
<Button on_click=move |_| set_theme.set(Theme::Light)>"Light"</Button>
|
||||||
{theme_option(Theme::Light, "Light", "bg-gray-100 border border-gray-300")}
|
<Button on_click=move |_| set_theme.set(Theme::Amoled)>"Amoled"</Button>
|
||||||
{theme_option(Theme::Amoled, "Amoled", "bg-black border border-gray-800")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6 rounded-2xl border border-border bg-card shadow-sm">
|
|
||||||
<h3 class="text-lg font-bold mb-2 text-foreground">"About VibeTorrent"</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">"Version 3.0.0 (Rust + WebAssembly)"</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_view()
|
}.into_view()
|
||||||
} else if active_tab.get() == "dashboard" {
|
|
||||||
view! {
|
|
||||||
<div class="text-center py-20 opacity-50 text-muted-foreground">"Dashboard Charts Coming Soon..."</div>
|
|
||||||
}.into_view()
|
|
||||||
} else {
|
} else {
|
||||||
view! {
|
view! { <TorrentTable torrents=processed_torrents /> }.into_view()
|
||||||
// Torrent List (Desktop)
|
|
||||||
<div class="hidden md:block rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
|
|
||||||
<table class="w-full text-left table-fixed">
|
|
||||||
<thead class="uppercase text-xs font-bold tracking-wider text-muted-foreground bg-muted/50 border-b border-border">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors" on:click=move |_| sort(0)>"Name"</th>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors w-28 text-right whitespace-nowrap" on:click=move |_| sort(1)>"Size"</th>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors w-36" on:click=move |_| sort(2)>"Progress"</th>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors w-28 text-right whitespace-nowrap" on:click=move |_| sort(3)>"Down"</th>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors w-28 text-right whitespace-nowrap" on:click=move |_| sort(4)>"Up"</th>
|
|
||||||
<th class="px-6 py-4 cursor-pointer hover:text-foreground transition-colors w-28 text-right whitespace-nowrap" on:click=move |_| sort(5)>"ETA"</th>
|
|
||||||
<th class="px-6 py-4 text-center w-28">"Status"</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-border">
|
|
||||||
<For
|
|
||||||
each=move || processed_torrents.get()
|
|
||||||
key=|t| format!("{}-{}-{:?}-{}-{}-{}-{}", t.hash, t.name, t.status, t.down_rate, t.up_rate, t.percent_complete, t.error_message)
|
|
||||||
children=move |torrent| {
|
|
||||||
let status_color = match torrent.status {
|
|
||||||
TorrentStatus::Downloading => "text-blue-500 bg-blue-500/10 border-blue-500/20",
|
|
||||||
TorrentStatus::Seeding => "text-green-500 bg-green-500/10 border-green-500/20",
|
|
||||||
TorrentStatus::Paused => "text-yellow-500 bg-yellow-500/10 border-yellow-500/20",
|
|
||||||
TorrentStatus::Error => "text-destructive bg-destructive/10 border-destructive/20",
|
|
||||||
_ => "text-muted-foreground bg-muted"
|
|
||||||
};
|
|
||||||
let status_text = format!("{:?}", torrent.status);
|
|
||||||
let error_msg = torrent.error_message.clone();
|
|
||||||
let error_msg_view = error_msg.clone();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<tr
|
|
||||||
class="transition-colors group hover:bg-muted/50"
|
|
||||||
on:contextmenu=move |e: web_sys::MouseEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
set_cm_pos.set((e.client_x(), e.client_y()));
|
|
||||||
set_cm_target_hash.set(torrent.hash.clone());
|
|
||||||
set_cm_visible.set(true);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<td class="px-6 py-4 max-w-sm">
|
|
||||||
<div class="font-medium truncate transition-colors text-foreground" title={torrent.name.clone()}>
|
|
||||||
{torrent.name}
|
|
||||||
</div>
|
|
||||||
<Show when=move || !error_msg.is_empty() fallback=|| ()>
|
|
||||||
<div class="text-xs text-destructive mt-1">{error_msg_view.clone()}</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm font-mono text-right whitespace-nowrap text-muted-foreground">{format_bytes(torrent.size)}</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div class="flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>{format!("{:.1}%", torrent.percent_complete)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-secondary rounded-full h-1.5 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="bg-primary h-full rounded-full transition-all duration-500"
|
|
||||||
style=format!("width: {}%", torrent.percent_complete)
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 font-mono text-xs text-right whitespace-nowrap text-muted-foreground">
|
|
||||||
{if torrent.down_rate > 0 {
|
|
||||||
view! { <span class="text-blue-500">{format_bytes(torrent.down_rate)} "/s"</span> }.into_view()
|
|
||||||
} else {
|
|
||||||
view! { <span class="text-muted-foreground/50">"-"</span> }.into_view()
|
|
||||||
}}
|
}}
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 font-mono text-xs text-right whitespace-nowrap text-muted-foreground">
|
<StatusBar />
|
||||||
{if torrent.up_rate > 0 {
|
|
||||||
view! { <span class="text-green-500">{format_bytes(torrent.up_rate)} "/s"</span> }.into_view()
|
|
||||||
} else {
|
|
||||||
view! { <span class="text-muted-foreground/50">"-"</span> }.into_view()
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-xs font-mono text-right whitespace-nowrap text-muted-foreground">
|
|
||||||
{format_eta(torrent.eta)}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-center">
|
|
||||||
<span class={format!("text-[10px] font-bold px-2.5 py-1 rounded-full border {}", status_color)}>
|
|
||||||
{status_text}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Torrent List (Mobile)
|
// Add Torrent Modal (Inlined)
|
||||||
<div class="md:hidden space-y-4">
|
|
||||||
<For
|
|
||||||
each=move || processed_torrents.get()
|
|
||||||
key=|t| format!("{}-{}-{:?}-{}-{}-{}-{}", t.hash, t.name, t.status, t.down_rate, t.up_rate, t.percent_complete, t.error_message)
|
|
||||||
children=move |torrent| {
|
|
||||||
let status_color = match torrent.status {
|
|
||||||
TorrentStatus::Downloading => "text-blue-500",
|
|
||||||
TorrentStatus::Seeding => "text-green-500",
|
|
||||||
TorrentStatus::Paused => "text-yellow-500",
|
|
||||||
TorrentStatus::Error => "text-destructive",
|
|
||||||
_ => "text-muted-foreground"
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="rounded-2xl p-4 border border-border shadow-sm active:scale-[0.98] transition-transform bg-card">
|
|
||||||
<div class="flex justify-between items-start mb-3">
|
|
||||||
<div class="font-medium line-clamp-2 pr-4 text-foreground">{torrent.name}</div>
|
|
||||||
<div class={format!("text-xs font-bold {}", status_color)}>
|
|
||||||
{format!("{:?}", torrent.status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex justify-between text-xs mb-1 text-muted-foreground">
|
|
||||||
<span>{format_bytes(torrent.size)}</span>
|
|
||||||
<span>{format!("{:.1}%", torrent.percent_complete)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-secondary rounded-full h-1.5 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="bg-primary h-full rounded-full transition-all duration-500"
|
|
||||||
style=format!("width: {}%", torrent.percent_complete)
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center text-xs font-mono opacity-80">
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<span class="text-blue-500">"↓ " {format_bytes(torrent.down_rate)} "/s"</span>
|
|
||||||
<span class="text-green-500">"↑ " {format_bytes(torrent.up_rate)} "/s"</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground">{format_eta(torrent.eta)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Show when=move || processed_torrents.get().is_empty() fallback=|| ()>
|
|
||||||
<div class="p-12 text-center mt-10 text-muted-foreground">
|
|
||||||
<div class="mb-4 text-6xl opacity-20">"📭"</div>
|
|
||||||
"No torrents found."
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}.into_view()
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
// MOBILE BOTTOM NAV
|
|
||||||
<nav class="md:hidden fixed bottom-0 inset-x-0 backdrop-blur-xl border-t border-border pb-safe z-30 flex justify-between items-center px-6 py-2 bg-background/80">
|
|
||||||
<button class={move || tab_btn_class("torrents")} on:click=move |_| set_active_tab.set("torrents")>
|
|
||||||
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
|
||||||
<span class="text-[10px] font-medium">"List"</span>
|
|
||||||
</button>
|
|
||||||
<button class={move || tab_btn_class("dashboard")} on:click=move |_| set_active_tab.set("dashboard")>
|
|
||||||
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
|
||||||
<span class="text-[10px] font-medium">"Dashboard"</span>
|
|
||||||
</button>
|
|
||||||
<button class={move || tab_btn_class("settings")} on:click=move |_| set_active_tab.set("settings")>
|
|
||||||
<svg class="w-6 h-6 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
|
|
||||||
<span class="text-[10px] font-medium">"Settings"</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
// Modal (Dark backdrop always)
|
|
||||||
<Show when=move || show_modal.get() fallback=|| ()>
|
<Show when=move || show_modal.get() fallback=|| ()>
|
||||||
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-50 animate-in fade-in duration-200 sm:p-4">
|
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
||||||
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-lg shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<h3 class="text-lg font-semibold text-card-foreground mb-4">"Add Torrent"</h3>
|
||||||
<h3 class="text-xl font-bold text-card-foreground">"Add New Torrent"</h3>
|
|
||||||
<button on:click=move |_| set_show_modal.set(false) class="p-1 hover:bg-accent rounded-full transition-colors text-muted-foreground">
|
<div class="space-y-4 mb-6">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
<p class="text-sm text-muted-foreground">"Paste a magnet link or URL to start downloading."</p>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="relative mb-6">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-input border border-input rounded-md p-3 pl-10 text-foreground focus:border-ring focus:ring-1 focus:ring-ring focus:outline-none transition-all placeholder:text-muted-foreground"
|
class="w-full bg-input border border-input rounded-md p-2 text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
placeholder="Paste Magnet Link or URL"
|
placeholder="magnet:?xt=urn:btih:..."
|
||||||
on:input=move |ev| set_magnet_link.set(event_target_value(&ev))
|
on:input=move |ev| set_magnet_link.set(event_target_value(&ev))
|
||||||
prop:value=magnet_link
|
prop:value=magnet_link
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
||||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<Button class="flex-1" on_click=Callback::from(move |e| add_torrent(e))>
|
|
||||||
"Add Download"
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<ContextMenu
|
<div class="flex justify-end gap-3">
|
||||||
position=cm_pos.get()
|
<button
|
||||||
visible=cm_visible.get()
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
||||||
torrent_hash=cm_target_hash.get()
|
on:click=move |_| set_show_modal.set(false)
|
||||||
on_close=Callback::from(move |_| set_cm_visible.set(false))
|
|
||||||
on_action=Callback::from(move |(action, hash): (String, String)| {
|
|
||||||
logging::log!("App: Received action '{}' for hash '{}'", action, hash);
|
|
||||||
if action == "delete" || action == "delete_with_data" {
|
|
||||||
logging::log!("App: Showing delete modal");
|
|
||||||
set_pending_action.set(Some((action, hash)));
|
|
||||||
set_show_delete_modal.set(true);
|
|
||||||
} else {
|
|
||||||
// Execute immediately for start/stop
|
|
||||||
spawn_local(async move {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"hash": hash,
|
|
||||||
"action": action
|
|
||||||
});
|
|
||||||
let _ = gloo_net::http::Request::post("/api/torrents/action")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(body.to_string())
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Delete Confirmation Modal
|
|
||||||
<Modal
|
|
||||||
title="Confirm Deletion"
|
|
||||||
visible=show_delete_modal
|
|
||||||
is_danger=true
|
|
||||||
confirm_text="Delete Forever"
|
|
||||||
on_cancel=Callback::from(move |_| {
|
|
||||||
set_show_delete_modal.set(false);
|
|
||||||
set_pending_action.set(None);
|
|
||||||
})
|
|
||||||
on_confirm=Callback::from(move |_| {
|
|
||||||
if let Some((action, hash)) = pending_action.get() {
|
|
||||||
spawn_local(async move {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"hash": hash,
|
|
||||||
"action": action
|
|
||||||
});
|
|
||||||
let _ = gloo_net::http::Request::post("/api/torrents/action")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(body.to_string())
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
set_show_delete_modal.set(false);
|
|
||||||
set_pending_action.set(None);
|
|
||||||
})
|
|
||||||
>
|
>
|
||||||
<p>"Are you definitely sure you want to delete this torrent?"</p>
|
"Cancel"
|
||||||
<Show when=move || pending_action.get().map(|(a, _)| a == "delete_with_data").unwrap_or(false)>
|
</button>
|
||||||
<p class="mt-2 text-destructive font-bold">"⚠️ This will also permanently delete the downloaded files from the disk."</p>
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||||
|
on:click=move |_| add_torrent(())
|
||||||
|
>
|
||||||
|
"Add Download"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod ui;
|
pub mod toolbar;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod status_bar;
|
||||||
|
pub mod torrent_table;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub fn Modal(
|
|||||||
children: Children,
|
children: Children,
|
||||||
#[prop(into)] on_confirm: Callback<()>,
|
#[prop(into)] on_confirm: Callback<()>,
|
||||||
#[prop(into)] on_cancel: Callback<()>,
|
#[prop(into)] on_cancel: Callback<()>,
|
||||||
#[prop(into)] visible: Signal<bool>,
|
#[prop(into)] is_open: MaybeSignal<bool>,
|
||||||
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
|
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
|
||||||
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
|
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
|
||||||
#[prop(into, default = false)] is_danger: bool,
|
#[prop(into, default = false)] is_danger: bool,
|
||||||
@@ -20,7 +20,7 @@ pub fn Modal(
|
|||||||
let cancel_text = store_value(cancel_text);
|
let cancel_text = store_value(cancel_text);
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || visible.get() fallback=|| ()>
|
<Show when=move || is_open.get() fallback=|| ()>
|
||||||
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
||||||
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
||||||
<h3 class="text-lg font-semibold text-card-foreground mb-4">{title.get_value()}</h3>
|
<h3 class="text-lg font-semibold text-card-foreground mb-4">{title.get_value()}</h3>
|
||||||
|
|||||||
52
frontend/src/components/sidebar.rs
Normal file
52
frontend/src/components/sidebar.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use thaw::*;
|
||||||
|
use shared::TorrentStatus;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Sidebar(
|
||||||
|
#[prop(into)] active_filter: Signal<Option<TorrentStatus>>,
|
||||||
|
#[prop(into)] on_filter_change: Callback<Option<TorrentStatus>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="w-64 border-r border-border bg-card/30 flex flex-col">
|
||||||
|
<div class="p-4 font-bold text-lg">"Groups"</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
<Button
|
||||||
|
variant=if active_filter.get().is_none() { ButtonVariant::Primary } else { ButtonVariant::Text }
|
||||||
|
class="w-full justify-start text-left"
|
||||||
|
on_click=move |_| on_filter_change.call(None)
|
||||||
|
>
|
||||||
|
"All"
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant=if active_filter.get() == Some(TorrentStatus::Downloading) { ButtonVariant::Primary } else { ButtonVariant::Text }
|
||||||
|
class="w-full justify-start text-left"
|
||||||
|
on_click=move |_| on_filter_change.call(Some(TorrentStatus::Downloading))
|
||||||
|
>
|
||||||
|
"Downloading"
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant=if active_filter.get() == Some(TorrentStatus::Seeding) { ButtonVariant::Primary } else { ButtonVariant::Text }
|
||||||
|
class="w-full justify-start text-left"
|
||||||
|
on_click=move |_| on_filter_change.call(Some(TorrentStatus::Seeding))
|
||||||
|
>
|
||||||
|
"Seeding"
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant=if active_filter.get() == Some(TorrentStatus::Paused) { ButtonVariant::Primary } else { ButtonVariant::Text }
|
||||||
|
class="w-full justify-start text-left"
|
||||||
|
on_click=move |_| on_filter_change.call(Some(TorrentStatus::Paused))
|
||||||
|
>
|
||||||
|
"Paused"
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant=if active_filter.get() == Some(TorrentStatus::Error) { ButtonVariant::Primary } else { ButtonVariant::Text }
|
||||||
|
class="w-full justify-start text-left"
|
||||||
|
on_click=move |_| on_filter_change.call(Some(TorrentStatus::Error))
|
||||||
|
>
|
||||||
|
"Errors"
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/src/components/status_bar.rs
Normal file
20
frontend/src/components/status_bar.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use thaw::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn StatusBar() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="h-8 border-t border-border bg-card/30 flex items-center px-4 text-xs space-x-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="i-mdi-arrow-down text-green-500"></span>
|
||||||
|
"0 KB/s"
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="i-mdi-arrow-up text-blue-500"></span>
|
||||||
|
"0 KB/s"
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<div>"Free Space: 700 GB"</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/src/components/toolbar.rs
Normal file
34
frontend/src/components/toolbar.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use thaw::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Toolbar(
|
||||||
|
#[prop(into)] on_add: Callback<()>,
|
||||||
|
#[prop(into)] on_start: Callback<()>,
|
||||||
|
#[prop(into)] on_pause: Callback<()>,
|
||||||
|
#[prop(into)] on_delete: Callback<()>,
|
||||||
|
#[prop(into)] on_settings: Callback<()>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="flex items-center gap-2 p-2 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<Button variant=ButtonVariant::Text on_click=move |_| on_add.call(())>
|
||||||
|
<span class="i-mdi-plus mr-2"/> "Add"
|
||||||
|
</Button>
|
||||||
|
<div class="h-4 w-px bg-border mx-2"></div>
|
||||||
|
<Button variant=ButtonVariant::Text on_click=move |_| on_start.call(())>
|
||||||
|
<span class="i-mdi-play mr-2"/> "Start"
|
||||||
|
</Button>
|
||||||
|
<Button variant=ButtonVariant::Text on_click=move |_| on_pause.call(())>
|
||||||
|
<span class="i-mdi-pause mr-2"/> "Pause"
|
||||||
|
</Button>
|
||||||
|
<Button variant=ButtonVariant::Text color=ButtonColor::Error on_click=move |_| on_delete.call(())>
|
||||||
|
<span class="i-mdi-delete mr-2"/> "Delete"
|
||||||
|
</Button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Input placeholder="Filter..." class="w-48" />
|
||||||
|
<Button variant=ButtonVariant::Text on_click=move |_| on_settings.call(())>
|
||||||
|
<span class="i-mdi-cog mr-2"/> "Settings"
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
51
frontend/src/components/torrent_table.rs
Normal file
51
frontend/src/components/torrent_table.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use thaw::*;
|
||||||
|
use shared::Torrent;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TorrentTable(
|
||||||
|
#[prop(into)] torrents: Signal<Vec<Torrent>>
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="flex-1 overflow-auto bg-background">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead class="bg-muted/50 border-b border-border text-muted-foreground font-medium sticky top-0 bg-background z-10">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1.5 font-medium">"Name"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-20 text-right">"Size"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-24">"Progress"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-20 text-center">"Status"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-20 text-right">"Down"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-20 text-right">"Up"</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium w-20 text-right">"ETA"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border">
|
||||||
|
<For
|
||||||
|
each=move || torrents.get()
|
||||||
|
key=|t| t.hash.clone()
|
||||||
|
children=move |torrent| {
|
||||||
|
view! {
|
||||||
|
<tr class="hover:bg-muted/50 group transition-colors">
|
||||||
|
<td class="px-2 py-1.5 truncate max-w-[200px]">{torrent.name}</td>
|
||||||
|
<td class="px-2 py-1.5 text-right whitespace-nowrap text-muted-foreground">{torrent.size}</td>
|
||||||
|
<td class="px-2 py-1.5">
|
||||||
|
<Progress percentage=torrent.percent_complete as f32 />
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 text-center">
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-background">
|
||||||
|
{format!("{:?}", torrent.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 text-right whitespace-nowrap text-blue-500">{torrent.down_rate}</td>
|
||||||
|
<td class="px-2 py-1.5 text-right whitespace-nowrap text-green-500">{torrent.up_rate}</td>
|
||||||
|
<td class="px-2 py-1.5 text-right whitespace-nowrap text-muted-foreground">{torrent.eta}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use leptos::*;
|
|
||||||
use crate::utils::cn;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
|
||||||
pub enum ButtonVariant {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Destructive,
|
|
||||||
Outline,
|
|
||||||
Secondary,
|
|
||||||
Ghost,
|
|
||||||
Link,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
|
||||||
pub enum ButtonSize {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Sm,
|
|
||||||
Lg,
|
|
||||||
Icon,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Button(
|
|
||||||
#[prop(into, optional)] variant: ButtonVariant,
|
|
||||||
#[prop(into, optional)] size: ButtonSize,
|
|
||||||
#[prop(into, optional)] class: MaybeSignal<String>,
|
|
||||||
#[prop(into, optional)] on_click: Option<Callback<web_sys::MouseEvent>>,
|
|
||||||
children: Children,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let variant_classes = match variant {
|
|
||||||
ButtonVariant::Default => "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
ButtonVariant::Destructive => "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
||||||
ButtonVariant::Outline => "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
||||||
ButtonVariant::Secondary => "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ButtonVariant::Ghost => "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
ButtonVariant::Link => "text-primary underline-offset-4 hover:underline",
|
|
||||||
};
|
|
||||||
|
|
||||||
let size_classes = match size {
|
|
||||||
ButtonSize::Default => "h-10 px-4 py-2",
|
|
||||||
ButtonSize::Sm => "h-9 rounded-md px-3",
|
|
||||||
ButtonSize::Lg => "h-11 rounded-md px-8",
|
|
||||||
ButtonSize::Icon => "h-10 w-10",
|
|
||||||
};
|
|
||||||
|
|
||||||
let base_classes = "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
class=move || cn(format!("{} {} {} {}", base_classes, variant_classes, size_classes, class.get()))
|
|
||||||
on:click=move |e| {
|
|
||||||
if let Some(cb) = on_click {
|
|
||||||
cb.call(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children()}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod button;
|
|
||||||
Reference in New Issue
Block a user