From c85c75659ece180e718eb4bf300915dbd67b86da Mon Sep 17 00:00:00 2001 From: spinline Date: Tue, 10 Feb 2026 19:02:53 +0300 Subject: [PATCH] feat: modernize stack with shadcn, struct_patch and msgpack --- .gitea/workflows/build-mips.yml | 2 +- backend/Cargo.toml | 2 + backend/src/diff.rs | 83 +------ backend/src/sse.rs | 37 +++- frontend/Cargo.toml | 19 ++ frontend/input.css | 126 ++++++++++- frontend/package.json | 1 - frontend/src/components/layout/sidebar.rs | 143 ++++++------ frontend/src/components/layout/statusbar.rs | 234 ++++++++++---------- frontend/src/components/layout/toolbar.rs | 51 +++-- frontend/src/components/toast.rs | 45 ++-- frontend/src/components/torrent/table.rs | 220 +++++++++++------- frontend/src/store.rs | 71 +++--- shared/Cargo.toml | 2 + shared/src/lib.rs | 12 +- 15 files changed, 597 insertions(+), 451 deletions(-) diff --git a/.gitea/workflows/build-mips.yml b/.gitea/workflows/build-mips.yml index b14d65f..7ed5ee6 100644 --- a/.gitea/workflows/build-mips.yml +++ b/.gitea/workflows/build-mips.yml @@ -26,7 +26,7 @@ jobs: run: | cd frontend npm install - npx @tailwindcss/cli -i input.css -o public/tailwind.css + npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs' # Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor. trunk build --release diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 678e06e..fcf3d74 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,6 +15,8 @@ tower = { version = "0.5", features = ["util", "timeout"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +rmp-serde = "1.3" +struct_patch = "0.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-stream = "0.1" diff --git a/backend/src/diff.rs b/backend/src/diff.rs index c39787a..463dd58 100644 --- a/backend/src/diff.rs +++ b/backend/src/diff.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; -use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate}; +use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent}; +use struct_patch::traits::Patchable; #[derive(Debug)] pub enum DiffResult { @@ -9,70 +10,28 @@ pub enum DiffResult { } pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { - // 1. Structural Check: Eğer torrent sayısı değişmişse (yeni eklenen veya silinen), - // şimdilik basitlik adına FullUpdate gönderiyoruz. if old.len() != new.len() { return DiffResult::FullUpdate; } - // 2. Hash Set Karşılaştırması: - // Sıralama değişmiş olabilir ama torrentler aynı mı? let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect(); - // Eğer yeni listedeki bir hash eski listede yoksa, yapı değişmiş demektir. for new_t in new { if !old_map.contains_key(new_t.hash.as_str()) { return DiffResult::FullUpdate; } } - // 3. Alan Güncellemeleri (Partial Updates) - // Buraya geldiğimizde biliyoruz ki old ve new listelerindeki torrentler (hash olarak) aynı, - // sadece sıraları farklı olabilir veya içindeki veriler güncellenmiş olabilir. let mut events = Vec::new(); for new_t in new { - // old_map'ten ilgili torrente hash ile ulaşalım (sıradan bağımsız) let old_t = old_map.get(new_t.hash.as_str()).unwrap(); - let mut update = TorrentUpdate { - hash: new_t.hash.clone(), - name: None, - size: None, - down_rate: None, - up_rate: None, - percent_complete: None, - completed: None, - eta: None, - status: None, - error_message: None, - label: None, - }; + // struct_patch::diff uses the Patch trait we derived in shared crate + let patch = old_t.diff(new_t); - let mut has_changes = false; - - // Alanları karşılaştır - if old_t.name != new_t.name { - update.name = Some(new_t.name.clone()); - has_changes = true; - } - if old_t.size != new_t.size { - update.size = Some(new_t.size); - has_changes = true; - } - if old_t.down_rate != new_t.down_rate { - update.down_rate = Some(new_t.down_rate); - has_changes = true; - } - if old_t.up_rate != new_t.up_rate { - update.up_rate = Some(new_t.up_rate); - has_changes = true; - } - if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 { - update.percent_complete = Some(new_t.percent_complete); - has_changes = true; - - // Torrent tamamlanma kontrolü + if !patch.is_empty() { + // If percent_complete jumped to 100, send notification if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 { tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash); events.push(AppEvent::Notification(SystemNotification { @@ -80,35 +39,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { message: format!("Torrent tamamlandı: {}", new_t.name), })); } - } - if old_t.completed != new_t.completed { - update.completed = Some(new_t.completed); - has_changes = true; - } - if old_t.eta != new_t.eta { - update.eta = Some(new_t.eta); - has_changes = true; - } - if old_t.status != new_t.status { - update.status = Some(new_t.status.clone()); - has_changes = true; - - tracing::debug!( - "Torrent status changed: {} ({}) {:?} -> {:?}", - new_t.name, new_t.hash, old_t.status, new_t.status - ); - } - if old_t.error_message != new_t.error_message { - update.error_message = Some(new_t.error_message.clone()); - has_changes = true; - } - if old_t.label != new_t.label { - update.label = new_t.label.clone(); - has_changes = true; - } - - if has_changes { - events.push(AppEvent::Update(update)); + events.push(AppEvent::Update(patch)); } } diff --git a/backend/src/sse.rs b/backend/src/sse.rs index fe9ad57..9efa97e 100644 --- a/backend/src/sse.rs +++ b/backend/src/sse.rs @@ -192,9 +192,25 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result, -) -> Sse>> { +) -> impl IntoResponse { // Notify background worker to wake up and poll immediately state.notify_poll.notify_one(); @@ -213,8 +229,8 @@ pub async fn sse_handler( timestamp, }; - match serde_json::to_string(&event_data) { - Ok(json) => Event::default().data(json), + match rmp_serde::to_vec(&event_data) { + Ok(bytes) => Event::default().data(BASE64.encode(bytes)), Err(_) => Event::default().comment("init_error"), } }; @@ -226,10 +242,10 @@ pub async fn sse_handler( let rx = state.event_bus.subscribe(); let update_stream = stream::unfold(rx, |mut rx| async move { match rx.recv().await { - Ok(event) => match serde_json::to_string(&event) { - Ok(json) => Some((Ok::(Event::default().data(json)), rx)), + Ok(event) => match rmp_serde::to_vec(&event) { + Ok(bytes) => Some((Ok::(Event::default().data(BASE64.encode(bytes))), rx)), Err(e) => { - tracing::warn!("Failed to serialize SSE event: {}", e); + tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e); Some(( Ok::(Event::default().comment("error")), rx, @@ -244,6 +260,11 @@ pub async fn sse_handler( } }); - Sse::new(initial_stream.chain(update_stream)) - .keep_alive(axum::response::sse::KeepAlive::default()) + let sse = Sse::new(initial_stream.chain(update_stream)) + .keep_alive(axum::response::sse::KeepAlive::default()); + + ( + [("content-type", "application/x-msgpack")], + sse + ) } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 6da7428..9799194 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -31,3 +31,22 @@ serde-wasm-bindgen = "0.6.5" leptos-use = { version = "0.16", features = ["storage"] } codee = "0.3" thiserror = "2.0" +rmp-serde = "1.3" +struct_patch = "0.5" +leptos-shadcn-ui = { version = "0.5.0", features = [ + "button", + "input", + "sheet", + "navigation-menu", + "toast", + "table", + "card", + "separator", + "label", + "checkbox", + "badge", + "progress", + "dropdown-menu", + "skeleton", + "avatar" +] } diff --git a/frontend/input.css b/frontend/input.css index 74b81dd..bc485a4 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -1,16 +1,130 @@ @import "tailwindcss"; @config "./tailwind.config.js"; -@plugin "daisyui" { - themes: - light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset, - abyss; +@theme { + /* Shadcn Colors */ + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } } @layer base { - html, + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } body { - @apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none; + @apply bg-background text-foreground; } } diff --git a/frontend/package.json b/frontend/package.json index de5d739..29397a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,6 @@ "license": "ISC", "devDependencies": { "autoprefixer": "^10.4.23", - "daisyui": "^5.5.1-beta.2", "postcss": "^8.5.6", "tailwindcss": "^4.1.18" }, diff --git a/frontend/src/components/layout/sidebar.rs b/frontend/src/components/layout/sidebar.rs index 65058fe..d6a0c3d 100644 --- a/frontend/src/components/layout/sidebar.rs +++ b/frontend/src/components/layout/sidebar.rs @@ -51,10 +51,9 @@ pub fn Sidebar() -> impl IntoView { }; let close_drawer = move || { - if let Some(element) = document().get_element_by_id("my-drawer") { - if let Ok(input) = element.dyn_into::() { - input.set_checked(false); - } + // With Shadcn Sheet, this logic might change, but for now we keep DOM manipulation minimal or handled by parent + if let Some(element) = document().get_element_by_id("mobile-sheet-trigger") { + // Logic to close sheet if open (simulated click or state change) } }; @@ -64,10 +63,11 @@ pub fn Sidebar() -> impl IntoView { }; let filter_class = move |f: crate::store::FilterStatus| { + let base = "w-full justify-start gap-2 h-9 px-4 py-2 inline-flex items-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"; if store.filter.get() == f { - "active" + format!("{} bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", base) } else { - "" + format!("{} hover:bg-accent hover:text-accent-foreground text-muted-foreground", base) } }; @@ -89,80 +89,77 @@ pub fn Sidebar() -> impl IntoView { }; view! { -
-
- +
+
+
+ "VibeTorrent" +
+
+

