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 setup;
pub mod notifications;
#[derive(RustEmbed)]
#[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/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();

View File

@@ -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),

View File

@@ -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);

View File

@@ -1,5 +1,7 @@
use leptos::prelude::*;
use crate::components::ui::context_menu::*;
use crate::components::ui::context_menu::{
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
};
#[component]
pub fn TorrentContextMenu(
@@ -7,72 +9,38 @@ pub fn TorrentContextMenu(
torrent_hash: String,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let hash = StoredValue::new(torrent_hash);
let menu_action = move |action: &'static str| {
on_action.run((action.to_string(), hash.get_value()));
let hash = torrent_hash.clone();
let on_action_stored = StoredValue::new(on_action);
// 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! {
<ContextMenu>
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
<ContextMenuAction
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("start")
>
<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="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" />
</svg>
"Start"
</ContextMenuAction>
<ContextMenuAction
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 class="w-48">
<ContextMenuItem on:click={let h = hash_c1; move |_| on_action_stored.get_value().run(("start".to_string(), h.clone()))}>
"Başlat"
</ContextMenuItem>
<ContextMenuItem on:click={let h = hash_c2; move |_| on_action_stored.get_value().run(("stop".to_string(), h.clone()))}>
"Durdur"
</ContextMenuItem>
<ContextMenuItem class="text-destructive" on:click={let h = hash_c3; move |_| on_action_stored.get_value().run(("delete".to_string(), h.clone()))}>
"Sil"
</ContextMenuItem>
<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"
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
}
}
}

View File

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

View File

