diff --git a/frontend/src/components/layout/protected.rs b/frontend/src/components/layout/protected.rs index fd1e7f0..0639124 100644 --- a/frontend/src/components/layout/protected.rs +++ b/frontend/src/components/layout/protected.rs @@ -2,51 +2,30 @@ use leptos::prelude::*; use crate::components::layout::sidebar::Sidebar; use crate::components::layout::toolbar::Toolbar; use crate::components::layout::statusbar::StatusBar; +use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset}; #[component] pub fn Protected(children: Children) -> impl IntoView { - // Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing) - let is_mobile_menu_open = RwSignal::new(false); - - // Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin - provide_context(is_mobile_menu_open); - view! { -
- - // --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) --- - + - // Mobil arka plan karartma (Overlay) - -
-
- - // --- MAIN CONTENT AREA --- -
- // --- TOOLBAR (TOP) --- + // İçerik Alanı + + // Toolbar (Üst Bar) - // --- MAIN CONTENT --- + // Ana İçerik
{children()}
- // --- STATUS BAR (BOTTOM) --- + // Alt Bar -
-
+ + } -} \ No newline at end of file +} diff --git a/frontend/src/components/layout/sidebar.rs b/frontend/src/components/layout/sidebar.rs index cfde086..997fdb1 100644 --- a/frontend/src/components/layout/sidebar.rs +++ b/frontend/src/components/layout/sidebar.rs @@ -1,11 +1,12 @@ use leptos::prelude::*; 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; #[component] pub fn Sidebar() -> impl IntoView { let store = use_context::().expect("store not provided"); - let is_mobile_menu_open = use_context::>().expect("mobile menu state not provided"); let total_count = move || store.torrents.with(|map| map.len()); let downloading_count = move || { @@ -52,7 +53,6 @@ pub fn Sidebar() -> impl IntoView { let set_filter = move |f: crate::store::FilterStatus| { store.filter.set(f); - is_mobile_menu_open.set(false); }; let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f; @@ -66,83 +66,89 @@ pub fn Sidebar() -> impl IntoView { }; view! { -
-
-
- "VibeTorrent" + +
+
+ + +
-
-

"Filters"