"Filters"

+ + + + + + + + + + + + +
-
+
-
-
- {first_letter} -
+
+ + {first_letter} +
-
{username}
-
"Online"
+
{username}
+
"Online"
- - } - }).collect::>() - } - + // --- UPLOAD SPEED DROPDOWN --- -
-
- } - } /> + +
+
+ + +
+ } + } + } /> +
@@ -252,17 +307,16 @@ fn TorrentRow( let t = torrent.get().unwrap(); let t_hash = hash.clone(); let t_name = t.name.clone(); - let status_class = match t.status { shared::TorrentStatus::Seeding => "text-success", shared::TorrentStatus::Downloading => "text-primary", shared::TorrentStatus::Paused => "text-warning", shared::TorrentStatus::Error => "text-error", _ => "text-base-content/50" }; - let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" }; - + let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" }; + let selected_hash_clone = selected_hash.clone(); let t_hash_row = t_hash.clone(); view! { - - {t_name.clone()} - {format_bytes(t.size)} - +
{t_name.clone()}
+
{format_bytes(t.size)}
+
- - {format!("{:.1}%", t.percent_complete)} +
+
+
+ {format!("{:.1}%", t.percent_complete)}
- - {format!("{:?}", t.status)} - {format_speed(t.down_rate)} - {format_speed(t.up_rate)} - {format_duration(t.eta)} - {format_date(t.added_date)} - +
+
{format!("{:?}", t.status)}
+
{format_speed(t.down_rate)}
+
{format_speed(t.up_rate)}
+
{format_duration(t.eta)}
+
{format_date(t.added_date)}
+
} } } @@ -318,7 +374,7 @@ fn TorrentCard( let t = torrent.get().unwrap(); let t_hash = hash.clone(); let t_name = t.name.clone(); - let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "badge-success badge-soft", shared::TorrentStatus::Downloading => "badge-primary badge-soft", shared::TorrentStatus::Paused => "badge-warning badge-soft", shared::TorrentStatus::Error => "badge-error badge-soft", _ => "badge-ghost" }; + let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" }; let t_hash_long = t_hash.clone(); let set_menu_position = set_menu_position.clone(); @@ -340,7 +396,7 @@ fn TorrentCard( view! {
-
+