@@ -8,15 +8,26 @@ pub struct ThemeMode {
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 {
expect_context::<ThemeMode>()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
impl ThemeMode {
#[must_use]
/// Initializes a new ThemeMode instance.
pub fn init() -> Self {
let theme_mode = Self { state: RwSignal::new(false) };
provide_context(theme_mode);
// Use Effect to handle browser-only initialization
Effect::new(move |_| {
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
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 {
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> {
window().local_storage().ok().flatten()
}
/// Retrieves the dark mode state from local storage, if available.
fn get_storage_state() -> Option<bool> {
Self::get_storage()
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
@@ -47,6 +89,7 @@ impl ThemeMode {
.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 {
window()
.match_media("(prefers-color-scheme: dark)")
@@ -56,9 +99,10 @@ impl ThemeMode {
.unwrap_or_default()
}
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) {
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::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::theme_toggle::ThemeToggle;
use crate::components::ui::switch::Switch;
#[component]
pub fn Sidebar() -> impl IntoView {
@@ -65,6 +66,19 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
let on_push_toggle = move |checked: bool| {
spawn_local(async move {
if checked {
crate::store::subscribe_to_push_notifications().await;
} else {
crate::store::unsubscribe_from_push_notifications().await;
}
if let Ok(enabled) = crate::store::is_push_subscribed().await {
store.push_enabled.set(enabled);
}
});
};
view! {
<SidenavHeader>
<div class="flex items-center gap-2 px-2 py-4">
@@ -133,35 +147,49 @@ pub fn Sidebar() -> impl IntoView {
</SidenavContent>
<SidenavFooter>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
<div class="flex flex-col gap-4 p-4">
// Push Notification Toggle
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
</div>
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<div class="flex items-center gap-1">
<ThemeToggle />
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</Button>
<div class="flex items-center gap-1">
<ThemeToggle />
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</Button>
</div>
</div>
</div>
</SidenavFooter>

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ pub enum BadgeVariant {
#[default]
Default,
Secondary,
Outline,
Destructive,
Success,
Warning,
@@ -21,6 +22,7 @@ pub fn Badge(
let variant_classes = match variant {
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::Outline => "text-foreground",
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::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
data-name="ContextMenuTrigger"
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 {
cb.run(());
}

View File

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

View File

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

View File

@@ -94,6 +94,8 @@ pub fn DropdownMenuAction(
#[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>,
) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
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",
class
@@ -173,15 +175,17 @@ pub enum DropdownMenuAlign {
#[derive(Clone)]
struct DropdownMenuContext {
target_id: String,
align: DropdownMenuAlign,
}
#[component]
pub fn DropdownMenu(
children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> impl IntoView {
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! {
<Provider value=ctx>
@@ -248,13 +252,12 @@ pub enum DropdownMenuPosition {
pub fn DropdownMenuContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
) -> impl IntoView {
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 width_class = match align {
let width_class = match ctx.align {
DropdownMenuAlign::Center => "min-w-full",
_ => "w-[180px]",
};
@@ -262,7 +265,7 @@ pub fn DropdownMenuContent(
let class = tw_merge!(width_class, base_classes, class);
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::StartOuter => "start-outer",
DropdownMenuAlign::End => "end",
@@ -439,6 +442,26 @@ pub fn DropdownMenuContent(
trigger.addEventListener('click', (e) => {{
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
if (isOpen) {{
closeDropdown();
@@ -510,4 +533,4 @@ pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: St
{children()}
</li>
}
}
}

View File

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

View File

@@ -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;
// * Reuse @select.rs
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)]

View File

@@ -1,35 +1,20 @@
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]
pub fn Separator(
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
#[prop(into, optional)] class: String,
// children: Children,
) -> impl IntoView {
let merged_class = Memo::new(move |_| {
let orientation = orientation.get();
let separator = SeparatorClass { orientation };
separator.with_class(class.clone())
});
view! { <div class=merged_class role="separator" /> }
let class_signal = move || {
let orient_class = match orientation.get() {
SeparatorOrientation::Horizontal => "h-[1px] w-full",
SeparatorOrientation::Vertical => "h-full w-[1px]",
};
tw_merge!("shrink-0 bg-border", orient_class, class.clone())
};
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 ✨ */
/* ========================================================== */
pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;
#[component]
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let sheet_target_id = use_random_id_for("sheet");
@@ -200,7 +203,7 @@ pub fn SheetContent(
target_id_for_script,
)}
</script>
}
}.into_any()
}
/* ========================================================== */

View File

@@ -1,232 +1,79 @@
use leptos::prelude::*;
use leptos_router::hooks::use_location;
use leptos_ui::{clx, variants, void};
use tw_merge::tw_merge;
mod components {
use super::*;
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"}
// 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"}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum SidenavState { #[default] Expanded, Collapsed }
void! {SidenavInput, input,
"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",
"focus-visible:border-ring focus-visible:ring-ring/50",
"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"
}
#[derive(Clone)]
pub struct SidenavContext {
pub state: RwSignal<SidenavState>,
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn SidenavWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let state = RwSignal::new(SidenavState::Expanded);
provide_context(SidenavContext { state });
let class = tw_merge!("flex min-h-screen w-full bg-background", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn SidenavLink(
children: Children,
#[prop(into)] href: String,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"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
);
let location = use_location();
// 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))
pub fn Sidenav(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let ctx = expect_context::<SidenavContext>();
let class_signal = move || {
let width_class = match ctx.state.get() {
SidenavState::Expanded => "w-[var(--sidenav-width)]",
SidenavState::Collapsed => "w-[var(--sidenav-width-icon)]",
};
tw_merge!(
"hidden md:flex flex-col border-r bg-card transition-all duration-300",
width_class,
class.clone()
)
};
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,
view! { <aside class=class_signal>{children()}</aside> }
}
#[component]
pub fn Sidenav(
#[prop(into, optional)] class: String,
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
#[prop(default = SidenavState::default())] data_state: SidenavState,
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
pub fn SidenavInset(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex flex-col flex-1 min-w-0", class);
view! { <main class=class>{children()}</main> }
}
#[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,
#[prop(into, optional)] variant: Signal<SidenavMenuButtonVariant>,
#[prop(into, optional)] class: Signal<String>,
) -> impl IntoView {
view! {
{if data_collapsible == SidenavCollapsible::None {
view! {
<aside
data-name="Sidenav"
class=tw_merge!(
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone()
)
>
{children()}
</aside>
}
.into_any()
} 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()
}}
}
let class_signal = move || {
let variant_class = if variant.get() == SidenavMenuButtonVariant::Outline {
"border border-input bg-background shadow-xs"
} else {
""
};
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,
class.get()
)
};
view! { <button class=class_signal>{children()}</button> }
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavState {
#[default]
Expanded,
Collapsed,
#[component] pub fn SidenavLink(children: Children, #[prop(into)] href: String) -> impl IntoView {
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> }
}
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
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

View File

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