Compare commits

..

3 Commits

Author SHA1 Message Date
spinline
dd3b3f3504 fix: resolve all compilation errors in push notification logic and stabilize UI components
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 13:26:22 +03:00
spinline
bb9e06c9ed fix: resolve syntax error and restore sidebar content in sidebar.rs
Some checks failed
Build MIPS Binary / build (push) Failing after 37s
2026-02-13 13:13:14 +03:00
spinline
a834d185e3 feat: add Notifications switch to sidebar and implement robust push subscription logic
Some checks failed
Build MIPS Binary / build (push) Failing after 27s
2026-02-13 13:09:54 +03:00
5 changed files with 236 additions and 75 deletions

View File

@@ -3,6 +3,7 @@ use leptos::task::spawn_local;
use crate::components::ui::sidenav::*;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::theme_toggle::ThemeToggle;
use crate::components::ui::switch::Switch;
#[component]
pub fn Sidebar() -> impl IntoView {
@@ -65,6 +66,19 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
let on_push_toggle = move |checked: bool| {
spawn_local(async move {
if checked {
crate::store::subscribe_to_push_notifications().await;
} else {
crate::store::unsubscribe_from_push_notifications().await;
}
if let Ok(enabled) = crate::store::is_push_subscribed().await {
store.push_enabled.set(enabled);
}
});
};
view! {
<SidenavHeader>
<div class="flex items-center gap-2 px-2 py-4">
@@ -133,35 +147,49 @@ pub fn Sidebar() -> impl IntoView {
</SidenavContent>
<SidenavFooter>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
<div class="flex flex-col gap-4 p-4">
// Push Notification Toggle
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
</div>
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<div class="flex items-center gap-1">
<ThemeToggle />
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</Button>
<div class="flex items-center gap-1">
<ThemeToggle />
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</Button>
</div>
</div>
</div>
</SidenavFooter>

View File

@@ -17,6 +17,7 @@ pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod svg_icon;
pub mod switch;
pub mod table;
pub mod theme_toggle;
pub mod toast;

View File

@@ -0,0 +1,42 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[component]
pub fn Switch(
#[prop(into)] checked: Signal<bool>,
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
#[prop(into, optional)] class: String,
#[prop(into, optional)] disabled: Signal<bool>,
) -> impl IntoView {
let checked_val = move || checked.get();
let disabled_val = move || disabled.get();
let track_class = move || tw_merge!(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
if checked_val() { "bg-primary" } else { "bg-input" },
class.clone()
);
let thumb_class = move || tw_merge!(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
if checked_val() { "translate-x-4" } else { "translate-x-0" }
);
view! {
<button
type="button"
role="switch"
aria-checked=move || checked_val().to_string()
disabled=disabled_val
class=track_class
on:click=move |e| {
e.prevent_default();
if let Some(cb) = on_checked_change {
cb.run(!checked_val());
}
}
>
<span class=thumb_class />
</button>
}
}

View File

@@ -5,7 +5,9 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast};
@@ -24,8 +26,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
toast(msg, variant);
}
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
@@ -54,6 +54,7 @@ pub struct TorrentStore {
pub global_stats: RwSignal<GlobalStats>,
pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>,
pub push_enabled: RwSignal<bool>,
}
pub fn provide_torrent_store() {
@@ -63,12 +64,20 @@ pub fn provide_torrent_store() {
let global_stats = RwSignal::new(GlobalStats::default());
let user = RwSignal::new(Option::<String>::None);
let selected_torrent = RwSignal::new(Option::<String>::None);
let push_enabled = RwSignal::new(false);
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent, push_enabled };
provide_context(store);
// Initial check for push status
spawn_local(async move {
if let Ok(enabled) = is_push_subscribed().await {
push_enabled.set(enabled);
}
});
let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone();
@@ -79,17 +88,12 @@ pub fn provide_torrent_store() {
let mut disconnect_notified = false;
loop {
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing...");
if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
@@ -101,47 +105,30 @@ pub fn provide_torrent_store() {
}
if let Some(data_str) = msg.data().as_string() {
// Decode Base64
match BASE64.decode(&data_str) {
Ok(bytes) => {
// Deserialize MessagePack
match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => {
match event {
AppEvent::FullList(list, _) => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = 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) => {
let hash_opt = patch.hash.clone();
if let Some(hash) = hash_opt {
torrents_for_sse.update(|map| {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch);
}
});
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
if let Ok(bytes) = BASE64.decode(&data_str) {
if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
match event {
AppEvent::FullList(list, _) => {
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = 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); }
});
}
AppEvent::Update(patch) => {
if let Some(hash) = patch.hash.clone() {
torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(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),
}
}
}
@@ -158,13 +145,106 @@ pub fn provide_torrent_store() {
}
}
}
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
}
});
}
pub async fn subscribe_to_push_notifications() {
// ...
pub async fn is_push_subscribed() -> Result<bool, String> {
let window = web_sys::window().ok_or("no window")?;
let navigator = window.navigator();
let sw_container = navigator.service_worker();
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().map_err(|e| format!("{:?}", e))?)
.await
.map_err(|e| format!("{:?}", e))?
.dyn_into::<web_sys::ServiceWorkerRegistration>()
.map_err(|_| "not a registration")?;
let push_manager = registration.push_manager().map_err(|e| format!("{:?}", e))?;
let subscription = wasm_bindgen_futures::JsFuture::from(push_manager.get_subscription().map_err(|e| format!("{:?}", e))?)
.await
.map_err(|e| format!("{:?}", e))?;
Ok(!subscription.is_null())
}
pub async fn subscribe_to_push_notifications() {
let window = web_sys::window().expect("no window");
let navigator = window.navigator();
let sw_container = navigator.service_worker();
let registration = match wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await {
Ok(reg) => reg.dyn_into::<web_sys::ServiceWorkerRegistration>().expect("not a reg"),
Err(e) => { log::error!("SW Ready Error: {:?}", e); return; }
};
// 1. Get Public Key from Backend
let public_key_res: Result<String, _> = shared::server_fns::push::get_public_key().await;
let public_key = match public_key_res {
Ok(key) => key,
Err(e) => { log::error!("Failed to get public key: {:?}", e); return; }
};
// 2. Convert base64 key to Uint8Array
let decoded_key = BASE64_URL.decode(public_key.trim()).expect("invalid public key");
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
// 3. Prepare Options
let mut options = web_sys::PushSubscriptionOptionsInit::new();
options.set_user_visible_only(true);
options.set_application_server_key(&key_array.into());
// 4. Subscribe
let push_manager = registration.push_manager().expect("no push manager");
match wasm_bindgen_futures::JsFuture::from(push_manager.subscribe_with_options(&options).expect("subscribe failed")).await {
Ok(subscription) => {
let sub_js = subscription.clone();
// Use JS to extract JSON string representation
let json_str = js_sys::JSON::stringify(&sub_js).expect("stringify failed").as_string().expect("not a string");
let sub_obj: serde_json::Value = serde_json::from_str(&json_str).expect("serde from str failed");
let endpoint = sub_obj["endpoint"].as_str().expect("no endpoint").to_string();
let p256dh = sub_obj["keys"]["p256dh"].as_str().expect("no p256dh").to_string();
let auth = sub_obj["keys"]["auth"].as_str().expect("no auth").to_string();
// 5. Save to Backend
match shared::server_fns::push::subscribe_push(endpoint, p256dh, auth).await {
Ok(_) => {
log::info!("Push subscription saved successfully");
toast_success("Bildirimler aktif edildi");
}
Err(e) => log::error!("Failed to save subscription: {:?}", e),
}
}
Err(e) => log::error!("Subscription Error: {:?}", e),
}
}
pub async fn unsubscribe_from_push_notifications() {
let window = web_sys::window().expect("no window");
let sw_container = window.navigator().service_worker();
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await
.unwrap().dyn_into::<web_sys::ServiceWorkerRegistration>().unwrap();
let push_manager = registration.push_manager().unwrap();
if let Ok(sub_future) = push_manager.get_subscription() {
if let Ok(subscription) = wasm_bindgen_futures::JsFuture::from(sub_future).await {
if !subscription.is_null() {
let sub = subscription.dyn_into::<web_sys::PushSubscription>().unwrap();
let endpoint = sub.endpoint();
// 1. Unsubscribe in Browser
let _ = wasm_bindgen_futures::JsFuture::from(sub.unsubscribe().unwrap()).await;
// 2. Remove from Backend
let _ = shared::server_fns::push::unsubscribe_push(endpoint).await;
log::info!("Push subscription removed");
show_toast(NotificationLevel::Info, "Bildirimler kapatıldı");
}
}
}
}

View File

@@ -20,3 +20,13 @@ pub async fn subscribe_push(
.await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
}
#[server(UnsubscribePush, "/api/server_fns")]
pub async fn unsubscribe_push(endpoint: String) -> Result<(), ServerFnError> {
let db_ctx = expect_context::<crate::DbContext>();
db_ctx
.db
.remove_push_subscription(&endpoint)
.await
.map_err(|e| ServerFnError::new(format!("Failed to remove subscription: {}", e)))
}