{t_name.clone()}

-
{format!("{:?}", t.status)}
+
{format!("{:?}", t.status)}
-
+
{format_bytes(t.size)} {format!("{:.1}%", t.percent_complete)}
- +
+
+
-
-
"DL"{format_speed(t.down_rate)}
-
"UP"{format_speed(t.up_rate)}
+
+
"DL"{format_speed(t.down_rate)}
+
"UP"{format_speed(t.up_rate)}
"ETA"{format_duration(t.eta)}
"DATE"{format_date(t.added_date)}
diff --git a/frontend/src/store.rs b/frontend/src/store.rs index 95183f5..150a2ed 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -4,7 +4,8 @@ use leptos::prelude::*; use leptos::task::spawn_local; use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent}; use std::collections::HashMap; -use serde::{Serialize, Deserialize}; +use struct_patch::traits::Patchable; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; #[derive(Clone, Debug, PartialEq)] pub struct NotificationItem { @@ -116,44 +117,44 @@ pub fn provide_torrent_store() { } if let Some(data_str) = msg.data().as_string() { - log::debug!("SSE: Parsing JSON: {}", data_str); - if let Ok(event) = serde_json::from_str::(&data_str) { - match event { - AppEvent::FullList { torrents: list, .. } => { - log::info!("SSE: Received FullList with {} torrents", list.len()); - torrents_for_sse.update(|map| { - let new_hashes: std::collections::HashSet = list.iter().map(|t| t.hash.clone()).collect(); - map.retain(|hash, _| new_hashes.contains(hash)); - for new_torrent in list { - map.insert(new_torrent.hash.clone(), new_torrent); + // Decode Base64 + match BASE64.decode(&data_str) { + Ok(bytes) => { + // Deserialize MessagePack + match rmp_serde::from_slice::(&bytes) { + Ok(event) => { + match event { + AppEvent::FullList { torrents: list, .. } => { + log::info!("SSE: Received FullList with {} torrents", list.len()); + torrents_for_sse.update(|map| { + let new_hashes: std::collections::HashSet = list.iter().map(|t| t.hash.clone()).collect(); + map.retain(|hash, _| new_hashes.contains(hash)); + for new_torrent in list { + map.insert(new_torrent.hash.clone(), new_torrent); + } + }); + log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len())); + } + AppEvent::Update(patch) => { + torrents_for_sse.update(|map| { + if let Some(t) = map.get_mut(&patch.hash) { + t.apply(patch); + } + }); + } + AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); } + AppEvent::Notification(n) => { + show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone()); + if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error { + show_browser_notification("VibeTorrent", &n.message); + } + } } - }); - log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len())); - } - AppEvent::Update(update) => { - torrents_for_sse.update(|map| { - if let Some(t) = map.get_mut(&update.hash) { - if let Some(v) = update.name { t.name = v; } - if let Some(v) = update.size { t.size = v; } - if let Some(v) = update.down_rate { t.down_rate = v; } - if let Some(v) = update.up_rate { t.up_rate = v; } - if let Some(v) = update.percent_complete { t.percent_complete = v; } - if let Some(v) = update.completed { t.completed = v; } - if let Some(v) = update.eta { t.eta = v; } - if let Some(v) = update.status { t.status = v; } - if let Some(v) = update.error_message { t.error_message = v; } - if let Some(v) = update.label { t.label = Some(v); } - } - }); - } - AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); } - AppEvent::Notification(n) => { - show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone()); - if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error { - show_browser_notification("VibeTorrent", &n.message); } + Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e), } } + Err(e) => log::error!("SSE: Failed to decode Base64: {}", e), } } } diff --git a/shared/Cargo.toml b/shared/Cargo.toml index fd63801..01de061 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -21,6 +21,8 @@ hydrate = ["leptos/hydrate"] [dependencies] serde = { version = "1.0", features = ["derive"] } utoipa = { version = "5.4.0", features = ["axum_extras"] } +struct_patch = "0.5" +rmp-serde = "1.3" # Leptos 0.8.7 leptos = { version = "0.8.7", features = ["nightly"] } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 6a8fef6..dfeb95b 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use struct_patch::Patch; use utoipa::ToSchema; #[cfg(feature = "ssr")] @@ -23,7 +24,8 @@ pub struct DbContext { pub db: db::Db, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Patch)] +#[patch_derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct Torrent { pub hash: String, pub name: String, @@ -50,14 +52,20 @@ pub enum TorrentStatus { } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] -#[serde(tag = "type", content = "data")] +#[serde(tag = "t", content = "d")] pub enum AppEvent { + #[serde(rename = "f")] FullList { + #[serde(rename = "t")] torrents: Vec, + #[serde(rename = "ts")] timestamp: u64, }, + #[serde(rename = "u")] Update(TorrentUpdate), + #[serde(rename = "s")] Stats(GlobalStats), + #[serde(rename = "n")] Notification(SystemNotification), }