feat(frontend): add clean-code toast notifications

This commit is contained in:
spinline
2026-02-05 20:40:11 +03:00
parent 6e4a9e2d51
commit 6c7379483e
4 changed files with 170 additions and 30 deletions

View File

@@ -1,35 +1,89 @@
use leptos::*; use leptos::*;
use shared::NotificationLevel; use shared::NotificationLevel;
// ============================================================================
// Toast Icons (Clean Code: Separation of Concerns)
// ============================================================================
/// Returns the appropriate SVG icon for the notification level
fn get_toast_icon(level: &NotificationLevel) -> impl IntoView {
match level {
NotificationLevel::Info => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}.into_view(),
NotificationLevel::Success => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}.into_view(),
NotificationLevel::Warning => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
}.into_view(),
NotificationLevel::Error => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}.into_view(),
}
}
/// Returns the DaisyUI alert class for the notification level
fn get_alert_class(level: &NotificationLevel) -> &'static str {
match level {
NotificationLevel::Info => "alert-info",
NotificationLevel::Success => "alert-success",
NotificationLevel::Warning => "alert-warning",
NotificationLevel::Error => "alert-error",
}
}
// ============================================================================
// Toast Components (Clean Code: Single Responsibility)
// ============================================================================
/// Individual toast item component
#[component] #[component]
pub fn ToastContainer() -> impl IntoView { fn ToastItem(
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); level: NotificationLevel,
let notifications = store.notifications; message: String,
) -> impl IntoView {
let alert_class = get_alert_class(&level);
view! { view! {
<div class="toast toast-end toast-bottom z-[9999]"> <div class={format!(
{move || notifications.get().into_iter().map(|item| { "alert {} shadow-lg min-w-[280px] max-w-[400px] transition-all duration-300 animate-in slide-in-from-right fade-in",
let alert_class = match item.notification.level { alert_class
NotificationLevel::Info => "alert-info", )}>
NotificationLevel::Success => "alert-success", {get_toast_icon(&level)}
NotificationLevel::Warning => "alert-warning", <span class="text-sm font-medium truncate">{message}</span>
NotificationLevel::Error => "alert-error", </div>
}; }
}
let icon = match item.notification.level {
NotificationLevel::Info => view! { <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> }, /// Main toast container - renders all active notifications
NotificationLevel::Success => view! { <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, #[component]
NotificationLevel::Warning => view! { <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> }, pub fn ToastContainer() -> impl IntoView {
NotificationLevel::Error => view! { <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
}; let notifications = store.notifications;
view! { view! {
<div class={format!("alert {} shadow-lg transition-all duration-300 animate-in slide-in-from-bottom-5 fade-in", alert_class)}> <div class="toast toast-end toast-bottom z-[9999] gap-2">
{icon} <For
<span>{item.notification.message}</span> each=move || notifications.get()
</div> key=|item| item.id
} children=move |item| {
}).collect::<Vec<_>>()} view! {
<ToastItem
level=item.notification.level
message=item.notification.message
/>
}
}
/>
</div> </div>
} }
} }

View File

@@ -1,5 +1,6 @@
use leptos::*; use leptos::*;
use leptos::html::Dialog; use leptos::html::Dialog;
use crate::store::{toast_success, toast_error, toast_warning};
#[component] #[component]
@@ -22,6 +23,7 @@ pub fn AddTorrentModal(
let handle_submit = move |_| { let handle_submit = move |_| {
let uri_val = uri.get(); let uri_val = uri.get();
if uri_val.is_empty() { if uri_val.is_empty() {
toast_warning("Lütfen bir Magnet URI veya URL girin");
set_error_msg.set(Some("Please enter a Magnet URI or URL".to_string())); set_error_msg.set(Some("Please enter a Magnet URI or URL".to_string()));
return; return;
} }
@@ -42,6 +44,7 @@ pub fn AddTorrentModal(
Ok(resp) => { Ok(resp) => {
if resp.ok() { if resp.ok() {
logging::log!("Torrent added successfully"); logging::log!("Torrent added successfully");
toast_success("Torrent eklendi");
set_loading.set(false); set_loading.set(false);
if let Some(dialog) = dialog_ref.get() { if let Some(dialog) = dialog_ref.get() {
dialog.close(); dialog.close();
@@ -51,12 +54,14 @@ pub fn AddTorrentModal(
let status = resp.status(); let status = resp.status();
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
logging::error!("Failed to add torrent: {} - {}", status, text); logging::error!("Failed to add torrent: {} - {}", status, text);
toast_error("Torrent eklenemedi");
set_error_msg.set(Some(format!("Error {}: {}", status, text))); set_error_msg.set(Some(format!("Error {}: {}", status, text)));
set_loading.set(false); set_loading.set(false);
} }
} }
Err(e) => { Err(e) => {
logging::error!("Network error: {}", e); logging::error!("Network error: {}", e);
toast_error("Bağlantı hatası");
set_error_msg.set(Some(format!("Network Error: {}", e))); set_error_msg.set(Some(format!("Network Error: {}", e)));
set_loading.set(false); set_loading.set(false);
} }
@@ -64,6 +69,7 @@ pub fn AddTorrentModal(
} }
Err(e) => { Err(e) => {
logging::error!("Serialization error: {}", e); logging::error!("Serialization error: {}", e);
toast_error("İstek hatası");
set_error_msg.set(Some(format!("Request Error: {}", e))); set_error_msg.set(Some(format!("Request Error: {}", e)));
set_loading.set(false); set_loading.set(false);
} }

View File

@@ -1,6 +1,7 @@
use leptos::*; use leptos::*;
use wasm_bindgen::closure::Closure; use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use crate::store::{get_action_messages, toast_success, toast_error};
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -181,6 +182,11 @@ pub fn TorrentTable() -> impl IntoView {
logging::log!("TorrentTable Action: {} on {}", action, hash); logging::log!("TorrentTable Action: {} on {}", action, hash);
set_menu_visible.set(false); // Close menu immediately set_menu_visible.set(false); // Close menu immediately
// Get action messages for toast (Clean Code: DRY)
let (success_msg, error_msg) = get_action_messages(&action);
let success_msg = success_msg.to_string();
let error_msg = error_msg.to_string();
spawn_local(async move { spawn_local(async move {
let action_req = if action == "delete_with_data" { let action_req = if action == "delete_with_data" {
"delete_with_data" "delete_with_data"
@@ -204,13 +210,21 @@ pub fn TorrentTable() -> impl IntoView {
resp.status(), resp.status(),
resp.status_text() resp.status_text()
); );
toast_error(error_msg);
} else { } else {
logging::log!("Action {} executed successfully", action); logging::log!("Action {} executed successfully", action);
toast_success(success_msg);
} }
} }
Err(e) => logging::error!("Network error executing action: {}", e), Err(e) => {
logging::error!("Network error executing action: {}", e);
toast_error(format!("{}: Bağlantı hatası", error_msg));
}
}, },
Err(e) => logging::error!("Failed to serialize request: {}", e), Err(e) => {
logging::error!("Failed to serialize request: {}", e);
toast_error(error_msg);
}
} }
}); });
}; };

