Compare commits

..

15 Commits

Author SHA1 Message Date
spinline
8eb34905fc chore: final cleanup of all compiler warnings and unused code across the project
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 13:17: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
spinline
4e81565ab6 fix: ensure problematic push subscriptions are always removed on any error
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 13:01:47 +03:00
spinline
795eef4bda fix: refine push error matching and maximize webhook logging for debugging
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:51:46 +03:00
spinline
3ad8424d17 fix: resolve borrow-after-move error in notification handler
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-13 12:44:55 +03:00
spinline
83feb5a5cf fix: broaden push notification error handling to clear invalid subscriptions more effectively
Some checks failed
Build MIPS Binary / build (push) Failing after 1m5s
2026-02-13 12:41:11 +03:00
spinline
0dd97f3d7e chore: improve webhook logging for better debugging
Some checks failed
Build MIPS Binary / build (push) Failing after 1m5s
2026-02-13 12:38:29 +03:00
spinline
bb32c1f7f6 fix: improve push notification reliability by removing invalid subscriptions and update rTorrent webhook logging
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:31:06 +03:00
spinline
3bb2d68a65 perf: increase background polling interval to 60 seconds
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 12:26:09 +03:00
spinline
fe117cdaec chore: add detailed logging for web push notifications in webhook handler
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:11:14 +03:00
spinline
e062a3c8cd feat: add internal notification endpoint for rTorrent event hooks
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 12:08:40 +03:00
spinline
ae2c9c934d fix: center toast notifications on mobile screens
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-13 00:08:54 +03:00
spinline
f7e1356eae fix: restrict Add Torrent dialog width for better UI
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 00:06:40 +03:00
spinline
98b1f059c7 feat: modernize Add Torrent dialog and perform final code cleanup of warnings and dead code
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 00:03:26 +03:00
25 changed files with 560 additions and 496 deletions

View File

@@ -7,6 +7,7 @@ use rust_embed::RustEmbed;
pub mod auth; pub mod auth;
pub mod setup; pub mod setup;
pub mod notifications;
#[derive(RustEmbed)] #[derive(RustEmbed)]
#[folder = "../frontend/dist"] #[folder = "../frontend/dist"]

View 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
}

View File

