use leptos::*; use crate::models::{Torrent, AppEvent, TorrentStatus, Theme}; use crate::components::context_menu::ContextMenu; use gloo_net::eventsource::futures::EventSource; use futures::StreamExt; #[component] pub fn App() -> impl IntoView { // Signals let (torrents, set_torrents) = create_signal(Vec::::new()); let (sort_key, set_sort_key) = create_signal(6); // 6=Added Date let (sort_asc, set_sort_asc) = create_signal(false); // Descending (Newest first) let (filter_status, set_filter_status) = create_signal(Option::::None); let (active_tab, set_active_tab) = create_signal("torrents"); let (show_mobile_sidebar, set_show_mobile_sidebar) = create_signal(false); // Theme with Persistence let (theme, set_theme) = create_signal({ let storage = window().local_storage().ok().flatten(); let saved = storage.and_then(|s| s.get_item("vibetorrent_theme").ok().flatten()); match saved.as_deref() { Some("Light") => Theme::Light, Some("Amoled") => Theme::Amoled, _ => Theme::Midnight, } }); // Persist Theme create_effect(move |_| { let val = match theme.get() { Theme::Midnight => "Midnight", Theme::Light => "Light", Theme::Amoled => "Amoled", }; if let Some(storage) = window().local_storage().ok().flatten() { let _ = storage.set_item("vibetorrent_theme", val); } }); // Remove Loading Spinner (Fix for spinner hanging) create_effect(move |_| { if let Some(doc) = window().document() { if let Some(el) = doc.get_element_by_id("app-loading") { el.remove(); } } }); // 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()); // Debug: Last Updated Timestamp let (last_updated, set_last_updated) = create_signal(0u64); // Derived: Filtered & Sorted Logic let processed_torrents = create_memo(move |_| { let mut items = torrents.get(); if let Some(status) = filter_status.get() { items.retain(|t| t.status == status); } let key = sort_key.get(); let asc = sort_asc.get(); items.sort_by(|a, b| { let cmp = match key { 0 => a.name.to_lowercase().cmp(&b.name.to_lowercase()), 1 => a.size.cmp(&b.size), 2 => a.percent_complete.partial_cmp(&b.percent_complete).unwrap_or(std::cmp::Ordering::Equal), 3 => a.down_rate.cmp(&b.down_rate), 4 => a.up_rate.cmp(&b.up_rate), 5 => a.eta.cmp(&b.eta), 6 => a.added_date.cmp(&b.added_date), _ => std::cmp::Ordering::Equal, }; if asc { cmp } else { cmp.reverse() } }); 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 let (show_modal, set_show_modal) = create_signal(false); let (magnet_link, set_magnet_link) = create_signal(String::new()); let add_torrent = move |_| { spawn_local(async move { let uri = magnet_link.get(); if uri.is_empty() { return; } let client = gloo_net::http::Request::post("/api/torrents/add") .header("Content-Type", "application/json") .body(serde_json::to_string(&serde_json::json!({ "uri": uri })).unwrap()) .unwrap(); if client.send().await.is_ok() { set_magnet_link.set(String::new()); set_show_modal.set(false); } }); }; // Connect SSE create_effect(move |_| { spawn_local(async move { logging::log!("Connecting to SSE..."); let mut es = EventSource::new("/api/events").unwrap(); let mut stream = es.subscribe("message").unwrap(); loop { match stream.next().await { Some(Ok((_, msg))) => { let data = msg.data().as_string().unwrap(); match serde_json::from_str::(&data) { Ok(event) => { if let AppEvent::FullList(list, ts) = event { set_torrents.set(list); set_last_updated.set(ts); } } Err(e) => { logging::error!("Failed to parse SSE JSON: {}", e); } } } 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 let format_bytes = |bytes: i64| { if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1048576 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1073741824 { format!("{:.1} MB", bytes as f64 / 1048576.0) } else { format!("{:.1} GB", bytes as f64 / 1073741824.0) } }; 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 get_theme_classes = move || { match theme.get() { Theme::Midnight => ( "bg-[#0a0a0c] text-white selection:bg-blue-500/30", // Main bg "bg-[#111116]/80 backdrop-blur-xl border-white/5", // Sidebar "bg-[#111116] border-white/5 shadow-2xl", // Card/Table bg "text-gray-200", // Primary Text "text-gray-400", // Secondary Text "hover:bg-white/5", // Hover "border-white/5" // Border ), Theme::Light => ( "bg-gray-50 text-gray-900 selection:bg-blue-500/20", "bg-white/80 backdrop-blur-xl border-gray-200", "bg-white border-gray-200 shadow-xl", "text-gray-900", "text-gray-500", "hover:bg-gray-100", "border-gray-200" ), Theme::Amoled => ( "bg-black text-white selection:bg-blue-600/40", "bg-black border-gray-800", "bg-black border-gray-800", "text-gray-200", "text-gray-500", "hover:bg-gray-900", "border-gray-800" ), } }; let filter_btn_class = move |status: Option| { let (_base_bg, _, _, _, text_sec, hover, _) = get_theme_classes(); let base = "block px-4 py-2 rounded-xl transition-all duration-200 text-left w-full flex items-center gap-3 border"; let active = filter_status.get() == status; if active { format!("{} bg-blue-600/20 text-blue-500 border-blue-500/30 font-medium", base) } else { format!("{} {} {} border-transparent hover:text-gray-300", base, hover, text_sec) } }; let tab_btn_class = move |tab: &str| { let active = active_tab.get() == tab; let base = "flex flex-col items-center justify-center p-2 flex-1 transition-colors relative"; if active { format!("{} text-blue-500", base) } else { "flex flex-col items-center justify-center p-2 flex-1 transition-colors relative text-gray-400 hover:text-gray-300".to_string() } }; // Sidebar Content Logic let sidebar_content = move || { let (_, _, _, _text_pri, text_sec, _, border) = get_theme_classes(); view! {