View File

@@ -1,7 +1,7 @@
use futures::StreamExt; use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource; use gloo_net::eventsource::futures::EventSource;
use leptos::*; use leptos::*;
use shared::{AppEvent, GlobalStats, SystemNotification, Torrent}; use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem { pub struct NotificationItem {
@@ -9,6 +9,72 @@ pub struct NotificationItem {
pub notification: SystemNotification, pub notification: SystemNotification,
} }
// ============================================================================
// Toast Helper Functions (Clean Code: Single Responsibility)
// ============================================================================
/// Shows a toast notification with the given level and message.
/// Auto-removes after 5 seconds.
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
if let Some(store) = use_context::<TorrentStore>() {
let id = js_sys::Date::now() as u64;
let notification = SystemNotification {
level,
message: message.into(),
};
let item = NotificationItem { id, notification };
store.notifications.update(|list| list.push(item));
// Auto-remove after 5 seconds
let notifications = store.notifications;
let _ = set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
},
std::time::Duration::from_secs(5),
);
}
}
/// Convenience function for success toasts
pub fn toast_success(message: impl Into<String>) {
show_toast(NotificationLevel::Success, message);
}
/// Convenience function for error toasts
pub fn toast_error(message: impl Into<String>) {
show_toast(NotificationLevel::Error, message);
}
/// Convenience function for info toasts
pub fn toast_info(message: impl Into<String>) {
show_toast(NotificationLevel::Info, message);
}
/// Convenience function for warning toasts
pub fn toast_warning(message: impl Into<String>) {
show_toast(NotificationLevel::Warning, message);
}
// ============================================================================
// Action Message Mapping (Clean Code: DRY Principle)
// ============================================================================
/// Maps torrent action strings to user-friendly Turkish messages.
/// Returns (success_message, error_message)
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
match action {
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
"stop" => ("Torrent durduruldu", "Torrent durdurulamadı"),
"pause" => ("Torrent duraklatıldı", "Torrent duraklatılamadı"),
"delete" => ("Torrent silindi", "Torrent silinemedi"),
"delete_with_data" => ("Torrent ve verileri silindi", "Torrent silinemedi"),
"recheck" => ("Torrent kontrol ediliyor", "Kontrol başlatılamadı"),
_ => ("İşlem tamamlandı", "İşlem başarısız"),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterStatus { pub enum FilterStatus {
All, All,