diff --git a/Cargo.lock b/Cargo.lock index abfdcaa..c81bb7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,7 @@ dependencies = [ "tailwind_fuse", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index ecd9fa2..2ab998d 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -18,6 +18,7 @@ serde_json = "1" gloo-net = "0.5" gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" uuid = { version = "1", features = ["v4", "js"] } futures = "0.3" chrono = { version = "0.4", features = ["serde"] } @@ -37,7 +38,10 @@ web-sys = { version = "0.3", features = [ "TouchEvent", "TouchList", "Touch", - "Navigator" + "Navigator", + "Notification", + "NotificationOptions", + "NotificationPermission" ] } shared = { path = "../shared" } tailwind_fuse = "0.3.2" diff --git a/frontend/index.html b/frontend/index.html index 5b8abc0..bb628df 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,6 +20,8 @@ + + - --> diff --git a/frontend/manifest.json b/frontend/manifest.json index 9fad16c..2860ca0 100644 --- a/frontend/manifest.json +++ b/frontend/manifest.json @@ -1,11 +1,13 @@ { "name": "VibeTorrent", "short_name": "VibeTorrent", + "description": "Modern web-based torrent client with real-time updates", "start_url": "/", "display": "standalone", "background_color": "#1d232a", "theme_color": "#1d232a", "orientation": "any", + "categories": ["productivity", "utilities"], "icons": [ { "src": "icon-512.png", diff --git a/frontend/src/store.rs b/frontend/src/store.rs index a6f4e90..9e5aeb9 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -213,7 +213,30 @@ pub fn provide_torrent_store() { global_stats.set(stats); } AppEvent::Notification(n) => { - show_toast_with_signal(notifications, n.level, n.message); + // Show toast notification + show_toast_with_signal(notifications, n.level.clone(), n.message.clone()); + + // Show browser notification for critical events + // (torrent completed, connection lost/restored, errors) + let is_critical = n.message.contains("tamamlandı") + || n.message.contains("Reconnected") + || n.message.contains("yeniden kuruldu") + || n.message.contains("Lost connection") + || n.level == shared::NotificationLevel::Error; + + if is_critical { + let title = match n.level { + shared::NotificationLevel::Success => "✅ VibeTorrent", + shared::NotificationLevel::Error => "❌ VibeTorrent", + shared::NotificationLevel::Warning => "⚠️ VibeTorrent", + shared::NotificationLevel::Info => "ℹ️ VibeTorrent", + }; + + crate::utils::notification::show_notification_if_enabled( + title, + &n.message + ); + } } } } diff --git a/frontend/src/utils/mod.rs b/frontend/src/utils/mod.rs index 197ad25..2e566f3 100644 --- a/frontend/src/utils/mod.rs +++ b/frontend/src/utils/mod.rs @@ -1,5 +1,7 @@ use tailwind_fuse::merge::tw_merge; +pub mod notification; + pub fn cn(classes: impl AsRef) -> String { tw_merge(classes.as_ref()) } diff --git a/frontend/src/utils/notification.rs b/frontend/src/utils/notification.rs new file mode 100644 index 0000000..337557f --- /dev/null +++ b/frontend/src/utils/notification.rs @@ -0,0 +1,113 @@ +use wasm_bindgen::prelude::*; +use web_sys::{Notification, NotificationOptions}; + +/// Request browser notification permission from user +pub async fn request_notification_permission() -> bool { + let window = web_sys::window().expect("no global window"); + + // Check if Notification API is available + if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) { + let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification")) + .expect("Notification should exist"); + + // Request permission + let promise = js_sys::Reflect::get(¬ification, &JsValue::from_str("requestPermission")) + .expect("requestPermission should exist"); + + if let Ok(function) = promise.dyn_into::() { + if let Ok(promise) = function.call0(¬ification) { + if let Ok(promise) = promise.dyn_into::() { + let result = wasm_bindgen_futures::JsFuture::from(promise).await; + + if let Ok(permission) = result { + let permission_str = permission.as_string().unwrap_or_default(); + return permission_str == "granted"; + } + } + } + } + } + + false +} + +/// Check if browser notifications are supported and permitted +pub fn is_notification_supported() -> bool { + let window = web_sys::window().expect("no global window"); + js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) +} + +/// Get current notification permission status +pub fn get_notification_permission() -> String { + if !is_notification_supported() { + return "unsupported".to_string(); + } + + let window = web_sys::window().expect("no global window"); + let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification")) + .expect("Notification should exist"); + + let permission = js_sys::Reflect::get(¬ification, &JsValue::from_str("permission")) + .unwrap_or(JsValue::from_str("default")); + + permission.as_string().unwrap_or("default".to_string()) +} + +/// Show a browser notification +/// Returns true if notification was shown successfully +pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool { + // Check permission first + let permission = get_notification_permission(); + if permission != "granted" { + log::warn!("Notification permission not granted: {}", permission); + return false; + } + + // Create notification options + let opts = NotificationOptions::new(); + opts.set_body(body); + opts.set_icon(icon.unwrap_or("/icon-192.png")); + opts.set_badge("/icon-192.png"); + opts.set_tag("vibetorrent"); + opts.set_require_interaction(false); + opts.set_silent(Some(false)); + + // Create and show notification + match Notification::new_with_options(title, &opts) { + Ok(notification) => { + log::info!("Browser notification shown: {}", title); + + // Auto-close after 5 seconds + let _ = gloo_timers::callback::Timeout::new(5000, move || { + notification.close(); + }).forget(); + + true + } + Err(e) => { + log::error!("Failed to create notification: {:?}", e); + false + } + } +} + +/// Show notification only if enabled in settings and permission granted +pub fn show_notification_if_enabled(title: &str, body: &str) -> bool { + // Check localStorage for user preference + let window = web_sys::window().expect("no global window"); + let storage = window.local_storage().ok().flatten(); + + if let Some(storage) = storage { + let enabled = storage + .get_item("vibetorrent_browser_notifications") + .ok() + .flatten() + .unwrap_or("true".to_string()); + + if enabled == "true" { + return show_browser_notification(title, body, None); + } + } + + false +} diff --git a/frontend/sw.js b/frontend/sw.js index 18fb62e..238a1a1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -2,40 +2,116 @@ const CACHE_NAME = 'vibetorrent-v1'; const ASSETS_TO_CACHE = [ '/', '/index.html', - '/vibetorrent_frontend.js', - '/vibetorrent_frontend_bg.wasm', - '/tailwind.css', '/manifest.json', '/icon-192.png', '/icon-512.png' ]; +// Install event - cache assets self.addEventListener('install', (event) => { + console.log('[Service Worker] Installing...'); event.waitUntil( caches.open(CACHE_NAME).then((cache) => { + console.log('[Service Worker] Caching static assets'); return cache.addAll(ASSETS_TO_CACHE); + }).then(() => { + console.log('[Service Worker] Skip waiting'); + return self.skipWaiting(); }) ); }); -self.addEventListener('fetch', (event) => { - event.respondWith( - caches.match(event.request).then((response) => { - return response || fetch(event.request); - }) - ); -}); - +// Activate event - clean old caches self.addEventListener('activate', (event) => { + console.log('[Service Worker] Activating...'); event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((key) => { if (key !== CACHE_NAME) { + console.log('[Service Worker] Deleting old cache:', key); return caches.delete(key); } }) ); + }).then(() => { + console.log('[Service Worker] Claiming clients'); + return self.clients.claim(); }) ); }); + +// Fetch event - network first, cache fallback for API calls +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Network-first strategy for API calls + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(event.request) + .catch(() => { + // Could return cached API response or offline page + return new Response( + JSON.stringify({ error: 'Offline' }), + { headers: { 'Content-Type': 'application/json' } } + ); + }) + ); + return; + } + + // Cache-first strategy for static assets + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request).then((fetchResponse) => { + // Optionally cache new requests + if (fetchResponse && fetchResponse.status === 200) { + const responseToCache = fetchResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseToCache); + }); + } + return fetchResponse; + }); + }) + ); +}); + +// Notification click event - focus or open app +self.addEventListener('notificationclick', (event) => { + console.log('[Service Worker] Notification clicked:', event.notification.tag); + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // If app is already open, focus it + for (let client of clientList) { + if (client.url === '/' && 'focus' in client) { + return client.focus(); + } + } + // Otherwise open new window + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); + +// Push notification event (for future use) +self.addEventListener('push', (event) => { + console.log('[Service Worker] Push received'); + const data = event.data ? event.data.json() : {}; + + const options = { + body: data.message || 'New notification', + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: data.tag || 'vibetorrent-notification', + requireInteraction: false, + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'VibeTorrent', options) + ); +});