"VibeTorrent"

"Filters"
"Storage"
"700 GB used" "1 TB total"
} }; 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! { {move || { let (main_bg, sidebar_bg, card_bg, text_pri, text_sec, hover, border) = get_theme_classes(); view! {
// DESKTOP SIDEBAR // MOBILE SIDEBAR
// MAIN CONTENT

{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" } }}

"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) } }}
{move || if active_tab.get() == "settings" { view! {

"Appearance"

{theme_option(Theme::Midnight, "Midnight", "bg-[#0a0a0c] border border-gray-700")} {theme_option(Theme::Light, "Light", "bg-gray-100 border border-gray-300")} {theme_option(Theme::Amoled, "Amoled", "bg-black border border-gray-800")}

"About VibeTorrent"

"Version 3.0.0 (Rust + WebAssembly)"

}.into_view() } else if active_tab.get() == "dashboard" { view! {
"Dashboard Charts Coming Soon..."
}.into_view() } else { view! { // Torrent List (Desktop)
"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-red-500 bg-red-500/10 border-red-500/20", _ => "text-gray-400 bg-gray-500/10" }; let status_text = format!("{:?}", torrent.status); let error_msg = torrent.error_message.clone(); let error_msg_view = error_msg.clone(); view! { } } />
"Name" "Size" "Progress" "Down" "Up" "ETA" "Status"
{torrent.name}
{error_msg_view.clone()}
{format_bytes(torrent.size)}
{format!("{:.1}%", torrent.percent_complete)}
{if torrent.down_rate > 0 { view! { {format_bytes(torrent.down_rate)} "/s" }.into_view() } else { view! { "-" }.into_view() }} {if torrent.up_rate > 0 { view! { {format_bytes(torrent.up_rate)} "/s" }.into_view() } else { view! { "-" }.into_view() }} {format_eta(torrent.eta)} {status_text}
// Torrent List (Mobile)
"text-blue-500", TorrentStatus::Seeding => "text-green-500", TorrentStatus::Paused => "text-yellow-500", TorrentStatus::Error => "text-red-500", _ => "text-gray-400" }; view! {
{torrent.name}
{format!("{:?}", torrent.status)}
{format_bytes(torrent.size)} {format!("{:.1}%", torrent.percent_complete)}
"↓ " {format_bytes(torrent.down_rate)} "/s" "↑ " {format_bytes(torrent.up_rate)} "/s"
{format_eta(torrent.eta)}
} } />
"📭"
"No torrents found."
}.into_view() }}
// MOBILE BOTTOM NAV // Modal (Dark backdrop always)

"Add New Torrent"

} }} } }