@@ -60,6 +60,7 @@ async fn auth_middleware(
|| path.starts_with("/api/server_fns/get_setup_status") || 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/server_fns/setup") || path.starts_with("/api/server_fns/setup")
|| path.starts_with("/api/internal/")
|| path.starts_with("/swagger-ui") || path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs") || path.starts_with("/api-docs")
|| !path.starts_with("/api/") || !path.starts_with("/api/")
@@ -313,7 +314,7 @@ async fn main() {
let loop_interval = if active_clients > 0 { let loop_interval = if active_clients > 0 {
Duration::from_secs(1) Duration::from_secs(1)
} else { } else {
Duration::from_secs(30) Duration::from_secs(60)
}; };
// 1. Fetch Torrents // 1. Fetch Torrents
@@ -434,6 +435,7 @@ async fn main() {
let db_for_ctx = db.clone(); let db_for_ctx = db.clone();
let app = app let app = app
.route("/api/events", get(sse::sse_handler)) .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({ .route("/api/server_fns/{*fn_name}", post({
let scgi_path = scgi_path_for_ctx.clone(); let scgi_path = scgi_path_for_ctx.clone();
let db = db_for_ctx.clone(); let db = db_for_ctx.clone();

View File

@@ -191,11 +191,21 @@ pub async fn send_push_notification(
tracing::debug!("Push notification sent to: {}", subscription.endpoint); tracing::debug!("Push notification sent to: {}", subscription.endpoint);
} }
Err(e) => { 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), Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),

View File

@@ -6,7 +6,7 @@ use crate::components::auth::setup::Setup;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route}; 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::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode; use crate::components::hooks::use_theme_mode::ThemeMode;
@@ -41,6 +41,7 @@ pub fn App() -> impl IntoView {
fn InnerApp() -> impl IntoView { fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>(); let store = use_context::<crate::store::TorrentStore>();
let _loc = use_location();
let is_loading = signal(true); let is_loading = signal(true);
let is_authenticated = signal(false); let is_authenticated = signal(false);

View File

@@ -1,5 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::components::ui::context_menu::*; use crate::components::ui::context_menu::{
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
};
#[component] #[component]
pub fn TorrentContextMenu( pub fn TorrentContextMenu(
@@ -7,72 +9,38 @@ pub fn TorrentContextMenu(
torrent_hash: String, torrent_hash: String,
on_action: Callback<(String, String)>, on_action: Callback<(String, String)>,
) -> impl IntoView { ) -> impl IntoView {
let hash = StoredValue::new(torrent_hash); let hash = torrent_hash.clone();
let on_action_stored = StoredValue::new(on_action);
let menu_action = move |action: &'static str| {
on_action.run((action.to_string(), hash.get_value())); // Define helper to avoid move issues
let run_action = move |action: &str| {
on_action_stored.get_value().run((action.to_string(), hash.clone()));
}; };
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
let hash_c4 = torrent_hash.clone();
view! { view! {
<ContextMenu> <ContextMenu>
<ContextMenuTrigger> <ContextMenuTrigger>
{children()} {children()}
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuContent class="w-56"> <ContextMenuItem on:click={let h = hash_c1; move |_| on_action_stored.get_value().run(("start".to_string(), h.clone()))}>
<ContextMenuAction "Başlat"
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm" </ContextMenuItem>
on:click=move |_| menu_action("start") <ContextMenuItem on:click={let h = hash_c2; move |_| on_action_stored.get_value().run(("stop".to_string(), h.clone()))}>
> "Durdur"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70"> </ContextMenuItem>
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" /> <ContextMenuItem class="text-destructive" on:click={let h = hash_c3; move |_| on_action_stored.get_value().run(("delete".to_string(), h.clone()))}>
</svg> "Sil"
"Start" </ContextMenuItem>
</ContextMenuAction> <ContextMenuItem class="text-destructive font-bold" on:click={let h = hash_c4; move |_| on_action_stored.get_value().run(("delete_with_data".to_string(), h.clone()))}>
"Verilerle Birlikte Sil"
<ContextMenuAction </ContextMenuItem>
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("stop")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Stop"
</ContextMenuAction>
<ContextMenuAction
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("recheck")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
"Recheck"
</ContextMenuAction>
<div class="-mx-1 my-1 h-px bg-border" />
<ContextMenuAction
class="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
on:click=move |_| menu_action("delete")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
"Remove"
</ContextMenuAction>
<ContextMenuHoldAction
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
on_hold_complete=move |_| menu_action("delete_with_data")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
"Remove with Data"
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
</ContextMenuHoldAction>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
} }
} }

View File

@@ -1,3 +1,5 @@
use leptos::prelude::*;
pub fn use_random_id_for(prefix: &str) -> String { pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", "")) format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
} }

View File

@@ -8,15 +8,26 @@ pub struct ThemeMode {
const LOCALSTORAGE_KEY: &str = "darkmode"; const LOCALSTORAGE_KEY: &str = "darkmode";
/// Hook to access the dark mode context
///
/// Returns the ThemeMode instance from context for easy access
pub fn use_theme_mode() -> ThemeMode { pub fn use_theme_mode() -> ThemeMode {
expect_context::<ThemeMode>() expect_context::<ThemeMode>()
} }
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
impl ThemeMode { impl ThemeMode {
#[must_use]
/// Initializes a new ThemeMode instance.
pub fn init() -> Self { pub fn init() -> Self {
let theme_mode = Self { state: RwSignal::new(false) }; let theme_mode = Self { state: RwSignal::new(false) };
provide_context(theme_mode); provide_context(theme_mode);
// Use Effect to handle browser-only initialization
Effect::new(move |_| { Effect::new(move |_| {
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode()); let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
theme_mode.state.set(initial); theme_mode.state.set(initial);
@@ -32,14 +43,45 @@ impl ThemeMode {
}); });
} }
pub fn set_dark(&self) {
self.set(true);
}
pub fn set_light(&self) {
self.set(false);
}
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
pub fn set(&self, dark: bool) {
self.state.set(dark);
Self::set_storage_state(dark);
}
#[must_use]
pub fn get(&self) -> bool { pub fn get(&self) -> bool {
self.state.get() self.state.get()
} }
#[must_use]
pub fn is_dark(&self) -> bool {
self.state.get()
}
#[must_use]
pub fn is_light(&self) -> bool {
!self.state.get()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
/// Retrieves the local storage object, if available.
fn get_storage() -> Option<Storage> { fn get_storage() -> Option<Storage> {
window().local_storage().ok().flatten() window().local_storage().ok().flatten()
} }
/// Retrieves the dark mode state from local storage, if available.
fn get_storage_state() -> Option<bool> { fn get_storage_state() -> Option<bool> {
Self::get_storage() Self::get_storage()
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok()) .and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
@@ -47,6 +89,7 @@ impl ThemeMode {
.and_then(|entry| entry.parse::<bool>().ok()) .and_then(|entry| entry.parse::<bool>().ok())
} }
/// Checks whether the user's system prefers dark mode based on media queries.
fn prefers_dark_mode() -> bool { fn prefers_dark_mode() -> bool {
window() window()
.match_media("(prefers-color-scheme: dark)") .match_media("(prefers-color-scheme: dark)")
@@ -56,9 +99,10 @@ impl ThemeMode {
.unwrap_or_default() .unwrap_or_default()
} }
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) { fn set_storage_state(state: bool) {
if let Some(storage) = Self::get_storage() { if let Some(storage) = Self::get_storage() {
let _ = storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()); storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
} }
} }
} }

View File

@@ -3,6 +3,7 @@ use leptos::task::spawn_local;
use crate::components::ui::sidenav::*; use crate::components::ui::sidenav::*;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize}; use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::theme_toggle::ThemeToggle; use crate::components::ui::theme_toggle::ThemeToggle;
use crate::components::ui::switch::Switch;
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> impl IntoView {
@@ -65,6 +66,19 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string() 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! { view! {
<SidenavHeader> <SidenavHeader>
<div class="flex items-center gap-2 px-2 py-4"> <div class="flex items-center gap-2 px-2 py-4">
@@ -133,35 +147,49 @@ pub fn Sidebar() -> impl IntoView {
</SidenavContent> </SidenavContent>
<SidenavFooter> <SidenavFooter>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden"> <div class="flex flex-col gap-4 p-4">
<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"> // Push Notification Toggle
{first_letter} <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>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div> <div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div> <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">
</div> {first_letter}
</div>
<div class="flex items-center gap-1"> <div class="flex-1 overflow-hidden">
<ThemeToggle /> <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 <div class="flex items-center gap-1">
variant=ButtonVariant::Ghost <ThemeToggle />
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10" <Button
on:click=move |_| { variant=ButtonVariant::Ghost
spawn_local(async move { size=ButtonSize::Icon
if shared::server_fns::auth::logout().await.is_ok() { class="size-7 text-destructive hover:bg-destructive/10"
let window = web_sys::window().expect("window should exist"); on:click=move |_| {
let _ = window.location().set_href("/login"); 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> <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>
</div> </div>
</SidenavFooter> </SidenavFooter>

View File

@@ -1,14 +1,13 @@
use leptos::prelude::*; use leptos::prelude::*;
use icons::PanelLeft; use icons::{PanelLeft, Plus};
use crate::components::torrent::add_torrent::AddTorrentDialog; use crate::components::torrent::add_torrent::AddTorrentDialogContent;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize}; use crate::components::ui::button::{ButtonVariant, ButtonSize};
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection}; use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
use crate::components::layout::sidebar::Sidebar; use crate::components::layout::sidebar::Sidebar;
#[component] #[component]
pub fn Toolbar() -> impl IntoView { pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false);
view! { 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);"> <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 // Sol kısım: Menü butonu (Mobil) + Add Torrent
@@ -33,25 +32,24 @@ pub fn Toolbar() -> impl IntoView {
</Sheet> </Sheet>
</div> </div>
<Button <Dialog>
on:click=move |_| show_add_modal.1.set(true) <DialogTrigger
class="gap-2" variant=ButtonVariant::Default
> 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" /> <Plus class="w-4 h-4 md:w-5 md:h-5" />
</svg> <span class="hidden sm:inline">"Add Torrent"</span>
<span class="hidden sm:inline">"Add Torrent"</span> <span class="sm:hidden">"Add"</span>
<span class="sm:hidden">"Add"</span> </DialogTrigger>
</Button> <DialogContent id="add-torrent-dialog" class="sm:max-w-[425px]">
<AddTorrentDialogContent />
</DialogContent>
</Dialog>
</div> </div>
// Sağ kısım boş // Sağ kısım boş
<div class="flex flex-1 items-center justify-end gap-2"> <div class="flex flex-1 items-center justify-end gap-2">
</div> </div>
<Show when=move || show_add_modal.0.get()>
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
</Show>
</div> </div>
} }
} }

View File

@@ -1,17 +1,15 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use wasm_bindgen::JsCast;
use crate::components::ui::input::{Input, InputType}; use crate::components::ui::input::{Input, InputType};
use crate::store::TorrentStore;
use crate::api; use crate::api;
use crate::components::ui::button::Button;
use crate::components::ui::button::{Button, ButtonVariant}; use crate::components::ui::dialog::{
DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
};
#[component] #[component]
pub fn AddTorrentDialog( pub fn AddTorrentDialogContent() -> impl IntoView {
on_close: Callback<()>,
) -> impl IntoView {
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let uri = RwSignal::new(String::new()); let uri = RwSignal::new(String::new());
let is_loading = signal(false); let is_loading = signal(false);
let error_msg = signal(Option::<String>::None); let error_msg = signal(Option::<String>::None);
@@ -21,20 +19,30 @@ pub fn AddTorrentDialog(
let uri_val = uri.get(); let uri_val = uri.get();
if uri_val.is_empty() { 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; return;
} }
is_loading.1.set(true); is_loading.1.set(true);
error_msg.1.set(None); error_msg.1.set(None);
let on_close = on_close.clone();
spawn_local(async move { spawn_local(async move {
match api::torrent::add(&uri_val).await { match api::torrent::add(&uri_val).await {
Ok(_) => { Ok(_) => {
log::info!("Torrent added successfully"); log::info!("Torrent added successfully");
crate::store::toast_success("Torrent başarıyla eklendi"); 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) => { Err(e) => {
log::error!("Failed to add torrent: {:?}", 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! { view! {
// Backdrop overlay <DialogBody>
<div <DialogHeader>
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" <DialogTitle>"Add Torrent"</DialogTitle>
on:click=handle_backdrop <DialogDescription>
/> "Enter a Magnet link or a .torrent file URL."
// Dialog panel </DialogDescription>
<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]"> </DialogHeader>
// 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>
<form on:submit=handle_submit class="space-y-4"> <form on:submit=handle_submit class="space-y-4 pt-4">
<Input <Input
r#type=InputType::Text r#type=InputType::Text
placeholder="magnet:?xt=urn:btih:..." placeholder="magnet:?xt=urn:btih:..."
@@ -81,14 +76,10 @@ pub fn AddTorrentDialog(
</div> </div>
})} })}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"> <DialogFooter class="pt-2">
<Button <DialogClose>
variant=ButtonVariant::Ghost
attr:r#type="button"
on:click=move |_| on_close.run(())
>
"Cancel" "Cancel"
</Button> </DialogClose>
<Button <Button
attr:r#type="submit" attr:r#type="submit"
attr:disabled=move || is_loading.0.get() attr:disabled=move || is_loading.0.get()
@@ -102,21 +93,8 @@ pub fn AddTorrentDialog(
leptos::either::Either::Right(view! { "Add" }) leptos::either::Either::Right(view! { "Add" })
}} }}
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</DialogBody>
// 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>
} }
} }

View File

@@ -6,6 +6,7 @@ pub enum BadgeVariant {
#[default] #[default]
Default, Default,
Secondary, Secondary,
Outline,
Destructive, Destructive,
Success, Success,
Warning, Warning,
@@ -21,6 +22,7 @@ pub fn Badge(
let variant_classes = match variant { let variant_classes = match variant {
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
BadgeVariant::Outline => "text-foreground",
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",

View File

@@ -209,7 +209,7 @@ pub fn ContextMenuTrigger(
class=trigger_class class=trigger_class
data-name="ContextMenuTrigger" data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id data-context-trigger=ctx.target_id
on:contextmenu=move |_e: web_sys::MouseEvent| { on:contextmenu=move |e: web_sys::MouseEvent| {
if let Some(cb) = on_open { if let Some(cb) = on_open {
cb.run(()); cb.run(());
} }

View File

@@ -1,6 +1,6 @@
// * Reuse @table.rs // * Reuse @table.rs
pub use crate::components::ui::table::{ pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCell as DataTableCell, Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
TableHead as DataTableHead, TableHeader as DataTableHeader, TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper, TableRow as DataTableRow, TableWrapper as DataTableWrapper,
}; };

View File

@@ -72,13 +72,13 @@ pub fn DialogTrigger(
pub fn DialogContent( pub fn DialogContent(
children: Children, children: Children,
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(optional, into)] id: Option<String>,
#[prop(into, optional)] hide_close_button: Option<bool>, #[prop(into, optional)] hide_close_button: Option<bool>,
#[prop(default = true)] close_on_backdrop_click: bool, #[prop(default = true)] close_on_backdrop_click: bool,
#[prop(default = "Dialog")] data_name_prefix: &'static str, #[prop(default = "Dialog")] data_name_prefix: &'static str,
) -> impl IntoView { ) -> impl IntoView {
let ctx = expect_context::<DialogContext>(); let ctx = expect_context::<DialogContext>();
let merged_class = tw_merge!( 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", "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 class
); );
@@ -88,10 +88,14 @@ pub fn DialogContent(
let target_id_clone = ctx.target_id.clone(); let target_id_clone = ctx.target_id.clone();
let backdrop_id = format!("{}_backdrop", ctx.target_id); 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_id_for_script = backdrop_id.clone();
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" }; 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! { view! {
<script src="/lock_scroll.js"></script> <script src="/lock_scroll.js"></script>
@@ -105,7 +109,7 @@ pub fn DialogContent(
<div <div
data-name=content_data_name data-name=content_data_name
class=merged_class class=merged_class
id=ctx.target_id id=final_id
data-target="target__dialog" data-target="target__dialog"
data-state="closed" data-state="closed"
data-backdrop=backdrop_behavior data-backdrop=backdrop_behavior
@@ -147,9 +151,7 @@ pub fn DialogContent(
dialog.setAttribute('data-initialized', 'true'); dialog.setAttribute('data-initialized', 'true');
const openDialog = () => {{ const openDialog = () => {{
// Lock scrolling if (window.ScrollLock) window.ScrollLock.lock();
window.ScrollLock.lock();
dialog.setAttribute('data-state', 'open'); dialog.setAttribute('data-state', 'open');
backdrop.setAttribute('data-state', 'open'); backdrop.setAttribute('data-state', 'open');
dialog.style.pointerEvents = 'auto'; dialog.style.pointerEvents = 'auto';
@@ -161,28 +163,18 @@ pub fn DialogContent(
backdrop.setAttribute('data-state', 'closed'); backdrop.setAttribute('data-state', 'closed');
dialog.style.pointerEvents = 'none'; dialog.style.pointerEvents = 'none';
backdrop.style.pointerEvents = 'none'; backdrop.style.pointerEvents = 'none';
// Unlock scrolling after animation
window.ScrollLock.unlock(200); window.ScrollLock.unlock(200);
}}; }};
// Open dialog when trigger is clicked
trigger.addEventListener('click', openDialog); trigger.addEventListener('click', openDialog);
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => {{
// Close buttons
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
closeButtons.forEach(btn => {{
btn.addEventListener('click', closeDialog); btn.addEventListener('click', closeDialog);
}}); }});
// Close on backdrop click (if data-backdrop="auto")
backdrop.addEventListener('click', () => {{ backdrop.addEventListener('click', () => {{
if (dialog.getAttribute('data-backdrop') === 'auto') {{ if (dialog.getAttribute('data-backdrop') === 'auto') {{
closeDialog(); closeDialog();
}} }}
}}); }});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {{ document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{ if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
e.preventDefault(); e.preventDefault();
@@ -190,17 +182,12 @@ pub fn DialogContent(
}} }}
}}); }});
}}; }};
setupDialog();
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupDialog);
}} else {{
setupDialog();
}}
}})(); }})();
"#, "#,
target_id_for_script, final_id_for_script,
backdrop_id_for_script, backdrop_id_for_script,
target_id_for_script, trigger_id_for_script,
)} )}
</script> </script>
} }