- - - - - - - +
+ "VibeTorrent" + "v3.0.0"
+ - // Separator -
+ + + "Filtreler" + + + + + + + + + + + + -
-
- // Avatar -
- {first_letter} -
-
-
{username}
-
"Online"
-
- - // Theme toggle button -
- -
- // Logout button + +
+
+ {first_letter} +
+
+
{username}
+
"Yönetici"
+
+ +
+ +
-
+ } } #[component] -fn SidebarButton( +fn SidebarItem( active: Signal, - on_click: impl Fn(web_sys::MouseEvent) + 'static, + on_click: impl Fn(web_sys::MouseEvent) + 'static + Send, #[prop(into)] icon: String, #[prop(into)] label: &'static str, count: Signal, ) -> impl IntoView { - let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost }; - + let variant = move || if active.get() { SidenavMenuButtonVariant::Outline } else { SidenavMenuButtonVariant::Default }; + let class = move || if active.get() { "bg-accent/50 border-accent text-foreground".to_string() } else { "text-muted-foreground hover:text-foreground".to_string() }; + view! { - + + + + + + {label} + {count} + + } } diff --git a/frontend/src/components/layout/toolbar.rs b/frontend/src/components/layout/toolbar.rs index 207ab00..011e0e8 100644 --- a/frontend/src/components/layout/toolbar.rs +++ b/frontend/src/components/layout/toolbar.rs @@ -1,23 +1,37 @@ use leptos::prelude::*; +use icons::PanelLeft; use crate::components::torrent::add_torrent::AddTorrentDialog; -use crate::components::ui::button::{Button}; +use crate::components::ui::button::{Button, ButtonVariant, ButtonSize}; +use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection}; +use crate::components::layout::sidebar::Sidebar; #[component] pub fn Toolbar() -> impl IntoView { let show_add_modal = signal(false); - let is_mobile_menu_open = use_context::>().expect("mobile menu state not provided"); view! {
- // Sol kısım: Menü butonu + Add Torrent + // Sol kısım: Menü butonu (Mobil) + Add Torrent
- // Mobile Menu Trigger - + + // --- MOBILE SHEET (SIDEBAR) --- +
+ + + + + + +
+ +
+
+
+
- // Sağ kısım boşaltıldı (arama kutusu kaldırıldı) + // Sağ kısım boş
@@ -40,4 +54,4 @@ pub fn Toolbar() -> impl IntoView {
} -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/mod.rs b/frontend/src/components/ui/mod.rs index 825cdf2..dc60720 100644 --- a/frontend/src/components/ui/mod.rs +++ b/frontend/src/components/ui/mod.rs @@ -11,7 +11,11 @@ pub mod input; pub mod multi_select; pub mod select; pub mod separator; +pub mod sonner; pub mod svg_icon; pub mod table; pub mod theme_toggle; -pub mod toast; \ No newline at end of file +pub mod toast; +pub mod sidenav; +pub mod sheet; +pub mod accordion; \ No newline at end of file diff --git a/frontend/src/components/ui/sheet.rs b/frontend/src/components/ui/sheet.rs new file mode 100644 index 0000000..06602bc --- /dev/null +++ b/frontend/src/components/ui/sheet.rs @@ -0,0 +1,239 @@ +use icons::X; +use leptos::context::Provider; +use leptos::prelude::*; +use leptos_ui::clx; +use tw_merge::*; + +use super::button::ButtonSize; +use crate::components::hooks::use_random::use_random_id_for; +use crate::components::ui::button::{Button, ButtonVariant}; + +mod components { + use super::*; + clx! {SheetTitle, h2, "font-bold text-2xl"} + clx! {SheetDescription, p, "text-muted-foreground"} + clx! {SheetBody, div, "flex flex-col gap-4"} + clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"} +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ CONTEXT ✨ */ +/* ========================================================== */ + +#[derive(Clone)] +pub struct SheetContext { + pub target_id: String, +} + +/* ========================================================== */ +/* ✨ 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"); + let ctx = SheetContext { target_id: sheet_target_id }; + + let merged_class = tw_merge!("", class); + + view! { + +
+ {children()} +
+
+ } +} + +#[component] +pub fn SheetTrigger( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + let ctx = expect_context::(); + let trigger_id = format!("trigger_{}", ctx.target_id); + + view! { + + } +} + +#[component] +pub fn SheetClose( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, + #[prop(default = ButtonSize::Default)] size: ButtonSize, +) -> impl IntoView { + let ctx = expect_context::(); + + view! { + + } +} + +#[component] +pub fn SheetContent( + children: Children, + #[prop(optional, into)] class: String, + #[prop(default = SheetDirection::Right)] direction: SheetDirection, + #[prop(into, optional)] hide_close_button: Option, +) -> impl IntoView { + let ctx = expect_context::(); + + 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 merged_class = tw_merge!( + "fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain", + direction.initial_position(), + direction.closed_class(), + class + ); + + view! { +
+ +
+ + + {children()} +
+ + + }.into_any() +} + +/* ========================================================== */ +/* ✨ ENUM ✨ */ +/* ========================================================== */ + +#[derive(Clone, Copy, strum::AsRefStr, strum::Display)] +pub enum SheetDirection { + Right, + Left, + Top, + Bottom, +} + +impl SheetDirection { + fn closed_class(self) -> &'static str { + match self { + SheetDirection::Right => "translate-x-full", + SheetDirection::Left => "-translate-x-full", + SheetDirection::Top => "-translate-y-full", + SheetDirection::Bottom => "translate-y-full", + } + } + + fn initial_position(self) -> &'static str { + match self { + SheetDirection::Right => "top-0 right-0 h-full w-[400px]", + SheetDirection::Left => "top-0 left-0 h-full w-[400px]", + SheetDirection::Top => "top-0 left-0 w-full h-[400px]", + SheetDirection::Bottom => "bottom-0 left-0 w-full h-[400px]", + } + } +} diff --git a/frontend/src/components/ui/sidenav.rs b/frontend/src/components/ui/sidenav.rs new file mode 100644 index 0000000..91f0aa4 --- /dev/null +++ b/frontend/src/components/ui/sidenav.rs @@ -0,0 +1,232 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_location; +use leptos_ui::{clx, variants, void}; + +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"} + + 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" + } +} + +pub use components::*; + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[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)) + }; + + let aria_current = move || if is_active() { "page" } else { "false" }; + + view! { + + {children()} + + } +} + +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] +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, + children: Children, +) -> impl IntoView { + view! { + {if data_collapsible == SidenavCollapsible::None { + view! { + + } + .into_any() + } else { + view! { + + } + .into_any() + }} + } +} + +/* ========================================================== */ +/* ✨ FUNCTIONS ✨ */ +/* ========================================================== */ + +#[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. + + + } +} + +#[component] +fn SidenavToggleRail() -> impl IntoView { + view! { +