Compare commits
9 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fb411bb1 | ||
|
|
1225c550b7 | ||
|
|
48193db81b | ||
|
|
48d8a8e0ee | ||
|
|
945f4718eb | ||
|
|
6a2952c6f3 | ||
|
|
03b63dd5d0 | ||
|
|
7717dffc56 | ||
|
|
3a2cab7ca7 |
@@ -25,6 +25,8 @@
|
|||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
<link data-trunk rel="copy-file" href="icon-512.png" />
|
<link data-trunk rel="copy-file" href="icon-512.png" />
|
||||||
|
<link data-trunk rel="copy-file" href="public/lock_scroll.js" />
|
||||||
|
<script src="/lock_scroll.js"></script>
|
||||||
<link data-trunk rel="copy-file" href="sw.js" />
|
<link data-trunk rel="copy-file" href="sw.js" />
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -2,51 +2,30 @@ use leptos::prelude::*;
|
|||||||
use crate::components::layout::sidebar::Sidebar;
|
use crate::components::layout::sidebar::Sidebar;
|
||||||
use crate::components::layout::toolbar::Toolbar;
|
use crate::components::layout::toolbar::Toolbar;
|
||||||
use crate::components::layout::statusbar::StatusBar;
|
use crate::components::layout::statusbar::StatusBar;
|
||||||
|
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Protected(children: Children) -> impl IntoView {
|
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! {
|
view! {
|
||||||
<div class="flex h-screen w-full overflow-hidden bg-background">
|
<SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
|
||||||
|
// Masaüstü Sidenav
|
||||||
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
|
<Sidenav>
|
||||||
<aside class=move || {
|
|
||||||
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
|
|
||||||
if is_mobile_menu_open.get() {
|
|
||||||
format!("{} translate-x-0", base)
|
|
||||||
} else {
|
|
||||||
format!("{} -translate-x-full", base)
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</aside>
|
</Sidenav>
|
||||||
|
|
||||||
// Mobil arka plan karartma (Overlay)
|
// İçerik Alanı
|
||||||
<Show when=move || is_mobile_menu_open.get()>
|
<SidenavInset class="flex flex-col h-screen overflow-hidden">
|
||||||
<div
|
// Toolbar (Üst Bar)
|
||||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
|
||||||
on:click=move |_| is_mobile_menu_open.set(false)
|
|
||||||
></div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// --- MAIN CONTENT AREA ---
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
|
||||||
// --- TOOLBAR (TOP) ---
|
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
|
||||||
// --- MAIN CONTENT ---
|
// Ana İçerik
|
||||||
<main class="flex-1 overflow-hidden relative bg-background">
|
<main class="flex-1 overflow-hidden relative bg-background">
|
||||||
{children()}
|
{children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
// --- STATUS BAR (BOTTOM) ---
|
// Alt Bar
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</SidenavInset>
|
||||||
</div>
|
</SidenavWrapper>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
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;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
|
||||||
|
|
||||||
let total_count = move || store.torrents.with(|map| map.len());
|
let total_count = move || store.torrents.with(|map| map.len());
|
||||||
let downloading_count = move || {
|
let downloading_count = move || {
|
||||||
@@ -52,7 +53,6 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
|
|
||||||
let set_filter = move |f: crate::store::FilterStatus| {
|
let set_filter = move |f: crate::store::FilterStatus| {
|
||||||
store.filter.set(f);
|
store.filter.set(f);
|
||||||
is_mobile_menu_open.set(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f;
|
||||||
@@ -66,83 +66,89 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
<SidenavHeader>
|
||||||
<div class="p-4 flex-1 overflow-y-auto">
|
<div class="flex items-center gap-2 px-2 py-4">
|
||||||
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
|
<div class="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
"VibeTorrent"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
|
||||||
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
|
<span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
|
||||||
|
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
|
||||||
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
|
||||||
label="All"
|
|
||||||
count=Signal::derive(total_count)
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
|
||||||
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
|
||||||
label="Downloading"
|
|
||||||
count=Signal::derive(downloading_count)
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
|
||||||
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
||||||
label="Seeding"
|
|
||||||
count=Signal::derive(seeding_count)
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
|
||||||
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
label="Completed"
|
|
||||||
count=Signal::derive(completed_count)
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
|
||||||
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
|
||||||
label="Paused"
|
|
||||||
count=Signal::derive(paused_count)
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
|
|
||||||
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
|
|
||||||
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
|
||||||
label="Inactive"
|
|
||||||
count=Signal::derive(inactive_count)
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SidenavHeader>
|
||||||
|
|
||||||
// Separator
|
<SidenavContent>
|
||||||
<div class="border-t border-border" />
|
<SidenavGroup>
|
||||||
|
<SidenavGroupLabel>"Filtreler"</SidenavGroupLabel>
|
||||||
|
<SidenavGroupContent>
|
||||||
|
<SidenavMenu>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::All)
|
||||||
|
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
label="Tümü"
|
||||||
|
count=Signal::derive(total_count)
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
|
||||||
|
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||||
|
label="İndirilenler"
|
||||||
|
count=Signal::derive(downloading_count)
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
|
||||||
|
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
label="Gönderilenler"
|
||||||
|
count=Signal::derive(seeding_count)
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
|
||||||
|
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
label="Tamamlananlar"
|
||||||
|
count=Signal::derive(completed_count)
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
|
||||||
|
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||||
|
label="Durdurulanlar"
|
||||||
|
count=Signal::derive(paused_count)
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
|
||||||
|
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
|
||||||
|
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
|
label="Pasif"
|
||||||
|
count=Signal::derive(inactive_count)
|
||||||
|
/>
|
||||||
|
</SidenavMenu>
|
||||||
|
</SidenavGroupContent>
|
||||||
|
</SidenavGroup>
|
||||||
|
</SidenavContent>
|
||||||
|
|
||||||
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
|
<SidenavFooter>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
|
||||||
// Avatar
|
<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 class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
{first_letter}
|
||||||
{first_letter}
|
</div>
|
||||||
</div>
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
|
||||||
<div class="font-medium text-sm truncate text-foreground">{username}</div>
|
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
|
||||||
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
// Theme toggle button
|
<ThemeToggle />
|
||||||
<div class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
<crate::components::ui::theme_toggle::ThemeToggle />
|
|
||||||
</div>
|
|
||||||
// Logout button
|
|
||||||
<Button
|
<Button
|
||||||
variant=ButtonVariant::Ghost
|
variant=ButtonVariant::Ghost
|
||||||
size=ButtonSize::Icon
|
size=ButtonSize::Icon
|
||||||
class="text-destructive hover:bg-destructive/10"
|
class="size-7 text-destructive hover:bg-destructive/10"
|
||||||
attr:disabled=move || false
|
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if shared::server_fns::auth::logout().await.is_ok() {
|
if shared::server_fns::auth::logout().await.is_ok() {
|
||||||
@@ -152,37 +158,40 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
<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" />
|
<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>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SidenavFooter>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn SidebarButton(
|
fn SidebarItem(
|
||||||
active: Signal<bool>,
|
active: Signal<bool>,
|
||||||
on_click: impl Fn(web_sys::MouseEvent) + 'static,
|
on_click: impl Fn(web_sys::MouseEvent) + 'static + Send,
|
||||||
#[prop(into)] icon: String,
|
#[prop(into)] icon: String,
|
||||||
#[prop(into)] label: &'static str,
|
#[prop(into)] label: &'static str,
|
||||||
count: Signal<usize>,
|
count: Signal<usize>,
|
||||||
) -> impl IntoView {
|
) -> 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! {
|
view! {
|
||||||
<Button
|
<SidenavMenuItem>
|
||||||
variant=Signal::derive(variant)
|
<SidenavMenuButton
|
||||||
class="justify-start gap-2 w-full h-8 px-3"
|
variant=Signal::derive(variant)
|
||||||
on:click=on_click
|
class=Signal::derive(class)
|
||||||
>
|
on:click=on_click
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
|
||||||
{label}
|
</svg>
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
|
<span class="flex-1 truncate">{label}</span>
|
||||||
</Button>
|
<span class="text-[10px] font-mono opacity-50">{count}</span>
|
||||||
|
</SidenavMenuButton>
|
||||||
|
</SidenavMenuItem>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,52 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use icons::PanelLeft;
|
||||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
||||||
|
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||||
|
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
|
||||||
|
use crate::components::layout::sidebar::Sidebar;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Toolbar() -> impl IntoView {
|
pub fn Toolbar() -> impl IntoView {
|
||||||
let show_add_modal = signal(false);
|
let show_add_modal = signal(false);
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
|
||||||
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
|
|
||||||
|
|
||||||
let search_value = RwSignal::new(String::new());
|
|
||||||
|
|
||||||
// Sync search_value to store
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let val = search_value.get();
|
|
||||||
store.search_query.set(val);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 + Add Torrent
|
// Sol kısım: Menü butonu (Mobil) + Add Torrent
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
// Mobile Menu Trigger
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden"
|
|
||||||
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
// --- MOBILE SHEET (SIDEBAR) ---
|
||||||
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all active:scale-[0.98]"
|
<div class="lg:hidden">
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger variant=ButtonVariant::Ghost size=ButtonSize::Icon class="size-9">
|
||||||
|
<PanelLeft class="size-5" />
|
||||||
|
<span class="hidden">"Menüyü Aç"</span>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
direction=SheetDirection::Left
|
||||||
|
class="p-0 w-[18rem] bg-card border-r border-border"
|
||||||
|
hide_close_button=true
|
||||||
|
>
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
on:click=move |_| show_add_modal.1.set(true)
|
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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</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>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Sağ kısım: Search kutusu
|
// 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 class="hidden md:flex items-center gap-2 w-full max-w-xs">
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search..."
|
|
||||||
class="file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 md:text-sm pl-8"
|
|
||||||
bind:value=search_value
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || show_add_modal.0.get()>
|
<Show when=move || show_add_modal.0.get()>
|
||||||
|
|||||||
@@ -220,11 +220,9 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when=move || has_selection.get()>
|
<Show when=move || has_selection.get()>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger class="gap-2 bg-secondary text-secondary-foreground border-none hover:bg-secondary/80">
|
||||||
<Button variant=ButtonVariant::Secondary size=ButtonSize::Sm class="gap-2">
|
<Ellipsis class="size-4" />
|
||||||
<Ellipsis class="size-4" />
|
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="w-48">
|
<DropdownMenuContent class="w-48">
|
||||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||||
@@ -239,10 +237,10 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
<div class="my-1 h-px bg-border" />
|
<div class="my-1 h-px bg-border" />
|
||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger class="w-full">
|
<AlertDialogTrigger class="w-full text-left">
|
||||||
<DropdownMenuItem class="text-destructive focus:bg-destructive/10">
|
<div class="inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm transition-colors text-destructive hover:bg-destructive/10 focus:bg-destructive/10">
|
||||||
<Trash2 class="mr-2 size-4" /> "Toplu Sil"
|
<Trash2 class="size-4" /> "Toplu Sil"
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -612,4 +610,4 @@ fn TorrentCard(
|
|||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}
|
||||||
39
frontend/src/components/ui/accordion.rs
Normal file
39
frontend/src/components/ui/accordion.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("w-full", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("border-b", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("flex", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!(
|
||||||
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
view! {
|
||||||
|
<button type="button" class=class>
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("overflow-hidden text-sm transition-all", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
@@ -237,8 +237,6 @@ pub fn ContextMenuContent(
|
|||||||
let target_id_for_script = ctx.target_id.clone();
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/lock_scroll.js"></script>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="ContextMenuContent"
|
data-name="ContextMenuContent"
|
||||||
class=class
|
class=class
|
||||||
|
|||||||
@@ -280,8 +280,6 @@ pub fn DropdownMenuContent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/hooks/lock_scroll.js"></script>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="DropdownMenuContent"
|
data-name="DropdownMenuContent"
|
||||||
class=class
|
class=class
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod accordion;
|
||||||
pub mod alert_dialog;
|
pub mod alert_dialog;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
@@ -11,7 +12,9 @@ pub mod input;
|
|||||||
pub mod multi_select;
|
pub mod multi_select;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod separator;
|
pub mod separator;
|
||||||
|
pub mod sheet;
|
||||||
|
pub mod sidenav;
|
||||||
pub mod svg_icon;
|
pub mod svg_icon;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod theme_toggle;
|
pub mod theme_toggle;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
@@ -180,8 +180,6 @@ pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: Str
|
|||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/lock_scroll.js"></script>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="MultiSelectContent"
|
data-name="MultiSelectContent"
|
||||||
class=class
|
class=class
|
||||||
|
|||||||
@@ -172,8 +172,6 @@ pub fn SelectContent(
|
|||||||
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/lock_scroll.js"></script>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-name="SelectContent"
|
data-name="SelectContent"
|
||||||
class=merged_class
|
class=merged_class
|
||||||
|
|||||||
239
frontend/src/components/ui/sheet.rs
Normal file
239
frontend/src/components/ui/sheet.rs
Normal file
@@ -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! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<div data-name="Sheet" class=merged_class>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<SheetContext>();
|
||||||
|
let trigger_id = format!("trigger_{}", ctx.target_id);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<SheetContext>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
|
||||||
|
{children()}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SheetContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = SheetDirection::Right)] direction: SheetDirection,
|
||||||
|
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<SheetContext>();
|
||||||
|
|
||||||
|
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! {
|
||||||
|
<div
|
||||||
|
data-name="SheetBackdrop"
|
||||||
|
id=backdrop_id
|
||||||
|
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
|
||||||
|
data-state="closed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name="SheetContent"
|
||||||
|
class=merged_class
|
||||||
|
id=ctx.target_id
|
||||||
|
data-direction=direction.to_string()
|
||||||
|
data-state="closed"
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=format!(
|
||||||
|
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
|
||||||
|
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
|
||||||
|
)
|
||||||
|
data-sheet-close=ctx.target_id.clone()
|
||||||
|
aria-label="Close sheet"
|
||||||
|
>
|
||||||
|
<span class="hidden">"Close Sheet"</span>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupSheet = () => {{
|
||||||
|
const sheet = document.querySelector('#{}');
|
||||||
|
const backdrop = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!sheet || !backdrop || !trigger) {{
|
||||||
|
setTimeout(setupSheet, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (sheet.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
sheet.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
const openSheet = () => {{
|
||||||
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
|
sheet.setAttribute('data-state', 'open');
|
||||||
|
backdrop.setAttribute('data-state', 'open');
|
||||||
|
sheet.style.pointerEvents = 'auto';
|
||||||
|
backdrop.style.pointerEvents = 'auto';
|
||||||
|
const direction = sheet.getAttribute('data-direction');
|
||||||
|
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
|
||||||
|
sheet.classList.add('translate-x-0', 'translate-y-0');
|
||||||
|
}};
|
||||||
|
|
||||||
|
const closeSheet = () => {{
|
||||||
|
sheet.setAttribute('data-state', 'closed');
|
||||||
|
backdrop.setAttribute('data-state', 'closed');
|
||||||
|
sheet.style.pointerEvents = 'none';
|
||||||
|
backdrop.style.pointerEvents = 'none';
|
||||||
|
const direction = sheet.getAttribute('data-direction');
|
||||||
|
sheet.classList.remove('translate-x-0', 'translate-y-0');
|
||||||
|
if (direction === 'Right') sheet.classList.add('translate-x-full');
|
||||||
|
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
|
||||||
|
else if (direction === 'Top') sheet.classList.add('-translate-y-full');
|
||||||
|
else if (direction === 'Bottom') sheet.classList.add('translate-y-full');
|
||||||
|
if (window.ScrollLock) window.ScrollLock.unlock(300);
|
||||||
|
}};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', openSheet);
|
||||||
|
const closeButtons = sheet.querySelectorAll('[data-sheet-close]');
|
||||||
|
closeButtons.forEach(btn => btn.addEventListener('click', closeSheet));
|
||||||
|
backdrop.addEventListener('click', closeSheet);
|
||||||
|
document.addEventListener('keydown', (e) => {{
|
||||||
|
if (e.key === 'Escape' && sheet.getAttribute('data-state') === 'open') {{
|
||||||
|
e.preventDefault();
|
||||||
|
closeSheet();
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {{
|
||||||
|
document.addEventListener('DOMContentLoaded', setupSheet);
|
||||||
|
}} else {{
|
||||||
|
setupSheet();
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"#,
|
||||||
|
target_id_for_script,
|
||||||
|
backdrop_id_for_script,
|
||||||
|
target_id_for_script,
|
||||||
|
)}
|
||||||
|
</script>
|
||||||
|
}.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]",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
frontend/src/components/ui/sidenav.rs
Normal file
232
frontend/src/components/ui/sidenav.rs
Normal file
@@ -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! {
|
||||||
|
<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]
|
||||||
|
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! {
|
||||||
|
<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()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ 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.
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,50 +45,66 @@ pub fn SonnerTrigger(
|
|||||||
index: usize,
|
index: usize,
|
||||||
total: usize,
|
total: usize,
|
||||||
position: SonnerPosition,
|
position: SonnerPosition,
|
||||||
|
is_expanded: Signal<bool>,
|
||||||
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let variant_classes = match toast.variant {
|
let variant_classes = match toast.variant {
|
||||||
ToastType::Default => "bg-background text-foreground border-border",
|
ToastType::Default => "bg-background text-foreground border-border",
|
||||||
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-success",
|
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-green-500",
|
||||||
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
||||||
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
|
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-yellow-500",
|
||||||
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
|
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-blue-500",
|
||||||
ToastType::Loading => "bg-background text-foreground border-border",
|
ToastType::Loading => "bg-background text-foreground border-border",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sonner Stacking Logic
|
let bar_color = match toast.variant {
|
||||||
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
|
ToastType::Success => "bg-green-500",
|
||||||
let inverse_index = index;
|
ToastType::Error => "bg-destructive",
|
||||||
let offset = inverse_index as f64 * 16.0;
|
ToastType::Warning => "bg-yellow-500",
|
||||||
let scale = 1.0 - (inverse_index as f64 * 0.05);
|
ToastType::Info => "bg-blue-500",
|
||||||
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
|
_ => "bg-primary",
|
||||||
|
};
|
||||||
let is_bottom = !position.to_string().contains("Top");
|
|
||||||
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
|
||||||
let translate_y = offset * y_direction;
|
|
||||||
|
|
||||||
let style = format!(
|
// Stacking & Expansion Logic
|
||||||
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
|
let style = move || {
|
||||||
total - index,
|
let is_bottom = position.to_string().contains("Bottom");
|
||||||
translate_y,
|
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
||||||
scale,
|
|
||||||
opacity
|
let (translate_y, scale, opacity) = if is_expanded.get() {
|
||||||
);
|
// Expanded state: Full list layout
|
||||||
|
let y = index as f64 * 70.0; // height + gap
|
||||||
|
(y * y_direction, 1.0, 1.0)
|
||||||
|
} else {
|
||||||
|
// Stacked state: Sonner look
|
||||||
|
let y = index as f64 * 10.0;
|
||||||
|
let s = 1.0 - (index as f64 * 0.05);
|
||||||
|
let o = if index > 2 { 0.0 } else { 1.0 - (index as f64 * 0.2) };
|
||||||
|
(y * y_direction, s, o)
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
|
||||||
|
total - index,
|
||||||
|
translate_y,
|
||||||
|
scale,
|
||||||
|
opacity
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let icon = match toast.variant {
|
let icon = match toast.variant {
|
||||||
ToastType::Success => Some(view! { <span class="icon text-success">"✓"</span> }.into_any()),
|
ToastType::Success => Some(view! { <span class="icon font-bold">"✓"</span> }.into_any()),
|
||||||
ToastType::Error => Some(view! { <span class="icon text-destructive">"✕"</span> }.into_any()),
|
ToastType::Error => Some(view! { <span class="icon font-bold">"✕"</span> }.into_any()),
|
||||||
ToastType::Warning => Some(view! { <span class="icon text-warning">"⚠"</span> }.into_any()),
|
ToastType::Warning => Some(view! { <span class="icon font-bold">"⚠"</span> }.into_any()),
|
||||||
ToastType::Info => Some(view! { <span class="icon text-info">"ℹ"</span> }.into_any()),
|
ToastType::Info => Some(view! { <span class="icon font-bold">"ℹ"</span> }.into_any()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<div
|
||||||
class=tw_merge!(
|
class=tw_merge!(
|
||||||
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
|
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto overflow-hidden",
|
||||||
"flex items-center gap-3 min-w-[350px] p-4 rounded-lg border shadow-lg bg-card",
|
"flex items-center gap-3 w-full max-w-[calc(100vw-2rem)] sm:max-w-[380px] p-4 rounded-lg border shadow-lg bg-card",
|
||||||
if is_bottom { "bottom-0" } else { "top-0" },
|
if position.to_string().contains("Bottom") { "bottom-0" } else { "top-0" },
|
||||||
variant_classes
|
variant_classes
|
||||||
)
|
)
|
||||||
style=style
|
style=style
|
||||||
@@ -99,15 +115,23 @@ pub fn SonnerTrigger(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-0.5 overflow-hidden flex-1">
|
||||||
<div class="text-sm font-semibold">{toast.title}</div>
|
<div class="text-sm font-semibold truncate leading-tight">{toast.title}</div>
|
||||||
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
|
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70 truncate">{d.clone()}</div> })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// Progress Bar
|
||||||
|
<div
|
||||||
|
class=tw_merge!("absolute bottom-0 left-0 h-1 w-full opacity-20", bar_color)
|
||||||
|
style=format!(
|
||||||
|
"animation: sonner-progress {}ms linear forwards; transform-origin: left;",
|
||||||
|
toast.duration
|
||||||
|
)
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thread local storage for global access
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
||||||
}
|
}
|
||||||
@@ -134,16 +158,21 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
<style>
|
||||||
|
"@keyframes sonner-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }"
|
||||||
|
</style>
|
||||||
<div
|
<div
|
||||||
class=tw_merge!("fixed z-[100] flex flex-col pointer-events-none min-h-[200px] w-[400px]", container_class)
|
class=tw_merge!(
|
||||||
|
"fixed z-[100] flex flex-col pointer-events-none min-h-[100px] w-full sm:w-[400px]",
|
||||||
|
container_class,
|
||||||
|
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
|
||||||
|
)
|
||||||
on:mouseenter=move |_| is_hovered.set(true)
|
on:mouseenter=move |_| is_hovered.set(true)
|
||||||
on:mouseleave=move |_| is_hovered.set(false)
|
on:mouseleave=move |_| is_hovered.set(false)
|
||||||
>
|
>
|
||||||
<For
|
<For
|
||||||
each=move || {
|
each=move || {
|
||||||
let list = toasts.get();
|
let list = toasts.get();
|
||||||
// Reverse the list so newest is at the end (for stacking)
|
|
||||||
// or newest is at the beginning (for display logic)
|
|
||||||
list.into_iter().rev().enumerate().collect::<Vec<_>>()
|
list.into_iter().rev().enumerate().collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
key=|(_, toast)| toast.id
|
key=|(_, toast)| toast.id
|
||||||
@@ -151,30 +180,17 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
let id = toast.id;
|
let id = toast.id;
|
||||||
let total = toasts.with(|t| t.len());
|
let total = toasts.with(|t| t.len());
|
||||||
|
|
||||||
// If hovered, expand the stack
|
|
||||||
let expanded_style = move || {
|
|
||||||
if is_hovered.get() {
|
|
||||||
let offset = index as f64 * 70.0;
|
|
||||||
let is_bottom = !position.to_string().contains("Top");
|
|
||||||
let y_dir = if is_bottom { -1.0 } else { 1.0 };
|
|
||||||
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
|
|
||||||
} else {
|
|
||||||
"".to_string()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div style=expanded_style>
|
<SonnerTrigger
|
||||||
<SonnerTrigger
|
toast=toast
|
||||||
toast=toast
|
index=index
|
||||||
index=index
|
total=total
|
||||||
total=total
|
position=position
|
||||||
position=position
|
is_expanded=is_hovered.into()
|
||||||
on_dismiss=Callback::new(move |_| {
|
on_dismiss=Callback::new(move |_| {
|
||||||
toasts.update(|vec| vec.retain(|t| t.id != id));
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
})
|
})
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -182,7 +198,6 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global Helper Functions
|
|
||||||
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||||
let signal_opt = TOASTS.with(|t| *t.borrow());
|
let signal_opt = TOASTS.with(|t| *t.borrow());
|
||||||
|
|
||||||
@@ -218,4 +233,4 @@ pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||||
Reference in New Issue
Block a user