View File

@@ -94,6 +94,8 @@ pub fn DropdownMenuAction(
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>, #[prop(optional, into)] href: Option<String>,
) -> impl IntoView { ) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
let class = tw_merge!( let class = tw_merge!(
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground", "inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
class class
@@ -173,15 +175,17 @@ pub enum DropdownMenuAlign {
#[derive(Clone)] #[derive(Clone)]
struct DropdownMenuContext { struct DropdownMenuContext {
target_id: String, target_id: String,
align: DropdownMenuAlign,
} }
#[component] #[component]
pub fn DropdownMenu( pub fn DropdownMenu(
children: Children, children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> impl IntoView { ) -> impl IntoView {
let dropdown_target_id = use_random_id_for("dropdown"); let dropdown_target_id = use_random_id_for("dropdown");
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone() }; let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
view! { view! {
<Provider value=ctx> <Provider value=ctx>
@@ -248,13 +252,12 @@ pub enum DropdownMenuPosition {
pub fn DropdownMenuContent( pub fn DropdownMenuContent(
children: Children, children: Children,
#[prop(optional, into)] class: String, #[prop(optional, into)] class: String,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition, #[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
) -> impl IntoView { ) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>(); let ctx = expect_context::<DropdownMenuContext>();
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100"; let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
let width_class = match align { let width_class = match ctx.align {
DropdownMenuAlign::Center => "min-w-full", DropdownMenuAlign::Center => "min-w-full",
_ => "w-[180px]", _ => "w-[180px]",
}; };
@@ -262,7 +265,7 @@ pub fn DropdownMenuContent(
let class = tw_merge!(width_class, base_classes, class); let class = tw_merge!(width_class, base_classes, class);
let target_id_for_script = ctx.target_id.clone(); let target_id_for_script = ctx.target_id.clone();
let align_for_script = match align { let align_for_script = match ctx.align {
DropdownMenuAlign::Start => "start", DropdownMenuAlign::Start => "start",
DropdownMenuAlign::StartOuter => "start-outer", DropdownMenuAlign::StartOuter => "start-outer",
DropdownMenuAlign::End => "end", DropdownMenuAlign::End => "end",
@@ -439,6 +442,26 @@ pub fn DropdownMenuContent(
trigger.addEventListener('click', (e) => {{ trigger.addEventListener('click', (e) => {{
e.stopPropagation(); e.stopPropagation();
// Check if any other dropdown is open
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
let otherDropdownOpen = false;
allDropdowns.forEach(dd => {{
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
otherDropdownOpen = true;
dd.setAttribute('data-state', 'closed');
dd.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}}
}});
// If another dropdown was open, just close it and don't open this one
if (otherDropdownOpen) {{
return;
}}
// Normal toggle behavior // Normal toggle behavior
if (isOpen) {{ if (isOpen) {{
closeDropdown(); closeDropdown();
@@ -510,4 +533,4 @@ pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: St
{children()} {children()}
</li> </li>
} }
} }

View File

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

View File

@@ -9,7 +9,7 @@ use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for; use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs // * Reuse @select.rs
pub use crate::components::ui::select::{ pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
}; };
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]

View File

@@ -1,35 +1,20 @@
use leptos::prelude::*; use leptos::prelude::*;
use tw_merge::*; use tailwind_fuse::tw_merge;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum SeparatorOrientation { #[default] Horizontal, Vertical }
#[component] #[component]
pub fn Separator( pub fn Separator(
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>, #[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
#[prop(into, optional)] class: String, #[prop(into, optional)] class: String,
// children: Children,
) -> impl IntoView { ) -> impl IntoView {
let merged_class = Memo::new(move |_| { let class_signal = move || {
let orientation = orientation.get(); let orient_class = match orientation.get() {
let separator = SeparatorClass { orientation }; SeparatorOrientation::Horizontal => "h-[1px] w-full",
separator.with_class(class.clone()) SeparatorOrientation::Vertical => "h-full w-[1px]",
}); };
tw_merge!("shrink-0 bg-border", orient_class, class.clone())
view! { <div class=merged_class role="separator" /> } };
view! { <div class=class_signal role="none" /> }
} }
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[derive(TwClass, Default)]
#[tw(class = "shrink-0 bg-border")]
pub struct SeparatorClass {
orientation: SeparatorOrientation,
}
#[derive(TwVariant)]
pub enum SeparatorOrientation {
#[tw(default, class = "w-full h-[1px]")]
Default,
#[tw(class = "h-full w-[1px]")]
Vertical,
}

View File

@@ -31,6 +31,9 @@ pub struct SheetContext {
/* ✨ FUNCTIONS ✨ */ /* ✨ FUNCTIONS ✨ */
/* ========================================================== */ /* ========================================================== */
pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;
#[component] #[component]
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let sheet_target_id = use_random_id_for("sheet"); let sheet_target_id = use_random_id_for("sheet");
@@ -200,7 +203,7 @@ pub fn SheetContent(
target_id_for_script, target_id_for_script,
)} )}
</script> </script>
} }.into_any()
} }
/* ========================================================== */ /* ========================================================== */

View File

@@ -1,232 +1,79 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_location; use tw_merge::tw_merge;
use leptos_ui::{clx, variants, void};
mod components { #[derive(Clone, Copy, PartialEq, Eq, Default)]
use super::*; #[allow(dead_code)]
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"} pub enum SidenavState { #[default] Expanded, Collapsed }
// clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=Inset]:m-2 md:peer-data-[variant=Inset]:ml-0 md:peer-data-[variant=Inset]:rounded-xl md:peer-data-[variant=Inset]:shadow-sm md:peer-data-[variant=Inset]:peer-data-[state=Collapsed]:ml-2"}
clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col data-[variant=Inset]:rounded-lg data-[variant=Inset]:border data-[variant=Inset]:border-sidenav-border data-[variant=Inset]:shadow-sm data-[variant=Inset]:m-2"}
// * data-[], not group-data-[]
clx! {SidenavInner, div, "flex flex-col w-full h-full bg-sidenav data-[variant=Floating]:rounded-lg data-[variant=Floating]:border data-[variant=Floating]:border-sidenav-border data-[variant=Floating]:shadow-sm"}
clx! {SidenavHeader, div, "flex flex-col gap-2 p-2"}
clx! {SidenavMenu, ul, "flex flex-col gap-1 w-full min-w-0"}
clx! {SidenavMenuSub, ul, "border-sidenav-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=Icon]:hidden"}
clx! {SidenavMenuItem, li, "relative group/menu-item"}
clx! {SidenavContent, div, "scrollbar__on_hover", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=Icon]:overflow-hidden"}
clx! {SidenavGroup, div, "flex relative flex-col p-2 w-full min-w-0"}
clx! {SidenavGroupContent, div, "w-full text-sm"}
clx! {SidenavGroupLabel, div, "text-sidenav-foreground/70 ring-sidenav-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:-mt-8 group-data-[collapsible=Icon]:opacity-0"}
clx! {SidenavFooter, footer, "flex flex-col gap-2 p-2"}
// Button "More"
clx! {DropdownMenuTriggerEllipsis, button, "text-sidenav-foreground ring-sidenav-ring hover:bg-sidenav-accent hover:text-sidenav-accent-foreground peer-hover/menu-button:text-sidenav-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=Icon]:hidden peer-data-[active=true]/menu-button:text-sidenav-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0"}
void! {SidenavInput, input, #[derive(Clone)]
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", pub struct SidenavContext {
"focus-visible:border-ring focus-visible:ring-ring/50", pub state: RwSignal<SidenavState>,
"focus-visible:ring-2", // TODO. Port tw_merge to Tailwind V4.
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
// Specific to Sidenav
"w-full h-8 shadow-none bg-background"
}
} }
pub use components::*; #[component]
pub fn SidenavWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
/* ========================================================== */ let state = RwSignal::new(SidenavState::Expanded);
/* ✨ FUNCTIONS ✨ */ provide_context(SidenavContext { state });
/* ========================================================== */ let class = tw_merge!("flex min-h-screen w-full bg-background", class);
view! { <div class=class>{children()}</div> }
}
#[component] #[component]
pub fn SidenavLink( pub fn Sidenav(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
children: Children, let ctx = expect_context::<SidenavContext>();
#[prop(into)] href: String, let class_signal = move || {
#[prop(optional, into)] class: String, let width_class = match ctx.state.get() {
) -> impl IntoView { SidenavState::Expanded => "w-[var(--sidenav-width)]",
let merged_class = tw_merge!( SidenavState::Collapsed => "w-[var(--sidenav-width-icon)]",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidenav-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-semibold aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidenav-accent hover:text-sidenav-accent-foreground h-8 text-sm", };
class tw_merge!(
); "hidden md:flex flex-col border-r bg-card transition-all duration-300",
width_class,
let location = use_location(); class.clone()
)
// Check if the link is active based on current path
let href_clone = href.clone();
let is_active = move || {
let path = location.pathname.get();
path == href_clone || path.starts_with(&format!("{}/", href_clone))
}; };
view! { <aside class=class_signal>{children()}</aside> }
let aria_current = move || if is_active() { "page" } else { "false" };
view! {
<a data-name="SidenavLink" class=merged_class href=href aria-current=aria_current>
{children()}
</a>
}
}
variants! {
SidenavMenuButton {
base: "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidenav-ring transition-[width,height,padding] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-medium aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
variants: {
variant: {
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base
Outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidenav-border))] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidenav-accent))]",
},
size: {
Default: "h-8 text-sm",
Sm: "h-7 text-xs",
Lg: "h-12",
}
},
component: {
element: button,
support_href: true,
support_aria_current: true
}
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, strum::IntoStaticStr)]
pub enum SidenavVariant {
#[default]
Sidenav,
Floating,
Inset,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavSide {
#[default]
Left,
Right,
}
#[derive(Default, Clone, Copy, PartialEq, Eq, strum::Display)]
pub enum SidenavCollapsible {
#[default]
Offcanvas,
None,
Icon,
} }
#[component] #[component]
pub fn Sidenav( pub fn SidenavInset(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
#[prop(into, optional)] class: String, let class = tw_merge!("flex flex-col flex-1 min-w-0", class);
#[prop(default = SidenavVariant::default())] variant: SidenavVariant, view! { <main class=class>{children()}</main> }
#[prop(default = SidenavState::default())] data_state: SidenavState, }
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible, #[component] pub fn SidenavHeader(children: Children) -> impl IntoView { view! { <div class="flex flex-col">{children()}</div> } }
#[component] pub fn SidenavContent(children: Children) -> impl IntoView { view! { <div class="flex-1 overflow-auto">{children()}</div> } }
#[component] pub fn SidenavFooter(children: Children) -> impl IntoView { view! { <div class="mt-auto">{children()}</div> } }
#[component] pub fn SidenavGroup(children: Children) -> impl IntoView { view! { <div class="px-2 py-2">{children()}</div> } }
#[component] pub fn SidenavGroupLabel(children: Children) -> impl IntoView { view! { <div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">{children()}</div> } }
#[component] pub fn SidenavGroupContent(children: Children) -> impl IntoView { view! { <div class="space-y-1">{children()}</div> } }
#[component] pub fn SidenavMenu(children: Children) -> impl IntoView { view! { <nav class="grid gap-1">{children()}</nav> } }
#[component] pub fn SidenavMenuItem(children: Children) -> impl IntoView { view! { <div>{children()}</div> } }
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum SidenavMenuButtonVariant { #[default] Default, Outline }
#[component]
pub fn SidenavMenuButton(
children: Children, children: Children,
#[prop(into, optional)] variant: Signal<SidenavMenuButtonVariant>,
#[prop(into, optional)] class: Signal<String>,
) -> impl IntoView { ) -> impl IntoView {
view! { let class_signal = move || {
{if data_collapsible == SidenavCollapsible::None { let variant_class = if variant.get() == SidenavMenuButtonVariant::Outline {
view! { "border border-input bg-background shadow-xs"
<aside } else {
data-name="Sidenav" ""
class=tw_merge!( };
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone() tw_merge!(
) "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
> variant_class,
{children()} class.get()
</aside> )
} };
.into_any() view! { <button class=class_signal>{children()}</button> }
} else {
view! {
<aside
data-name="Sidenav"
data-sidenav=data_state.to_string()
data-side=data_side.to_string()
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
>
// * SidenavGap: This is what handles the sidenav gap on desktop
<div
data-name="SidenavGap"
class=tw_merge!(
"relative w-(--sidenav-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=Offcanvas]:w-0",
"group-data-[side=Right]:rotate-180",
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
SidenavVariant::Floating | SidenavVariant::Inset =>
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
}
)
/>
<div
data-name="SidenavContainer"
class=tw_merge!(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidenav-width) transition-[left,right,width] duration-200 ease-linear md:flex",
class,
match data_side {
SidenavSide::Left => "left-0 group-data-[collapsible=Offcanvas]:left-[calc(var(--sidenav-width)*-1)]",
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
},
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Floating | SidenavVariant::Inset =>
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
},
)
>
// * Act as a Sidenav for the onclick trigger to work with nested Sidenavs.
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
{children()}
<SidenavToggleRail />
</SidenavInner>
</div>
</aside>
}
.into_any()
}}
}
} }
/* ========================================================== */ #[component] pub fn SidenavLink(children: Children, #[prop(into)] href: String) -> impl IntoView {
/* ✨ FUNCTIONS ✨ */ view! { <a href=href class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent">{children()}</a> }
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavState {
#[default]
Expanded,
Collapsed,
} }
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
#[component]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
view! {
// TODO. Use Button.
<button
onclick=ONCLICK_TRIGGER
data-name="SidenavTrigger"
class="inline-flex gap-2 justify-center items-center -ml-1 text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none disabled:opacity-50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-7 dark:aria-invalid:ring-destructive/40 dark:hover:bg-accent/50 hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
{children()}
</button>
}
}
#[component]
fn SidenavToggleRail() -> impl IntoView {
view! {
<button
data-name="SidenavToggleRail"
aria-label="Toggle Sidenav"
tabindex="-1"
onclick=ONCLICK_TRIGGER
class="hidden absolute inset-y-0 z-20 w-4 transition-all ease-linear -translate-x-1/2 sm:flex group-data-[side=Left]:-right-4 group-data-[side=Right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] in-data-[side=Left]:cursor-w-resize in-data-[side=Right]:cursor-e-resize [[data-side=Left][data-state=Collapsed]_&]:cursor-e-resize [[data-side=Right][data-state=Collapsed]_&]:cursor-w-resize group-data-[collapsible=Offcanvas]:translate-x-0 group-data-[collapsible=Offcanvas]:after:left-full [[data-side=Left][data-collapsible=Offcanvas]_&]:-right-2 [[data-side=Right][data-collapsible=Offcanvas]_&]:-left-0eft-2 hover:after:bg-sidenav-border hover:group-data-[collapsible=Offcanvas]:bg-sidenav"
/>
}
}

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

