Compare commits
17 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3b3f3504 | ||
|
|
bb9e06c9ed | ||
|
|
a834d185e3 | ||
|
|
4e81565ab6 | ||
|
|
795eef4bda | ||
|
|
3ad8424d17 | ||
|
|
83feb5a5cf | ||
|
|
0dd97f3d7e | ||
|
|
bb32c1f7f6 | ||
|
|
3bb2d68a65 | ||
|
|
fe117cdaec | ||
|
|
e062a3c8cd | ||
|
|
ae2c9c934d | ||
|
|
f7e1356eae | ||
|
|
98b1f059c7 | ||
|
|
a3735d0931 | ||
|
|
55f00729ee |
@@ -7,6 +7,7 @@ use rust_embed::RustEmbed;
|
||||
|
||||
pub mod auth;
|
||||
pub mod setup;
|
||||
pub mod notifications;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../frontend/dist"]
|
||||
|
||||
54
backend/src/handlers/notifications.rs
Normal file
54
backend/src/handlers/notifications.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use axum::{
|
||||
extract::{State, Query},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use shared::{AppEvent, SystemNotification, NotificationLevel};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TorrentFinishedQuery {
|
||||
pub name: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
pub async fn torrent_finished_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TorrentFinishedQuery>,
|
||||
) -> StatusCode {
|
||||
tracing::info!("WEBHOOK: Received notification from rTorrent. Name: {:?}, Hash: {:?}", params.name, params.hash);
|
||||
|
||||
let torrent_name = if params.name.is_empty() || params.name == "$d.name=" {
|
||||
"Bilinmeyen Torrent".to_string()
|
||||
} else {
|
||||
params.name.clone()
|
||||
};
|
||||
|
||||
let message = format!("Torrent tamamlandı: {}", torrent_name);
|
||||
|
||||
// 1. Send to active SSE clients (for Toast)
|
||||
let notification = SystemNotification {
|
||||
level: NotificationLevel::Success,
|
||||
message: message.clone(),
|
||||
};
|
||||
let _ = state.event_bus.send(AppEvent::Notification(notification));
|
||||
|
||||
// 2. Send Web Push Notification (for Background)
|
||||
#[cfg(feature = "push-notifications")]
|
||||
{
|
||||
let push_store = state.push_store.clone();
|
||||
let title = "Torrent Tamamlandı".to_string();
|
||||
let body = message;
|
||||
let name_for_log = torrent_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("Attempting to send Web Push notification for torrent: {}", name_for_log);
|
||||
match crate::push::send_push_notification(&push_store, &title, &body).await {
|
||||
Ok(_) => tracing::info!("Web Push notification task completed for: {}", name_for_log),
|
||||
Err(e) => tracing::error!("Failed to send Web Push notification for {}: {:?}", name_for_log, e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
@@ -60,6 +60,7 @@ async fn auth_middleware(
|
||||
|| path.starts_with("/api/server_fns/get_setup_status")
|
||||
|| path.starts_with("/api/server_fns/Setup")
|
||||
|| path.starts_with("/api/server_fns/setup")
|
||||
|| path.starts_with("/api/internal/")
|
||||
|| path.starts_with("/swagger-ui")
|
||||
|| path.starts_with("/api-docs")
|
||||
|| !path.starts_with("/api/")
|
||||
@@ -313,7 +314,7 @@ async fn main() {
|
||||
let loop_interval = if active_clients > 0 {
|
||||
Duration::from_secs(1)
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
Duration::from_secs(60)
|
||||
};
|
||||
|
||||
// 1. Fetch Torrents
|
||||
@@ -434,6 +435,7 @@ async fn main() {
|
||||
let db_for_ctx = db.clone();
|
||||
let app = app
|
||||
.route("/api/events", get(sse::sse_handler))
|
||||
.route("/api/internal/torrent-finished", post(handlers::notifications::torrent_finished_handler))
|
||||
.route("/api/server_fns/{*fn_name}", post({
|
||||
let scgi_path = scgi_path_for_ctx.clone();
|
||||
let db = db_for_ctx.clone();
|
||||
|
||||
@@ -191,11 +191,21 @@ pub async fn send_push_notification(
|
||||
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
|
||||
let err_msg = format!("{:?}", e);
|
||||
tracing::error!("Delivery failed for {}: {}", subscription.endpoint, err_msg);
|
||||
// Always remove on delivery failure (Gone, Unauthorized, etc.)
|
||||
tracing::info!("Removing problematic subscription after delivery failure: {}", subscription.endpoint);
|
||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to build push message: {}", e),
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", e);
|
||||
tracing::error!("Encryption/Build failed for {}: {}", subscription.endpoint, err_msg);
|
||||
// Always remove on encryption failure
|
||||
tracing::info!("Removing problematic subscription after encryption failure: {}", subscription.endpoint);
|
||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::components::auth::setup::Setup;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::components::{Router, Routes, Route};
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use leptos_router::hooks::{use_navigate, use_location};
|
||||
use crate::components::ui::toast::Toaster;
|
||||
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||
|
||||
@@ -41,6 +41,7 @@ pub fn App() -> impl IntoView {
|
||||
fn InnerApp() -> impl IntoView {
|
||||
crate::store::provide_torrent_store();
|
||||
let store = use_context::<crate::store::TorrentStore>();
|
||||
let _loc = use_location();
|
||||
|
||||
let is_loading = signal(true);
|
||||
let is_authenticated = signal(false);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use leptos::prelude::*;
|
||||
use icons::PanelLeft;
|
||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
use icons::{PanelLeft, Plus};
|
||||
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
|
||||
use crate::components::ui::button::{ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
|
||||
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
|
||||
#[component]
|
||||
pub fn Toolbar() -> impl IntoView {
|
||||
let show_add_modal = signal(false);
|
||||
|
||||
view! {
|
||||
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
|
||||
// Sol kısım: Menü butonu (Mobil) + Add Torrent
|
||||
@@ -33,25 +32,24 @@ pub fn Toolbar() -> impl IntoView {
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
on:click=move |_| show_add_modal.1.set(true)
|
||||
class="gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
||||
<span class="sm:hidden">"Add"</span>
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
variant=ButtonVariant::Default
|
||||
class="gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4 md:w-5 md:h-5" />
|
||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
||||
<span class="sm:hidden">"Add"</span>
|
||||
</DialogTrigger>
|
||||
<DialogContent id="add-torrent-dialog" class="sm:max-w-[425px]">
|
||||
<AddTorrentDialogContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
// Sağ kısım boş
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
</div>
|
||||
|
||||
<Show when=move || show_add_modal.0.get()>
|
||||
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::ui::input::{Input, InputType};
|
||||
use crate::store::TorrentStore;
|
||||
use crate::api;
|
||||
|
||||
use crate::components::ui::button::{Button, ButtonVariant};
|
||||
use crate::components::ui::button::Button;
|
||||
use crate::components::ui::dialog::{
|
||||
DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn AddTorrentDialog(
|
||||
on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
||||
|
||||
pub fn AddTorrentDialogContent() -> impl IntoView {
|
||||
let uri = RwSignal::new(String::new());
|
||||
let is_loading = signal(false);
|
||||
let error_msg = signal(Option::<String>::None);
|
||||
@@ -21,20 +19,30 @@ pub fn AddTorrentDialog(
|
||||
let uri_val = uri.get();
|
||||
|
||||
if uri_val.is_empty() {
|
||||
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
||||
error_msg.1.set(Some("Lütfen bir Magnet URI veya URL girin".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.1.set(true);
|
||||
error_msg.1.set(None);
|
||||
|
||||
let on_close = on_close.clone();
|
||||
spawn_local(async move {
|
||||
match api::torrent::add(&uri_val).await {
|
||||
Ok(_) => {
|
||||
log::info!("Torrent added successfully");
|
||||
crate::store::toast_success("Torrent başarıyla eklendi");
|
||||
on_close.run(());
|
||||
|
||||
// Programmatically close the dialog by triggering the close button
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.get_element_by_id("add-torrent-dialog") {
|
||||
if let Some(close_btn) = el.query_selector("[data-dialog-close]").ok().flatten() {
|
||||
let _ = close_btn.dyn_into::<web_sys::HtmlElement>().map(|btn| btn.click());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uri.set(String::new());
|
||||
is_loading.1.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to add torrent: {:?}", e);
|
||||
@@ -45,29 +53,16 @@ pub fn AddTorrentDialog(
|
||||
});
|
||||
};
|
||||
|
||||
let handle_backdrop = {
|
||||
let on_close = on_close.clone();
|
||||
move |e: web_sys::MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_close.run(());
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
// Backdrop overlay
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
on:click=handle_backdrop
|
||||
/>
|
||||
// Dialog panel
|
||||
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
|
||||
// Header
|
||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
|
||||
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
|
||||
</div>
|
||||
<DialogBody>
|
||||
<DialogHeader>
|
||||
<DialogTitle>"Add Torrent"</DialogTitle>
|
||||
<DialogDescription>
|
||||
"Enter a Magnet link or a .torrent file URL."
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form on:submit=handle_submit class="space-y-4">
|
||||
<form on:submit=handle_submit class="space-y-4 pt-4">
|
||||
<Input
|
||||
r#type=InputType::Text
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
@@ -81,14 +76,10 @@ pub fn AddTorrentDialog(
|
||||
</div>
|
||||
})}
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
attr:r#type="button"
|
||||
on:click=move |_| on_close.run(())
|
||||
>
|
||||
<DialogFooter class="pt-2">
|
||||
<DialogClose>
|
||||
"Cancel"
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
attr:r#type="submit"
|
||||
attr:disabled=move || is_loading.0.get()
|
||||
@@ -102,21 +93,8 @@ pub fn AddTorrentDialog(
|
||||
leptos::either::Either::Right(view! { "Add" })
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
// Close button (X)
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
|
||||
on:click=move |_| on_close.run(())
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
<span class="sr-only">"Close"</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use wasm_bindgen::JsCast;
|
||||
use std::collections::HashSet;
|
||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
||||
use crate::store::{get_action_messages, show_toast};
|
||||
@@ -230,70 +231,78 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when=move || has_selection.get()>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
||||
<Ellipsis class="size-4" />
|
||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-48">
|
||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||
<DropdownMenuGroup class="mt-2">
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||
<Play class="mr-2 size-4" /> "Başlat"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||
<Square class="mr-2 size-4" /> "Durdur"
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div class="my-1 h-px bg-border" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger class="w-full text-left">
|
||||
<div class="inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm transition-colors text-destructive hover:bg-destructive/10 focus:bg-destructive/10 cursor-pointer">
|
||||
<Trash2 class="size-4" /> "Toplu Sil..."
|
||||
<div class="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
||||
<Ellipsis class="size-4" />
|
||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-48">
|
||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||
<DropdownMenuGroup class="mt-2">
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||
<Play class="mr-2 size-4" /> "Başlat"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||
<Square class="mr-2 size-4" /> "Durdur"
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div class="my-1 h-px bg-border" />
|
||||
|
||||
// Trigger the hidden AlertDialog from this menu item
|
||||
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
|
||||
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
|
||||
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
|
||||
}
|
||||
}>
|
||||
<Trash2 class="mr-2 size-4" /> "Toplu Sil..."
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
|
||||
<AlertDialogContent class="sm:max-w-[425px]">
|
||||
<AlertDialogBody>
|
||||
<AlertDialogHeader class="space-y-3">
|
||||
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
|
||||
<Trash2 class="size-6" />
|
||||
"Toplu Silme Onayı"
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-sm leading-relaxed text-left">
|
||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
||||
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
|
||||
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter class="mt-6">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
|
||||
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant=ButtonVariant::Secondary
|
||||
class="w-full sm:w-auto font-medium"
|
||||
on:click=move |_| bulk_action("delete")
|
||||
>
|
||||
"Sadece Sil"
|
||||
</Button>
|
||||
<Button
|
||||
variant=ButtonVariant::Destructive
|
||||
class="w-full sm:w-auto font-bold"
|
||||
on:click=move |_| bulk_action("delete_with_data")
|
||||
>
|
||||
"Verilerle Sil"
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent class="sm:max-w-[425px]">
|
||||
<AlertDialogBody>
|
||||
<AlertDialogHeader class="space-y-3">
|
||||
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
|
||||
<Trash2 class="size-6" />
|
||||
"Toplu Silme Onayı"
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-sm leading-relaxed">
|
||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
||||
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
|
||||
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter class="mt-6">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
|
||||
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant=ButtonVariant::Secondary
|
||||
class="w-full sm:w-auto font-medium"
|
||||
on:click=move |_| bulk_action("delete")
|
||||
>
|
||||
"Sadece Sil"
|
||||
</Button>
|
||||
<Button
|
||||
variant=ButtonVariant::Destructive
|
||||
class="w-full sm:w-auto font-bold"
|
||||
on:click=move |_| bulk_action("delete_with_data")
|
||||
>
|
||||
"Verilerle Sil"
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogBody>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogBody>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Mobile Sort Menu
|
||||
|
||||
@@ -72,13 +72,13 @@ pub fn DialogTrigger(
|
||||
pub fn DialogContent(
|
||||
children: Children,
|
||||
#[prop(optional, into)] class: String,
|
||||
#[prop(optional, into)] id: Option<String>,
|
||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||
#[prop(default = true)] close_on_backdrop_click: bool,
|
||||
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
||||
) -> impl IntoView {
|
||||
let ctx = expect_context::<DialogContext>();
|
||||
let merged_class = tw_merge!(
|
||||
// "flex flex-col gap-4", // TODO 🐛 Bug when I try to have this.. Using DialogBody instead.
|
||||
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
|
||||
class
|
||||
);
|
||||
@@ -88,10 +88,14 @@ pub fn DialogContent(
|
||||
|
||||
let target_id_clone = ctx.target_id.clone();
|
||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
||||
let target_id_for_script = ctx.target_id.clone();
|
||||
let backdrop_id_for_script = backdrop_id.clone();
|
||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
||||
|
||||
// Use provided id or fallback to random target_id
|
||||
let final_id = id.unwrap_or_else(|| ctx.target_id.clone());
|
||||
let final_id_for_script = final_id.clone();
|
||||
let trigger_id_for_script = ctx.target_id.clone();
|
||||
|
||||
view! {
|
||||
<script src="/lock_scroll.js"></script>
|
||||
|
||||
@@ -105,7 +109,7 @@ pub fn DialogContent(
|
||||
<div
|
||||
data-name=content_data_name
|
||||
class=merged_class
|
||||
id=ctx.target_id
|
||||
id=final_id
|
||||
data-target="target__dialog"
|
||||
data-state="closed"
|
||||
data-backdrop=backdrop_behavior
|
||||
@@ -147,9 +151,7 @@ pub fn DialogContent(
|
||||
dialog.setAttribute('data-initialized', 'true');
|
||||
|
||||
const openDialog = () => {{
|
||||
// Lock scrolling
|
||||
window.ScrollLock.lock();
|
||||
|
||||
if (window.ScrollLock) window.ScrollLock.lock();
|
||||
dialog.setAttribute('data-state', 'open');
|
||||
backdrop.setAttribute('data-state', 'open');
|
||||
dialog.style.pointerEvents = 'auto';
|
||||
@@ -161,28 +163,18 @@ pub fn DialogContent(
|
||||
backdrop.setAttribute('data-state', 'closed');
|
||||
dialog.style.pointerEvents = 'none';
|
||||
backdrop.style.pointerEvents = 'none';
|
||||
|
||||
// Unlock scrolling after animation
|
||||
window.ScrollLock.unlock(200);
|
||||
}};
|
||||
|
||||
// Open dialog when trigger is clicked
|
||||
trigger.addEventListener('click', openDialog);
|
||||
|
||||
// Close buttons
|
||||
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
|
||||
closeButtons.forEach(btn => {{
|
||||
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => {{
|
||||
btn.addEventListener('click', closeDialog);
|
||||
}});
|
||||
|
||||
// Close on backdrop click (if data-backdrop="auto")
|
||||
backdrop.addEventListener('click', () => {{
|
||||
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
||||
closeDialog();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Handle ESC key to close
|
||||
document.addEventListener('keydown', (e) => {{
|
||||
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
||||
e.preventDefault();
|
||||
@@ -190,17 +182,12 @@ pub fn DialogContent(
|
||||
}}
|
||||
}});
|
||||
}};
|
||||
|
||||
if (document.readyState === 'loading') {{
|
||||
document.addEventListener('DOMContentLoaded', setupDialog);
|
||||
}} else {{
|
||||
setupDialog();
|
||||
}}
|
||||
setupDialog();
|
||||
}})();
|
||||
"#,
|
||||
target_id_for_script,
|
||||
final_id_for_script,
|
||||
backdrop_id_for_script,
|
||||
target_id_for_script,
|
||||
trigger_id_for_script,
|
||||
)}
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
42
frontend/src/components/ui/switch.rs
Normal file
42
frontend/src/components/ui/switch.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -170,9 +170,10 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
||||
<div
|
||||
class=tw_merge!(
|
||||
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
|
||||
"left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 px-4 sm:px-0", // Mobile centering fix
|
||||
if is_bottom { "flex-col-reverse" } else { "flex-col" },
|
||||
container_class,
|
||||
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
|
||||
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
|
||||
)
|
||||
>
|
||||
<For
|
||||
|
||||
@@ -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ı");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user