diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 21224f6..02b5e02 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -39,6 +39,7 @@ struct-patch = "0.5" leptos_ui = "0.3" tw_merge = "0.1" strum = { version = "0.26", features = ["derive"] } +icons = { version = "0.18.0", features = ["leptos"] } [package.metadata.leptos] -tailwind-input-file = "input.css" \ No newline at end of file +tailwind-input-file = "input.css" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e778bff..60c0b92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", @@ -3737,6 +3738,15 @@ "node": ">=8.0" } }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a676a9e..d35cbdd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0" }, "description": "", "devDependencies": { @@ -25,4 +26,4 @@ }, "type": "module", "version": "1.0.0" -} \ No newline at end of file +} diff --git a/frontend/src/components/context_menu.rs b/frontend/src/components/context_menu.rs index e70b563..74f29a8 100644 --- a/frontend/src/components/context_menu.rs +++ b/frontend/src/components/context_menu.rs @@ -1,12 +1,5 @@ use leptos::prelude::*; -use web_sys::MouseEvent; -use wasm_bindgen::prelude::*; -use wasm_bindgen::JsCast; - -// ── Kendi reaktif Context Menu implementasyonumuz ── -// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te -// `if open.get()` statik kontrolü reaktif değil. Aşağıda -// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor. +use crate::components::ui::context_menu::*; #[component] pub fn TorrentContextMenu( @@ -15,144 +8,61 @@ pub fn TorrentContextMenu( on_action: Callback<(String, String)>, ) -> impl IntoView { let hash = StoredValue::new(torrent_hash); - let on_action = StoredValue::new(on_action); - - let open = RwSignal::new(false); - let position = RwSignal::new((0i32, 0i32)); - - // Sağ tıklama handler - let on_contextmenu = move |e: MouseEvent| { - e.prevent_default(); - e.stop_propagation(); - position.set((e.client_x(), e.client_y())); - open.set(true); - }; - - // Menü dışına tıklandığında kapanma - Effect::new(move |_| { - if open.get() { - let cb = Closure::wrap(Box::new(move |_: MouseEvent| { - open.set(false); - }) as Box); - - let window = web_sys::window().unwrap(); - let document = window.document().unwrap(); - let _ = document.add_event_listener_with_callback( - "click", - cb.as_ref().unchecked_ref(), - ); - - // Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor - // ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok. - cb.forget(); - } - }); - + let menu_action = move |action: &'static str| { - open.set(false); - on_action.get_value().run((action.to_string(), hash.get_value())); + on_action.run((action.to_string(), hash.get_value())); }; view! { -
- {children()} -
+ + + {children()} + + + + + + + + "Start" + - - { - let (x, y) = position.get(); - // Menü yaklaşık boyutları - let menu_width = 200; - let menu_height = 220; - let window = web_sys::window().unwrap(); - let vw = window.inner_width().unwrap().as_f64().unwrap() as i32; - let vh = window.inner_height().unwrap().as_f64().unwrap() as i32; - // Sağa taşarsa sola aç, alta taşarsa yukarı aç - let final_x = if x + menu_width > vw { x - menu_width } else { x }; - let final_y = if y + menu_height > vh { y - menu_height } else { y }; - let final_x = final_x.max(0); - let final_y = final_y.max(0); - view! { -
-
- // Start -
- - - - "Start" -
+ + + + + "Stop" + - // Stop -
- - - - "Stop" -
+ + + + + "Recheck" + - // Recheck -
- - - - "Recheck" -
+
- // Separator -
+ + + + + "Remove" + - // Remove -
- - - - "Remove" -
- - // Remove with Data -
- - - - "Remove with Data" -
-
- } - } - + + + + + "Remove with Data" + + + } } \ No newline at end of file diff --git a/frontend/src/components/hooks/mod.rs b/frontend/src/components/hooks/mod.rs new file mode 100644 index 0000000..6def963 --- /dev/null +++ b/frontend/src/components/hooks/mod.rs @@ -0,0 +1 @@ +pub mod use_random; diff --git a/frontend/src/components/hooks/use_random.rs b/frontend/src/components/hooks/use_random.rs new file mode 100644 index 0000000..d2f5617 --- /dev/null +++ b/frontend/src/components/hooks/use_random.rs @@ -0,0 +1,31 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-" + +pub fn use_random_id() -> String { + format!("_{PREFIX}_{}", generate_hash()) +} + +pub fn use_random_id_for(element: &str) -> String { + format!("{}_{PREFIX}_{}", element, generate_hash()) +} + +pub fn use_random_transition_name() -> String { + let random_id = use_random_id(); + format!("view-transition-name: {random_id}") +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +static COUNTER: AtomicUsize = AtomicUsize::new(1); + +fn generate_hash() -> u64 { + let mut hasher = DefaultHasher::new(); + let counter = COUNTER.fetch_add(1, Ordering::SeqCst); + counter.hash(&mut hasher); + hasher.finish() +} \ No newline at end of file diff --git a/frontend/src/components/ui/context_menu.rs b/frontend/src/components/ui/context_menu.rs new file mode 100644 index 0000000..99f9164 --- /dev/null +++ b/frontend/src/components/ui/context_menu.rs @@ -0,0 +1,366 @@ +use icons::ChevronRight; +use leptos::context::Provider; +use leptos::prelude::*; +use leptos_ui::clx; +use tw_merge::*; +use wasm_bindgen::JsCast; + +use crate::components::hooks::use_random::use_random_id_for; + +/// Programmatically close any open context menu. +pub fn close_context_menu() { + let Some(document) = window().document() else { + return; + }; + let Some(menu) = document.query_selector("[data-target='target__context'][data-state='open']").ok().flatten() + else { + return; + }; + let _ = menu.set_attribute("data-state", "closed"); + if let Some(el) = menu.dyn_ref::() { + let _ = el.style().set_property("pointer-events", "none"); + } +} + +mod components { + use super::*; + clx! {ContextMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"} + clx! {ContextMenuGroup, ul, "group"} + clx! {ContextMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"} + clx! {ContextMenuSubContent, ul, "context__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"} + clx! {ContextMenuLink, a, "w-full inline-flex gap-2 items-center"} +} + +pub use components::*; + +#[component] +pub fn ContextMenuAction( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional, into)] aria_selected: Option>, + #[prop(optional, into)] href: Option, +) -> impl IntoView { + let _ctx = expect_context::(); + + 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", + class + ); + + let aria_selected_attr = move || aria_selected.map(|s| s.get()).unwrap_or(false).to_string(); + + if let Some(href) = href { + view! { + + {children()} + + } + .into_any() + } else { + view! { + + } + .into_any() + } +} + +#[derive(Clone)] +struct ContextMenuContext { + target_id: String, +} + +#[component] +pub fn ContextMenu(children: Children) -> impl IntoView { + let context_target_id = use_random_id_for("context"); + + let ctx = ContextMenuContext { target_id: context_target_id.clone() }; + + view! { + + + +
+ {children()} +
+
+ } +} + +/// Wrapper that triggers the context menu on right-click. +/// The `on_open` callback is triggered when the context menu opens (right-click). +#[component] +pub fn ContextMenuTrigger( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional)] on_open: Option>, +) -> impl IntoView { + let ctx = expect_context::(); + let trigger_class = tw_merge!("contents", class); + + view! { +
+ {children()} +
+ } +} + +/// Content of the context menu that appears on right-click. +/// The `on_close` callback is triggered when the menu closes (click outside, ESC key, or action click). +#[component] +pub fn ContextMenuContent( + children: Children, + #[prop(optional, into)] class: String, + #[prop(optional)] on_close: Option>, +) -> impl IntoView { + let ctx = expect_context::(); + + let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md w-[200px] 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 class = tw_merge!(base_classes, class); + + let target_id_for_script = ctx.target_id.clone(); + + view! { + + +
+ {children()} +
+ + + } +} + +#[component] +pub fn ContextMenuSub(children: Children) -> impl IntoView { + clx! {ContextMenuSubRoot, li, "context__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"} + + view! { {children()} } +} + +#[component] +pub fn ContextMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!("flex items-center justify-between w-full", class); + + view! { + + {children()} + + + } +} + +#[component] +pub fn ContextMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView { + let class = tw_merge!( + "inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]", + class + ); + + view! { +
  • + {children()} +
  • + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index f386bd2..7944b90 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -2,3 +2,4 @@ pub mod button; pub mod card; pub mod input; pub mod toast; +pub mod context_menu;