@@ -170,9 +170,10 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
<div <div
class=tw_merge!( class=tw_merge!(
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]", "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" }, if is_bottom { "flex-col-reverse" } else { "flex-col" },
container_class, 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 <For

View File

@@ -5,7 +5,9 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent}; use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap; use std::collections::HashMap;
use struct_patch::traits::Patch; 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 base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast}; use crate::components::ui::toast::{ToastType, toast};
@@ -24,8 +26,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
toast(msg, variant); toast(msg, variant);
} }
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); } 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); } 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 global_stats: RwSignal<GlobalStats>,
pub user: RwSignal<Option<String>>, pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>, pub selected_torrent: RwSignal<Option<String>>,
pub push_enabled: RwSignal<bool>,
} }
pub fn provide_torrent_store() { pub fn provide_torrent_store() {
@@ -63,12 +64,20 @@ pub fn provide_torrent_store() {
let global_stats = RwSignal::new(GlobalStats::default()); let global_stats = RwSignal::new(GlobalStats::default());
let user = RwSignal::new(Option::<String>::None); let user = RwSignal::new(Option::<String>::None);
let selected_torrent = 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 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); 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 global_stats_for_sse = global_stats;
let torrents_for_sse = torrents; let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone(); let show_browser_notification = show_browser_notification.clone();
@@ -79,17 +88,12 @@ pub fn provide_torrent_store() {
let mut disconnect_notified = false; let mut disconnect_notified = false;
loop { loop {
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events"); let es_result = EventSource::new("/api/events");
match es_result { match es_result {
Ok(mut es) => { Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing...");
if let Ok(mut stream) = es.subscribe("message") { if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false; let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await { while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message { if !got_first_message {
got_first_message = true; got_first_message = true;
backoff_ms = 1000; backoff_ms = 1000;
@@ -101,47 +105,30 @@ pub fn provide_torrent_store() {
} }
if let Some(data_str) = msg.data().as_string() { if let Some(data_str) = msg.data().as_string() {
// Decode Base64 if let Ok(bytes) = BASE64.decode(&data_str) {
match BASE64.decode(&data_str) { if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(bytes) => { match event {
// Deserialize MessagePack AppEvent::FullList(list, _) => {
match rmp_serde::from_slice::<AppEvent>(&bytes) { torrents_for_sse.update(|map| {
Ok(event) => { let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
match event { map.retain(|hash, _| new_hashes.contains(hash));
AppEvent::FullList(list, _) => { for new_torrent in list { map.insert(new_torrent.hash.clone(), new_torrent); }
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(); AppEvent::Update(patch) => {
map.retain(|hash, _| new_hashes.contains(hash)); if let Some(hash) = patch.hash.clone() {
for new_torrent in list { torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
map.insert(new_torrent.hash.clone(), new_torrent); }
} }
}); AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len())); AppEvent::Notification(n) => {
} show_toast(n.level.clone(), n.message.clone());
AppEvent::Update(patch) => { if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
let hash_opt = patch.hash.clone(); show_browser_notification("VibeTorrent", &n.message);
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);
}
}
} }
} }
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
} }
} }
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
} }
} }
} }
@@ -158,13 +145,103 @@ pub fn provide_torrent_store() {
} }
} }
} }
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
gloo_timers::future::TimeoutFuture::new(backoff_ms).await; gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000); 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 sw_container = window.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 sw_container = window.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 = match shared::server_fns::push::get_public_key().await {
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 .await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e))) .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)))
}